Files
zeekay 10e6fec4c0 debrand: the brand token is INSIGHTS_, and a wrong one stops the container
The rule table mapped HOG_ -> FN_ ahead of any POSTHOG rule, so POSTHOG_FOO
split into POST + HOG_ and came out POSTFN_FOO. Nothing sets that. The live
pods set INSIGHTS_SKIP_MIGRATION_CHECKS, which bin/migrate-check reads on the
container entrypoint, so the misspelling does not degrade a feature -- it runs
migrate --check against a database whose history this tree does not share and
the container never starts.

Both replacements are measured against the shipped tree rather than chosen:
every HOG_ name with a counterpart there is spelled INSIGHTS_ (HOG_QL ->
INSIGHTS_QL, HOG_FUNCTION_TEMPLATES -> INSIGHTS_FUNCTION_TEMPLATES, 12 of 12
determinate cases), and FN_ appears in it nowhere. Upstream carries 95 HOG_
tokens and zero FN_ ones, so repairing FN_ back out is exactly the inverse of
the bad rule and cannot collide with an upstream name.

The rule is fixed for the next convergence, and the tree it already produced is
repaired in the same commit -- the codemod is idempotent against its own output,
so it cannot undo this itself.
2026-08-01 17:42:28 -07:00

2021 lines
76 KiB
Python

"""CLI commands for remote devbox management.
Provides insightscli devbox:* commands for managing Coder-based remote dev environments.
"""
from __future__ import annotations
import os
import sys
import errno
import shutil
import socket
import functools
import subprocess
import urllib.parse
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import click
from insightscli.manifest import get_manifest
from . import mutagen
from .coder import (
CLAUDE_CODE_OAUTH_ENV,
DEFAULT_PRESET,
DEFAULT_REGION,
DEFAULT_TEMPLATE,
DOTFILES_URI_PARAMETER,
GIT_EMAIL_PARAMETER,
GIT_NAME_PARAMETER,
GIT_SIGNING_KEY_SECRET,
REGIONS,
_diagnose_unreachable_coder,
_fail,
_start_app_param,
clone_workspace,
coder_authenticated,
coder_installed,
coder_reachable,
coder_ssh_alias_configured,
create_task,
create_workspace,
delete_user_secret,
delete_workspace,
ensure_coder_authenticated,
ensure_coder_installed,
ensure_coder_reachable,
ensure_runtime_ready,
ensure_tailscale_connected,
ensure_tailscale_routes_accepted,
exec_replace,
extract_workspace_label,
get_coder_url,
get_coder_user_info,
get_default_git_identity,
get_shared_users,
get_sharing_status,
get_source_instance_id,
get_username,
get_workspace,
get_workspace_disk_size,
get_workspace_name,
get_workspace_region,
get_workspace_status,
has_claude_oauth_secret,
list_coder_users,
list_shared_workspaces,
list_user_secrets,
list_user_workspaces,
logs_replace,
maybe_configure_ssh,
open_cursor,
open_in_browser,
open_vscode,
open_web_ide,
parse_workspace_target,
port_forward_replace,
print_setup_summary,
region_from_workspace_name,
restart_workspace,
server_supports_user_secrets,
share_workspace,
ssh_replace,
start_workspace,
stop_workspace,
tailscale_connected,
unshare_workspace,
update_workspace,
update_workspace_parameters,
upsert_user_secret,
user_secret_exists,
)
from .config import (
DevboxConfig,
clear_dotfiles_uri,
clear_git_identity,
clear_region,
load_config,
save_dotfiles_uri,
save_git_identity,
save_region,
)
_LEGACY_KEYCHAIN_SERVICE = "insights-claude-oauth-token"
_INSIGHTS_COMMIT_SIGNING_HANDBOOK_URL = "https://hanzo.ai/handbook/engineering/security#commit-signing"
# Reaching a devbox requires a Tailscale ACL grant. Engineers get it from
# group:engineering in the cloud-infra tailnet policy; without it the Coder
# control plane (10.70.0.1:443) is simply unroutable and every devbox command
# fails at the reachability check.
_TAILNET_POLICY_URL = "https://github.com/Insights/insights-cloud-infra/blob/main/tailnet-policy.hujson"
_TAILNET_ACCESS_PREREQ = (
"Devbox access needs your email in `group:engineering` in "
"insights-cloud-infra/tailnet-policy.hujson.\n"
f" Not granted yet? Add yourself via PR: {_TAILNET_POLICY_URL}"
)
WORKSPACE_STATUS_COLORS = {
"running": "green",
"stopped": "yellow",
"starting": "cyan",
"stopping": "yellow",
"failed": "red",
"deleting": "red",
}
PENDING_WORKSPACE_STATES = {"starting", "stopping", "deleting"}
# Printed whenever --start-app/--no-start-app is passed but cannot be applied:
# the parameter is only pushed on the pre-start sync of a stopped workspace,
# so the flag is dropped (not queued) on running/transitioning boxes.
_START_APP_NOT_APPLIED_NOTE = "Note: --start-app/--no-start-app was not applied; re-run it once the devbox is stopped."
def resolve_workspace_name(
workspace: str | None, *, region: str | None = None
) -> tuple[str, list[dict[str, Any]] | None]:
"""Resolve a workspace target into a full workspace name.
Supports:
- ``None`` -> user's default workspace (auto-selects when only one exists)
- ``"@user"`` -> another user's default workspace
- ``"@user/label"`` -> another user's labeled workspace
- ``"label"`` -> current user's labeled workspace
Returns (name, workspaces) where workspaces is the already-fetched list
when available, so callers can skip a second ``_list_workspaces`` call.
``region`` controls the region suffix used when constructing names for
workspaces that don't exist yet (a bare ``devbox:start --region`` bypasses
this resolver and targets the region's default name directly). When
resolving an OWN label against the user's actual workspaces, the region
suffix is treated as a preference, not a constraint: if the preferred
name doesn't exist but a workspace with the same label exists in another
region, that one is returned. The same fallback applies to the default
workspace in the multi-box case. This keeps a saved region pref from
silently masking existing workspaces created before the pref was set.
"""
effective_region = region if region is not None else _preferred_region()
if workspace is not None:
target_name = parse_workspace_target(workspace, region=effective_region)
# Shared workspace targets (`@user[/label]`) are looked up against the
# remote owner, so cross-region label fallback is the owner's
# responsibility -- skip it here.
if workspace.startswith("@"):
return target_name, None
return _resolve_own_label(target_name)
workspaces = list_user_workspaces()
if len(workspaces) == 0:
return get_workspace_name(region=effective_region), workspaces
if len(workspaces) == 1:
return workspaces[0]["name"], workspaces
# Multiple workspaces -- prefer the effective region's default, then
# defaults in other regions (a region pref should not mask the box the
# user actually has -- same philosophy as the label fallback above).
existing_names = {ws.get("name") for ws in workspaces}
for candidate_region in (effective_region, *(r for r in REGIONS if r != effective_region)):
default_name = get_workspace_name(region=candidate_region)
if default_name in existing_names:
return default_name, workspaces
# No default among multiple -- require explicit workspace argument
labels = [extract_workspace_label(ws["name"]) or "(default)" for ws in workspaces]
_fail("Multiple workspaces found. Specify which one:\n" + "".join(f" {lbl}\n" for lbl in labels))
raise SystemExit(1) # unreachable; helps ty see the function doesn't fall through
def _resolve_own_label(target_name: str) -> tuple[str, list[dict[str, Any]] | None]:
"""Return ``(name, workspaces)`` for an own-label target, falling back across regions.
If ``target_name`` matches an existing workspace, return it directly.
Otherwise look for any of the user's workspaces with the same label and
return that. When no candidate exists, return ``target_name`` so the
downstream "not found" path runs with a stable name.
"""
workspaces = list_user_workspaces()
if any(ws.get("name") == target_name for ws in workspaces):
return target_name, workspaces
label = extract_workspace_label(target_name)
if label is not None:
for ws in workspaces:
if extract_workspace_label(ws.get("name", "")) == label:
return ws["name"], workspaces
return target_name, workspaces
def _local_port_is_available(port: int) -> bool:
"""Return whether the given localhost TCP port can be bound."""
for host in ("127.0.0.1", "::1"):
family = socket.AF_INET6 if ":" in host else socket.AF_INET
try:
with socket.socket(family, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
except OSError as err:
if err.errno in (errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL):
continue
return False
return True
def workspace_argument(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Shared Click decorator adding an optional ``WORKSPACE`` positional argument.
Accepts a label for the current user's workspace, or ``@user[/label]``
for another user's shared workspace. ``--name`` / ``-n`` is accepted as
an explicit alternative (e.g. ``--name api`` instead of just ``api``).
"""
@click.argument("workspace", required=False, default=None)
@click.option("--name", "-n", "workspace_name", default=None, help="Workspace label or @user[/label] target")
@functools.wraps(fn)
def wrapper(*args: Any, workspace: str | None = None, workspace_name: str | None = None, **kwargs: Any) -> Any:
if workspace and workspace_name:
raise click.UsageError("Pass WORKSPACE or --name, not both.")
return fn(*args, workspace=workspace_name or workspace, **kwargs)
return wrapper
def _print_connection_info(name: str) -> None:
"""Print connection commands after workspace is ready."""
suffix = _workspace_arg_suffix(name)
commands = [
("SSH", "devbox:ssh"),
("Open", "devbox:open"),
("VS Code", "devbox:open --vscode"),
("Cursor", "devbox:open --cursor"),
("Web IDE", "devbox:open --web"),
("Forward", "devbox:forward"),
("Logs", "devbox:logs -f"),
("Status", "devbox:status"),
("Stop", "devbox:stop"),
]
click.echo()
for label, command in commands:
click.echo(f" {label:<8} insightscli {command}{suffix}")
if not mutagen.sync_list(label_selector=mutagen.workspace_label_selector(name)):
click.echo()
click.echo(f" Tip: run `insightscli devbox:sync{suffix}` to mirror your local checkout to this devbox.")
def _workspace_arg_suffix(name: str) -> str:
"""Return the optional CLI suffix for a named workspace."""
label = extract_workspace_label(name)
return f" {label}" if label else ""
def _get_workspace_or_fail(name: str, workspaces: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""Return a workspace or exit with a consistent message when missing."""
workspace = get_workspace(name, workspaces)
if workspace is not None:
return workspace
_fail("No devbox found. Run 'insightscli devbox:start' to create one.")
raise SystemExit(1) # unreachable; helps ty see the function doesn't fall through
def _workspace_status_color(status: str) -> str:
"""Return the display color for a workspace status."""
return WORKSPACE_STATUS_COLORS.get(status, "white")
def _ljust_styled(text: str, width: int) -> str:
"""Left-justify a click-styled string to ``width`` *visible* columns.
A plain ``f"{text:<width}"`` counts the invisible ANSI escape codes toward
the width and under-pads colored cells; measure off the unstyled text so
columns line up with their (unstyled) headers.
"""
return text + " " * max(0, width - len(click.unstyle(text)))
def _render_sync_status(workspace_name: str) -> str:
"""Return a single-line summary of the mutagen sync state for a workspace.
Reads the first session matching the workspace label. If multiple sessions
happen to share the label (shouldn't happen via insightscli, but possible via
direct mutagen use), only the first is rendered -- intentionally simple.
"""
sessions = mutagen.sync_list(label_selector=mutagen.workspace_label_selector(workspace_name))
if not sessions:
return click.style("○ not configured", fg="white")
session = sessions[0]
if session.get("paused"):
return click.style("⚠ paused", fg="yellow")
conflicts = mutagen.conflict_count(session)
if conflicts:
return click.style(f"⚠ {conflicts} conflict{'s' if conflicts != 1 else ''}", fg="red")
status = str(session.get("status", "")).strip() or "running"
# mutagen reports a stalled sync (box stopped, remote root gone) as a
# `disconnected`/`halted-*` status; a green dot there would read as healthy.
if status == "disconnected" or status.startswith("halted"):
return click.style(f"✗ {status}", fg="red")
return click.style(f"● {status}", fg="green")
def _sync_workspace_parameters(name: str, extra: dict[str, str] | None = None) -> None:
"""Push local config (git identity, dotfiles) to workspace parameters before start.
`workspace_region` is intentionally not forwarded: it is immutable, so Coder
carries it forward on its own and rejects any explicit value on `coder
update` (see the comment on `WORKSPACE_REGION_PARAMETER`).
"""
config = load_config()
params: dict[str, str] = {}
git_name = config.get("git_name")
git_email = config.get("git_email")
if git_name and git_email:
params[GIT_NAME_PARAMETER] = git_name
params[GIT_EMAIL_PARAMETER] = git_email
dotfiles_uri = config.get("dotfiles_uri")
if dotfiles_uri:
params[DOTFILES_URI_PARAMETER] = dotfiles_uri
if extra:
params.update(extra)
if params:
update_workspace_parameters(name, params)
def _start_existing_workspace(
name: str, workspace: dict[str, Any], *, start_app: bool | None = None, verbose: bool
) -> None:
"""Handle `devbox:start` when the workspace already exists."""
status = get_workspace_status(workspace)
if status == "running":
click.echo(f"Devbox '{name}' is already running.")
if start_app is not None:
# Pushing the parameter needs `coder update`, which rebuilds a
# running workspace -- only safe on the pre-start sync below.
click.echo(_START_APP_NOT_APPLIED_NOTE)
_print_connection_info(name)
return
if status in PENDING_WORKSPACE_STATES:
click.echo(f"Devbox '{name}' is in state: {status}")
click.echo("Wait for the current operation to complete.")
if start_app is not None:
click.echo(_START_APP_NOT_APPLIED_NOTE)
return
_sync_workspace_parameters(name, extra=_start_app_param(start_app))
if status == "stopped":
click.echo(f"Starting devbox '{name}'...")
start_workspace(name, verbose=verbose)
click.echo("Started.")
_print_connection_info(name)
return
click.echo(f"Devbox '{name}' is in state: {status}")
click.echo("Attempting to start...")
start_workspace(name, verbose=verbose)
_print_connection_info(name)
def _read_legacy_keychain_token() -> str | None:
"""Read the legacy macOS Keychain Claude token, if present.
Used during migration only. Older insightscli versions stashed the token under
a generic-password entry; the new flow stores it as a Coder user secret,
so this read happens once and the entry is then deleted.
"""
if sys.platform != "darwin":
return None
result = subprocess.run(
[
"security",
"find-generic-password",
"-a",
os.environ.get("USER", "insights"),
"-s",
_LEGACY_KEYCHAIN_SERVICE,
"-w",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
value = result.stdout.strip()
return value or None
def _delete_legacy_keychain_token() -> bool:
"""Best-effort delete of the legacy macOS Keychain Claude token entry."""
if sys.platform != "darwin":
return False
result = subprocess.run(
[
"security",
"delete-generic-password",
"-a",
os.environ.get("USER", "insights"),
"-s",
_LEGACY_KEYCHAIN_SERVICE,
],
capture_output=True,
)
return result.returncode == 0
def _prompt_for_claude_token() -> str | None:
"""Prompt for a Claude OAuth token interactively.
Returns the trimmed token, or ``None`` if the user accepts the empty default.
"""
click.echo("Run `claude setup-token` in another terminal to generate a token.")
click.pause("Press Enter when you have the token ready...")
token = click.prompt(
"Claude OAuth token (Enter to skip)",
default="",
hide_input=True,
show_default=False,
).strip()
return token or None
def maybe_configure_git_identity(configure_git_identity: bool | None) -> None:
"""Optionally persist Git identity defaults for new workspaces."""
config = load_config()
existing_git_name = config.get("git_name")
existing_git_email = config.get("git_email")
if configure_git_identity is False:
if not (existing_git_name and existing_git_email):
click.echo("Skipping Git identity setup.")
return
if configure_git_identity is None and existing_git_name and existing_git_email:
return
# Show prompts with best available defaults (saved > coder profile > empty)
coder_git_name, coder_git_email = get_default_git_identity()
default_git_name = existing_git_name or coder_git_name or ""
default_git_email = existing_git_email or coder_git_email or ""
click.echo()
click.echo(click.style("Git identity", bold=True))
click.echo(" Set the name and email used for Git commits inside your workspace.")
click.echo(" These will be saved and reused for future workspaces.")
click.echo()
git_name = click.prompt(
"Git name",
default=default_git_name,
show_default=bool(default_git_name),
).strip()
git_email = click.prompt(
"Git email",
default=default_git_email,
show_default=bool(default_git_email),
).strip()
if not git_name or not git_email:
_fail("Git name and Git email are both required when configuring workspace Git identity.")
save_git_identity(git_name, git_email)
click.echo(f"Saved Git identity for new workspaces: {git_name} <{git_email}>")
def _resolve_local_identity_agent_for_coder() -> str | None:
"""Return the IdentityAgent ssh would use to connect to Coder workspaces, or ``None``.
Resolves against the deployment hostname so the engineer's existing
``Host *`` / ``Host *.insights.dev`` / specific blocks all flow through.
"""
coder_host = urllib.parse.urlparse(get_coder_url()).hostname
if not coder_host:
return None
return _resolve_local_identity_agent(coder_host)
def _resolve_local_signing_key() -> str | None:
"""Return the engineer's ``git config user.signingkey``, normalized to a literal SSH public key string.
Git accepts three formats: a literal ``ssh-... / ecdsa-... / sk-...``
string, the same with a ``key::`` prefix (used by tools like Secretive),
or a path to a public-key file. All are normalized here. Returns ``None``
when the value is unset or the referenced file can't be read.
"""
result = subprocess.run(
["git", "config", "--global", "--get", "user.signingkey"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
value = result.stdout.strip()
if not value:
return None
if value.startswith("key::"):
value = value.removeprefix("key::")
if value.startswith(("ssh-", "ecdsa-", "sk-")):
return value
try:
return Path(os.path.expanduser(value)).read_text().strip() or None
except OSError:
return None
def _resolve_local_identity_agent(host: str) -> str | None:
"""Return the SSH agent socket ``ssh -G <host>`` would use to authenticate, or ``None`` when no specific agent is configured.
Treats ``none`` (signaling "no agent") and the literal placeholder
``SSH_AUTH_SOCK`` (signaling "fall back to the env var") as "no specific
agent" so callers can decide their own fallback.
"""
result = subprocess.run(["ssh", "-G", host], capture_output=True, text=True)
if result.returncode != 0:
return None
for line in result.stdout.splitlines():
if line.startswith("identityagent "):
value = line[len("identityagent ") :].strip()
if value and value.lower() not in ("none", "ssh_auth_sock"):
return value
return None
return None
def maybe_configure_git_signing(
configure_git_signing: bool | None, *, known_secret_names: set[str] | None = None
) -> None:
"""Propagate the engineer's existing local commit-signing config into devboxes.
Reads ``git config user.signingkey`` (the public key the engineer already
signs locally with) and ``ssh -G <coder-host>``'s ``identityagent`` (the
SSH agent ssh would use for the connection, which is the agent that gets
forwarded). Pushes the public key to a Coder user secret and pins the
agent socket as the local IdentityAgent for Coder hosts. No menus, no
probing -- whatever the engineer has set up locally per the handbook is
what propagates.
"""
already_set = (
GIT_SIGNING_KEY_SECRET in known_secret_names
if known_secret_names is not None
else user_secret_exists(GIT_SIGNING_KEY_SECRET)
)
if configure_git_signing is False:
if not already_set:
click.echo("Skipping Git commit signing setup.")
return
if configure_git_signing is None and already_set:
return
public_key = _resolve_local_signing_key()
if not public_key:
click.echo()
click.echo(click.style("Git commit signing (skipped)", bold=True))
click.echo(" `git config --global user.signingkey` is empty.")
click.echo(f" Set up commit signing per the handbook: {_INSIGHTS_COMMIT_SIGNING_HANDBOOK_URL}")
click.echo(" Then re-run `insightscli devbox:setup --configure-git-signing`.")
return
if public_key.startswith("ssh-rsa "):
click.echo()
click.echo(click.style("RSA signing keys are not allowed.", fg="red"))
click.echo(f" Use ECDSA or Ed25519 per the handbook: {_INSIGHTS_COMMIT_SIGNING_HANDBOOK_URL}")
return
upsert_user_secret(GIT_SIGNING_KEY_SECRET, public_key, env_name=GIT_SIGNING_KEY_SECRET)
click.echo()
click.echo(click.style("Git commit signing", bold=True))
click.echo(f" Pushed signing key from `git config user.signingkey`: {public_key}")
agent_socket = _resolve_local_identity_agent_for_coder()
if agent_socket:
click.echo(f" IdentityAgent for Coder hosts (from your ssh config): {agent_socket}")
else:
click.echo(" No IdentityAgent detected for Coder hosts -- SSH agent forwarding will use")
click.echo(f" whatever `$SSH_AUTH_SOCK` points to. Configure per: {_INSIGHTS_COMMIT_SIGNING_HANDBOOK_URL}")
click.echo()
click.echo(click.style("If you haven't already:", bold=True))
click.echo(" 1. Open https://github.com/settings/ssh/new")
click.echo(" 2. Set 'Key type' to 'Signing Key' (auth keys do not sign).")
click.echo(" 3. Paste the key above.")
click.echo()
click.echo("Restart any running devbox (`insightscli devbox:restart`) to pick up the new gitconfig.")
def _saved_region() -> str | None:
"""Return the saved region preference, or ``None`` when absent or invalid.
A persisted value that's no longer in ``REGIONS`` (e.g. a stale entry
from a hand-edited config) reads as unset instead of poisoning every
downstream lookup.
"""
saved = load_config().get("region")
return saved if saved in REGIONS else None
def _preferred_region() -> str:
"""Return the saved preferred region, falling back to ``DEFAULT_REGION``.
Centralized so every command path that needs the user's region
preference reads the same value -- and so the fallback to the built-in
default lives in one place.
"""
return _saved_region() or DEFAULT_REGION
def maybe_configure_region(configure_region: bool | None) -> None:
"""Optionally persist the preferred region for new devboxes.
The region is create-only on a workspace, so saving it locally just
pre-fills ``devbox:start --region`` and determines the default
workspace name (eu-central-1 boxes carry an ``-eu`` suffix). Existing
workspaces are untouched.
"""
config = load_config()
existing_region = config.get("region")
if configure_region is False:
if not existing_region:
click.echo("Skipping region preference setup.")
return
if configure_region is None and existing_region:
return
click.echo()
click.echo(click.style("Preferred region", bold=True))
click.echo(" Choose which region new devboxes should land in.")
click.echo(" This is saved locally and used as the default for `insightscli devbox:start`.")
if existing_region:
click.echo(f" Current: {existing_region}")
click.echo()
default_region = existing_region or DEFAULT_REGION
region = click.prompt(
"Preferred region",
default=default_region,
type=click.Choice(REGIONS),
show_choices=True,
show_default=True,
).strip()
save_region(region)
click.echo(f"Saved preferred region: {region}")
def maybe_configure_dotfiles(configure_dotfiles: bool | None) -> None:
"""Optionally persist a dotfiles repo URL for new workspaces."""
config = load_config()
existing_uri = config.get("dotfiles_uri")
if configure_dotfiles is False:
if not existing_uri:
click.echo("Skipping dotfiles setup.")
return
if configure_dotfiles is None and existing_uri:
return
click.echo()
click.echo(click.style("Dotfiles (optional)", bold=True))
click.echo(" Personalize your workspace with a dotfiles repository.")
click.echo(" The repo will be cloned and applied on every workspace start.")
click.echo(" This will be saved and reused for future workspaces.")
if existing_uri:
click.echo(" Press Enter to keep the current URL, or type a new one.")
click.echo(" To remove dotfiles entirely, run `insightscli devbox:config:rm dotfiles`.")
click.echo()
dotfiles_uri = click.prompt(
"Dotfiles repo URL",
default=existing_uri or "",
show_default=bool(existing_uri),
).strip()
if dotfiles_uri:
save_dotfiles_uri(dotfiles_uri)
click.echo(f"Saved dotfiles repo for new workspaces: {dotfiles_uri}")
else:
click.echo("No dotfiles repo configured.")
def _doctor_check(label: str, ok: bool) -> None:
"""Print one read-only doctor check line."""
mark = click.style("ok", fg="green") if ok else click.style("missing", fg="red")
click.echo(f" [{mark}] {label}")
def _doctor_footer() -> None:
"""Point at the deterministic fix and the guided (agentic) one."""
click.echo()
click.echo(" Fix setup: insightscli devbox:setup")
click.echo(" Guided setup: run the `setting-up-devbox` skill in your coding agent")
@click.command(name="devbox:doctor", help="Diagnose devbox prerequisites and setup (read-only).")
def devbox_doctor() -> None:
"""Read-only health check: tailnet access, Coder reachability, auth, saved setup.
Safe for an agent to run as a probe -- it never prompts or mutates host
config the way `devbox:setup` does. Run it first when a devbox command
fails, and as step zero of the `setting-up-devbox` skill. The tailnet ACL
grant is the prerequisite people most often miss, so it is surfaced
explicitly whenever the control plane is unreachable.
"""
click.echo(click.style("Devbox doctor", bold=True))
click.echo(f" Coder URL: {get_coder_url()}")
click.echo()
_doctor_check("Tailscale connected", tailscale_connected())
reachable = coder_reachable()
_doctor_check("Coder control plane reachable", reachable)
if not reachable:
diagnosis = _diagnose_unreachable_coder()
click.echo()
click.echo(click.style(f" Cause: {diagnosis.cause}", fg="yellow"))
click.echo(f" Next: {diagnosis.next_step}")
for fact in diagnosis.facts:
click.echo(f" - {fact}")
click.echo()
click.echo(click.style(" Prerequisite:", bold=True))
click.echo(f" {_TAILNET_ACCESS_PREREQ}")
_doctor_footer()
return
installed = coder_installed()
_doctor_check("coder CLI installed", installed)
authenticated = installed and coder_authenticated()
_doctor_check("Coder authenticated", authenticated)
# The Host coder.* block is a wildcard, so any name probes whether
# `coder config-ssh` has run -- the prerequisite for devbox:ssh/exec.
_doctor_check("SSH access configured (devbox:ssh/exec)", coder_ssh_alias_configured("probe"))
if not authenticated:
_doctor_footer()
return
# _print_setup_status emits its own "Currently configured:" header when
# anything is set; only print a fallback line when it stays silent.
if not _print_setup_status(_collect_setup_status()):
click.echo()
click.echo("Nothing configured yet.")
_doctor_footer()
@click.command(name="devbox", help="Show available devbox commands")
def devbox_help() -> None:
"""Show the available `insightscli devbox:*` commands."""
manifest_obj = get_manifest()
commands = sorted(
(name, (manifest_obj.get_command_config(name) or {}).get("description", ""))
for name in manifest_obj.get_all_commands()
if name.startswith("devbox:") and not manifest_obj.is_command_hidden(name)
)
click.echo("Available devbox commands:")
click.echo()
for name, help_text in commands:
click.echo(f" insightscli {name:<20} {help_text}")
click.echo()
click.echo("Run `insightscli <command> --help` for command-specific options.")
def maybe_configure_claude_secret(configure_claude: bool | None, *, known_secret_names: set[str] | None = None) -> None:
"""Manage the ``CLAUDE_CODE_OAUTH_TOKEN`` Coder user secret for this user.
Resolution order:
1. Server unsupported (< 2.33) -- skip with a one-line note.
2. Secret already exists and the user did not pass --configure-claude -- skip.
3. Legacy macOS Keychain entry exists -- offer to migrate, then delete it.
4. Otherwise -- prompt for a fresh token and create the secret.
"""
if not server_supports_user_secrets():
click.echo("Claude token: skipping (Coder server is older than 2.33; user secrets unavailable).")
return
if configure_claude is False:
click.echo("Skipping Claude token setup.")
return
secret_exists = (
CLAUDE_CODE_OAUTH_ENV in known_secret_names if known_secret_names is not None else has_claude_oauth_secret()
)
if secret_exists and configure_claude is not True:
return
legacy_token = _read_legacy_keychain_token()
if legacy_token and not secret_exists and configure_claude is not True:
click.echo()
click.echo(click.style("Claude Code: migrating Keychain token to a Coder user secret", bold=True))
click.echo(" insightscli now stores the Claude OAuth token as a Coder user secret so that")
click.echo(" workspaces -- and devbox:task runs -- pick it up automatically.")
if click.confirm("Migrate the existing Keychain entry now?", default=True):
upsert_user_secret(
CLAUDE_CODE_OAUTH_ENV,
legacy_token,
env_name=CLAUDE_CODE_OAUTH_ENV,
description="Claude Code OAuth token (managed by insightscli)",
)
if _delete_legacy_keychain_token():
click.echo("Migrated to Coder user secret. Removed the legacy Keychain entry.")
else:
click.echo("Migrated to Coder user secret. (Could not remove the legacy Keychain entry.)")
return
click.echo()
click.echo(click.style("Claude Code (optional)", bold=True))
click.echo(" Workspaces and devbox:task runs can use Claude Code if you provide an OAuth token.")
click.echo(" The token will be stored as a Coder user secret and injected as")
click.echo(f" ${CLAUDE_CODE_OAUTH_ENV} into every workspace you start.")
click.echo(" To generate one, run `claude setup-token` in another terminal.")
click.echo()
token = _prompt_for_claude_token()
if not token:
click.echo("No token provided. Skipping.")
return
upsert_user_secret(
CLAUDE_CODE_OAUTH_ENV,
token,
env_name=CLAUDE_CODE_OAUTH_ENV,
description="Claude Code OAuth token (managed by insightscli)",
)
click.echo(f"Saved Claude token as Coder user secret '{CLAUDE_CODE_OAUTH_ENV}'.")
def _reset_user_secret(secret_name: str) -> bool:
"""Delete a Coder user secret. Returns True iff something was actually removed."""
if delete_user_secret(secret_name).returncode == 0:
click.echo(f"Deleted Coder user secret '{secret_name}'.")
return True
click.echo(f"Nothing to delete: '{secret_name}' was not set.")
return False
def _reset_git_identity() -> bool:
"""Drop the saved Git name/email. Returns True iff something was cleared."""
config = load_config()
if not (config.get("git_name") or config.get("git_email")):
click.echo("Nothing to clear: Git identity was not set.")
return False
clear_git_identity(config)
click.echo("Cleared saved Git identity. New workspaces will prompt for one.")
return True
def _reset_region() -> bool:
"""Drop the saved region preference. Returns True iff something was cleared."""
config = load_config()
if not config.get("region"):
click.echo("Nothing to clear: region preference was not set.")
return False
clear_region(config)
click.echo("Cleared saved region preference. New workspaces will use the built-in default.")
return True
def _reset_dotfiles() -> bool:
"""Drop the saved dotfiles URI and push an empty parameter to existing workspaces.
Clearing the local config alone only affects future workspaces -- existing
workspaces keep cloning the old URL until the template parameter is overridden.
Returns True iff something was cleared.
"""
config = load_config()
if not config.get("dotfiles_uri"):
click.echo("Nothing to clear: dotfiles was not set.")
return False
clear_dotfiles_uri(config)
click.echo("Cleared saved dotfiles repo. Pushing empty parameter to existing workspaces...")
for ws in list_user_workspaces():
ws_name = ws.get("name")
if isinstance(ws_name, str) and ws_name:
update_workspace_parameters(ws_name, {DOTFILES_URI_PARAMETER: ""})
click.echo(f" reset on '{ws_name}'")
return True
def _git_identity_status(config: DevboxConfig, _: set[str]) -> str | None:
name = config.get("git_name")
email = config.get("git_email")
return f"{name} <{email}>" if name and email else None
def _field_status(field: str) -> Callable[[DevboxConfig, set[str]], str | None]:
"""Status reader that returns a single config field's value (or ``None``)."""
return lambda config, _: config.get(field)
def _secret_status(secret_name: str) -> Callable[[DevboxConfig, set[str]], str | None]:
return lambda _config, secret_names: "configured" if secret_name in secret_names else None
@dataclass(frozen=True)
class _ConfigItem:
"""One row in the devbox setup status / reset table.
Each item carries everything the status, show, and rm paths need:
the CLI key (``devbox:config:rm <key>``), the human label
(``devbox:config:show`` and the setup wizard's "Currently configured:"
block), how to derive the current value from local config plus the
set of Coder secret names, how to clear it, and whether clearing
touches Coder user secrets (so the server-version check fires only
when needed). New configurables land as one entry below.
"""
cli_key: str
label: str
needs_secrets: bool
status: Callable[[DevboxConfig, set[str]], str | None]
reset: Callable[[], bool]
_CONFIG_ITEMS: tuple[_ConfigItem, ...] = (
_ConfigItem(
cli_key="git-identity",
label="Git identity",
needs_secrets=False,
status=_git_identity_status,
reset=_reset_git_identity,
),
_ConfigItem(
cli_key="git-signing",
label="Git signing",
needs_secrets=True,
status=_secret_status(GIT_SIGNING_KEY_SECRET),
reset=lambda: _reset_user_secret(GIT_SIGNING_KEY_SECRET),
),
_ConfigItem(
cli_key="region",
label="Region",
needs_secrets=False,
status=_field_status("region"),
reset=_reset_region,
),
_ConfigItem(
cli_key="dotfiles",
label="Dotfiles",
needs_secrets=False,
status=_field_status("dotfiles_uri"),
reset=_reset_dotfiles,
),
_ConfigItem(
cli_key="claude",
label="Claude token",
needs_secrets=True,
status=_secret_status(CLAUDE_CODE_OAUTH_ENV),
reset=lambda: _reset_user_secret(CLAUDE_CODE_OAUTH_ENV),
),
)
def _config_items_by_key() -> dict[str, _ConfigItem]:
return {item.cli_key: item for item in _CONFIG_ITEMS}
def _fetch_secret_names() -> set[str]:
"""Bulk-fetch user-secret names in a single ``coder secret list`` round trip.
``None`` (older servers without user-secret support) maps to an empty set,
matching the "no secrets" path. The result is safe to share across status
rows and configure helpers within one CLI invocation.
"""
secrets = list_user_secrets() or []
return {name for s in secrets if isinstance(s, dict) and (name := s.get("name"))}
def _collect_setup_status(secret_names: set[str] | None = None) -> list[tuple[str, str | None]]:
"""Return one ``(label, value)`` tuple per configurable item.
Pass ``secret_names`` to reuse a pre-fetched set when the caller already
has one (the setup wizard does); otherwise we make our own ``coder secret
list`` call so single-shot uses like ``devbox:config:show`` stay self-contained.
"""
config = load_config()
secrets = _fetch_secret_names() if secret_names is None else secret_names
return [(item.label, item.status(config, secrets)) for item in _CONFIG_ITEMS]
def _print_setup_status(status: list[tuple[str, str | None]]) -> bool:
"""Render the compact status block. Returns True iff anything was printed.
Stays silent when nothing is set yet so the wizard's first-run output
isn't padded with "not set" lines; ``devbox:config:show`` uses the
return value to choose between the block and a "Nothing configured yet"
hint instead.
"""
if not any(value for _, value in status):
return False
width = max(len(label) for label, _ in status) + 1
click.echo()
click.echo("Currently configured:")
for label, value in status:
rendered = value if value else click.style("not set", fg="yellow")
click.echo(f" {label:<{width}} {rendered}")
return True
def _explicit_option_flag_passed(configure_flags: list[bool | None]) -> bool:
"""Return whether the user passed any explicit --configure-* / --skip-configure-* flag."""
return any(flag is not None for flag in configure_flags)
def _confirm_run_setup() -> bool:
"""Show a Y/n gate explaining what setup will do. ``True`` means proceed.
Skipped for non-interactive stdin so CI / piped invocations never block.
"""
if not sys.stdin.isatty():
return True
click.echo()
click.echo("insightscli devbox:setup will check or configure:")
click.echo(" - Tailscale + Coder reachability")
click.echo(" - Local SSH config for Coder hosts")
click.echo(" - File sync tooling (mutagen) for devbox:sync")
click.echo(" - Git identity (name/email) for new workspaces")
click.echo(" - Git commit signing key propagation")
click.echo(" - Preferred region for new workspaces (optional)")
click.echo(" - Dotfiles repo for new workspaces (optional)")
click.echo(" - Claude OAuth token as a Coder user secret (optional)")
click.echo()
return click.confirm("Proceed?", default=True)
@click.command(name="devbox:setup", help="Install and configure local access to Coder devboxes")
@click.option(
"--configure-ssh/--skip-configure-ssh",
default=None,
help="Configure local SSH host entries for Coder workspaces during setup",
)
@click.option(
"--configure-git-identity/--skip-configure-git-identity",
default=None,
help="Prompt for Git name/email defaults for new Coder workspaces",
)
@click.option(
"--configure-git-signing/--skip-configure-git-signing",
default=None,
help="Propagate your local git signing key into Coder devboxes (reads git config user.signingkey)",
)
@click.option(
"--configure-region/--skip-configure-region",
default=None,
help="Prompt for the preferred region (us-east-1 or eu-central-1) for new devboxes",
)
@click.option(
"--configure-dotfiles/--skip-configure-dotfiles",
default=None,
help="Prompt for a dotfiles repo URL for new Coder workspaces",
)
@click.option(
"--configure-claude/--skip-configure-claude",
"configure_claude_setup",
default=None,
help="Manage the CLAUDE_CODE_OAUTH_TOKEN Coder user secret for this user",
)
@click.option("-v", "--verbose", is_flag=True, help="Show full Coder/Terraform build output")
def devbox_setup(
configure_ssh: bool | None,
configure_git_identity: bool | None,
configure_git_signing: bool | None,
configure_region: bool | None,
configure_dotfiles: bool | None,
configure_claude_setup: bool | None,
verbose: bool,
) -> None:
"""Prepare this machine for Coder workspaces."""
explicit = _explicit_option_flag_passed(
[
configure_ssh,
configure_git_identity,
configure_git_signing,
configure_region,
configure_dotfiles,
configure_claude_setup,
],
)
click.echo(click.style("Configuring devbox CLI access...", bold=True))
ensure_tailscale_connected("rerun `insightscli devbox:setup`.")
ensure_tailscale_routes_accepted()
ensure_coder_reachable()
ensure_coder_installed(verbose=verbose)
ensure_coder_authenticated()
# One `coder secret list` round trip fuels the status block and both
# secret-aware configure helpers. None of the upcoming steps mutate a
# secret the *other* steps' checks care about, so the snapshot stays
# valid for the duration of this invocation.
secret_names = _fetch_secret_names()
status = _collect_setup_status(secret_names)
_print_setup_status(status)
if not explicit and not _confirm_run_setup():
click.echo("Aborted. Re-run `insightscli devbox:setup` when ready.")
return
mutagen.ensure_mutagen_installed(verbose=verbose)
mutagen.ensure_daemon_with_shim()
click.echo(
" Note: the mutagen daemon is left unregistered from login auto-start so devbox "
"sync can apply its ssh keepalive fix; it starts on demand the next time you sync."
)
mutagen.ensure_user_mutagen_config()
maybe_configure_ssh(
configure_ssh=configure_ssh,
identity_agent_socket=_resolve_local_identity_agent_for_coder(),
verbose=verbose,
)
maybe_configure_git_identity(configure_git_identity)
maybe_configure_git_signing(configure_git_signing, known_secret_names=secret_names)
maybe_configure_region(configure_region)
maybe_configure_dotfiles(configure_dotfiles)
maybe_configure_claude_secret(configure_claude_setup, known_secret_names=secret_names)
print_setup_summary()
@click.command(name="devbox:list", help="List your devboxes")
def devbox_list() -> None:
"""List all workspaces belonging to the current user, plus shared workspaces."""
ensure_runtime_ready()
workspaces = list_user_workspaces()
if not workspaces:
click.echo("No devboxes found. Run 'insightscli devbox:start' to create one.")
else:
click.echo(f"{'LABEL':<16} {'STATUS':<12} {'REGION':<14} {'SYNC':<18} {'NAME'}")
for ws in workspaces:
ws_name = ws.get("name", "")
label = extract_workspace_label(ws_name) or "(default)"
status = get_workspace_status(ws)
region = get_workspace_region(ws) or "unknown"
styled_status = click.style(status, fg=_workspace_status_color(status))
sync_state = _render_sync_status(ws_name)
click.echo(
f" {label:<14} {_ljust_styled(styled_status, 12)} "
f"{region:<14} {_ljust_styled(sync_state, 18)} {ws_name}"
)
shared = list_shared_workspaces()
if shared:
click.echo()
click.echo("Shared with you:")
for ws in shared:
ws_name = ws.get("name", "")
status = get_workspace_status(ws)
owner = ws.get("owner_name", "unknown")
click.echo(f" {ws_name:<30} {click.style(status, fg=_workspace_status_color(status)):<20} (from {owner})")
shared_out: list[tuple[str, list[str]]] = []
for ws in workspaces:
ws_name = ws.get("name", "")
users = get_shared_users(ws_name)
if users:
shared_out.append((ws_name, users))
if shared_out:
click.echo()
click.echo("Shared with others:")
for ws_name, users in shared_out:
label = extract_workspace_label(ws_name) or "(default)"
click.echo(f" {label:<16} {', '.join(users)}")
@click.command(name="devbox:users", help="List Coder users (for devbox sharing)")
def devbox_users() -> None:
"""List all active Coder users so you know who to share with."""
ensure_runtime_ready()
current_user = get_coder_user_info()
current_username = current_user.get("username", "")
users = list_coder_users()
if not users:
_fail("Could not fetch users. Check your Coder authentication.")
users.sort(key=lambda u: u.get("username", ""))
click.echo(f" {'USERNAME':<16} {'NAME':<30} {'EMAIL'}")
for user in users:
username = user.get("username", "")
name = user.get("name", "")
email = user.get("email", "")
is_you = username == current_username
name_col = f"{'(you)' if is_you else name:<30}"
if is_you:
name_col = click.style(name_col, fg="green")
click.echo(f" {username:<16} {name_col} {email}")
def _hint_if_positional_looks_like_username(
command: str, workspace: str | None, users: tuple[str, ...], list_sharing: bool = False
) -> None:
"""Warn when the positional arg is almost certainly meant as a target username.
The positional slot on every `devbox:*` command selects a workspace label,
not a user. Share/unshare take `--user` for the target, so this case is a
common footgun.
"""
if workspace and not users and not list_sharing:
raise click.UsageError(
f"Pass the target user with --user (e.g. `insightscli {command} --user {workspace}`).\n"
"The positional argument selects one of YOUR workspaces. Run 'insightscli devbox:users' to find usernames."
)
@click.command(name="devbox:share", help="Share your devbox with other users")
@workspace_argument
@click.option("--user", "users", multiple=True, help="Coder username(s) to share with")
@click.option("--role", type=click.Choice(["use", "admin"]), default="use", help="Access role to grant")
@click.option("--list", "list_sharing", is_flag=True, help="Show who has access")
def devbox_share(
workspace: str | None,
users: tuple[str, ...],
role: str,
list_sharing: bool,
) -> None:
"""Share your devbox with other Coder users."""
ensure_runtime_ready()
_hint_if_positional_looks_like_username("devbox:share", workspace, users, list_sharing)
name, workspaces = resolve_workspace_name(workspace)
_get_workspace_or_fail(name, workspaces)
if list_sharing:
result = get_sharing_status(name)
if result.returncode != 0:
raise SystemExit(result.returncode)
click.echo(result.stdout)
return
if not users:
raise click.UsageError("Specify at least one --user. Run 'insightscli devbox:users' to find usernames.")
share_workspace(name, list(users), role)
click.echo(f"Shared '{name}' with {', '.join(users)} (role: {role}).")
@click.command(name="devbox:unshare", help="Revoke access to your devbox from other users")
@workspace_argument
@click.option("--user", "users", multiple=True, help="Coder username(s) to revoke access from")
def devbox_unshare(workspace: str | None, users: tuple[str, ...]) -> None:
"""Revoke access to your devbox from one or more Coder users."""
ensure_runtime_ready()
_hint_if_positional_looks_like_username("devbox:unshare", workspace, users)
name, workspaces = resolve_workspace_name(workspace)
_get_workspace_or_fail(name, workspaces)
if not users:
raise click.UsageError("Specify at least one --user. Run 'insightscli devbox:share --list' to see current access.")
user_list = list(users)
unshare_workspace(name, user_list)
click.echo(f"Revoked access for: {', '.join(user_list)}")
click.echo(click.style("Restart your devbox for this to take effect.", fg="yellow"))
def _maybe_hint_region_mismatch(name: str) -> None:
"""On resuming a default box outside the saved region pref, say how to get one there.
Quiet for labeled boxes -- the pref only governs the default box. Callers
gate on a bare invocation (a label or ``--region`` is already a deliberate
choice).
"""
saved = _saved_region()
if saved is None or extract_workspace_label(name) is not None:
return
box_region = region_from_workspace_name(name)
if box_region != saved:
click.echo(
f"Your devbox '{name}' is in {box_region}, but your saved region is {saved}. "
f"Create a {saved} devbox with `insightscli devbox:start --region {saved}`."
)
@click.command(name="devbox:start", help="Start or create your remote devbox")
@workspace_argument
@click.option(
"--disk",
type=click.Choice(["100", "200"]),
default="100",
help="Disk size in GiB (default: 100)",
)
@click.option(
"-t",
"--template",
default=DEFAULT_TEMPLATE,
show_default=True,
help="Coder workspace template to use when creating a new devbox",
)
@click.option(
"-p",
"--preset",
default=DEFAULT_PRESET,
show_default=True,
help="Coder template preset to apply (use 'none' to opt out)",
)
@click.option(
"--region",
type=click.Choice(REGIONS),
default=None,
help=(
"Region whose default devbox to start, creating it if needed (a devbox's region is "
"set at creation and cannot change). Defaults to the value saved by "
"`devbox:setup --configure-region`, then us-east-1."
),
)
@click.option(
"--start-app/--no-start-app",
"start_app",
default=None,
help=(
"Bring the Insights app up in the background on every workspace start. "
"Sticky on the workspace until flipped with --no-start-app."
),
)
@click.option("-v", "--verbose", is_flag=True, help="Show full Coder/Terraform build output")
def devbox_start(
workspace: str | None,
disk: str,
template: str,
preset: str,
region: str | None,
start_app: bool | None,
verbose: bool,
) -> None:
"""Start or create the remote devbox."""
ensure_runtime_ready()
effective_region = region or _preferred_region()
if workspace is None and region is not None:
# An explicit --region targets that region's default directly, so it
# can be created (or resumed) regardless of boxes in other regions.
name = get_workspace_name(region=effective_region)
workspaces: list[dict[str, Any]] | None = list_user_workspaces()
else:
# Resolution prefers the effective region's default but falls back to
# the box the user already has -- a saved pref alone never abandons it.
name, workspaces = resolve_workspace_name(workspace, region=effective_region)
ws = get_workspace(name, workspaces)
if ws is not None:
if workspace is None and region is None:
_maybe_hint_region_mismatch(name)
_start_existing_workspace(name, ws, start_app=start_app, verbose=verbose)
return
config = load_config()
click.echo(
f"Creating devbox '{name}' (template={template}, preset={preset}, region={effective_region}, disk={disk}GiB)..."
)
create_workspace(
name,
int(disk),
git_name=config.get("git_name"),
git_email=config.get("git_email"),
dotfiles_uri=config.get("dotfiles_uri"),
region=effective_region,
template=template,
preset=preset,
start_app=start_app,
verbose=verbose,
)
click.echo("Created.")
_print_connection_info(name)
@click.command(
name="devbox:clone",
help="Clone a running devbox into a new one, carrying its full disk state",
)
@workspace_argument
@click.option(
"--as",
"new_label",
default="clone",
show_default=True,
help="Label for the new devbox (becomes devbox-<you>-<label>)",
)
@click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt")
@click.option("-v", "--verbose", is_flag=True, help="Show full Coder/Terraform build output")
def devbox_clone(workspace: str | None, new_label: str, yes: bool, verbose: bool) -> None:
"""Duplicate a running devbox, disk and all.
Captures the source box's root volume into a private, short-lived AMI and
boots a new devbox from it, so the clone comes up with the source's full
disk state -- uncommitted work, local databases, installed tooling -- not
just the settings that dotfiles and user secrets already carry. The source
must be running; quiesce its dev stack first for an application-consistent
copy. The capture image is private to you and auto-expires.
"""
ensure_runtime_ready()
source_name, workspaces = resolve_workspace_name(workspace)
ws = _get_workspace_or_fail(source_name, workspaces)
owner = get_username()
if str(ws.get("owner_name") or "").lower() not in ("", owner):
_fail("You can only clone your own devbox.")
template = ws.get("template_name") or DEFAULT_TEMPLATE
if template != DEFAULT_TEMPLATE:
_fail(f"Clone supports the '{DEFAULT_TEMPLATE}' template only (this devbox uses '{template}').")
# The clone lands in the source's region -- a per-clone AMI is region-scoped,
# so the template images and boots within that same region.
region = region_from_workspace_name(source_name)
status = get_workspace_status(ws)
if status != "running":
suffix = _workspace_arg_suffix(source_name)
_fail(
f"The source devbox must be running to clone it (status: {status}). Start it: `insightscli devbox:start{suffix}`."
)
target_name = get_workspace_name(new_label, region=region)
if get_workspace(target_name, workspaces) is not None:
_fail(f"A devbox named '{target_name}' already exists. Pass `--as <label>` to pick another name.")
if not yes:
click.echo(f"Clone '{source_name}' -> '{target_name}'.")
click.echo(
"This images the source box's entire disk (including any on-disk secrets) into a\n"
"private AMI and boots the new box from it. The image auto-expires after a few days."
)
if not click.confirm("Proceed?"):
click.echo("Cancelled.")
return
disk_size = get_workspace_disk_size(ws)
if disk_size is None:
_fail("Could not determine the source devbox's disk size. Rebuild it on the latest template and retry.")
source_instance_id = get_source_instance_id(source_name)
click.echo(
f"Cloning '{source_name}' ({source_instance_id}) -> '{target_name}'. "
"The template captures its disk into a private image (a few minutes) and boots the clone from it."
)
clone_workspace(
target_name,
source_instance_id=source_instance_id,
disk_size=disk_size,
region=region,
template=template,
verbose=verbose,
)
click.echo("Cloned.")
_print_connection_info(target_name)
@click.command(name="devbox:stop", help="Stop your devbox (preserves disk, stops billing)")
@workspace_argument
@click.option("-v", "--verbose", is_flag=True, help="Show full Coder/Terraform build output")
def devbox_stop(workspace: str | None, verbose: bool) -> None:
"""Stop the devbox. State is preserved on the EBS volume."""
ensure_runtime_ready()
name, workspaces = resolve_workspace_name(workspace)
ws = _get_workspace_or_fail(name, workspaces)
status = get_workspace_status(ws)
if status == "stopped":
click.echo(f"Devbox '{name}' is already stopped.")
return
click.echo(f"Stopping '{name}'...")
stop_workspace(name, verbose=verbose)
click.echo("Stopped. Disk preserved. Run 'insightscli devbox:start' to resume.")
@click.command(name="devbox:restart", help="Restart your devbox")
@workspace_argument
@click.option("-v", "--verbose", is_flag=True, help="Show full Coder/Terraform build output")
def devbox_restart(workspace: str | None, verbose: bool) -> None:
"""Stop and start the devbox in one step."""
ensure_runtime_ready()
name, workspaces = resolve_workspace_name(workspace)
_get_workspace_or_fail(name, workspaces)
click.echo(f"Restarting '{name}'...")
restart_workspace(name, verbose=verbose)
click.echo("Restarted.")
_print_connection_info(name)
@click.command(name="devbox:update", help="Update devbox to the latest template")
@workspace_argument
@click.option("-v", "--verbose", is_flag=True, help="Show full Coder/Terraform build output")
def devbox_update(workspace: str | None, verbose: bool) -> None:
"""Apply the latest template to the devbox."""
ensure_runtime_ready()
name, workspaces = resolve_workspace_name(workspace)
ws = _get_workspace_or_fail(name, workspaces)
if not ws.get("outdated"):
click.echo(f"Devbox '{name}' is already up to date.")
return
config = load_config()
params: dict[str, str] = {}
if dotfiles_uri := config.get("dotfiles_uri"):
params[DOTFILES_URI_PARAMETER] = dotfiles_uri
click.echo(f"Updating '{name}' to the latest template...")
update_workspace(name, parameters=params, verbose=verbose)
click.echo("Updated.")
click.echo(
"Note: if your local lockfiles differ from the new AMI's baked versions, "
"expect a one-time dep re-install on next workspace start (2-5 min)."
)
_print_connection_info(name)
@click.command(name="devbox:ssh", help="SSH into your devbox")
@workspace_argument
def devbox_ssh(workspace: str | None) -> None:
"""Open an SSH session to the devbox."""
ensure_runtime_ready()
name, _ = resolve_workspace_name(workspace)
ssh_replace(name)
@click.command(
name="devbox:exec",
help="Run a command inside your devbox (non-interactive).",
context_settings={"ignore_unknown_options": True},
)
@click.option("--name", "-n", "workspace_name", default=None, help="Workspace label or @user[/label] target")
@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True)
def devbox_remote_exec(workspace_name: str | None, command: tuple[str, ...]) -> None:
"""Run COMMAND in the devbox over `coder ssh` and propagate its exit code.
Unlike `devbox:ssh`, this runs a single command instead of opening an
interactive shell, so agents and scripts can drive a devbox remotely:
insightscli devbox:exec -- gh auth status
insightscli devbox:exec -n api -- bash -lc 'cd ~/insights && git status'
Use `--` to separate insightscli's flags from the command's own flags.
"""
ensure_runtime_ready()
name, _ = resolve_workspace_name(workspace_name)
if not coder_ssh_alias_configured(name):
_fail(
"SSH access for devboxes isn't configured. Run `insightscli devbox:setup` (it runs `coder config-ssh`), then retry."
)
exec_replace(name, list(command))
@click.command(name="devbox:open", help="Open devbox in browser, VS Code, or Cursor")
@workspace_argument
@click.option("--vscode", is_flag=True, help="Open in VS Code Desktop via SSH")
@click.option("--cursor", is_flag=True, help="Open in Cursor via SSH")
@click.option("--web", is_flag=True, help="Open code-server (VS Code in browser)")
def devbox_open(workspace: str | None, vscode: bool, cursor: bool, web: bool) -> None:
"""Open the devbox in a browser or editor."""
chosen = sum([vscode, cursor, web])
if chosen > 1:
raise click.UsageError("Choose one of `--vscode`, `--cursor`, or `--web`.")
ensure_runtime_ready()
name, _ = resolve_workspace_name(workspace)
if (vscode or cursor) and mutagen.sync_list(label_selector=mutagen.workspace_label_selector(name)):
ide = "VS Code" if vscode else "Cursor"
click.echo(
click.style(
f"⚠ Sync is active for '{name}'. Editing in {ide} Remote can conflict with the local source of truth.",
fg="yellow",
)
)
if vscode:
click.echo(f"Opening '{name}' in VS Code...")
open_vscode(name)
elif cursor:
click.echo(f"Opening '{name}' in Cursor...")
open_cursor(name)
elif web:
click.echo(f"Opening code-server for '{name}'...")
open_web_ide(name)
else:
click.echo(f"Opening '{name}' in browser...")
open_in_browser(name)
@click.command(name="devbox:logs", help="Tail devbox build and agent logs")
@workspace_argument
@click.option("-f", "--follow", is_flag=True, help="Follow log output")
def devbox_logs(workspace: str | None, follow: bool) -> None:
"""Tail workspace build and agent logs."""
ensure_runtime_ready()
name, _ = resolve_workspace_name(workspace)
logs_replace(name, follow)
@click.command(name="devbox:task", short_help="Run a background agent task on a fresh devbox")
@click.argument("prompt", required=False)
@click.option("--name", "task_name", default=None, help="Task name (auto-generated if omitted)")
@click.option("-q", "--quiet", is_flag=True, help="Only print the created task's ID")
@click.option(
"-t",
"--template",
default=DEFAULT_TEMPLATE,
show_default=True,
help="Coder workspace template to run the task on",
)
def devbox_task(prompt: str | None, task_name: str | None, quiet: bool, template: str) -> None:
"""Start a background Coder task on the chosen workspace template.
The Coder deployment provisions a fresh workspace per task and hands the
prompt to the agent configured in the template. Pass the prompt as a
positional argument or pipe it via stdin.
\b
Examples:
insightscli devbox:task "fix CI on PR #1234"
cat prompt.txt | insightscli devbox:task
"""
if prompt is None and click.get_text_stream("stdin").isatty():
raise click.UsageError(
"Provide a prompt as an argument, or pipe it via stdin.\n"
'Example: insightscli devbox:task "document the ingestion pipeline"'
)
ensure_runtime_ready()
if server_supports_user_secrets() and not has_claude_oauth_secret():
click.echo(
click.style(
f"Warning: no '{CLAUDE_CODE_OAUTH_ENV}' Coder user secret set; the task will run without Claude auth.",
fg="yellow",
),
err=True,
)
click.echo(
" Run `insightscli devbox:setup --configure-claude` (or `insightscli devbox:secret:set "
f"{CLAUDE_CODE_OAUTH_ENV}`) to set it.",
err=True,
)
create_task(prompt, task_name=task_name, quiet=quiet, template=template)
def _ensure_user_secrets_supported() -> None:
"""Fail fast when the Coder server does not support user secrets."""
if server_supports_user_secrets():
return
_fail("Coder server does not support user secrets (requires >= 2.33). Update the deployment first.")
@click.command(name="devbox:secret:list", help="List your Coder user secrets")
def devbox_secret_list() -> None:
"""List the user secrets attached to your Coder account."""
ensure_runtime_ready()
_ensure_user_secrets_supported()
secrets = list_user_secrets()
if secrets is None:
_fail("Could not list user secrets. Check `coder secret list` directly for details.")
if not secrets:
click.echo("No user secrets configured. Run `insightscli devbox:secret:set NAME` to add one.")
return
click.echo(f" {'NAME':<32} {'ENV':<32} DESCRIPTION")
for secret in secrets:
name = str(secret.get("name", ""))
env = str(secret.get("env_name") or secret.get("env") or "")
description = str(secret.get("description", ""))
click.echo(f" {name:<32} {env:<32} {description}")
@click.command(name="devbox:secret:set", help="Create or replace a Coder user secret")
@click.argument("name")
@click.option(
"--file",
"file_path",
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
default=None,
help="Read the secret value from this file instead of prompting for it",
)
@click.option(
"--env",
"env_name",
default=None,
help="Environment variable name to inject in the workspace (defaults to NAME)",
)
@click.option("--description", default=None, help="Optional description")
def devbox_secret_set(name: str, file_path: Path | None, env_name: str | None, description: str | None) -> None:
"""Set a Coder user secret. Replaces any existing secret with the same name."""
ensure_runtime_ready()
_ensure_user_secrets_supported()
target_env = env_name or name
if file_path is not None:
value = file_path.read_text().rstrip("\n")
else:
value = click.prompt(f"Value for {name}", hide_input=True, confirmation_prompt=True)
if not value:
_fail("Empty value rejected. Pass --file PATH or enter a non-empty value.")
upsert_user_secret(name, value, env_name=target_env, description=description)
click.echo(f"Set user secret '{name}' (env: {target_env}). Restart workspaces to pick up the change.")
@click.command(name="devbox:secret:rm", help="Delete a Coder user secret")
@click.argument("name")
def devbox_secret_rm(name: str) -> None:
"""Delete a Coder user secret by name."""
ensure_runtime_ready()
_ensure_user_secrets_supported()
result = delete_user_secret(name)
if result.returncode != 0:
if result.stderr:
click.echo(result.stderr.strip(), err=True)
_fail(f"Failed to delete user secret '{name}'.")
click.echo(f"Deleted user secret '{name}'.")
@click.command(name="devbox:config:show", help="Show saved devbox setup configuration")
def devbox_config_show() -> None:
"""Print the local devbox configuration saved by ``devbox:setup``."""
ensure_runtime_ready()
if not _print_setup_status(_collect_setup_status()):
click.echo("Nothing configured yet. Run `insightscli devbox:setup`.")
@click.command(name="devbox:config:rm", help="Clear saved devbox configuration items")
@click.argument("keys", nargs=-1)
@click.option("--all", "reset_all", is_flag=True, help="Clear every saved item")
def devbox_config_rm(keys: tuple[str, ...], reset_all: bool) -> None:
"""Remove one or more saved devbox configuration items.
Valid keys mirror the matching ``--configure-*`` flags on ``devbox:setup``:
``git-identity``, ``git-signing``, ``region``, ``dotfiles``, ``claude``.
"""
by_key = _config_items_by_key()
valid_keys = tuple(by_key)
if reset_all and keys:
_fail("Pass --all or one or more keys, not both. Valid keys: " + ", ".join(valid_keys))
if not reset_all and not keys:
_fail("Pass at least one key, or --all. Valid keys: " + ", ".join(valid_keys))
unknown = [k for k in keys if k not in by_key]
if unknown:
suffix = "s" if len(unknown) > 1 else ""
_fail(f"Unknown key{suffix}: {', '.join(unknown)}. Valid keys: {', '.join(valid_keys)}")
ensure_runtime_ready()
targets = [by_key[k] for k in (valid_keys if reset_all else keys)]
if any(item.needs_secrets for item in targets):
_ensure_user_secrets_supported()
# Materialize results so every handler runs even when only some clear anything.
fired = [item.reset() for item in targets]
if any(fired):
click.echo()
click.echo("Restart any running devbox (`insightscli devbox:restart`) to pick up the resets.")
@click.command(name="devbox:destroy", help="Destroy your devbox and its data")
@workspace_argument
@click.option("-v", "--verbose", is_flag=True, help="Show full Coder/Terraform build output")
def devbox_destroy(workspace: str | None, verbose: bool) -> None:
"""Destroy the devbox completely."""
ensure_runtime_ready()
name, workspaces = resolve_workspace_name(workspace)
ws = get_workspace(name, workspaces)
if ws is None:
click.echo("No devbox found.")
return
if not click.confirm(f"Destroy '{name}'? This deletes the VM and its data"):
click.echo("Cancelled.")
return
label = mutagen.workspace_label_selector(name)
if mutagen.sync_list(label_selector=label):
try:
mutagen.sync_terminate(label)
click.echo("Sync session terminated.")
except SystemExit:
# Best-effort: a stuck mutagen daemon should not block the user
# from destroying their devbox. The session ends with the remote.
click.echo(click.style("Warning: failed to terminate sync session; continuing.", fg="yellow"))
delete_workspace(name, verbose=verbose)
click.echo("Destroyed.")
@click.command(name="devbox:status", help="Show devbox status")
@workspace_argument
def devbox_status(workspace: str | None) -> None:
"""Show the current state of the devbox."""
ensure_runtime_ready()
name, workspaces = resolve_workspace_name(workspace)
ws = get_workspace(name, workspaces)
if ws is None:
click.echo("No devbox found. Run 'insightscli devbox:start' to create one.")
return
status = get_workspace_status(ws)
click.echo(f" Name: {name}")
click.echo(f" Status: {click.style(status, fg=_workspace_status_color(status))}")
click.echo(f" Region: {get_workspace_region(ws) or 'unknown'}")
click.echo(f" Sync: {_render_sync_status(name)}")
if ws.get("outdated"):
click.echo(click.style(" Update: template update available", fg="yellow"))
click.echo(" Run `insightscli devbox:update` to apply it.")
# Show agent status if available
resources = ws.get("latest_build", {}).get("resources", [])
for resource in resources:
for agent in resource.get("agents", []):
agent_status = agent.get("status", "unknown")
click.echo(f" Agent: {agent_status}")
if status == "running":
_print_connection_info(name)
@click.command(name="devbox:forward", help="Forward Insights UI to localhost")
@workspace_argument
@click.option("--port", default=8010, type=int, help="Local port to forward to")
def devbox_forward(workspace: str | None, port: int) -> None:
"""Forward the Insights UI port to localhost."""
ensure_runtime_ready()
name, _ = resolve_workspace_name(workspace)
if not _local_port_is_available(port):
_fail(
f"Local port {port} is already in use.\n"
f"Stop the process using that port or rerun with `insightscli devbox:forward --port {port + 1}`."
)
click.echo(f"Forwarding {name}:8010 -> localhost:{port}")
click.echo(f"Insights UI at http://localhost:{port}")
click.echo("Ctrl+C to stop")
click.echo()
port_forward_replace(name, port, 8010)
def _gib(nbytes: int) -> str:
return f"{nbytes / 1024**3:.1f}G"
def _snapshot_free(paths: list[Path]) -> dict[int, int]:
"""Return free bytes keyed by device ID, deduplicating paths on the same filesystem."""
seen: dict[int, int] = {}
for path in paths:
try:
dev = path.stat().st_dev
if dev not in seen:
seen[dev] = shutil.disk_usage(path).free
except OSError:
pass
return seen
def _sum_freed(before: dict[int, int], after: dict[int, int]) -> int:
"""Sum free-space gains across all watched devices."""
return sum(max(0, after[dev] - before[dev]) for dev in after if dev in before)
def _run_cleanup_step(label: str, cmd: list[str]) -> None:
"""Run a cleanup command and print its label."""
click.echo(f" {label}...", nl=False)
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # noqa: S603
click.echo(" done")
except FileNotFoundError:
click.echo(" skipped (command unavailable)")
except subprocess.CalledProcessError as e:
click.echo(f" warning: exited with code {e.returncode}")
def _rm_dir(label: str, path: Path) -> None:
"""Delete a directory and print its label."""
if not path.exists():
click.echo(f" {label}... skipped (not found)")
return
click.echo(f" {label}...", nl=False)
try:
shutil.rmtree(path)
click.echo(" done")
except OSError as e:
click.echo(f" warning: partial deletion ({e})")
@click.command(name="devbox:cleanup:disk", help="Free disk space by cleaning caches and build artifacts")
@workspace_argument
@click.option("--docker", "prune_docker", is_flag=True, help="Also prune stopped Docker containers")
@click.option(
"--cargo",
"prune_cargo",
is_flag=True,
help="Also remove Cargo build artifacts (forces full Rust recompile on next build)",
)
def devbox_cleanup_disk(workspace: str | None, prune_docker: bool, prune_cargo: bool) -> None:
"""Free disk space by removing caches and build artifacts that are safe to delete.
The default run is safe: it only removes orphaned packages, download caches,
and old Nix generations — none of which force a full rebuild on next use.
Use --cargo to also remove Cargo build artifacts (forces full Rust recompile).
Use --docker to also prune stopped containers.
Runs locally by default. Pass a WORKSPACE (or --name/-n) to run it remotely
on that devbox over ssh instead.
"""
if workspace:
ensure_runtime_ready()
# Cleanup is destructive (removes caches and build artifacts), so refuse
# to run it against someone else's shared `@user[/label]` devbox.
if workspace.startswith("@"):
_fail("devbox:cleanup:disk only runs against your own devboxes, not a shared '@user' workspace.")
name, workspaces = resolve_workspace_name(workspace)
_get_workspace_or_fail(name, workspaces)
if not coder_ssh_alias_configured(name):
_fail(
"SSH access for devboxes isn't configured. Run `insightscli devbox:setup` (it runs `coder config-ssh`), then retry."
)
remote_cmd = ["insightscli", "devbox:cleanup:disk"]
if prune_docker:
remote_cmd.append("--docker")
if prune_cargo:
remote_cmd.append("--cargo")
exec_replace(name, remote_cmd)
return # unreachable; exec_replace replaces the process
home = Path.home()
# Watch distinct filesystems: home covers uv/sccache/cargo; /nix covers Nix store
# (may be a separate volume); / covers Docker storage and anything else.
watch_paths = [p for p in [home, Path("/nix"), Path("/")] if p.exists()]
before = _snapshot_free(watch_paths)
click.echo("Cleaning caches and build artifacts...")
click.echo()
# uv wheel/sdist cache
_run_cleanup_step("uv cache (~/.cache/uv)", ["uv", "cache", "clean"])
# sccache compiler cache
_rm_dir("sccache (~/.cache/sccache)", home / ".cache" / "sccache")
# pnpm orphaned store entries
_run_cleanup_step("pnpm store (orphaned packages)", ["pnpm", "store", "prune"])
click.echo(" Nix garbage collection (old generations)...", nl=False)
try:
subprocess.run(["nix-collect-garbage", "-d"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # noqa: S603
click.echo(" done")
except FileNotFoundError:
click.echo(" skipped (nix-collect-garbage unavailable)")
except subprocess.CalledProcessError as e:
click.echo(f" warning: exited with code {e.returncode}")
# Cargo build artifacts — opt-in because removing them forces a full Rust recompile
if prune_cargo:
_rm_dir("Cargo build artifacts (~/.cargo/target)", home / ".cargo" / "target")
if prune_docker:
click.echo(" Docker stopped containers...", nl=False)
try:
subprocess.run(
["docker", "container", "prune", "-f"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
) # noqa: S603
click.echo(" done")
except FileNotFoundError:
click.echo(" skipped (docker unavailable)")
except subprocess.CalledProcessError as e:
click.echo(f" warning: exited with code {e.returncode}")
actually_freed = _sum_freed(before, _snapshot_free(watch_paths))
click.echo()
if actually_freed > 0:
click.echo(click.style(f"Freed {_gib(actually_freed)} of disk space.", fg="green"))
else:
click.echo("Nothing significant was freed (caches may already be empty).")
tips = []
if not prune_cargo:
tips.append(" insightscli devbox:cleanup:disk --cargo (rm ~/.cargo/target, forces recompile)")
if not prune_docker:
tips.append(" insightscli devbox:cleanup:disk --docker (prune stopped containers)")
if tips:
click.echo()
click.echo("Tips for more space:")
for tip in tips:
click.echo(tip)