Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ amika sandbox list --remote -o json | jq '.[].name'
amika snapshot list -o json-pretty
```

Most list commands emit a JSON array (empty as `[]`, never `null`); `snapshot list` is the exception and emits a `{ "items": [...] }` envelope to match the API's `ListSandboxSnapshotsResponse`. Mutating commands emit a JSON result object or a per-item result array. Because JSON output cannot be interrupted by an interactive prompt, in JSON mode the CLI never prompts: destructive commands require their confirmation flag (`--force` for deletes, `--yes` for `sandbox create` mounts, `--no-interactive` for `snapshot create`), and commands that would open a shell or editor (`sandbox connect`, `sandbox code`, interactive `sandbox ssh`, `sandbox create --connect`, `auth login` without `--api-key-file`) refuse `-o json`. Human-readable progress and any subprocess output go to stderr so stdout carries only the JSON value.
Most list commands emit a JSON array (empty as `[]`, never `null`); `snapshot list` is the exception and emits a `{ "items": [...] }` envelope to match the API's `ListSandboxSnapshotsResponse`. Mutating commands emit a JSON result object or a per-item result array. Because JSON output cannot be interrupted by an interactive prompt, in JSON mode the CLI never prompts: destructive commands require their confirmation flag (`--force` for deletes, `--yes` for `sandbox create` mounts, `--no-interactive` for `snapshot create`), and commands that would open a shell or editor (`sandbox connect`, `sandbox code`, `sandbox codev2`, interactive `sandbox ssh`, `sandbox create --connect`, `auth login` without `--api-key-file`) refuse `-o json`. Human-readable progress and any subprocess output go to stderr so stdout carries only the JSON value.

```bash
# Create a sandbox and capture its name for a script
Expand All @@ -35,15 +35,15 @@ name=$(amika sandbox create --remote --no-git -o json | jq -r .name)
amika sandbox delete a b c --remote --force -o json | jq '.[] | select(.status=="error")'
```

Commands honoring `--output`: the read commands `sandbox list`, `snapshot list`, `volume list`, `service list`, `auth status`, and `secret <provider> list`, plus `sandbox create`, `sandbox start`, `sandbox stop`, `sandbox delete`, `sandbox agent-send`, `volume delete`, `snapshot create`, `snapshot delete`, `secret <provider> push`/`delete`, `auth login --api-key-file`, `auth logout`, and `materialize`. Commands that open a shell or editor (`sandbox connect`, `sandbox code`) or display a masked credential table and prompt for confirmation (`secret extract`, `secret push`) reject `-o json`/`json-pretty` since they produce no JSON result. `sandbox ssh` and `scp` do not accept `--output` at all (see above).
Commands honoring `--output`: the read commands `sandbox list`, `snapshot list`, `volume list`, `service list`, `auth status`, and `secret <provider> list`, plus `sandbox create`, `sandbox start`, `sandbox stop`, `sandbox delete`, `sandbox agent-send`, `volume delete`, `snapshot create`, `snapshot delete`, `secret <provider> push`/`delete`, `auth login --api-key-file`, `auth logout`, and `materialize`. Commands that open a shell or editor (`sandbox connect`, `sandbox code`, `sandbox codev2`) or display a masked credential table and prompt for confirmation (`secret extract`, `secret push`) reject `-o json`/`json-pretty` since they produce no JSON result. `sandbox ssh` and `scp` do not accept `--output` at all (see above).

## `amika sandbox`

Manage Docker-backed persistent sandboxes with bind mounts and named volumes.

### Global sandbox flags

These persistent flags apply to all `sandbox` subcommands (`create`, `list`, `connect`, `stop`, `start`, `delete`, `ssh`, `code`, `agent-send`):
These persistent flags apply to all `sandbox` subcommands (`create`, `list`, `connect`, `stop`, `start`, `delete`, `ssh`, `code`, `codev2`, `agent-send`):

| Flag | Default | Description |
| ---------- | ------- | -------------------------------- |
Expand Down Expand Up @@ -269,9 +269,7 @@ through the same Amika-managed SSH host alias (`amika-<id>`, written to
`~/.codex/config.toml`), then opens Codex; enable the host under
Settings > Connections.

The `claude` and `codex` editors are gated behind a feature flag: set
`AMIKA_OPEN_CLAUDE_CODEX_SUPPORT=true` to enable them (`cursor` is always
available).
This command requires a signed-in Amika account and a remote sandbox.

```bash
amika sandbox code my-sandbox
Expand All @@ -285,6 +283,37 @@ amika sandbox code my-sandbox --editor=codex
| `--editor <name>` | `cursor` | Editor or agent to open: `cursor`, `claude`, or `codex` |
| `--path <path>` | — | Override the remote path to open (absolute, or relative to the sandbox workspace root) |

### `amika sandbox codev2`

Open a sandbox in the same supported editors as `sandbox code`, but use the
beta direct WebSocket SSH transport instead of the provider's SSH route. Use
it when normal `sandbox code` cannot reach the provider SSH route. It works
with remote sandboxes only and requires a signed-in Amika account.

`codev2` adds a named SSH connection to Amika's managed config so Codex can
find it, then starts or configures Cursor, Claude Desktop, or Codex as
`sandbox code` does. The connection routes through Amika's direct transport;
you do not need to configure that transport yourself.

Before first use, create and upload an SSH key:

```bash
amika secret ssh-keygen
```

To use an existing key, pass `--import <public-key-file>` instead. Cursor is
the default editor; Claude Desktop and Codex are also available without an
environment-variable feature gate.

```bash
amika sandbox codev2 my-sandbox
amika sandbox codev2 my-sandbox --editor=cursor
amika sandbox codev2 my-sandbox --editor=claude
amika sandbox codev2 my-sandbox --editor=codex
```

`--editor` and `--path` have the same values and defaults as `sandbox code`.

### `amika sandbox agent-send`

Send a prompt to an AI agent CLI running inside a sandbox container. The message can be provided as a positional argument or piped via stdin. By default the command waits for the agent to finish and streams the response.
Expand Down
3 changes: 3 additions & 0 deletions go/cmd/amika/sandbox/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func New() *cobra.Command {
sandboxCmd.AddCommand(sandboxSSHCmd)
sandboxCmd.AddCommand(sandboxSSHV2Cmd)
sandboxCmd.AddCommand(sandboxCodeCmd)
sandboxCmd.AddCommand(sandboxCodeV2Cmd)
sandboxCmd.AddCommand(sandboxAgentSendCmd)

sandboxCmd.PersistentFlags().Bool("local", false, "Only operate on local sandboxes")
Expand Down Expand Up @@ -69,6 +70,8 @@ func New() *cobra.Command {
sandboxSSHV2Cmd.Flags().BoolP("t", "t", false, "Force pseudo-terminal allocation (like ssh -t)")
sandboxCodeCmd.Flags().String("editor", "cursor", "Editor or agent to open: \"cursor\", \"claude\", or \"codex\"")
sandboxCodeCmd.Flags().String("path", "", "Override the remote path to open (absolute, or relative to the sandbox workspace root)")
sandboxCodeV2Cmd.Flags().String("editor", "cursor", "Editor or agent to open: \"cursor\", \"claude\", or \"codex\"")
sandboxCodeV2Cmd.Flags().String("path", "", "Override the remote path to open (absolute, or relative to the sandbox workspace root)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align relative paths with the advertised workspace root

When a user passes codev2 --path foo, this flag promises a path relative to the sandbox workspace root, but resolveRemoteWorkspacePath resolves it to /home/amika/foo rather than /home/amika/workspace/foo. This can open the wrong or nonexistent directory; either resolve relative values beneath the workspace directory or describe them as relative to the sandbox home.

AGENTS.md reference: AGENTS.md:L136-L136

Useful? React with 👍 / 👎.

sandboxAgentSendCmd.Flags().Bool("no-wait", false, "Send the instruction and return immediately without waiting for a response")
sandboxAgentSendCmd.Flags().String("workdir", "$AMIKA_AGENT_CWD", "Working directory inside the container (default: $AMIKA_AGENT_CWD)")
sandboxAgentSendCmd.Flags().String("agent", "claude", "Agent CLI to use (default \"claude\")")
Expand Down
1 change: 1 addition & 0 deletions go/cmd/amika/sandbox/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// - delete
// - ssh
// - code
// - codev2
// - agent-send
//
// It also owns sandbox-specific flag parsing, local and remote execution
Expand Down
91 changes: 36 additions & 55 deletions go/cmd/amika/sandbox/sandbox_ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"os/exec"
"path"
"runtime"
"strings"

"github.com/gofixpoint/amika/go/internal/apiclient"
"github.com/gofixpoint/amika/go/internal/appcfg"
Expand All @@ -19,25 +18,10 @@ import (
"github.com/spf13/cobra"
)

// claudeCodexSupportEnv gates the claude/codex editors. They stay off until it
// is set to "true", mirroring the webapp's NEXT_PUBLIC_OPEN_CLAUDE_CODEX_SUPPORT
// flag so the two roll out together.
const claudeCodexSupportEnv = "AMIKA_OPEN_CLAUDE_CODEX_SUPPORT"

func claudeCodexEditorsEnabled() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(claudeCodexSupportEnv)), "true")
}

// validateEditor checks that the requested editor is known and enabled. cursor
// is always available; claude and codex require claudeCodexSupportEnv.
// validateEditor checks that the requested editor is supported.
func validateEditor(editor string) error {
switch editor {
case "cursor":
return nil
case "claude", "codex":
if !claudeCodexEditorsEnabled() {
return fmt.Errorf("editor %q is not enabled; set %s=true to enable it", editor, claudeCodexSupportEnv)
}
case "cursor", "claude", "codex":
return nil
default:
return fmt.Errorf("unsupported editor %q; supported editors are %q", editor, supportedEditors)
Expand Down Expand Up @@ -143,8 +127,6 @@ Supported --editor values:

For claude and codex, the command writes the local app config so the sandbox
appears as a remote environment; select it in the app to start the session.
These two editors are gated: set AMIKA_OPEN_CLAUDE_CODEX_SUPPORT=true to enable
them.

Examples:
amika sandbox code my-sandbox
Expand Down Expand Up @@ -184,15 +166,11 @@ Examples:
pathOverride, _ := cmd.Flags().GetString("path")
paths := basedir.New("")

switch editor {
case "cursor":
return openSandboxInCursor(cmd, client, paths, name, pathOverride)
case "claude":
return openSandboxInClaude(cmd, client, paths, name, pathOverride)
case "codex":
return openSandboxInCodex(cmd, client, paths, name, pathOverride)
sshTarget, err := resolveSandboxSSHAlias(client, paths, name)
if err != nil {
return err
}
return nil
return openSandboxInEditor(cmd, editor, paths, sshTarget, pathOverride)
},
}

Expand Down Expand Up @@ -248,24 +226,35 @@ func resolveSandboxSSHAlias(client sshInfoClient, paths basedir.Paths, name stri
return sandboxSSHAlias{alias: alias, sandboxName: sandboxName, repoName: info.RepoName}, nil
}

// openSandboxInCursor launches Cursor connected to the sandbox over SSH.
func openSandboxInCursor(cmd *cobra.Command, client sshInfoClient, paths basedir.Paths, name, pathOverride string) error {
// openSandboxInEditor starts the selected editor with a prepared SSH target.
func openSandboxInEditor(cmd *cobra.Command, editor string, paths basedir.Paths, target sandboxSSHAlias, pathOverride string) error {
switch editor {
case "cursor":
return openSandboxInCursorTarget(cmd, target, pathOverride)
case "claude":
return openSandboxInClaudeTarget(cmd, paths, target, pathOverride)
case "codex":
return openSandboxInCodexTarget(cmd, paths, target, pathOverride)
default:
return fmt.Errorf("unsupported editor %q", editor)
}
}

// openSandboxInCursor launches Cursor connected to a prepared SSH target.
func openSandboxInCursorTarget(cmd *cobra.Command, target sandboxSSHAlias, pathOverride string) error {
if _, err := exec.LookPath("cursor"); err != nil {
return fmt.Errorf("cursor CLI is not installed or not in PATH; install it from Cursor > Settings > Extensions > cursor-cli")
}

target, err := prepareCursorSSHTarget(client, paths, name, pathOverride)
if err != nil {
return err
}
remotePath := resolveRemoteWorkspacePath(target.repoName, pathOverride)

cursorCmd := exec.Command("cursor", "--remote", "ssh-remote+"+target.alias, target.remotePath)
cursorCmd := exec.Command("cursor", "--remote", "ssh-remote+"+target.alias, remotePath)
cursorCmd.Stdin = os.Stdin
cursorCmd.Stdout = os.Stdout
cursorCmd.Stderr = os.Stderr

fmt.Fprintf(cmd.OutOrStdout(), "Opening sandbox %q in Cursor via SSH (%s)...\n", name, target.alias)
fmt.Fprintf(cmd.OutOrStdout(), "Running: cursor --remote ssh-remote+%s %s\n", target.alias, target.remotePath)
fmt.Fprintf(cmd.OutOrStdout(), "Opening sandbox %q in Cursor via SSH (%s)...\n", target.sandboxName, target.alias)
fmt.Fprintf(cmd.OutOrStdout(), "Running: cursor --remote ssh-remote+%s %s\n", target.alias, remotePath)
fmt.Fprintf(cmd.OutOrStdout(), "Hint: if the file explorer is not visible, press Cmd+Shift+E in Cursor to open it.\n")
if err := cursorCmd.Run(); err != nil {
return fmt.Errorf("cursor failed: %w\n\nMake sure the \"Remote - SSH\" extension is installed in Cursor", err)
Expand All @@ -282,7 +271,11 @@ func openSandboxInClaude(cmd *cobra.Command, client sshInfoClient, paths basedir
if err != nil {
return err
}
return openSandboxInClaudeTarget(cmd, paths, target, pathOverride)
}

// openSandboxInClaudeTarget registers a prepared SSH target in Claude Desktop.
func openSandboxInClaudeTarget(cmd *cobra.Command, paths basedir.Paths, target sandboxSSHAlias, pathOverride string) error {
host := appcfg.ClaudeSSHHost{
ID: target.alias,
Name: "Amika: " + target.sandboxName,
Expand All @@ -294,7 +287,7 @@ func openSandboxInClaude(cmd *cobra.Command, client sshInfoClient, paths basedir
}

out := cmd.OutOrStdout()
fmt.Fprintf(out, "Registered SSH environment %q for sandbox %q in Claude Desktop.\n", host.Name, name)
fmt.Fprintf(out, "Registered SSH environment %q for sandbox %q in Claude Desktop.\n", host.Name, target.sandboxName)
if err := openApp("claude://code/new"); err != nil {
fmt.Fprintf(out, "Could not launch Claude Desktop automatically (%v); open it yourself.\n", err)
} else {
Expand All @@ -313,13 +306,17 @@ func openSandboxInCodex(cmd *cobra.Command, client sshInfoClient, paths basedir.
if err != nil {
return err
}
return openSandboxInCodexTarget(cmd, paths, target, pathOverride)
}

// openSandboxInCodexTarget enables Codex remote connections for a prepared SSH target.
func openSandboxInCodexTarget(cmd *cobra.Command, paths basedir.Paths, target sandboxSSHAlias, pathOverride string) error {
if _, err := appcfg.EnableCodexRemoteConnections(paths); err != nil {
return fmt.Errorf("enable Codex remote connections: %w", err)
}

out := cmd.OutOrStdout()
fmt.Fprintf(out, "Enabled Codex remote connections; SSH host %q for sandbox %q is available from ~/.ssh/config.\n", target.alias, name)
fmt.Fprintf(out, "Enabled Codex remote connections; SSH host %q for sandbox %q is available from ~/.ssh/config.\n", target.alias, target.sandboxName)
if err := openApp("codex://"); err != nil {
fmt.Fprintf(out, "Could not launch Codex automatically (%v); open it yourself.\n", err)
} else {
Expand All @@ -330,22 +327,6 @@ func openSandboxInCodex(cmd *cobra.Command, client sshInfoClient, paths basedir.
return nil
}

type cursorSSHTarget struct {
alias string
remotePath string
}

func prepareCursorSSHTarget(client sshInfoClient, paths basedir.Paths, name string, pathOverride string) (cursorSSHTarget, error) {
target, err := resolveSandboxSSHAlias(client, paths, name)
if err != nil {
return cursorSSHTarget{}, err
}
return cursorSSHTarget{
alias: target.alias,
remotePath: resolveRemoteWorkspacePath(target.repoName, pathOverride),
}, nil
}

// resolveRemoteWorkspacePath computes the remote path to open in the editor.
// An absolute pathOverride is used verbatim; a relative one is joined onto
// /home/amika so that e.g. "workspace/biz" → "/home/amika/workspace/biz".
Expand Down
Loading
Loading