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.
- package/CHANGELOG.md +32 -2
- package/README.md +21 -9
- package/docs/README.md +12 -8
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +62 -2
- package/docs/config-schema.md +192 -0
- package/docs/developing.md +13 -0
- package/docs/install.md +38 -1
- package/docs/mcp.md +43 -19
- package/docs/output-schema.md +74 -52
- package/docs/templates/cursor/rules/cli-program.mdc +10 -5
- package/examples/config-app/main.ts +20 -0
- package/examples/config-app/program.ts +81 -0
- package/examples/config-app/schema.ts +37 -0
- package/examples/config-app/types.ts +19 -0
- package/examples/consumer-app/README.md +56 -0
- package/examples/consumer-app/bun.lock +75 -0
- package/examples/consumer-app/capabilities.test.ts +69 -0
- package/examples/consumer-app/package.json +17 -0
- package/examples/consumer-app/schemas/configSchemas.ts +6 -0
- package/examples/consumer-app/schemas/generated/app-config.json +40 -0
- package/examples/consumer-app/schemas/generated/status.json +28 -0
- package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
- package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
- package/examples/consumer-app/scripts/schemagen.ts +76 -0
- package/examples/consumer-app/src/commands/status/types.ts +11 -0
- package/examples/consumer-app/src/main.ts +15 -0
- package/examples/consumer-app/src/program.ts +116 -0
- package/examples/consumer-app/src/types.ts +23 -0
- package/examples/consumer-app/tsconfig.json +14 -0
- package/examples/formats.ts +10 -3
- package/examples/mcp-test.ts +27 -8
- package/examples/minimal.ts +4 -3
- package/examples/nested.ts +5 -4
- package/examples/option-required.ts +8 -4
- package/index.d.ts +158 -75
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +13 -0
- package/src/builtins/config.test.ts +82 -0
- package/src/builtins/config.ts +220 -0
- package/src/builtins/dispatch.ts +18 -9
- package/src/builtins/export.ts +8 -33
- package/src/builtins/index.ts +1 -0
- package/src/builtins/install.ts +13 -0
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/presentation.ts +2 -17
- package/src/builtins/registry.ts +40 -0
- package/src/capabilities.ts +46 -0
- package/src/cli-errors.ts +15 -0
- package/src/cli.ts +389 -0
- package/src/config/bootstrap.ts +265 -0
- package/src/config/context.test.ts +79 -0
- package/src/config/context.ts +110 -0
- package/src/config/entry.ts +81 -0
- package/src/config/file.test.ts +112 -0
- package/src/config/file.ts +120 -0
- package/src/config/manifest.ts +62 -0
- package/src/config/resolve.test.ts +88 -0
- package/src/config/resolve.ts +167 -0
- package/src/config/schema.ts +101 -0
- package/src/config/validate.test.ts +63 -0
- package/src/config/validate.ts +292 -0
- package/src/config.integration.test.ts +100 -0
- package/src/context.ts +5 -0
- package/src/docs/docs.test.ts +16 -16
- package/src/docs/mcp-guide.ts +35 -11
- package/src/hidden-mcpb.test.ts +40 -2
- package/src/index.ts +4 -3
- package/src/install/index.ts +46 -3
- package/src/install/paths.ts +5 -20
- package/src/install/plan.ts +6 -0
- package/src/install/status.ts +12 -0
- package/src/install/uninstall.ts +11 -0
- package/src/install/update.test.ts +5 -5
- package/src/invoke.test.ts +207 -0
- package/src/mcp/bundle.ts +9 -116
- package/src/mcp/claude.test.ts +73 -0
- package/src/mcp/claude.ts +168 -0
- package/src/mcp/env.ts +3 -37
- package/src/mcp/server.ts +18 -10
- package/src/mcp/tools.ts +3 -7
- package/src/mcp/zip.ts +82 -0
- package/src/mcp.integration.test.ts +502 -0
- package/src/{index.test.ts → parse.test.ts} +24 -935
- package/src/paths/host.ts +40 -0
- package/src/schema.ts +1 -1
- package/src/skill/generate.ts +18 -5
- package/src/skill/hint.ts +18 -0
- package/src/skill/install.ts +1 -5
- package/src/test-fixtures.ts +192 -0
- package/src/types.ts +39 -13
- package/src/validate.ts +70 -0
- package/src/completion.ts +0 -13
- package/src/invoke.ts +0 -217
- package/src/mcp.ts +0 -28
- 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). */
|
package/src/docs/docs.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
84
|
-
const result = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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");
|
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -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
|
|
168
|
+
if (mcp.shellEnv) {
|
|
167
169
|
lines.push("## Environment", "");
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
"",
|
package/src/hidden-mcpb.test.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/install/index.ts
CHANGED
|
@@ -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 (
|
|
94
|
-
const
|
|
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 --
|
|
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);
|
package/src/install/paths.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
79
|
+
const xdgConfig = xdgConfigHome(home);
|
|
95
80
|
const claudeDesktopMcpPath = resolveClaudeDesktopMcpPath(home);
|
|
96
81
|
const chatGptMcpPath = resolveChatGptMcpPath(home);
|
|
97
82
|
|
package/src/install/plan.ts
CHANGED
|
@@ -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,
|
package/src/install/status.ts
CHANGED
|
@@ -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
|
}
|
package/src/install/uninstall.ts
CHANGED
|
@@ -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 {
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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
|
});
|