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
16 changes: 8 additions & 8 deletions docs/tx9-cli-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ tx9 create
| 1 | Image source | **Embedded assets, local build.** The binary `go:embed`s provision/, guest/, Dockerfile, box.env; first `create` builds the image locally (~10 min, then cached). No registry. |
| 2 | State home | **`~/.tx9/`** — `boxes/<name>.env` (token cache), `locks/`, `config.toml`. Docker objects are label-tagged so `tx9 list` reconstructs truth from the daemon even if `~/.tx9` is lost. Backups default to **`~/Downloads`**. |
| 3 | Naming + create UX | Friendly generated names (`large-cat` style) by default, `tx9 create my_box` for explicit. `create` ends with a printed numbered checklist (enter, auth claude/codex, hermes setup, dashboard URL). |
| 4 | Command surface | See table below. `enter`/`ssh` are aliases; `backup`/`export` are aliases. |
| 4 | Command surface | See table below. Natural aliases: `new` → `create`, `ls` → `list`, `ssh`/`shell` → `enter`, `export`/`save` → `backup`, `load`/`restore` → `import`, `update` → `upgrade`, and `rm`/`remove` → `delete`. |
| 5 | Password UX | Precedence: `--password` flag → `TX9_PASSWORD` env → interactive hidden prompt. **`--no-encrypt` escape hatch** for quick local snapshots. |
| 6 | Archive format | **`.tx9` extension** (tar.gz inside, GPG-wrapped unless --no-encrypt; CLI sniffs which). Embeds box-name metadata; `import` restores under that name, `--name` overrides, collision = hard fail. |
| 7 | Scope | **Local-only v1.** The CLI drives the local Docker daemon; cross-machine moves are backup → transfer yourself → import. (Remote is a saved future direction — see below.) |
Expand All @@ -40,17 +40,17 @@ tx9 create

| Command | Behavior |
|---|---|
| `tx9 create [name]` | Generate name if absent. Build `tx9-box:<version>` if missing (with real progress UX). Create network + volumes + both containers, mint token, wire MCP, run doctor. Print getting-started checklist. |
| `tx9 list` | All boxes on this machine from daemon labels: state (running/stopped/crashed), image version vs CLI version (drift flag), dashboard URL. |
| `tx9 enter <box>` (alias `ssh`) | Exec into the agent container as `agent`, tmux `main` attach. Starts the box if stopped. |
| `tx9 create [name]` (alias `new`) | Generate name if absent. Build `tx9-box:<version>` if missing (with real progress UX). Create network + volumes + both containers, mint token, wire MCP, run doctor. Print getting-started checklist. |
| `tx9 list` (alias `ls`) | All boxes on this machine from daemon labels: state (running/stopped/crashed), image version vs CLI version (drift flag), dashboard URL. |
| `tx9 enter <box>` (aliases `ssh`, `shell`) | Exec into the agent container as `agent`, tmux `main` attach. Starts the box if stopped. |
| `tx9 start <box>` / `tx9 stop <box>` | Both containers together. Volumes persist. |
| `tx9 backup <box>` (alias `export`) | Flags: `--path` (default `~/Downloads`), `--password`/env/prompt, `--no-encrypt`. Quiesce → archive agent /data → validate → (encrypt) → verify → `<box>-<timestamp>.tx9`. |
| `tx9 import <file.tx9>` | Flags: `--name`, `--password`/env/prompt. Validate before creating anything; restore staged; arrive quiesced + gateway-disabled + fresh token; fail on name collision. |
| `tx9 backup <box>` (aliases `export`, `save`) | Flags: `--path` (default `~/Downloads`), `--password`/env/prompt, `--no-encrypt`. Quiesce → archive agent /data → validate → (encrypt) → verify → `<box>-<timestamp>.tx9`. |
| `tx9 import <file.tx9>` (aliases `load`, `restore`) | Flags: `--name`, `--password`/env/prompt. Validate before creating anything; restore staged; arrive quiesced + gateway-disabled + fresh token; fail on name collision. |
| `tx9 gateway <status\|enable\|disable> <box>` | Inspect or control the container-supervised Hermes gateway. Enable requires `--confirm-single-writer`. |
| `tx9 open <box>` | Print (or open) the authenticated dashboard URL (`?_token=`). |
| `tx9 doctor <box>` | In-box `hb doctor` + host-side published-port probe. |
| `tx9 upgrade [box]` | No args: self-update (re-run installer logic). With box: recreate containers on current image + readiness gate. |
| `tx9 delete <box>` | Containers + volumes + token file, typed-name confirmation (`--force` to skip). |
| `tx9 upgrade [box]` (alias `update`) | No args: self-update (re-run installer logic). With box: recreate containers on current image + readiness gate. |
| `tx9 delete <box>` (aliases `rm`, `remove`) | Containers + volumes + token file, typed-name confirmation (`--force` to skip). |
| `tx9 prune` | Remove unused `tx9-box:*` image versions and stale state files. |

`create`, `import`, and box-specific `upgrade` accept the shared
Expand Down
90 changes: 41 additions & 49 deletions internal/cli/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cli

import (
"fmt"
"io"
"io/fs"
"os"

Expand All @@ -24,57 +25,43 @@ var BuildContext fs.FS
// itself (i.e. it's exactly what flag.FlagSet.Parse expects).
type commandFunc func(args []string) error

// commands maps canonical subcommand names to their implementation.
// Registered in registerCommands below.
var commands = map[string]commandFunc{}

// commandOrder lists canonical subcommand names in the order they should
// appear in help output (matches the command surface table in
// docs/tx9-cli-design.md).
var commandOrder = []string{
"create", "list", "enter", "start", "stop", "backup", "import",
"gateway", "open", "doctor", "upgrade", "delete", "prune",
type commandSpec struct {
name string
help string
aliases []string
run commandFunc
}

// commandHelp is a one-line description per canonical command, for help
// output (matches docs/tx9-cli-design.md's command surface table).
var commandHelp = map[string]string{
"create": "generate/build if needed, create and start a new box",
"list": "list boxes on this machine (state, image version, dashboard URL)",
"enter": "exec into a box's agent container (alias: ssh; --executor for the executor container)",
"start": "start a stopped box",
"stop": "stop a running box",
"backup": "archive a box to a .tx9 file (alias: export)",
"import": "restore a box from a .tx9 file",
"gateway": "status/enable/disable the supervised Hermes gateway",
"open": "print/open a box's authenticated dashboard URL",
"doctor": "run health checks against a box",
"upgrade": "self-update the CLI, or move a box onto the current image",
"delete": "delete a box's containers, volumes, and state",
"prune": "remove unused tx9-box images and stale state",
// commandSpecs is the ordered source of truth for the command surface and
// aliases shown in help (matches docs/tx9-cli-design.md). registerCommands
// derives the dispatcher maps from it so aliases cannot drift from help.
var commandSpecs = []commandSpec{
{name: "create", help: "generate/build if needed, create and start a new box", aliases: []string{"new"}, run: cmdCreate},
{name: "list", help: "list boxes on this machine (state, image version, dashboard URL)", aliases: []string{"ls"}, run: cmdList},
{name: "enter", help: "exec into a box's agent container (--executor for the executor container)", aliases: []string{"ssh", "shell"}, run: cmdEnter},
{name: "start", help: "start a stopped box", run: cmdStart},
{name: "stop", help: "stop a running box", run: cmdStop},
{name: "backup", help: "archive a box to a .tx9 file", aliases: []string{"export", "save"}, run: cmdBackup},
{name: "import", help: "restore a box from a .tx9 file", aliases: []string{"load", "restore"}, run: cmdImport},
{name: "gateway", help: "status/enable/disable the supervised Hermes gateway", run: cmdGateway},
{name: "open", help: "print/open a box's authenticated dashboard URL", run: cmdOpen},
{name: "doctor", help: "run health checks against a box", run: cmdDoctor},
{name: "upgrade", help: "self-update the CLI, or move a box onto the current image", aliases: []string{"update"}, run: cmdUpgrade},
{name: "delete", help: "delete a box's containers, volumes, and state", aliases: []string{"rm", "remove"}, run: cmdDelete},
{name: "prune", help: "remove unused tx9-box images and stale state", run: cmdPrune},
}

// aliases maps alternate spellings to their canonical command name
// (decision 4: enter/ssh and backup/export are aliases).
var aliases = map[string]string{
"ssh": "enter",
"export": "backup",
}
// commands and aliases are derived from commandSpecs in registerCommands.
var commands = map[string]commandFunc{}
var aliases = map[string]string{}

func registerCommands() {
commands["create"] = cmdCreate
commands["list"] = cmdList
commands["enter"] = cmdEnter
commands["start"] = cmdStart
commands["stop"] = cmdStop
commands["backup"] = cmdBackup
commands["import"] = cmdImport
commands["gateway"] = cmdGateway
commands["open"] = cmdOpen
commands["doctor"] = cmdDoctor
commands["upgrade"] = cmdUpgrade
commands["delete"] = cmdDelete
commands["prune"] = cmdPrune
for _, spec := range commandSpecs {
commands[spec.name] = spec.run
for _, alias := range spec.aliases {
aliases[alias] = spec.name
}
}
}

func init() {
Expand Down Expand Up @@ -122,14 +109,19 @@ func Run(args []string, buildContext fs.FS) int {
return 0
}

func printUsage(w *os.File) {
func printUsage(w io.Writer) {
fmt.Fprintf(w, "tx9 %s — manage hermes boxes\n\n", version.Version)
fmt.Fprintln(w, "Usage: tx9 <command> [args]")
fmt.Fprintln(w, "\nCommands:")
for _, name := range commandOrder {
fmt.Fprintf(w, " %-10s %s\n", name, commandHelp[name])
for _, spec := range commandSpecs {
fmt.Fprintf(w, " %-10s %s\n", spec.name, spec.help)
}
fmt.Fprintln(w, "\nAliases:")
for _, spec := range commandSpecs {
for _, alias := range spec.aliases {
fmt.Fprintf(w, " %-10s %s\n", alias, spec.name)
}
}
fmt.Fprintln(w, "\nAliases: ssh -> enter, export -> backup")
fmt.Fprintln(w, "\nOther:")
fmt.Fprintln(w, " help show this message")
fmt.Fprintln(w, " version print the CLI version")
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/dispatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cli

import (
"bytes"
"strings"
"testing"
)

func TestCommandAliasesTargetRegisteredCommands(t *testing.T) {
want := map[string]string{
"new": "create",
"ls": "list",
"ssh": "enter",
"shell": "enter",
"export": "backup",
"save": "backup",
"load": "import",
"restore": "import",
"update": "upgrade",
"rm": "delete",
"remove": "delete",
}

if len(aliases) != len(want) {
t.Fatalf("alias count = %d, want %d: %v", len(aliases), len(want), aliases)
}
for alias, target := range want {
if got := aliases[alias]; got != target {
t.Errorf("aliases[%q] = %q, want %q", alias, got, target)
}
if commands[target] == nil {
t.Errorf("alias %q targets unregistered command %q", alias, target)
}
}
}

func TestUsageShowsAliasesFromCommandSpecs(t *testing.T) {
var usage bytes.Buffer
printUsage(&usage)

for _, line := range []string{
" new create",
" ls list",
" ssh enter",
" shell enter",
" export backup",
" save backup",
" load import",
" restore import",
" update upgrade",
" rm delete",
" remove delete",
} {
if !strings.Contains(usage.String(), line) {
t.Errorf("usage does not contain alias line %q:\n%s", line, usage.String())
}
}
}
11 changes: 7 additions & 4 deletions internal/names/names.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ var animals = []string{
// reserved names must never be generated or accepted, to avoid colliding
// with CLI vocabulary or Docker/filesystem-sensitive words.
var reserved = map[string]bool{
"create": true, "list": true, "enter": true, "ssh": true,
"start": true, "stop": true, "backup": true, "export": true,
"import": true, "open": true, "doctor": true, "upgrade": true,
"delete": true, "prune": true, "help": true, "version": true,
"create": true, "new": true, "list": true, "ls": true,
"enter": true, "ssh": true, "shell": true, "start": true, "stop": true,
"backup": true, "export": true, "save": true,
"import": true, "load": true, "restore": true,
"gateway": true, "open": true, "doctor": true, "upgrade": true, "update": true,
"delete": true, "rm": true, "remove": true, "prune": true,
"help": true, "version": true,
"all": true, "none": true, "default": true,
}

Expand Down
10 changes: 10 additions & 0 deletions internal/names/names_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ func TestValidate(t *testing.T) {
{"Bad-Case", false},
{"-leading-dash", false},
{"create", false},
{"new", false},
{"ls", false},
{"shell", false},
{"save", false},
{"load", false},
{"restore", false},
{"gateway", false},
{"update", false},
{"rm", false},
{"remove", false},
{"way-too-long-name-that-exceeds-the-maximum-length-allowed", false},
}
for _, c := range cases {
Expand Down
2 changes: 1 addition & 1 deletion site/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ <h2>Use</h2>
<ul>
<li><code>tx9 list</code> — every box on the machine, straight from the Docker daemon</li>
<li><code>tx9 doctor</code> — end-to-end health checks, in-box and from the host</li>
<li><code>tx9 upgrade</code> — self-update the CLI, or move a box onto the current image</li>
<li><code>tx9 upgrade</code> (or <code>tx9 update</code>) — self-update the CLI, or move a box onto the current image</li>
<li><code>tx9 open</code> — authenticated Executor dashboard URL</li>
</ul>

Expand Down