argsbarg 3.6.4 → 4.0.1

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 (98) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/README.md +21 -9
  3. package/docs/README.md +12 -8
  4. package/docs/bundled-docs.md +1 -1
  5. package/docs/cli-program.md +62 -2
  6. package/docs/config-schema.md +192 -0
  7. package/docs/developing.md +13 -0
  8. package/docs/install.md +38 -1
  9. package/docs/mcp.md +43 -19
  10. package/docs/output-schema.md +74 -52
  11. package/docs/templates/cursor/rules/cli-program.mdc +10 -5
  12. package/examples/config-app/main.ts +20 -0
  13. package/examples/config-app/program.ts +81 -0
  14. package/examples/config-app/schema.ts +37 -0
  15. package/examples/config-app/types.ts +19 -0
  16. package/examples/consumer-app/README.md +56 -0
  17. package/examples/consumer-app/bun.lock +75 -0
  18. package/examples/consumer-app/capabilities.test.ts +69 -0
  19. package/examples/consumer-app/package.json +17 -0
  20. package/examples/consumer-app/schemas/configSchemas.ts +6 -0
  21. package/examples/consumer-app/schemas/generated/app-config.json +40 -0
  22. package/examples/consumer-app/schemas/generated/status.json +28 -0
  23. package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
  24. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
  25. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
  26. package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
  27. package/examples/consumer-app/scripts/schemagen.ts +76 -0
  28. package/examples/consumer-app/src/commands/status/types.ts +11 -0
  29. package/examples/consumer-app/src/main.ts +15 -0
  30. package/examples/consumer-app/src/program.ts +116 -0
  31. package/examples/consumer-app/src/types.ts +23 -0
  32. package/examples/consumer-app/tsconfig.json +14 -0
  33. package/examples/formats.ts +10 -3
  34. package/examples/mcp-test.ts +27 -8
  35. package/examples/minimal.ts +4 -3
  36. package/examples/nested.ts +5 -4
  37. package/examples/option-required.ts +8 -4
  38. package/index.d.ts +158 -75
  39. package/package.json +1 -1
  40. package/src/builtins/builtins.test.ts +13 -0
  41. package/src/builtins/config.test.ts +82 -0
  42. package/src/builtins/config.ts +220 -0
  43. package/src/builtins/dispatch.ts +18 -9
  44. package/src/builtins/export.ts +8 -33
  45. package/src/builtins/index.ts +1 -0
  46. package/src/builtins/install.ts +13 -0
  47. package/src/builtins/mcp.ts +1 -1
  48. package/src/builtins/presentation.ts +2 -17
  49. package/src/builtins/registry.ts +40 -0
  50. package/src/capabilities.ts +46 -0
  51. package/src/cli-errors.ts +15 -0
  52. package/src/cli.ts +389 -0
  53. package/src/config/bootstrap.ts +265 -0
  54. package/src/config/context.test.ts +79 -0
  55. package/src/config/context.ts +110 -0
  56. package/src/config/entry.ts +81 -0
  57. package/src/config/file.test.ts +112 -0
  58. package/src/config/file.ts +120 -0
  59. package/src/config/manifest.ts +62 -0
  60. package/src/config/resolve.test.ts +88 -0
  61. package/src/config/resolve.ts +167 -0
  62. package/src/config/schema.ts +101 -0
  63. package/src/config/validate.test.ts +63 -0
  64. package/src/config/validate.ts +292 -0
  65. package/src/config.integration.test.ts +100 -0
  66. package/src/context.ts +5 -0
  67. package/src/docs/docs.test.ts +16 -16
  68. package/src/docs/mcp-guide.ts +35 -11
  69. package/src/hidden-mcpb.test.ts +40 -2
  70. package/src/index.ts +4 -3
  71. package/src/install/index.ts +46 -3
  72. package/src/install/paths.ts +5 -20
  73. package/src/install/plan.ts +6 -0
  74. package/src/install/status.ts +12 -0
  75. package/src/install/uninstall.ts +11 -0
  76. package/src/install/update.test.ts +5 -5
  77. package/src/invoke.test.ts +207 -0
  78. package/src/mcp/bundle.ts +9 -116
  79. package/src/mcp/claude.test.ts +73 -0
  80. package/src/mcp/claude.ts +168 -0
  81. package/src/mcp/env.ts +3 -37
  82. package/src/mcp/server.ts +18 -10
  83. package/src/mcp/tools.ts +3 -7
  84. package/src/mcp/zip.ts +82 -0
  85. package/src/mcp.integration.test.ts +502 -0
  86. package/src/{index.test.ts → parse.test.ts} +24 -935
  87. package/src/paths/host.ts +40 -0
  88. package/src/schema.ts +1 -1
  89. package/src/skill/generate.ts +18 -5
  90. package/src/skill/hint.ts +18 -0
  91. package/src/skill/install.ts +1 -5
  92. package/src/test-fixtures.ts +192 -0
  93. package/src/types.ts +39 -13
  94. package/src/validate.ts +70 -0
  95. package/src/completion.ts +0 -13
  96. package/src/invoke.ts +0 -217
  97. package/src/mcp.ts +0 -28
  98. package/src/runtime.ts +0 -134
@@ -0,0 +1,100 @@
1
+ /*
2
+ App config bootstrap and MCP config enforcement regressions.
3
+ */
4
+
5
+ import { expect, test } from "bun:test";
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { bootstrapAppConfig } from "./config/bootstrap.ts";
10
+ import { mcpRequest, testProgram } from "./test-fixtures.ts";
11
+
12
+ test("bootstrapAppConfig prefers host env over config file", () => {
13
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-env-"));
14
+ const configFile = join(dir, "config");
15
+ writeFileSync(configFile, `${JSON.stringify({ foo: "fromfile", bar: "bar" })}\n`, "utf8");
16
+ process.env.FOO = "original";
17
+ try {
18
+ const p = testProgram({
19
+ key: "app",
20
+ version: "0",
21
+ description: "",
22
+ appConfig: {
23
+ path: configFile,
24
+ entries: {
25
+ foo: { description: "x", env: "FOO" },
26
+ bar: { description: "y", env: "BAR" },
27
+ },
28
+ },
29
+ handler: () => {},
30
+ });
31
+ bootstrapAppConfig(p, { validateFile: true });
32
+ expect(process.env.FOO).toBe("original");
33
+ expect(process.env.BAR).toBe("bar");
34
+ } finally {
35
+ delete process.env.FOO;
36
+ delete process.env.BAR;
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test("MCP program.appConfig fails when required config missing", async () => {
42
+ const responses = await mcpRequest(
43
+ [
44
+ {
45
+ jsonrpc: "2.0",
46
+ id: 13,
47
+ method: "tools/call",
48
+ params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
49
+ },
50
+ ],
51
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "" } },
52
+ );
53
+ const res = responses.get(13) as { result: { isError: boolean; content: { text: string }[] } };
54
+ expect(res.result.isError).toBe(true);
55
+ expect(res.result.content[0]?.text).toContain("argsTestSecret");
56
+ });
57
+
58
+ test("MCP program.appConfig succeeds when env present", async () => {
59
+ const responses = await mcpRequest(
60
+ [
61
+ {
62
+ jsonrpc: "2.0",
63
+ id: 14,
64
+ method: "tools/call",
65
+ params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
66
+ },
67
+ ],
68
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "sekrit" } },
69
+ );
70
+ const res = responses.get(14) as { result: { isError: boolean; content: { text: string }[] } };
71
+ expect(res.result.isError).toBe(false);
72
+ expect(res.result.content[0]?.text.trim()).toBe("sekrit");
73
+ });
74
+
75
+ test("MCP config file loads and exports vars for tool handlers", async () => {
76
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-mcp-"));
77
+ const configFile = join(dir, "config");
78
+ writeFileSync(
79
+ configFile,
80
+ `${JSON.stringify({ argsTestSecret: "file-value" }, null, 2)}\n`,
81
+ "utf8",
82
+ );
83
+ const responses = await mcpRequest(
84
+ [
85
+ {
86
+ jsonrpc: "2.0",
87
+ id: 15,
88
+ method: "tools/call",
89
+ params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
90
+ },
91
+ ],
92
+ {
93
+ script: "examples/mcp-test.ts",
94
+ env: { ARGS_TEST_CONFIG_FILE: configFile, ARGS_TEST_SECRET: "present" },
95
+ },
96
+ );
97
+ const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
98
+ expect(res.result.isError).toBe(false);
99
+ expect(res.result.content[0]?.text.trim()).toBe("present");
100
+ });
package/src/context.ts CHANGED
@@ -7,6 +7,8 @@ It keeps handlers small with a typed read API for flags, strings, numbers, and c
7
7
  parsed values.
8
8
  */
9
9
 
10
+ import type { AnyAppConfigSnapshot } from "./config/context.ts";
11
+ import { EmptyAppConfigSnapshot } from "./config/context.ts";
10
12
  import { parseCommaList, parseDate, parseDateTime, parseDurationMs } from "./formats.ts";
11
13
  import { collectOptionDefs } from "./parse.ts";
12
14
  import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram } from "./types.ts";
@@ -26,6 +28,7 @@ export class CliContext {
26
28
  readonly program: CliProgram;
27
29
  readonly opts: Record<string, string>;
28
30
  readonly invocation: CliInvocation;
31
+ readonly appConfig: AnyAppConfigSnapshot;
29
32
 
30
33
  /** Captures the program root, routed path, positional words, and option map for a leaf handler. */
31
34
  constructor(
@@ -35,6 +38,7 @@ export class CliContext {
35
38
  opts: Record<string, string>,
36
39
  program: CliProgram,
37
40
  invocation: CliInvocation = "cli",
41
+ appConfig: AnyAppConfigSnapshot = new EmptyAppConfigSnapshot(program),
38
42
  ) {
39
43
  this.appName = appName;
40
44
  this.commandPath = commandPath;
@@ -42,6 +46,7 @@ export class CliContext {
42
46
  this.opts = opts;
43
47
  this.program = program;
44
48
  this.invocation = invocation;
49
+ this.appConfig = appConfig;
45
50
  }
46
51
 
47
52
  /** Returns whether a presence flag was set (including implicit "1" for boolean options). */
@@ -2,9 +2,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { completionBashScript } from "../builtins/index.ts";
5
6
  import { cliPresentationRoot } from "../builtins/presentation.ts";
6
- import { completionBashScript } from "../completion.ts";
7
- import { cliInvoke } from "../index.ts";
7
+ import { Cli } from "../index.ts";
8
8
  import type { CliProgram } from "../types.ts";
9
9
  import { cliValidateProgram } from "../validate.ts";
10
10
  import { generateMcpGuide } from "./mcp-guide.ts";
@@ -80,14 +80,14 @@ test("docsEffectiveDefaultTopic uses first topic key", () => {
80
80
  expect(docsEffectiveDefaultTopic(docsFixture().docs!)).toBe("readme");
81
81
  });
82
82
 
83
- test("bare docs prints first topic via cliInvoke", async () => {
84
- const result = await cliInvoke(docsFixture(), ["docs"]);
83
+ test("bare docs prints first topic via Cli.invoke", async () => {
84
+ const result = await new Cli(docsFixture()).invoke(["docs"]);
85
85
  expect(result.exitCode).toBe(0);
86
86
  expect(result.stdout).toContain("Hello README");
87
87
  });
88
88
 
89
89
  test("docs readme prints bundled text", async () => {
90
- const result = await cliInvoke(docsFixture(), ["docs", "readme"]);
90
+ const result = await new Cli(docsFixture()).invoke(["docs", "readme"]);
91
91
  expect(result.exitCode).toBe(0);
92
92
  expect(result.stdout).toContain("Hello README");
93
93
  });
@@ -95,12 +95,12 @@ test("docs readme prints bundled text", async () => {
95
95
  test("docs defaultTopic override", async () => {
96
96
  const root = docsFixture();
97
97
  root.docs!.defaultTopic = "arch";
98
- const result = await cliInvoke(root, ["docs"]);
98
+ const result = await new Cli(root).invoke(["docs"]);
99
99
  expect(result.stdout).toContain("Architecture");
100
100
  });
101
101
 
102
102
  test("docs mcp when MCP enabled", async () => {
103
- const result = await cliInvoke(docsFixture(true), ["docs", "mcp"]);
103
+ const result = await new Cli(docsFixture(true)).invoke(["docs", "mcp"]);
104
104
  expect(result.exitCode).toBe(0);
105
105
  expect(result.stdout).toContain("MCP server (myapp)");
106
106
  expect(result.stdout).toContain("myapp mcp");
@@ -109,7 +109,7 @@ test("docs mcp when MCP enabled", async () => {
109
109
  });
110
110
 
111
111
  test("docs rejects unknown subcommand", async () => {
112
- const result = await cliInvoke(docsFixture(), ["docs", "all"]);
112
+ const result = await new Cli(docsFixture()).invoke(["docs", "all"]);
113
113
  expect(result.exitCode).not.toBe(0);
114
114
  });
115
115
 
@@ -121,7 +121,7 @@ test("docs mcp absent from router when MCP disabled", async () => {
121
121
  if (docsNode && "commands" in docsNode) {
122
122
  expect(docsNode.commands.some((c) => c.key === "mcp")).toBe(false);
123
123
  }
124
- const result = await cliInvoke(root, ["docs", "mcp"]);
124
+ const result = await new Cli(root).invoke(["docs", "mcp"]);
125
125
  expect(result.exitCode).not.toBe(0);
126
126
  });
127
127
 
@@ -135,7 +135,7 @@ test("presentation includes docs subtree", () => {
135
135
  });
136
136
 
137
137
  test("docs schema prints JSON", async () => {
138
- const result = await cliInvoke(docsFixture(), ["docs", "schema"]);
138
+ const result = await new Cli(docsFixture()).invoke(["docs", "schema"]);
139
139
  expect(result.exitCode).toBe(0);
140
140
  const schema = JSON.parse(result.stdout);
141
141
  expect(schema.key).toBe("myapp");
@@ -143,7 +143,7 @@ test("docs schema prints JSON", async () => {
143
143
  });
144
144
 
145
145
  test("docs api prints markdown reference", async () => {
146
- const result = await cliInvoke(docsFixture(), ["docs", "api"]);
146
+ const result = await new Cli(docsFixture()).invoke(["docs", "api"]);
147
147
  expect(result.exitCode).toBe(0);
148
148
  expect(result.stdout).toContain("# myapp — CLI API reference");
149
149
  expect(result.stdout).toContain("## `myapp run`");
@@ -152,7 +152,7 @@ test("docs api prints markdown reference", async () => {
152
152
  });
153
153
 
154
154
  test("docs skill prints Cursor SKILL.md", async () => {
155
- const result = await cliInvoke(docsFixture(), ["docs", "skill"]);
155
+ const result = await new Cli(docsFixture()).invoke(["docs", "skill"]);
156
156
  expect(result.exitCode).toBe(0);
157
157
  expect(result.stdout).toContain("---");
158
158
  expect(result.stdout).toContain("name: myapp");
@@ -209,7 +209,7 @@ test("generateMcpGuide includes schema URI and install targets", () => {
209
209
  });
210
210
 
211
211
  test("docs --save writes topic file", async () => {
212
- const result = await cliInvoke(docsFixture(), ["docs", "readme", "--save"]);
212
+ const result = await new Cli(docsFixture()).invoke(["docs", "readme", "--save"]);
213
213
  expect(result.exitCode).toBe(0);
214
214
  expect(result.stdout.trim()).toBe("docs/readme.md");
215
215
  const text = readFileSync(join(workDir, "docs/readme.md"), "utf8");
@@ -218,7 +218,7 @@ test("docs --save writes topic file", async () => {
218
218
  });
219
219
 
220
220
  test("docs api --save prepends generated hint", async () => {
221
- const result = await cliInvoke(docsFixture(), ["docs", "api", "--save"]);
221
+ const result = await new Cli(docsFixture()).invoke(["docs", "api", "--save"]);
222
222
  expect(result.exitCode).toBe(0);
223
223
  const text = readFileSync(join(workDir, "docs/api.md"), "utf8");
224
224
  expect(text.startsWith("<!-- Generated by myapp docs api --save; do not edit. -->\n\n")).toBe(
@@ -228,7 +228,7 @@ test("docs api --save prepends generated hint", async () => {
228
228
  });
229
229
 
230
230
  test("docs skill --save keeps frontmatter first", async () => {
231
- const result = await cliInvoke(docsFixture(), ["docs", "skill", "--save"]);
231
+ const result = await new Cli(docsFixture()).invoke(["docs", "skill", "--save"]);
232
232
  expect(result.exitCode).toBe(0);
233
233
  const text = readFileSync(join(workDir, "docs/skill.md"), "utf8");
234
234
  expect(text.startsWith("---\n")).toBe(true);
@@ -238,7 +238,7 @@ test("docs skill --save keeps frontmatter first", async () => {
238
238
  });
239
239
 
240
240
  test("docs schema --save writes JSON file", async () => {
241
- const result = await cliInvoke(docsFixture(), ["docs", "schema", "--save"]);
241
+ const result = await new Cli(docsFixture()).invoke(["docs", "schema", "--save"]);
242
242
  expect(result.exitCode).toBe(0);
243
243
  expect(result.stdout.trim()).toBe("docs/schema.json");
244
244
  const text = readFileSync(join(workDir, "docs/schema.json"), "utf8");
@@ -1,4 +1,6 @@
1
1
  import { resolveCapabilities } from "../capabilities.ts";
2
+ import { defaultConfigEntryTitle } from "../config/entry.ts";
3
+ import { displayAppConfigPath } from "../config/file.ts";
2
4
  import { expectedOpenCodeMcpEntry, OPENCODE_CONFIG_SCHEMA } from "../install/mcp-opencode.ts";
3
5
  import {
4
6
  collectMcpTools,
@@ -163,21 +165,43 @@ export function generateMcpGuide(root: CliProgram): string {
163
165
  "",
164
166
  );
165
167
 
166
- if (mcp.shellEnv || mcp.envFile) {
168
+ if (mcp.shellEnv) {
167
169
  lines.push("## Environment", "");
168
- if (mcp.shellEnv) {
169
- lines.push(
170
- "- **`shellEnv`** — captures login-shell environment at MCP startup (PATH, toolchain shims, exports).",
171
- );
172
- }
173
- if (mcp.envFile) {
174
- lines.push(
175
- `- **\`envFile\`** — loads \`${mcp.envFile}\` after shell env (overrides for its keys).`,
176
- );
177
- }
170
+ lines.push(
171
+ "- **`shellEnv`** — captures login-shell environment at MCP startup (PATH, toolchain shims, exports).",
172
+ );
178
173
  lines.push("");
179
174
  }
180
175
 
176
+ if (root.appConfig?.entries && Object.keys(root.appConfig.entries).length > 0) {
177
+ lines.push("## Configuration", "");
178
+ lines.push(
179
+ `Configure before first use in Cursor or Claude Desktop (MCP hosts are non-interactive): \`${root.key} install --configure\`.`,
180
+ "",
181
+ `Default config file: \`${displayAppConfigPath(root)}\` (flat JSON keys).`,
182
+ "",
183
+ );
184
+ for (const [key, entry] of Object.entries(root.appConfig.entries)) {
185
+ const label = entry.title ?? defaultConfigEntryTitle(key);
186
+ const req = entry.required === false ? "optional" : "required";
187
+ const envNote = entry.env ? ` → env \`${entry.env}\`` : "";
188
+ lines.push(`- **${label}** (\`${key}\`, ${req}${envNote}) — ${entry.description}`);
189
+ }
190
+ lines.push(
191
+ "",
192
+ "Example:",
193
+ "",
194
+ "```typescript",
195
+ "config: {",
196
+ " schema: {",
197
+ ' apiToken: { description: "…", env: "API_TOKEN", sensitive: true },',
198
+ " },",
199
+ "},",
200
+ "```",
201
+ "",
202
+ );
203
+ }
204
+
181
205
  lines.push(
182
206
  "## What agents get",
183
207
  "",
@@ -1,11 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { exportPresentationBuiltins } from "./builtins/export.ts";
6
6
  import { cliParseRoot, cliPresentationRoot } from "./builtins/presentation.ts";
7
7
  import { cliHelpRender } from "./help.ts";
8
- import { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
8
+ import {
9
+ defaultMcpBundlePaths,
10
+ generateMcpManifest,
11
+ packMcpBundle,
12
+ runMcpBundle,
13
+ } from "./mcp/bundle.ts";
9
14
  import { collectMcpTools } from "./mcp/tools.ts";
10
15
  import { cliSchemaExport } from "./schema.ts";
11
16
  import { CliOptionKind, type CliProgram } from "./types.ts";
@@ -149,4 +154,37 @@ describe("mcp bundle", () => {
149
154
  rmSync(work, { recursive: true, force: true });
150
155
  }
151
156
  });
157
+
158
+ test("runMcpBundle prints mcpb and plugin paths", () => {
159
+ const work = mkdtempSync(join(tmpdir(), "mcpb-run-"));
160
+ const stdout: string[] = [];
161
+ const orig = process.stdout.write.bind(process.stdout);
162
+ process.stdout.write = ((chunk: string | Uint8Array) => {
163
+ stdout.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
164
+ return true;
165
+ }) as typeof process.stdout.write;
166
+ try {
167
+ const dist = join(work, "dist");
168
+ mkdirSync(dist, { recursive: true });
169
+ writeFileSync(join(dist, "myapp"), "#!/bin/sh\n", { mode: 0o755 });
170
+ const prevCwd = process.cwd();
171
+ process.chdir(work);
172
+ try {
173
+ runMcpBundle(hiddenFixture);
174
+ } finally {
175
+ process.chdir(prevCwd);
176
+ }
177
+ const lines = stdout.join("").trim().split("\n");
178
+ const norm = (p: string) => realpathSync.native(p);
179
+ expect(lines.map(norm)).toEqual([
180
+ norm(join(dist, "myapp.mcpb")),
181
+ norm(join(dist, "claude-plugin", "myapp.zip")),
182
+ ]);
183
+ const zip = readFileSync(join(dist, "claude-plugin", "myapp.zip"));
184
+ expect(zip.indexOf(Buffer.from(".mcp.json"))).toBeGreaterThanOrEqual(0);
185
+ } finally {
186
+ process.stdout.write = orig;
187
+ rmSync(work, { recursive: true, force: true });
188
+ }
189
+ });
152
190
  });
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ It gives consumers one stable import path without forcing them to know the inter
7
7
  module layout.
8
8
  */
9
9
 
10
+ export { Cli, type CliInvokeKind, type CliInvokeResult } from "./cli.ts";
11
+ export { cliErrWithHelp } from "./cli-errors.ts";
10
12
  export type { CliLeafInputs } from "./context.ts";
11
13
  export { CliContext } from "./context.ts";
12
14
  export {
@@ -32,12 +34,11 @@ export {
32
34
  isAlreadyCurrent,
33
35
  parseReleaseTag,
34
36
  } from "./install/gh-release-update.ts";
35
- export type { CliInvokeKind, CliInvokeResult } from "./invoke.ts";
36
- export { cliInvoke } from "./invoke.ts";
37
37
  export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
38
38
  export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
39
- export { cliErrWithHelp, cliRun } from "./runtime";
40
39
  export type {
40
+ CliAppConfig,
41
+ CliAppConfigEntry,
41
42
  CliDocsConfig,
42
43
  CliDocsTopic,
43
44
  CliHandler,
@@ -1,5 +1,6 @@
1
1
  import { readSync } from "node:fs";
2
2
  import { resolveCapabilities } from "../capabilities.ts";
3
+ import { displayAppConfigPath, runInstallConfigure } from "../config/bootstrap.ts";
3
4
  import { cliSkillInstall } from "../skill/install.ts";
4
5
  import type { CliProgram } from "../types.ts";
5
6
  import { checkCodexMcpConflict } from "./mcp-codex.ts";
@@ -24,6 +25,8 @@ export function parseInstallOpts(raw: Record<string, string>): InstallOpts {
24
25
  from: raw.from,
25
26
  status: flag("status"),
26
27
  uninstall: flag("uninstall"),
28
+ configure: flag("configure"),
29
+ config: flag("config"),
27
30
  yes: flag("yes"),
28
31
  dry: flag("dry"),
29
32
  json: flag("json"),
@@ -33,6 +36,23 @@ export function parseInstallOpts(raw: Record<string, string>): InstallOpts {
33
36
  }
34
37
 
35
38
  export function validateInstallOpts(opts: InstallOpts): string | null {
39
+ if (opts.configure) {
40
+ if (
41
+ opts.all ||
42
+ opts.bin ||
43
+ opts.completions ||
44
+ opts.skill ||
45
+ opts.mcp ||
46
+ opts.config ||
47
+ opts.reinstall ||
48
+ opts.update ||
49
+ opts.uninstall ||
50
+ opts.status
51
+ ) {
52
+ return "--configure cannot be combined with other install flags.";
53
+ }
54
+ return null;
55
+ }
36
56
  if (opts.quiet && opts.dry) {
37
57
  return "--quiet cannot be combined with --dry.";
38
58
  }
@@ -90,10 +110,18 @@ export function validateInstallOpts(opts: InstallOpts): string | null {
90
110
  if (opts.uninstall && (opts.reinstall || opts.update || opts.status)) {
91
111
  return "--uninstall cannot be combined with --reinstall, --update, or --status.";
92
112
  }
93
- if (!opts.status && !opts.reinstall && !opts.update) {
94
- const hasTarget = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp;
113
+ if (opts.uninstall) {
114
+ const hasUninstallTarget =
115
+ opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.config;
116
+ if (!hasUninstallTarget) {
117
+ return "Specify at least one target: --all, --bin, --completions, --skill, --mcp, or --config.";
118
+ }
119
+ }
120
+ if (!opts.status && !opts.reinstall && !opts.update && !opts.uninstall) {
121
+ const hasTarget =
122
+ opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.config;
95
123
  if (!hasTarget) {
96
- return "Specify at least one target: --all, --bin, --completions, --skill, or --mcp.";
124
+ return "Specify at least one target: --all, --bin, --completions, --skill, --mcp, or --config.";
97
125
  }
98
126
  }
99
127
  return null;
@@ -242,6 +270,21 @@ export async function cliInstall(
242
270
  rawOpts: Record<string, string>,
243
271
  ): Promise<never> {
244
272
  const opts = parseInstallOpts(rawOpts);
273
+
274
+ if (opts.configure) {
275
+ if (!root.appConfig) {
276
+ installErr("install --configure requires program.appConfig on the program root.");
277
+ process.exit(1);
278
+ }
279
+ const result = runInstallConfigure(root);
280
+ if (result.changed) {
281
+ installOut(`Wrote config: ${displayAppConfigPath(root)}`, opts);
282
+ } else {
283
+ installOut(`Config unchanged: ${displayAppConfigPath(root)}`, opts);
284
+ }
285
+ process.exit(0);
286
+ }
287
+
245
288
  const err = validateInstallOpts(opts);
246
289
  if (err) {
247
290
  installErr(err);
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { dirname, join } from "node:path";
4
3
  import { mcpServerId, sanitizeToolSegment } from "../mcp/tools.ts";
4
+ import { expandTilde, userHome, xdgConfigHome } from "../paths/host.ts";
5
5
  import type { CliProgram } from "../types.ts";
6
6
  import { resolveOpenCodeConfigPathForInstall } from "./mcp-opencode.ts";
7
7
 
@@ -25,20 +25,7 @@ export interface InstallPaths {
25
25
  skillDirName: string;
26
26
  }
27
27
 
28
- /** Resolves the user home directory (`$HOME` when set). */
29
- export function userHome(): string {
30
- return process.env.HOME ?? homedir();
31
- }
32
-
33
- function expandTilde(path: string): string {
34
- if (path.startsWith("~/")) {
35
- return join(userHome(), path.slice(2));
36
- }
37
- if (path === "~") {
38
- return userHome();
39
- }
40
- return path;
41
- }
28
+ export { userHome } from "../paths/host.ts";
42
29
 
43
30
  /** Resolves the binary install directory from CLI flag, env, or config. */
44
31
  export function resolveBindir(root: CliProgram, prefix?: string): string {
@@ -58,8 +45,7 @@ export function resolveClaudeDesktopMcpPath(home: string): string {
58
45
  const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
59
46
  return join(appData, "Claude", "claude_desktop_config.json");
60
47
  }
61
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
62
- return join(xdgConfig, "Claude", "claude_desktop_config.json");
48
+ return join(xdgConfigHome(home), "Claude", "claude_desktop_config.json");
63
49
  }
64
50
 
65
51
  /** True when Claude Desktop app data exists (config file or app support directory). */
@@ -76,8 +62,7 @@ export function resolveChatGptMcpPath(home: string): string {
76
62
  const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
77
63
  return join(appData, "OpenAI", "ChatGPT", "chatgpt_mcp_config.json");
78
64
  }
79
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
80
- return join(xdgConfig, "ChatGPT", "chatgpt_mcp_config.json");
65
+ return join(xdgConfigHome(home), "ChatGPT", "chatgpt_mcp_config.json");
81
66
  }
82
67
 
83
68
  /** True when ChatGPT desktop app data exists (config file or app support directory). */
@@ -91,7 +76,7 @@ export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string })
91
76
  const bindir = resolveBindir(root, opts.prefix);
92
77
  const key = root.key;
93
78
  const skillDirName = sanitizeToolSegment(root.key);
94
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
79
+ const xdgConfig = xdgConfigHome(home);
95
80
  const claudeDesktopMcpPath = resolveClaudeDesktopMcpPath(home);
96
81
  const chatGptMcpPath = resolveChatGptMcpPath(home);
97
82
 
@@ -31,6 +31,8 @@ export interface InstallOpts {
31
31
  from?: string;
32
32
  status?: boolean;
33
33
  uninstall?: boolean;
34
+ configure?: boolean;
35
+ config?: boolean;
34
36
  yes?: boolean;
35
37
  dry?: boolean;
36
38
  json?: boolean;
@@ -73,6 +75,10 @@ export function wantsInstallMcp(opts: InstallOpts, root: CliProgram): boolean {
73
75
  return !!(opts.mcp || opts.all) && resolveCapabilities(root).mcp;
74
76
  }
75
77
 
78
+ export function wantsUninstallConfig(opts: InstallOpts, root: CliProgram): boolean {
79
+ return !!(opts.config || opts.all) && root.appConfig !== undefined;
80
+ }
81
+
76
82
  /** Builds install actions for normal mode (--all / scoped targets). */
77
83
  export function buildInstallPlan(
78
84
  root: CliProgram,
@@ -1,3 +1,4 @@
1
+ import { appConfigStatus } from "../config/bootstrap.ts";
1
2
  import type { CliProgram } from "../types.ts";
2
3
  import { buildInstallStatus, detectInstalledArtifacts } from "./detect-installed.ts";
3
4
  import { resolveInstallPaths } from "./paths.ts";
@@ -68,4 +69,15 @@ export function printInstallStatus(root: CliProgram, opts: InstallOpts): void {
68
69
  if (!any) {
69
70
  installOut(" (none detected)", opts);
70
71
  }
72
+
73
+ const configStatus = appConfigStatus(root);
74
+ if (configStatus) {
75
+ installOut(
76
+ ` app config: ${configStatus.path}${configStatus.exists ? "" : " (missing)"}`,
77
+ opts,
78
+ );
79
+ for (const req of configStatus.required) {
80
+ installOut(` ${req.key}: ${req.set ? "set" : "missing"}`, opts);
81
+ }
82
+ }
71
83
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, rmSync } from "node:fs";
2
+ import { resolveAppConfigPath, uninstallAppConfig } from "../config/file.ts";
2
3
  import type { CliProgram } from "../types.ts";
3
4
  import { uninstallBinary } from "./binary.ts";
4
5
  import { uninstallCompletions } from "./completions.ts";
@@ -13,6 +14,7 @@ import {
13
14
  wantsInstallCompletions,
14
15
  wantsInstallMcp,
15
16
  wantsInstallSkill,
17
+ wantsUninstallConfig,
16
18
  } from "./plan.ts";
17
19
 
18
20
  export interface UninstallAction {
@@ -147,6 +149,15 @@ export function buildUninstallPlan(
147
149
  }
148
150
  }
149
151
 
152
+ if (wantsUninstallConfig(opts, root)) {
153
+ const configPath = resolveAppConfigPath(root);
154
+ actions.push({
155
+ summary: `app config: ${configPath}`,
156
+ message: `Removing app config ${configPath}`,
157
+ run: () => (uninstallAppConfig(root, dry) ? [configPath] : []),
158
+ });
159
+ }
160
+
150
161
  return actions;
151
162
  }
152
163
 
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { cliPresentationRoot } from "../builtins/presentation.ts";
6
- import { cliInvoke } from "../index.ts";
6
+ import { Cli } from "../index.ts";
7
7
  import type { CliProgram, CliUpdateArtifact } from "../types.ts";
8
8
  import { cliValidateProgram } from "../validate.ts";
9
9
  import { parseInstallOpts, runInstallMutation } from "./index.ts";
@@ -80,7 +80,7 @@ test("runInstallMutation honors --from for binary copy", async () => {
80
80
  expect(readFileSync(dest, "utf8")).toContain("echo hi");
81
81
  });
82
82
 
83
- test("cliInvoke install --update uses hook and reinstalls", async () => {
83
+ test("Cli.invoke install --update uses hook and reinstalls", async () => {
84
84
  const source = join(home, "new-binary");
85
85
  writeFileSync(source, "#!/bin/sh\necho hi\n", "utf8");
86
86
  chmodSync(source, 0o755);
@@ -90,19 +90,19 @@ test("cliInvoke install --update uses hook and reinstalls", async () => {
90
90
  version: "2.0.0",
91
91
  }));
92
92
 
93
- const result = await cliInvoke(root, ["install", "--update"]);
93
+ const result = await new Cli(root).invoke(["install", "--update"]);
94
94
  expect(result.exitCode).toBe(0);
95
95
  expect(result.stdout).toContain("Updated testapp 1.0.0 → 2.0.0");
96
96
  expect(existsSync(join(home, ".local", "bin", "testapp"))).toBe(true);
97
97
  });
98
98
 
99
- test("cliInvoke install --update reports already current", async () => {
99
+ test("Cli.invoke install --update reports already current", async () => {
100
100
  const root = fixtureWithUpdate(async () => ({
101
101
  path: process.execPath,
102
102
  version: "1.0.0",
103
103
  }));
104
104
 
105
- const result = await cliInvoke(root, ["install", "--update"]);
105
+ const result = await new Cli(root).invoke(["install", "--update"]);
106
106
  expect(result.exitCode).toBe(0);
107
107
  expect(result.stdout).toContain("Already at v1.0.0");
108
108
  });