diff --git a/.github/wiki/guides/ci/Reference.md b/.github/wiki/guides/ci/Reference.md index 509dc31dd..736439bed 100644 --- a/.github/wiki/guides/ci/Reference.md +++ b/.github/wiki/guides/ci/Reference.md @@ -113,16 +113,23 @@ A flag is read from the source file that defines it. `verb *` means the flag is | Flag | Commands | |---|---| +| `--accepts` | `nodes *` | | `--addr` | `serve` | +| `--agent-addr` | `serve *` | +| `--agent-binary` | `nodes *` | | `--agent-source` | `providers github init` | | `--agent-url` | `nodes pair` | +| `--alert` | `doctor` | | `--all` | `nodes *` | | `--allow-deploy-host` | `nodes *` | | `--allow-downgrade` | `nodes *` | | `--allow-private` | `providers *` | | `--allow-unsandboxed` | `serve` | | `--allowed-repos` | `serve` | +| `--api-addr` | `serve *` | +| `--api-lock` | `serve *` | | `--api-public-url` | `serve *` | +| `--api-socket` | `serve *` | | `--api-submit-role` | `serve *` | | `--app-id` | `policy protect` | | `--apply` | `runs *` | @@ -134,24 +141,26 @@ A flag is read from the source file that defines it. `verb *` means the flag is | `--binary` | `serve *` | | `--branch` | `policy protect` | | `--by` | `config ignore add` | +| `--cert` | `nodes *` | | `--cert-ttl` | `nodes pair` | | `--check` | `providers *`, `runs reproduce` | | `--class` | `nodes *` | | `--clear` | `policy *` | | `--cmd` | `nodes sync` | | `--concurrency` | `serve` | -| `--config` | `explain`, `plan` | +| `--config` | `config *`, `explain`, `plan` | | `--confirm-flaky` | `runs *` | | `--container` | - | -| `--coordinator` | `artifacts *`, `runs *` | +| `--coordinator` | `artifacts *`, `nodes *`, `runs *` | | `--coordinator-id` | `nodes pair` | | `--data-dir` | `nodes pair`, `serve *` | | `--dest` | `artifacts *` | | `--detach` | - | | `--dir` | `providers *` | +| `--drain-deadline` | `serve *` | | `--dry-run` | `artifacts *`, `nodes sync`, `policy protect`, `runs *`, `serve *` | | `--effective` | `policy *` | -| `--enroll-addr` | `nodes pair` | +| `--enroll-addr` | `nodes pair`, `serve *` | | `--env` | `run` | | `--env-file` | `serve *` | | `--f` | `logs` | @@ -164,6 +173,7 @@ A flag is read from the source file that defines it. `verb *` means the flag is | `--gateway` | `run` | | `--h` | - | | `--help` | - | +| `--home` | `config *` | | `--hook` | `policy protect` | | `--host-key` | `nodes *` | | `--image` | - | @@ -171,11 +181,12 @@ A flag is read from the source file that defines it. `verb *` means the flag is | `--init` | `artifacts signer` | | `--inputs` | `providers gitlab init` | | `--insecure` | `serve` | +| `--installer` | `nodes *` | | `--interactive` | `nodes *` | | `--interval` | `serve watch` | | `--isolation` | - | | `--job` | `runs rerun` | -| `--json` | `artifacts *`, `evidence *`, `explain`, `forgejo`, `logs`, `nodes *`, `plan`, `policy *`, `providers *`, `runs *`, `serve *` | +| `--json` | `artifacts *`, `config *`, `doctor`, `evidence *`, `explain`, `forgejo`, `logs`, `nodes *`, `plan`, `policy *`, `providers *`, `runs *`, `serve *` | | `--keep` | `runs *` | | `--kek` | `serve *` | | `--key-id` | `nodes *` | @@ -187,30 +198,39 @@ A flag is read from the source file that defines it. `verb *` means the flag is | `--local` | `nodes status` | | `--max-age-days` | `runs *` | | `--max-log-bytes` | `runs *` | +| `--max-queue` | - | | `--mode` | `nodes *` | -| `--name` | `artifacts *` | +| `--name` | `artifacts *`, `nodes *` | | `--network` | - | | `--no-gitleaks` | - | -| `--no-status` | - | +| `--no-status` | `config *` | +| `--node-admin-routes` | `serve *` | +| `--node-key` | `nodes *` | +| `--nodes` | `doctor` | | `--older-than` | `artifacts *` | | `--on-battery` | `nodes *` | | `--once` | `serve watch` | | `--out` | `artifacts *`, `serve *` | | `--owner` | `build` | +| `--ownership` | `nodes *` | | `--persistence` | `nodes *` | | `--pipeline` | `evidence *`, `explain`, `plan` | | `--policy` | `evidence *` | | `--post-status` | - | +| `--prefer` | - | +| `--privacy-zone` | `nodes *` | | `--probe` | `netproxy` | | `--project` | `artifacts *`, `nodes *`, `providers *`, `runs *`, `serve admin roles` | +| `--providers` | `doctor` | | `--proxy` | `netproxy` | | `--pubkey` | `serve admin audit checkpoint` | | `--public` | `artifacts signer` | | `--public-url` | `nodes pair`, `serve *` | | `--reason` | `config ignore add` | | `--release` | `artifacts *` | +| `--release-routes` | `serve *` | | `--remove` | `nodes label` | -| `--repo` | `artifacts verify`, `build`, `config ignore add`, `evidence *`, `explain`, `plan`, `policy *`, `providers *`, `runs *`, `serve *` | +| `--repo` | `artifacts verify`, `build`, `config *`, `doctor`, `evidence *`, `explain`, `plan`, `policy *`, `providers *`, `runs *`, `serve *` | | `--repos` | `serve watch` | | `--reserve-cpu` | `nodes *` | | `--reserve-mem` | `nodes *` | @@ -218,9 +238,12 @@ A flag is read from the source file that defines it. `verb *` means the flag is | `--role` | `serve admin roles` | | `--rotate` | `artifacts signer` | | `--run` | `artifacts *`, `logs`, `runs reproduce` | +| `--runner` | - | | `--scope` | `policy *` | | `--secret` | `serve` | +| `--secret-classes` | `nodes *` | | `--self` | `serve *` | +| `--selftest` | `doctor` | | `--service` | - | | `--sha` | `runs *` | | `--shell` | `runs reproduce` | @@ -234,7 +257,7 @@ A flag is read from the source file that defines it. `verb *` means the flag is | `--systemd` | `serve *` | | `--tag` | `build` | | `--team` | `policy *`, `serve *` | -| `--timeout` | `build`, `run`, `runs wait`, `serve` | +| `--timeout` | `build`, `config *`, `nodes discover`, `run`, `runs wait`, `serve` | | `--trigger` | `explain`, `plan` | | `--trust` | - | | `--ttl` | `serve *` | @@ -248,6 +271,7 @@ A flag is read from the source file that defines it. `verb *` means the flag is | `--version` | `nodes *` | | `--where` | - | | `--workdir` | `serve` | +| `--write` | `config *` | | `--yes` | `artifacts key` | diff --git a/.github/wiki/plugins/CI.md b/.github/wiki/plugins/CI.md index f3ec7476d..f5f04c5cd 100644 --- a/.github/wiki/plugins/CI.md +++ b/.github/wiki/plugins/CI.md @@ -19,6 +19,7 @@ | Command | MCP tool | HTTP route | Side effect | Output | Exposure | Confirmation | |---|---|---|---|---|---|---| +| `nself ci` | `nself_ci` | `/v1/commands/ci` | remote | document | MCP and HTTP | nonce | | `nself ci artifacts` | `nself_ci_artifacts` | `/v1/commands/ci/artifacts` | read | document | MCP and HTTP | none | | `nself ci build` | - | - | remote | document | CLI only (json none) | none | | `nself ci config ignore add` | - | - | write | document | CLI only (json none) | none | diff --git a/.github/wiki/plugins/Functions.md b/.github/wiki/plugins/Functions.md index 17f9c5365..fe8d7831e 100644 --- a/.github/wiki/plugins/Functions.md +++ b/.github/wiki/plugins/Functions.md @@ -19,6 +19,7 @@ | Command | MCP tool | HTTP route | Side effect | Output | Exposure | Confirmation | |---|---|---|---|---|---|---| +| `nself functions` | `nself_functions` | `/v1/commands/functions` | write | document | MCP and HTTP | none | | `nself functions delete` | `nself_functions_delete` | `/v1/commands/functions/delete` | destructive | document | MCP and HTTP | nonce | | `nself functions deploy` | `nself_functions_deploy` | `/v1/commands/functions/deploy` | write | document | MCP and HTTP | none | | `nself functions invoke` | `nself_functions_invoke` | `/v1/commands/functions/invoke` | write | document | MCP and HTTP | none | diff --git a/.github/wiki/plugins/Infra.md b/.github/wiki/plugins/Infra.md index 8162a75cf..c4075bdab 100644 --- a/.github/wiki/plugins/Infra.md +++ b/.github/wiki/plugins/Infra.md @@ -19,6 +19,7 @@ | Command | MCP tool | HTTP route | Side effect | Output | Exposure | Confirmation | |---|---|---|---|---|---|---| +| `nself infra` | - | - | remote | document | CLI only (json none) | none | | `nself infra apply` | - | - | remote | document | CLI only (surface cli-only) | none | | `nself infra destroy` | - | - | destructive | document | CLI only (surface cli-only) | none | | `nself infra plan` | - | - | read | document | CLI only (json legacy) | none | diff --git a/.github/wiki/plugins/K8s.md b/.github/wiki/plugins/K8s.md index 309b7d57d..d9e9ecd91 100644 --- a/.github/wiki/plugins/K8s.md +++ b/.github/wiki/plugins/K8s.md @@ -19,6 +19,7 @@ | Command | MCP tool | HTTP route | Side effect | Output | Exposure | Confirmation | |---|---|---|---|---|---|---| +| `nself k8s` | - | - | remote | document | CLI only (json legacy) | none | | `nself k8s backup` | - | - | write | document | CLI only (json legacy) | none | | `nself k8s install` | - | - | remote | document | CLI only (json legacy) | none | | `nself k8s restore` | - | - | destructive | document | CLI only (json legacy) | none | diff --git a/.github/wiki/plugins/Plugin-Dev.md b/.github/wiki/plugins/Plugin-Dev.md index bd542dba0..3f035cd27 100644 --- a/.github/wiki/plugins/Plugin-Dev.md +++ b/.github/wiki/plugins/Plugin-Dev.md @@ -19,6 +19,7 @@ | Command | MCP tool | HTTP route | Side effect | Output | Exposure | Confirmation | |---|---|---|---|---|---|---| +| `nself plugin-dev` | `nself_plugin_dev` | `/v1/commands/plugin-dev` | write | document | MCP and HTTP | none | | `nself plugin-dev debug` | - | - | write | document | CLI only (surface cli-only) | none | | `nself plugin-dev dev` | - | - | write | stream | CLI only (surface cli-only) | none | | `nself plugin-dev init` | - | - | write | document | CLI only (surface cli-only) | none | diff --git a/.github/wiki/plugins/Sentry-CLI.md b/.github/wiki/plugins/Sentry-CLI.md index 3d0597e83..409fbfe11 100644 --- a/.github/wiki/plugins/Sentry-CLI.md +++ b/.github/wiki/plugins/Sentry-CLI.md @@ -19,6 +19,7 @@ | Command | MCP tool | HTTP route | Side effect | Output | Exposure | Confirmation | |---|---|---|---|---|---|---| +| `nself sentry` | `nself_sentry` | `/v1/commands/sentry` | read | document | MCP and HTTP | none | | `nself sentry alerts channels` | `nself_sentry_alerts_channels` | `/v1/commands/sentry/alerts/channels` | read | document | MCP and HTTP | none | | `nself sentry alerts test` | - | - | remote | document | CLI only (remote without confirm) | none | | `nself sentry incidents ack` | `nself_sentry_incidents_ack` | `/v1/commands/sentry/incidents/ack` | write | document | MCP and HTTP | none | diff --git a/.github/wiki/plugins/nSelf-Eval-Gate.md b/.github/wiki/plugins/nSelf-Eval-Gate.md index 97602d571..9cb9f6a1f 100644 --- a/.github/wiki/plugins/nSelf-Eval-Gate.md +++ b/.github/wiki/plugins/nSelf-Eval-Gate.md @@ -19,6 +19,7 @@ | Command | MCP tool | HTTP route | Side effect | Output | Exposure | Confirmation | |---|---|---|---|---|---|---| +| `nself eval` | - | - | remote | document | CLI only (json none) | none | | `nself eval gate` | - | - | read | document | CLI only (json legacy) | none | diff --git a/free/ci/tools/refgen/flags.go b/free/ci/tools/refgen/flags.go index 5e5e235f9..23e2cd7f5 100644 --- a/free/ci/tools/refgen/flags.go +++ b/free/ci/tools/refgen/flags.go @@ -19,6 +19,7 @@ import ( // flagDefs maps a FlagSet method to the index of its name argument. var flagDefs = map[string]int{ "Bool": 0, "String": 0, "Int": 0, "Int64": 0, "Uint": 0, "Float64": 0, "Duration": 0, + "Func": 0, "BoolFunc": 0, "BoolVar": 1, "StringVar": 1, "IntVar": 1, "Int64Var": 1, "UintVar": 1, "Float64Var": 1, "DurationVar": 1, "Var": 1, } @@ -28,7 +29,93 @@ type FlagRow struct { Commands []string } -// flagRows returns the flags of the cmd package, sorted by name. +// flagPackages lists the packages outside cmd that define flags of a verb, +// with the verb that owns them: `nodes add` builds its FlagSet in +// internal/nodes/lifecycle, and serve flags are added by packages through +// coordinator.RegisterFlag. TestNoFlagDefinedOutsideScannedPackages fails +// when another package defines a flag, so a flag is never silently omitted. +var flagPackages = map[string]string{ + modulePath + "/internal/nodes/lifecycle": "nodes", + modulePath + "/internal/coordinator/drain": "serve", + modulePath + "/internal/config": "config", + modulePath + "/internal/doctor": "doctor", + modulePath + "/internal/discovery": "nodes discover", +} + +// listenerVerb owns every flag registered with coordinator.RegisterListener: +// the listener flags are serve flags whichever file registers them. +const listenerVerb = "serve" + +// fileFlagNames returns the flag names a file defines on a FlagSet. +func fileFlagNames(m *module, p *pkg, f *srcFile) ([]string, error) { + var names []string + var bad error + ast.Inspect(f.ast, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + i, ok := flagDefs[sel.Sel.Name] + if !ok || len(call.Args) <= i || len(call.Args) < 2 { + return true + } + name, ok := m.str(p, f, call.Args[i], nil) + if !ok { + if _, lit := call.Args[i].(*ast.BasicLit); lit { + bad = fmt.Errorf("%s: flag name not resolvable", f.name) + } + return true + } + names = append(names, name) + return true + }) + return names, bad +} + +// listenerFlagNames returns the flag names a file registers through +// RegisterListener(name, flag, ...), expanding a range over a literal list +// (`for _, l := range []struct{...}{...} { RegisterListener(l.name, l.flag, ...) }`). +// A flag name it cannot resolve is an error: the table would silently lose a flag. +func listenerFlagNames(m *module, p *pkg, f *srcFile) ([]string, error) { + var names []string + var bad error + w := walker{m: m, p: p, f: f} + w.walk(f.ast, nil, nil, func(call *ast.CallExpr, env map[string][]string, it map[string]string) { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "RegisterListener" || len(call.Args) < 2 { + return + } + got := m.strs(p, f, call.Args[1], env) + if s, ok := call.Args[1].(*ast.SelectorExpr); ok && len(got) == 0 { + if v, ok := it[s.Sel.Name]; ok { + got = []string{v} + } + } + if len(got) == 0 { + bad = fmt.Errorf("%s: RegisterListener flag name not resolvable", f.name) + return + } + names = append(names, got...) + }) + return names, bad +} + +// ownersOfVerb returns the keys of the commands that are verb or start with it. +func ownersOfVerb(cmds []Command, verb string) []string { + var out []string + for _, c := range cmds { + if c.Key == verb || strings.HasPrefix(c.Key, verb+" ") { + out = append(out, c.Key) + } + } + return out +} + +// flagRows returns the flags of the cmd package and of flagPackages, sorted by name. func flagRows(m *module, cmds []Command) ([]FlagRow, error) { p := m.pkgs[modulePath+"/cmd"] if p == nil { @@ -41,6 +128,15 @@ func flagRows(m *module, cmds []Command) ([]FlagRow, error) { } } got := map[string]map[string]bool{} + add := func(name string, owners []string) { + if got[name] == nil { + got[name] = map[string]bool{} + } + for _, o := range owners { + got[name][o] = true + } + } + serve := ownersOfVerb(cmds, listenerVerb) for _, f := range p.files { owners := byFile[f.name] if len(owners) == 0 { @@ -54,37 +150,34 @@ func flagRows(m *module, cmds []Command) ([]FlagRow, error) { } } } - var bad error - ast.Inspect(f.ast, func(n ast.Node) bool { - call, ok := n.(*ast.CallExpr) - if !ok { - return true - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - i, ok := flagDefs[sel.Sel.Name] - if !ok || len(call.Args) <= i || len(call.Args) < 2 { - return true - } - name, ok := m.str(p, f, call.Args[i], nil) - if !ok { - if _, lit := call.Args[i].(*ast.BasicLit); lit { - bad = fmt.Errorf("%s: flag name not resolvable", f.name) - } - return true - } - if got[name] == nil { - got[name] = map[string]bool{} + names, err := fileFlagNames(m, p, f) + if err != nil { + return nil, err + } + for _, n := range names { + add(n, owners) + } + if names, err = listenerFlagNames(m, p, f); err != nil { + return nil, err + } + for _, n := range names { + add(n, serve) + } + } + for path, verb := range flagPackages { + ep := m.pkgs[path] + if ep == nil { + return nil, fmt.Errorf("flag package %s not found", path) + } + owners := ownersOfVerb(cmds, verb) + for _, f := range ep.files { + names, err := fileFlagNames(m, ep, f) + if err != nil { + return nil, err } - for _, o := range owners { - got[name][o] = true + for _, n := range names { + add(n, owners) } - return true - }) - if bad != nil { - return nil, bad } } var out []FlagRow diff --git a/free/ci/tools/refgen/flags_test.go b/free/ci/tools/refgen/flags_test.go new file mode 100644 index 000000000..6f670a57f --- /dev/null +++ b/free/ci/tools/refgen/flags_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestReferenceListsListenerAndPackageFlags fails when a flag that is not a +// FlagSet call in the cmd package is left out of Reference.md: the serve +// listener flags (coordinator.RegisterListener, one of them from a literal +// table) and the `nodes add` flags defined in internal/nodes/lifecycle. +func TestReferenceListsListenerAndPackageFlags(t *testing.T) { + out, err := generate(filepath.Join("..", "..", "..", "..")) + if err != nil { + t.Fatal(err) + } + ref := string(out[referencePage]) + for _, row := range []string{ + "| `--agent-addr` | `serve *` |", + "| `--api-addr` | `serve *` |", + "| `--api-socket` | `serve *` |", + "| `--agent-binary` | `nodes *` |", + "| `--drain-deadline` | `serve *` |", + } { + if !strings.Contains(ref, row) { + t.Errorf("Reference.md has no row %s", row) + } + } +} + +// unmappedFlagPackages are packages whose flags Reference.md does not list yet, +// with the reason. The agent binary and the bench and helper tools are not +// `nself ci` commands; the rest need an owner mapping decided by the CI wiki +// owner (found by P7-AUTO-13/generator-gaps work, reported, not guessed). +var unmappedFlagPackages = map[string]string{ + "bench/cache/cmd/cachebench": "benchmark tool, not a nself ci command", + "cmd/agent": "the nself-ci-agent binary has its own CLI", + "internal/agent/update": "agent upgrade CLI (nself-ci-agent)", + "internal/gates/truth": "gate helper CLI; owner command not mapped", + "internal/providers/github/jit": "runner mint flags; owner command not mapped", + "internal/providers/gitlab/runners": "runner mint flags; owner command not mapped", +} + +// TestNoFlagDefinedOutsideScannedPackages fails when a package that flagRows +// does not read defines a flag, so a new flag cannot be left out silently: +// add the package to flagPackages (or read its registration in cmd). +func TestNoFlagDefinedOutsideScannedPackages(t *testing.T) { + m, err := loadModule(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + for _, p := range m.sortedPkgs() { + if p.path == modulePath+"/cmd" || strings.HasPrefix(p.path, modulePath+"/tools/") { + continue + } + if _, ok := flagPackages[p.path]; ok { + continue + } + if _, ok := unmappedFlagPackages[strings.TrimPrefix(p.path, modulePath+"/")]; ok { + continue + } + for _, f := range p.files { + names, err := fileFlagNames(m, p, f) + if err != nil { + t.Fatal(err) + } + listeners, err := listenerFlagNames(m, p, f) + if err != nil { + t.Fatal(err) + } + if got := append(names, listeners...); len(got) > 0 { + t.Errorf("%s/%s defines flags %v that refgen does not read: add the package to flagPackages", p.path, f.name, got) + } + } + } +} + +// noPackages empties flagPackages for a test over a module that has none of them. +func noPackages(t *testing.T) { + t.Helper() + was := flagPackages + flagPackages = map[string]string{} + t.Cleanup(func() { flagPackages = was }) +} + +// fakeModule loads a one-file cmd package from src. +func fakeModule(t *testing.T, src string) *module { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "cmd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "cmd", "serve_x.go"), []byte(src), 0o600); err != nil { + t.Fatal(err) + } + m, err := loadModule(root) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestListenerFlagsFromLiteralAndTable(t *testing.T) { + noPackages(t) + m := fakeModule(t, `package cmd + +import "x/coordinator" + +func init() { + coordinator.RegisterListener("a", "a-addr", ":1", nil) + for _, l := range []struct{ name, flag, def string }{{"b", "b-addr", ":2"}, {"c", "c-sock", "auto"}} { + coordinator.RegisterListener(l.name, l.flag, l.def, nil) + } +} +`) + cmds := []Command{{Key: "serve", Files: []string{"serve_x.go"}}, {Key: "serve install"}} + rows, err := flagRows(m, cmds) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, r := range rows { + got[r.Name] = strings.Join(r.Commands, ",") + } + for _, n := range []string{"a-addr", "b-addr", "c-sock"} { + if got[n] != "serve *" { + t.Errorf("flag %s = %q, want serve *", n, got[n]) + } + } +} + +func TestUnresolvableListenerFlagIsAnError(t *testing.T) { + noPackages(t) + m := fakeModule(t, `package cmd + +import "x/coordinator" + +func init() { coordinator.RegisterListener("a", flagName(), ":1", nil) } +`) + if _, err := flagRows(m, []Command{{Key: "serve", Files: []string{"serve_x.go"}}}); err == nil { + t.Fatal("a listener flag that cannot be resolved was skipped") + } +} diff --git a/scripts/test_generate_wiki_plugin_page.py b/scripts/test_generate_wiki_plugin_page.py index 7bc632059..29d5542c1 100644 --- a/scripts/test_generate_wiki_plugin_page.py +++ b/scripts/test_generate_wiki_plugin_page.py @@ -256,6 +256,21 @@ def test_region_follows_catalog_region_with_a_row_per_command(self): self.assertIn("| `nself ci nodes provision` | `nself_ci_nodes_provision` | `/v1/commands/ci/nodes/provision` |", text) self.assertIn("| `nself ci serve` | - | - | read | document | CLI only (json none) | none |", text) + def test_bare_hub_command_has_its_own_row(self): + """`nself ci` (tool nself_ci) is a command too: a manifest with subcommands still gets the hub's own row.""" + self.generate_all() + text = self.page("CI.md") + self.assertIn("| `nself ci` | `nself_ci` | `/v1/commands/ci` | read | document | MCP and HTTP | none |", text) + rows = [r for r in text.splitlines() if r.startswith("| `nself ci")] + self.assertEqual(rows[0].split("|")[1].strip(), "`nself ci`", "the bare command sorts before its subcommands") + + def test_bare_hub_row_carries_its_confirmation(self): + manifest = ci_manifest(["run"]) + manifest["commands"].update({"side_effect": "remote", "confirm": {"flags": []}}) + rows = dict(gen.machine_rows(manifest)) + self.assertEqual(sorted(rows), ["ci", "ci run"]) + self.assertEqual(gen.exposure(rows["ci"]), ("MCP and HTTP", "", "nonce")) + def test_stream_read_row_is_http_only(self): self.write_manifest(["run"]) path = os.path.join(self.root, "free", "ci", "plugin.json") diff --git a/scripts/wiki_plugin_surface.py b/scripts/wiki_plugin_surface.py index 23576033b..7a281cdc5 100644 --- a/scripts/wiki_plugin_surface.py +++ b/scripts/wiki_plugin_surface.py @@ -61,14 +61,15 @@ def exposure(cmd): def machine_rows(manifest): - """Rows (path, cmd) of a manifest `commands` block; subcommands inherit the hub's fields.""" + """Rows (path, cmd) of a manifest `commands` block: the bare hub command (`nself ci`, tool `nself_ci`), then + each subcommand, which inherits the hub's fields.""" top = manifest.get("commands") or {} subs = [s for s in top.get("subcommands") or [] if isinstance(s, dict)] keys = ("json", "output", "side_effect", "surface", "confirm") base = {k: top[k] for k in keys if k in top} rows = [(f"{top['command']} {s['name']}", {**base, **{k: s[k] for k in keys if k in s}}) for s in subs] - if not rows and top.get("command"): - rows = [(top["command"], base)] + if top.get("command"): + rows.append((top["command"], base)) return sorted(rows, key=lambda r: r[0])