argsbarg 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +4 -6
  3. package/docs/ai-skills.md +1 -1
  4. package/docs/bundled-docs.md +18 -4
  5. package/docs/install.md +51 -4
  6. package/docs/mcp.md +4 -6
  7. package/examples/minimal.ts +6 -0
  8. package/examples/nested.ts +3 -0
  9. package/index.d.ts +93 -1
  10. package/package.json +1 -1
  11. package/plan.md +10 -185
  12. package/src/builtins/completion-bash.ts +1 -8
  13. package/src/builtins/completion-fish.ts +0 -5
  14. package/src/builtins/completion-zsh.ts +1 -9
  15. package/src/builtins/dispatch.ts +27 -0
  16. package/src/builtins/export.ts +4 -0
  17. package/src/builtins/install.ts +9 -3
  18. package/src/builtins/presentation.ts +4 -0
  19. package/src/builtins/shell-helpers.ts +0 -2
  20. package/src/builtins/update.ts +14 -0
  21. package/src/capabilities.ts +7 -1
  22. package/src/docs/api-guide.test.ts +55 -0
  23. package/src/docs/api-guide.ts +129 -0
  24. package/src/docs/builtin.ts +3 -0
  25. package/src/docs/docs.test.ts +47 -1
  26. package/src/docs/mcp-guide.ts +3 -3
  27. package/src/docs/resolve.ts +32 -1
  28. package/src/headless.test.ts +86 -0
  29. package/src/headless.ts +86 -0
  30. package/src/help.ts +3 -7
  31. package/src/index.test.ts +36 -65
  32. package/src/index.ts +20 -0
  33. package/src/install/binary.ts +8 -3
  34. package/src/install/gh-release-update.test.ts +22 -0
  35. package/src/install/gh-release-update.ts +229 -0
  36. package/src/install/index.ts +55 -30
  37. package/src/install/plan.ts +5 -3
  38. package/src/install/update.test.ts +106 -0
  39. package/src/install/update.ts +55 -0
  40. package/src/invoke.ts +1 -11
  41. package/src/mcp/tools.ts +1 -1
  42. package/src/parse.ts +0 -27
  43. package/src/runtime.ts +7 -6
  44. package/src/schema.ts +7 -2
  45. package/src/skill/generate.ts +2 -2
  46. package/src/types.ts +17 -0
  47. package/src/validate.ts +11 -6
@@ -15,13 +15,15 @@ import { buildUninstallPlan, uninstallSkillDir, type UninstallAction } from "./u
15
15
 
16
16
  function parseInstallOpts(raw: Record<string, string>): InstallOpts {
17
17
  const flag = (name: string) => raw[name] === "1";
18
+ const reinstall = flag("reinstall") || flag("update");
18
19
  return {
19
20
  all: flag("all"),
20
21
  bin: flag("bin"),
21
22
  completions: flag("completions"),
22
23
  skill: flag("skill"),
23
24
  mcp: flag("mcp"),
24
- update: flag("update"),
25
+ reinstall,
26
+ from: raw.from,
25
27
  status: flag("status"),
26
28
  uninstall: flag("uninstall"),
27
29
  yes: flag("yes"),
@@ -36,28 +38,32 @@ function validateOpts(opts: InstallOpts): string | null {
36
38
  if (opts.quiet && opts.dry) {
37
39
  return "--quiet cannot be combined with --dry.";
38
40
  }
39
- if (opts.quiet && !opts.yes && !opts.json && !opts.update) {
40
- return "--quiet requires --yes (or --json / --update).";
41
+ if (opts.quiet && !opts.yes && !opts.json && !opts.reinstall) {
42
+ return "--quiet requires --yes (or --json / --reinstall).";
41
43
  }
42
44
  if (opts.json) {
43
45
  opts.yes = true;
44
46
  }
45
- if (opts.update) {
47
+ if (opts.reinstall) {
46
48
  opts.bin = true;
47
49
  opts.yes = true;
48
50
  }
49
51
 
50
- const mutationFlags = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.update || opts.uninstall;
52
+ const mutationFlags =
53
+ opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.reinstall || opts.uninstall;
51
54
  if (opts.status && mutationFlags) {
52
- return "--status is mutually exclusive with install/update/uninstall targets.";
55
+ return "--status is mutually exclusive with install/reinstall/uninstall targets.";
53
56
  }
54
- if (opts.update && (opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.uninstall || opts.status)) {
55
- return "--update cannot be combined with other target flags.";
57
+ if (
58
+ opts.reinstall &&
59
+ (opts.all || opts.completions || opts.skill || opts.mcp || opts.uninstall || opts.status)
60
+ ) {
61
+ return "--reinstall cannot be combined with other target flags.";
56
62
  }
57
- if (opts.uninstall && (opts.all || opts.update || opts.status)) {
58
- return "--uninstall cannot be combined with --all, --update, or --status.";
63
+ if (opts.uninstall && (opts.all || opts.reinstall || opts.status)) {
64
+ return "--uninstall cannot be combined with --all, --reinstall, or --status.";
59
65
  }
60
- if (!opts.status && !opts.update && !opts.uninstall) {
66
+ if (!opts.status && !opts.reinstall && !opts.uninstall) {
61
67
  const hasTarget = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp;
62
68
  if (!hasTarget) {
63
69
  return "Specify at least one target: --all, --bin, --completions, --skill, or --mcp.";
@@ -114,31 +120,31 @@ function executePlan(
114
120
  return changed;
115
121
  }
116
122
 
117
- /** Main install command orchestrator. */
118
- export async function cliInstall(root: CliProgram, rawOpts: Record<string, string>): Promise<never> {
123
+ /** Runs install/reinstall/uninstall mutations without exiting the process. */
124
+ export async function runInstallMutation(
125
+ root: CliProgram,
126
+ rawOpts: Record<string, string>,
127
+ ): Promise<{ changed: string[]; opts: InstallOpts; paths: ReturnType<typeof resolveInstallPaths> }> {
119
128
  const opts = parseInstallOpts(rawOpts);
120
129
  const err = validateOpts(opts);
121
130
  if (err) {
122
- installErr(err);
123
- process.exit(1);
131
+ throw new Error(err);
124
132
  }
125
133
 
126
134
  const paths = resolveInstallPaths(root, opts);
127
135
 
128
136
  if (opts.status) {
129
137
  printInstallStatus(root, opts);
130
- process.exit(0);
138
+ return { changed: [], opts, paths };
131
139
  }
132
140
 
133
- // MCP conflict checks before planning
134
141
  if (!opts.uninstall && resolveCapabilities(root).mcp && (opts.all || opts.mcp)) {
135
142
  const entry = expectedMcpEntry(root);
136
143
  const yes = !!opts.yes;
137
144
  for (const p of [paths.cursorMcpPath, paths.claudeMcpPath]) {
138
145
  const conflict = checkMcpConflict(p, paths.mcpName, entry, yes);
139
146
  if (conflict) {
140
- installErr(conflict);
141
- process.exit(1);
147
+ throw new Error(conflict);
142
148
  }
143
149
  }
144
150
  }
@@ -146,37 +152,52 @@ export async function cliInstall(root: CliProgram, rawOpts: Record<string, strin
146
152
  let actions: Array<InstallAction | UninstallAction>;
147
153
  if (opts.uninstall) {
148
154
  actions = buildUninstallPlan(root, paths, opts);
149
- } else if (opts.update) {
155
+ } else if (opts.reinstall) {
150
156
  actions = buildUpdatePlan(root, paths, opts);
151
157
  } else {
152
158
  actions = buildInstallPlan(root, paths, opts);
153
159
  }
154
160
 
155
161
  if (actions.length === 0) {
156
- installErr("Nothing to do.");
157
- process.exit(1);
162
+ throw new Error("Nothing to do.");
158
163
  }
159
164
 
160
165
  if (!opts.quiet && !opts.json) {
161
- installOut("About to " + (opts.uninstall ? "remove" : opts.update ? "update" : "install") + ":", opts);
166
+ installOut("About to " + (opts.uninstall ? "remove" : opts.reinstall ? "reinstall" : "install") + ":", opts);
162
167
  for (const a of actions) {
163
168
  installOut(" - " + a.summary, opts);
164
169
  }
165
170
  }
166
171
 
167
- const autoYes = opts.yes || opts.json || opts.update;
172
+ const autoYes = opts.yes || opts.json || opts.reinstall;
168
173
  if (!autoYes) {
169
174
  if (!process.stdin.isTTY) {
170
- installErr("Refusing to proceed without --yes (stdin is not a TTY).");
171
- process.exit(1);
175
+ throw new Error("Refusing to proceed without --yes (stdin is not a TTY).");
172
176
  }
173
177
  if (!promptConfirm()) {
174
- installErr("Aborted.");
175
- process.exit(1);
178
+ throw new Error("Aborted.");
176
179
  }
177
180
  }
178
181
 
179
182
  const changed = executePlan(root, actions, opts);
183
+ return { changed, opts, paths };
184
+ }
185
+
186
+ /** Main install command orchestrator. */
187
+ export async function cliInstall(root: CliProgram, rawOpts: Record<string, string>): Promise<never> {
188
+ let result: Awaited<ReturnType<typeof runInstallMutation>>;
189
+ try {
190
+ result = await runInstallMutation(root, rawOpts);
191
+ } catch (err) {
192
+ installErr(err instanceof Error ? err.message : String(err));
193
+ process.exit(1);
194
+ }
195
+
196
+ const { changed, opts, paths } = result;
197
+
198
+ if (opts.status) {
199
+ process.exit(0);
200
+ }
180
201
 
181
202
  if (opts.json) {
182
203
  process.stdout.write(JSON.stringify(changed, null, 2) + "\n");
@@ -184,9 +205,13 @@ export async function cliInstall(root: CliProgram, rawOpts: Record<string, strin
184
205
  }
185
206
 
186
207
  if (!opts.quiet && changed.length > 0) {
187
- const verb = opts.uninstall ? "Removed" : opts.update ? "Updated" : "Installed";
208
+ const verb = opts.uninstall ? "Removed" : opts.reinstall ? "Reinstalled" : "Installed";
188
209
  installOut(`${verb} ${changed.length} file(s).`, opts);
189
- if (!opts.uninstall && (opts.all || opts.bin) && changed.some((p) => p === paths.bashRc || p === paths.zshRc || p === paths.binaryPath)) {
210
+ if (
211
+ !opts.uninstall &&
212
+ (opts.all || opts.bin) &&
213
+ changed.some((p) => p === paths.bashRc || p === paths.zshRc || p === paths.binaryPath)
214
+ ) {
190
215
  installOut("Open a new shell, or run: hash -r (bash) / rehash (zsh)", opts);
191
216
  }
192
217
  }
@@ -15,7 +15,8 @@ export interface InstallOpts {
15
15
  completions?: boolean;
16
16
  skill?: boolean;
17
17
  mcp?: boolean;
18
- update?: boolean;
18
+ reinstall?: boolean;
19
+ from?: string;
19
20
  status?: boolean;
20
21
  uninstall?: boolean;
21
22
  yes?: boolean;
@@ -35,7 +36,7 @@ export interface InstallAction {
35
36
  }
36
37
 
37
38
  function wantsBin(opts: InstallOpts): boolean {
38
- return !!(opts.all || opts.bin || opts.update);
39
+ return !!(opts.all || opts.bin || opts.reinstall);
39
40
  }
40
41
 
41
42
  function wantsCompletions(opts: InstallOpts): boolean {
@@ -56,11 +57,12 @@ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: In
56
57
  const dry = !!opts.dry;
57
58
 
58
59
  if (wantsBin(opts)) {
60
+ const sourcePath = opts.from ?? process.execPath;
59
61
  actions.push({
60
62
  kind: "binary",
61
63
  summary: `binary: ${paths.binaryPath}`,
62
64
  message: `Installing binary to ${paths.binaryPath}`,
63
- run: () => installBinary(root, paths, dry).changedFiles,
65
+ run: () => installBinary(root, paths, dry, sourcePath).changedFiles,
64
66
  });
65
67
  }
66
68
 
@@ -0,0 +1,106 @@
1
+ import { expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { cliPresentationRoot } from "../builtins/presentation.ts";
6
+ import { cliInvoke } from "../index.ts";
7
+ import type { CliProgram, CliUpdateArtifact } from "../types.ts";
8
+ import { cliValidateProgram } from "../validate.ts";
9
+ import { parseInstallOpts, runInstallMutation } from "./index.ts";
10
+
11
+ let home: string;
12
+ let prevHome: string | undefined;
13
+ let prevExecPath: string;
14
+
15
+ beforeEach(() => {
16
+ home = mkdtempSync(join(tmpdir(), "argsbarg-update-"));
17
+ prevHome = process.env.HOME;
18
+ process.env.HOME = home;
19
+ prevExecPath = process.execPath;
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (prevHome === undefined) delete process.env.HOME;
24
+ else process.env.HOME = prevHome;
25
+ process.execPath = prevExecPath;
26
+ rmSync(home, { recursive: true, force: true });
27
+ });
28
+
29
+ function fixtureWithUpdate(hook: () => Promise<CliUpdateArtifact>): CliProgram {
30
+ return {
31
+ key: "testapp",
32
+ version: "1.0.0",
33
+ description: "Test",
34
+ install: { updateGetLatest: hook },
35
+ commands: [{ key: "run", description: "Run", handler: () => {} }],
36
+ };
37
+ }
38
+
39
+ test("update reserved when updateGetLatest is set", () => {
40
+ const root: CliProgram = {
41
+ ...fixtureWithUpdate(async () => ({ path: process.execPath })),
42
+ commands: [{ key: "update", description: "conflict", handler: () => {} }],
43
+ };
44
+ expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: update/);
45
+ });
46
+
47
+ test("presentation includes update builtin when hook is set", () => {
48
+ const root = fixtureWithUpdate(async () => ({ path: process.execPath }));
49
+ const presentation = cliPresentationRoot(root);
50
+ expect(presentation.commands.some((c) => c.key === "update")).toBe(true);
51
+ });
52
+
53
+ test("parseInstallOpts maps deprecated --update to reinstall", () => {
54
+ const opts = parseInstallOpts({ update: "1" });
55
+ expect(opts.reinstall).toBe(true);
56
+ });
57
+
58
+ test("runInstallMutation honors --from for binary copy", async () => {
59
+ const root: CliProgram = {
60
+ key: "testapp",
61
+ version: "1.0.0",
62
+ description: "Test",
63
+ handler: () => {},
64
+ };
65
+ const source = join(home, "new-binary");
66
+ writeFileSync(source, "#!/bin/sh\necho hi\n", "utf8");
67
+ chmodSync(source, 0o755);
68
+
69
+ const { changed } = await runInstallMutation(root, {
70
+ reinstall: "1",
71
+ yes: "1",
72
+ quiet: "1",
73
+ from: source,
74
+ });
75
+
76
+ const dest = join(home, ".local", "bin", "testapp");
77
+ expect(changed).toContain(dest);
78
+ expect(readFileSync(dest, "utf8")).toContain("echo hi");
79
+ });
80
+
81
+ test("cliInvoke update uses hook and reinstalls", async () => {
82
+ const source = join(home, "new-binary");
83
+ writeFileSync(source, "#!/bin/sh\necho hi\n", "utf8");
84
+ chmodSync(source, 0o755);
85
+
86
+ const root = fixtureWithUpdate(async () => ({
87
+ path: source,
88
+ version: "2.0.0",
89
+ }));
90
+
91
+ const result = await cliInvoke(root, ["update"]);
92
+ expect(result.exitCode).toBe(0);
93
+ expect(result.stdout).toContain("Updated testapp 1.0.0 → 2.0.0");
94
+ expect(existsSync(join(home, ".local", "bin", "testapp"))).toBe(true);
95
+ });
96
+
97
+ test("cliInvoke update reports already current", async () => {
98
+ const root = fixtureWithUpdate(async () => ({
99
+ path: process.execPath,
100
+ version: "1.0.0",
101
+ }));
102
+
103
+ const result = await cliInvoke(root, ["update"]);
104
+ expect(result.exitCode).toBe(0);
105
+ expect(result.stdout).toContain("Already at v1.0.0");
106
+ });
@@ -0,0 +1,55 @@
1
+ import { existsSync } from "node:fs";
2
+ import type { CliProgram } from "../types.ts";
3
+ import { runInstallMutation } from "./index.ts";
4
+ import { installErr } from "./status.ts";
5
+
6
+ /** Downloads the latest release and reinstalls installed artifacts (`myapp update`). */
7
+ export async function cliUpdate(root: CliProgram): Promise<never> {
8
+ const hook = root.install?.updateGetLatest;
9
+ if (!hook) {
10
+ installErr("update is not configured. Set install.updateGetLatest on the program root.");
11
+ process.exit(1);
12
+ }
13
+
14
+ let artifact: Awaited<ReturnType<typeof hook>>;
15
+ try {
16
+ artifact = await hook({ version: root.version });
17
+ } catch (err) {
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ installErr(message);
20
+ process.exit(1);
21
+ }
22
+
23
+ if (!artifact.path || !existsSync(artifact.path)) {
24
+ installErr(`updateGetLatest returned missing binary: ${JSON.stringify(artifact.path)}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ if (artifact.version !== undefined && artifact.version === root.version) {
29
+ process.stdout.write(`Already at v${root.version}\n`);
30
+ await artifact.cleanup?.();
31
+ process.exit(0);
32
+ }
33
+
34
+ const currentVersion = root.version;
35
+ try {
36
+ await runInstallMutation(root, {
37
+ reinstall: "1",
38
+ yes: "1",
39
+ quiet: "1",
40
+ from: artifact.path,
41
+ });
42
+ } catch (err) {
43
+ await artifact.cleanup?.();
44
+ installErr(err instanceof Error ? err.message : String(err));
45
+ process.exit(1);
46
+ }
47
+
48
+ await artifact.cleanup?.();
49
+
50
+ if (artifact.version !== undefined && artifact.version !== currentVersion) {
51
+ process.stdout.write(`Updated ${root.key} ${currentVersion} → ${artifact.version}\n`);
52
+ }
53
+
54
+ process.exit(0);
55
+ }
package/src/invoke.ts CHANGED
@@ -12,7 +12,7 @@ import { type CliNode, type CliProgram, isCliLeaf, isCliRouter } from "./types.t
12
12
  import { format } from "node:util";
13
13
 
14
14
  /** Outcome of a non-exiting CLI invocation. */
15
- export type CliInvokeKind = "ok" | "help" | "schema" | "error";
15
+ export type CliInvokeKind = "ok" | "help" | "error";
16
16
 
17
17
  /** Result of cliInvoke: captured output and exit metadata without process.exit. */
18
18
  export interface CliInvokeResult {
@@ -74,16 +74,6 @@ export async function cliInvoke(root: CliProgram, argv: string[]): Promise<CliIn
74
74
  };
75
75
  }
76
76
 
77
- if (pr.kind === ParseKind.Schema) {
78
- return {
79
- kind: "schema",
80
- exitCode: 1,
81
- stdout: "",
82
- stderr: "",
83
- errorMsg: "Schema export is not available via MCP tool calls.",
84
- };
85
- }
86
-
87
77
  if (pr.kind === ParseKind.Error) {
88
78
  return {
89
79
  kind: "error",
package/src/mcp/tools.ts CHANGED
@@ -134,7 +134,7 @@ export function allMcpResources(root: CliProgram): McpResourceEntry[] {
134
134
  const builtIn: McpResourceEntry = {
135
135
  uri: schemaUri,
136
136
  name: "cli-schema",
137
- description: "Full CLI command tree (same as --schema).",
137
+ description: "Full CLI command tree (same as docs schema).",
138
138
  mimeType: "application/json",
139
139
  load: () => cliSchemaJson(root),
140
140
  };
package/src/parse.ts CHANGED
@@ -29,8 +29,6 @@ export enum ParseKind {
29
29
  Ok = "ok",
30
30
  /** User requested help (explicit or implicit). */
31
31
  Help = "help",
32
- /** User requested machine-readable schema export (`--schema`). */
33
- Schema = "schema",
34
32
  /** User error (unknown command, bad option, etc.). */
35
33
  Error = "error",
36
34
  }
@@ -59,18 +57,12 @@ export interface ParseResult {
59
57
 
60
58
  const helpShort = "-h";
61
59
  const helpLong = "--help";
62
- const schemaLong = "--schema";
63
60
 
64
61
  /** Returns true if the argv token is `-h` or `--help`. */
65
62
  function isHelpTok(tok: string): boolean {
66
63
  return tok === helpShort || tok === helpLong;
67
64
  }
68
65
 
69
- /** Returns true if the argv token is `--schema`. */
70
- function isSchemaTok(tok: string): boolean {
71
- return tok === schemaLong;
72
- }
73
-
74
66
  /** Looks up a subcommand or routing node by `key`. */
75
67
  function findChild(cmds: CliNode[], name: string): CliNode | undefined {
76
68
  return cmds.find((c) => c.key === name);
@@ -195,7 +187,6 @@ function consumeOptions(
195
187
  const tok = argv[idx];
196
188
 
197
189
  if (isHelpTok(tok)) break;
198
- if (isSchemaTok(tok)) break;
199
190
  if (!tok.startsWith("-")) break;
200
191
 
201
192
  if (tok === "--") {
@@ -371,20 +362,6 @@ function helpResult(p: string[], explicit: boolean): ParseResult {
371
362
  };
372
363
  }
373
364
 
374
- /** Builds a schema-export result for the program root. */
375
- function schemaResult(): ParseResult {
376
- return {
377
- kind: ParseKind.Schema,
378
- path: [],
379
- opts: {},
380
- args: [],
381
- helpExplicit: false,
382
- helpPath: [],
383
- errorMsg: "",
384
- errorHelpPath: [],
385
- };
386
- }
387
-
388
365
  /**
389
366
  * Parses `argv` against the program root, routing into subcommands and filling `opts` / `args`.
390
367
  */
@@ -420,10 +397,6 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
420
397
  return helpResult([], true);
421
398
  }
422
399
 
423
- if (i < argv.length && !forcePositionals && isSchemaTok(argv[i])) {
424
- return schemaResult();
425
- }
426
-
427
400
  // Determine which subcommand to route to
428
401
  let cmdName: string;
429
402
  let node: CliNode | undefined;
package/src/runtime.ts CHANGED
@@ -10,7 +10,6 @@ import { type CliNode, type CliProgram, isCliLeaf, isCliRouter } from "./types.t
10
10
  import { CliContext } from "./context.ts";
11
11
  import { cliHelpRender } from "./help.ts";
12
12
  import { parse, postParseValidate, ParseKind } from "./parse.ts";
13
- import { cliSchemaJson } from "./schema.ts";
14
13
  import { cliValidateProgram } from "./validate.ts";
15
14
 
16
15
  function cliRootMergedWithBuiltins(program: CliProgram): CliRouter {
@@ -41,6 +40,13 @@ export async function cliRun(program: CliProgram, argv: string[] = process.argv.
41
40
  process.exit(1);
42
41
  }
43
42
 
43
+ if (argv.length >= 1 && argv[0] === "update" && !caps.update) {
44
+ process.stderr.write(
45
+ "update is not enabled. Set install.updateGetLatest on the program root.\n",
46
+ );
47
+ process.exit(1);
48
+ }
49
+
44
50
  if (argv.length >= 1 && argv[0] === "docs" && !caps.docs) {
45
51
  process.stderr.write("docs is not enabled. Set docs: { enabled: true } on the program root.\n");
46
52
  process.exit(1);
@@ -73,11 +79,6 @@ export async function cliRun(program: CliProgram, argv: string[] = process.argv.
73
79
  process.exit(pr.helpExplicit ? 0 : 1);
74
80
  }
75
81
 
76
- if (pr.kind === ParseKind.Schema) {
77
- process.stdout.write(cliSchemaJson(program));
78
- process.exit(0);
79
- }
80
-
81
82
  if (pr.kind === "error") {
82
83
  const color = process.stderr.isTTY;
83
84
  const msg = color ? `\u001B[31m${pr.errorMsg}\u001B[0m` : pr.errorMsg;
package/src/schema.ts CHANGED
@@ -5,7 +5,7 @@ This module serializes the CLI schema tree to JSON for machine-readable introspe
5
5
  import { type CliNode, type CliProgram, isCliLeaf, isCliRouter } from "./types.ts";
6
6
  import { exportPresentationBuiltins, type CliSchemaExport } from "./builtins/export.ts";
7
7
 
8
- const RESERVED = new Set(["completion", "install", "docs", "mcp", "version"]);
8
+ const RESERVED = new Set(["completion", "install", "docs", "mcp", "version", "update"]);
9
9
 
10
10
  function exportCommand(cmd: CliNode): CliSchemaExport {
11
11
  const out: CliSchemaExport = {
@@ -44,8 +44,13 @@ function exportCommand(cmd: CliNode): CliSchemaExport {
44
44
  return out;
45
45
  }
46
46
 
47
+ /** Returns the JSON-safe command tree (handlers omitted). */
48
+ export function cliSchemaExport(root: CliProgram): CliSchemaExport {
49
+ return exportCommand(root);
50
+ }
51
+
47
52
  export function cliSchemaJson(root: CliProgram): string {
48
- return JSON.stringify(exportCommand(root), null, 2) + "\n";
53
+ return JSON.stringify(cliSchemaExport(root), null, 2) + "\n";
49
54
  }
50
55
 
51
56
  export type { CliSchemaExport };
@@ -96,7 +96,7 @@ function buildSkillMd(root: CliProgram, target: SkillTarget, dirName: string): s
96
96
  "",
97
97
  "## Reference",
98
98
  "",
99
- "See `reference.md` in this skill directory for the full `--schema` JSON export.",
99
+ "See `reference.md` in this skill directory for the full `docs schema` JSON export.",
100
100
  "",
101
101
  );
102
102
 
@@ -130,7 +130,7 @@ function buildReferenceMd(root: CliProgram): string {
130
130
  return [
131
131
  `# ${root.key} — CLI reference`,
132
132
  "",
133
- "Generated from the program `--schema` export. Handlers and runtime-only nodes are omitted.",
133
+ "Generated from the program `docs schema` export. Handlers and runtime-only nodes are omitted.",
134
134
  "",
135
135
  "```json",
136
136
  cliSchemaJson(root).trimEnd(),
package/src/types.ts CHANGED
@@ -153,11 +153,28 @@ export interface CliMcpToolConfig {
153
153
  /**
154
154
  * Opt-out and defaults for the `install` built-in (program root only).
155
155
  */
156
+ export interface CliUpdateArtifact {
157
+ /** Path to an executable binary to copy into the install location. */
158
+ path: string;
159
+ /** Release version of `path` (used for already-current checks and success messages). */
160
+ version?: string;
161
+ /** Called after reinstall completes (e.g. remove a temp download directory). */
162
+ cleanup?: () => void | Promise<void>;
163
+ }
164
+
165
+ /** Fetches the latest release binary for the `update` built-in. */
166
+ export type CliUpdateGetLatest = (ctx: { version: string }) => Promise<CliUpdateArtifact>;
167
+
156
168
  export interface CliInstallConfig {
157
169
  /** When `false`, hide/disable `install` (default: enabled). */
158
170
  enabled?: boolean;
159
171
  /** Default bin directory (default: `~/.local/bin`). Overridden by `INSTALL_PREFIX` env and `--prefix`. */
160
172
  prefix?: string;
173
+ /**
174
+ * When set, enables the `update` built-in (`myapp update`).
175
+ * Should download or locate the latest release binary and return its path.
176
+ */
177
+ updateGetLatest?: CliUpdateGetLatest;
161
178
  }
162
179
 
163
180
  /**
package/src/validate.ts CHANGED
@@ -63,6 +63,17 @@ export function cliValidateProgram(program: CliProgram): void {
63
63
  validateDocsConfig(program.docs);
64
64
  }
65
65
 
66
+ if (program.install?.updateGetLatest !== undefined) {
67
+ if (program.install.enabled === false) {
68
+ throw new CliSchemaValidationError(
69
+ "install.updateGetLatest requires install to be enabled (omit install.enabled: false)",
70
+ );
71
+ }
72
+ if (typeof program.install.updateGetLatest !== "function") {
73
+ throw new CliSchemaValidationError("install.updateGetLatest must be a function");
74
+ }
75
+ }
76
+
66
77
  const caps = resolveCapabilities(program);
67
78
  const reserved = reservedCommandNames(caps);
68
79
 
@@ -166,12 +177,6 @@ function validateOptions(scopeKey: string, options: import("./types.ts").CliOpti
166
177
  );
167
178
  }
168
179
 
169
- if (opt.name === "schema") {
170
- throw new CliSchemaValidationError(
171
- `Option name "schema" is reserved for --schema: ${scopeKey}/${opt.name}`,
172
- );
173
- }
174
-
175
180
  if (opt.shortName !== undefined) {
176
181
  if (opt.shortName === "h") {
177
182
  throw new CliSchemaValidationError(