argsbarg 4.0.3 → 4.1.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.
- package/CHANGELOG.md +42 -1
- package/README.md +6 -6
- package/docs/ai-skills.md +9 -4
- package/docs/bundled-docs.md +1 -0
- package/docs/cli-program.md +4 -4
- package/docs/config-schema.md +1 -4
- package/docs/developing.md +1 -1
- package/docs/install.md +160 -39
- package/docs/mcp.md +38 -11
- package/docs/templates/cursor/rules/cli-program.mdc +1 -1
- package/examples/config-app/program.ts +0 -3
- package/examples/consumer-app/README.md +1 -1
- package/examples/consumer-app/src/program.ts +5 -13
- package/examples/mcp-test.ts +14 -24
- package/index.d.ts +60 -5
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +20 -4
- package/src/builtins/config.test.ts +31 -25
- package/src/builtins/config.ts +4 -3
- package/src/builtins/install.ts +50 -57
- package/src/builtins/mcp.ts +1 -1
- package/src/capabilities.ts +4 -4
- package/src/cli.ts +2 -0
- package/src/config/bootstrap.ts +167 -66
- package/src/config/context.test.ts +22 -36
- package/src/config/context.ts +5 -4
- package/src/config/file.test.ts +66 -56
- package/src/config/file.ts +33 -25
- package/src/config/resolve.test.ts +25 -1
- package/src/config/resolve.ts +43 -8
- package/src/config.integration.test.ts +17 -10
- package/src/docs/api-guide.test.ts +1 -1
- package/src/docs/docs.test.ts +1 -1
- package/src/docs/mcp-guide.ts +15 -4
- package/src/docs/mcp-resources.test.ts +63 -0
- package/src/docs/mcp-resources.ts +68 -0
- package/src/hidden-mcpb.test.ts +41 -1
- package/src/index.ts +4 -0
- package/src/install/{binary.ts → app.ts} +13 -13
- package/src/install/bootstrap.ts +22 -0
- package/src/install/detect-installed.ts +2 -97
- package/src/install/index.ts +187 -110
- package/src/install/install-validate.test.ts +61 -0
- package/src/install/install.test.ts +138 -42
- package/src/install/mcp-openclaw.test.ts +40 -0
- package/src/install/mcp-openclaw.ts +106 -0
- package/src/install/normalize.ts +35 -0
- package/src/install/paths.ts +27 -13
- package/src/install/plan.ts +30 -259
- package/src/install/shell.ts +2 -2
- package/src/install/status.test.ts +85 -0
- package/src/install/status.ts +22 -9
- package/src/install/target-base.ts +93 -0
- package/src/install/target-detect.ts +20 -0
- package/src/install/target-effective.ts +131 -0
- package/src/install/target-mcp-cli.ts +149 -0
- package/src/install/target-mcp-json.ts +130 -0
- package/src/install/target-plan-build.ts +67 -0
- package/src/install/target-registry.ts +57 -0
- package/src/install/target-scope.ts +266 -0
- package/src/install/target-skill.ts +104 -0
- package/src/install/target-types.ts +145 -0
- package/src/install/targets/app.ts +69 -0
- package/src/install/targets/chatgpt-mcp.ts +12 -0
- package/src/install/targets/claude-code-mcp.ts +15 -0
- package/src/install/targets/claude-desktop-mcp.ts +12 -0
- package/src/install/targets/claude-skill.ts +16 -0
- package/src/install/targets/codex-mcp.ts +25 -0
- package/src/install/targets/codex-skill.ts +14 -0
- package/src/install/targets/completions.ts +133 -0
- package/src/install/targets/configure.ts +59 -0
- package/src/install/targets/cursor-mcp.ts +15 -0
- package/src/install/targets/cursor-skill.ts +16 -0
- package/src/install/targets/index.ts +53 -0
- package/src/install/targets/openclaw-mcp.ts +25 -0
- package/src/install/targets/openclaw-skill.ts +17 -0
- package/src/install/targets/opencode-mcp.ts +101 -0
- package/src/install/targets/opencode-skill.ts +15 -0
- package/src/install/targets.test.ts +136 -0
- package/src/install/uninstall.ts +16 -152
- package/src/install/update.test.ts +17 -2
- package/src/install/update.ts +2 -5
- package/src/invoke.test.ts +7 -1
- package/src/mcp/bundle.ts +16 -4
- package/src/mcp/claude.test.ts +23 -4
- package/src/mcp/claude.ts +18 -13
- package/src/mcp/tools.ts +4 -1
- package/src/mcp/zip.test.ts +17 -0
- package/src/mcp/zip.ts +62 -9
- package/src/mcp.integration.test.ts +18 -1
- package/src/parse.test.ts +57 -4
- package/src/paths/host.ts +11 -11
- package/src/paths/remove-empty-dir.ts +13 -0
- package/src/skill/generate.ts +89 -5
- package/src/skill/hint.ts +5 -0
- package/src/skill/install.ts +33 -6
- package/src/skill/naming.ts +28 -0
- package/src/types.ts +65 -4
- package/src/validate.ts +79 -4
package/src/config/file.test.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import type { CliProgram } from "../types.ts";
|
|
6
6
|
import { bootstrapAppConfig } from "./bootstrap.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
readAppConfigFile,
|
|
9
|
+
resolveAppConfigDir,
|
|
10
|
+
resolveAppConfigPath,
|
|
11
|
+
uninstallAppConfig,
|
|
12
|
+
writeAppConfigFile,
|
|
13
|
+
} from "./file.ts";
|
|
8
14
|
import { buildProgramUserConfig } from "./manifest.ts";
|
|
9
15
|
import { formatMissingConfigMessage, missingRequiredConfig, resolveAppConfig } from "./resolve.ts";
|
|
10
16
|
|
|
@@ -13,7 +19,6 @@ const program: CliProgram = {
|
|
|
13
19
|
version: "1.0.0",
|
|
14
20
|
description: "Demo.",
|
|
15
21
|
appConfig: {
|
|
16
|
-
path: "~/.config/myapp-test/config",
|
|
17
22
|
entries: {
|
|
18
23
|
apiToken: { description: "Token.", env: "API_TOKEN" },
|
|
19
24
|
port: { description: "HTTP listen port (default 8080).", required: false },
|
|
@@ -22,6 +27,19 @@ const program: CliProgram = {
|
|
|
22
27
|
handler: () => {},
|
|
23
28
|
};
|
|
24
29
|
|
|
30
|
+
function withHome<T>(fn: (home: string) => T): T {
|
|
31
|
+
const home = mkdtempSync(join(tmpdir(), "cfg-test-"));
|
|
32
|
+
const prevHome = process.env.HOME;
|
|
33
|
+
process.env.HOME = home;
|
|
34
|
+
try {
|
|
35
|
+
return fn(home);
|
|
36
|
+
} finally {
|
|
37
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
38
|
+
else process.env.HOME = prevHome;
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
describe("config/file", () => {
|
|
26
44
|
test("buildProgramUserConfig from program.appConfig env entries", () => {
|
|
27
45
|
const cfg = buildProgramUserConfig(program);
|
|
@@ -33,31 +51,29 @@ describe("config/file", () => {
|
|
|
33
51
|
expect(cfg?.port).toBeUndefined();
|
|
34
52
|
});
|
|
35
53
|
|
|
54
|
+
test("resolveAppConfigPath uses config.json", () => {
|
|
55
|
+
withHome((home) => {
|
|
56
|
+
expect(resolveAppConfigPath(program)).toBe(
|
|
57
|
+
join(home, ".local", "lib", "myapp", "config.json"),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
36
62
|
test("resolveAppConfig prefers host env over file", () => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
writeFileSync(resolveAppConfigPath(p), `${JSON.stringify({ apiToken: "from-file" })}\n`);
|
|
52
|
-
const resolved = resolveAppConfig(p, { apiToken: "from-file" });
|
|
53
|
-
expect(resolved.apiToken).toBe("from-host");
|
|
54
|
-
} finally {
|
|
55
|
-
if (prevHome === undefined) delete process.env.HOME;
|
|
56
|
-
else process.env.HOME = prevHome;
|
|
57
|
-
if (prevToken === undefined) delete process.env.API_TOKEN;
|
|
58
|
-
else process.env.API_TOKEN = prevToken;
|
|
59
|
-
rmSync(dir, { recursive: true, force: true });
|
|
60
|
-
}
|
|
63
|
+
withHome((_home) => {
|
|
64
|
+
const prevToken = process.env.API_TOKEN;
|
|
65
|
+
process.env.API_TOKEN = "from-host";
|
|
66
|
+
try {
|
|
67
|
+
const configPath = resolveAppConfigPath(program);
|
|
68
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
69
|
+
writeFileSync(configPath, `${JSON.stringify({ apiToken: "from-file" })}\n`);
|
|
70
|
+
const resolved = resolveAppConfig(program, { apiToken: "from-file" });
|
|
71
|
+
expect(resolved.apiToken).toBe("from-host");
|
|
72
|
+
} finally {
|
|
73
|
+
if (prevToken === undefined) delete process.env.API_TOKEN;
|
|
74
|
+
else process.env.API_TOKEN = prevToken;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
61
77
|
});
|
|
62
78
|
|
|
63
79
|
test("missingRequiredConfig and formatMissingConfigMessage", () => {
|
|
@@ -76,37 +92,31 @@ describe("config/file", () => {
|
|
|
76
92
|
});
|
|
77
93
|
|
|
78
94
|
test("rejects unknown keys on read", () => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
path: join(dir, "config"),
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
writeFileSync(join(dir, "config"), `${JSON.stringify({ extra: true })}\n`);
|
|
89
|
-
expect(() => readAppConfigFile(p)).toThrow(/Unknown config key/);
|
|
90
|
-
} finally {
|
|
91
|
-
rmSync(dir, { recursive: true, force: true });
|
|
92
|
-
}
|
|
95
|
+
withHome(() => {
|
|
96
|
+
const configPath = resolveAppConfigPath(program);
|
|
97
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
98
|
+
writeFileSync(configPath, `${JSON.stringify({ extra: true })}\n`);
|
|
99
|
+
expect(() => readAppConfigFile(program)).toThrow(/Unknown config key/);
|
|
100
|
+
});
|
|
93
101
|
});
|
|
94
102
|
|
|
95
103
|
test("writeAppConfigFile round-trip", () => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
...program,
|
|
100
|
-
appConfig: {
|
|
101
|
-
...program.appConfig!,
|
|
102
|
-
path: join(dir, "config"),
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
writeAppConfigFile(p, { apiToken: "saved" });
|
|
106
|
-
const { resolved } = bootstrapAppConfig(p, { validateFile: true });
|
|
104
|
+
withHome(() => {
|
|
105
|
+
writeAppConfigFile(program, { apiToken: "saved" });
|
|
106
|
+
const { resolved } = bootstrapAppConfig(program, { validateFile: true });
|
|
107
107
|
expect(resolved.apiToken).toBe("saved");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("uninstallAppConfig removes config directory recursively", () => {
|
|
112
|
+
withHome(() => {
|
|
113
|
+
writeAppConfigFile(program, { apiToken: "saved" });
|
|
114
|
+
const configPath = resolveAppConfigPath(program);
|
|
115
|
+
const configDir = resolveAppConfigDir(program);
|
|
116
|
+
writeFileSync(join(configDir, "extra.txt"), "leftover", "utf8");
|
|
117
|
+
expect(uninstallAppConfig(program, false)).toEqual([configPath, `${configDir}/`]);
|
|
118
|
+
expect(existsSync(configPath)).toBe(false);
|
|
119
|
+
expect(existsSync(configDir)).toBe(false);
|
|
120
|
+
});
|
|
111
121
|
});
|
|
112
122
|
});
|
package/src/config/file.ts
CHANGED
|
@@ -2,24 +2,20 @@
|
|
|
2
2
|
JSON app config file path helpers and strict read/write.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { sanitizeToolSegment } from "../mcp/tools.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { appConfigLibHome, displayHomePath } from "../paths/host.ts";
|
|
9
9
|
import type { CliProgram } from "../types.ts";
|
|
10
10
|
import { effectiveJsonSchema } from "./schema.ts";
|
|
11
11
|
import { validateConfigDocument } from "./validate.ts";
|
|
12
12
|
|
|
13
13
|
export type AppConfigFileData = Record<string, unknown>;
|
|
14
14
|
|
|
15
|
-
/** Resolved absolute path to the app JSON config file. */
|
|
15
|
+
/** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
|
|
16
16
|
export function resolveAppConfigPath(program: CliProgram): string {
|
|
17
|
-
const custom = program.appConfig?.path;
|
|
18
|
-
if (custom) {
|
|
19
|
-
return expandTilde(custom);
|
|
20
|
-
}
|
|
21
17
|
const dirName = sanitizeToolSegment(program.key);
|
|
22
|
-
return join(
|
|
18
|
+
return join(appConfigLibHome(), dirName, "config.json");
|
|
23
19
|
}
|
|
24
20
|
|
|
25
21
|
/** Resolved absolute directory containing the app JSON config file. */
|
|
@@ -27,14 +23,9 @@ export function resolveAppConfigDir(program: CliProgram): string {
|
|
|
27
23
|
return dirname(resolveAppConfigPath(program));
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
/** Human-readable config path for error messages (
|
|
26
|
+
/** Human-readable config path for error messages (`~/…` when under home). */
|
|
31
27
|
export function displayAppConfigPath(program: CliProgram): string {
|
|
32
|
-
|
|
33
|
-
const home = process.env.HOME ?? "";
|
|
34
|
-
if (home.length > 0 && resolved.startsWith(home)) {
|
|
35
|
-
return `~${resolved.slice(home.length)}`;
|
|
36
|
-
}
|
|
37
|
-
return resolved;
|
|
28
|
+
return displayHomePath(resolveAppConfigPath(program));
|
|
38
29
|
}
|
|
39
30
|
|
|
40
31
|
function parseConfigJson(text: string, path: string): AppConfigFileData {
|
|
@@ -46,7 +37,7 @@ function parseConfigJson(text: string, path: string): AppConfigFileData {
|
|
|
46
37
|
return parsed as AppConfigFileData;
|
|
47
38
|
} catch (err) {
|
|
48
39
|
const msg = err instanceof Error ? err.message : String(err);
|
|
49
|
-
throw new Error(`Invalid JSON in config file ${path}: ${msg}`);
|
|
40
|
+
throw new Error(`Invalid JSON in config file ${displayHomePath(path)}: ${msg}`);
|
|
50
41
|
}
|
|
51
42
|
}
|
|
52
43
|
|
|
@@ -60,7 +51,7 @@ export function readAppConfigFileRaw(path: string): AppConfigFileData {
|
|
|
60
51
|
text = readFileSync(path, "utf8");
|
|
61
52
|
} catch (err) {
|
|
62
53
|
const msg = err instanceof Error ? err.message : String(err);
|
|
63
|
-
throw new Error(`Could not read config file ${path}: ${msg}`);
|
|
54
|
+
throw new Error(`Could not read config file ${displayHomePath(path)}: ${msg}`);
|
|
64
55
|
}
|
|
65
56
|
return parseConfigJson(text, path);
|
|
66
57
|
}
|
|
@@ -89,7 +80,7 @@ export function validateAppConfigData(
|
|
|
89
80
|
const allowed = new Set(Object.keys(appConfig.entries));
|
|
90
81
|
for (const key of Object.keys(data)) {
|
|
91
82
|
if (!allowed.has(key)) {
|
|
92
|
-
const where = pathLabel
|
|
83
|
+
const where = pathLabel ? displayHomePath(pathLabel) : "config";
|
|
93
84
|
throw new Error(`Unknown config key '${key}' in ${where}`);
|
|
94
85
|
}
|
|
95
86
|
}
|
|
@@ -99,7 +90,7 @@ export function validateAppConfigData(
|
|
|
99
90
|
}
|
|
100
91
|
const result = validateConfigDocument(data, jsonSchema);
|
|
101
92
|
if (!result.valid) {
|
|
102
|
-
const where = pathLabel
|
|
93
|
+
const where = pathLabel ? displayHomePath(pathLabel) : "config";
|
|
103
94
|
throw new Error(`Invalid config in ${where}: ${result.errors.join("; ")}`);
|
|
104
95
|
}
|
|
105
96
|
}
|
|
@@ -112,14 +103,31 @@ export function writeAppConfigFile(program: CliProgram, data: AppConfigFileData)
|
|
|
112
103
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
113
104
|
}
|
|
114
105
|
|
|
115
|
-
/**
|
|
116
|
-
export function
|
|
106
|
+
/** True when the app config file or config directory is present. */
|
|
107
|
+
export function appConfigInstalled(program: CliProgram): boolean {
|
|
117
108
|
const path = resolveAppConfigPath(program);
|
|
118
|
-
if (
|
|
119
|
-
|
|
109
|
+
if (existsSync(path)) return true;
|
|
110
|
+
return existsSync(resolveAppConfigDir(program));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Removes the app config file and config directory when present. */
|
|
114
|
+
export function uninstallAppConfig(program: CliProgram, dry: boolean): string[] {
|
|
115
|
+
const path = resolveAppConfigPath(program);
|
|
116
|
+
const dir = resolveAppConfigDir(program);
|
|
117
|
+
const hasFile = existsSync(path);
|
|
118
|
+
const hasDir = existsSync(dir);
|
|
119
|
+
|
|
120
|
+
if (!hasFile && !hasDir) {
|
|
121
|
+
return [];
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
const changed: string[] = [];
|
|
125
|
+
if (hasFile) changed.push(path);
|
|
126
|
+
if (hasDir) changed.push(`${dir}/`);
|
|
127
|
+
|
|
121
128
|
if (!dry) {
|
|
122
|
-
unlinkSync(path);
|
|
129
|
+
if (hasFile) unlinkSync(path);
|
|
130
|
+
if (hasDir) rmSync(dir, { recursive: true, force: true });
|
|
123
131
|
}
|
|
124
|
-
return
|
|
132
|
+
return changed;
|
|
125
133
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { CliProgram } from "../types.ts";
|
|
3
|
-
import { resolveAppConfig } from "./resolve.ts";
|
|
3
|
+
import { captureMappedHostEnv, exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
|
|
4
4
|
|
|
5
5
|
const program: CliProgram = {
|
|
6
6
|
key: "app",
|
|
@@ -85,4 +85,28 @@ describe("config/resolve", () => {
|
|
|
85
85
|
const resolved = resolveAppConfig(stringProgram, {});
|
|
86
86
|
expect(resolved.greeting).toBe("world");
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test("prefers captured host env over file when process.env was exported from file", () => {
|
|
90
|
+
const hostEnv = { API_TOKEN: "from-host" };
|
|
91
|
+
process.env.API_TOKEN = "from-file-export";
|
|
92
|
+
try {
|
|
93
|
+
const resolved = resolveAppConfig(program, { apiToken: "from-file" }, hostEnv);
|
|
94
|
+
expect(resolved.apiToken).toBe("from-host");
|
|
95
|
+
} finally {
|
|
96
|
+
delete process.env.API_TOKEN;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("exportConfigToEnv does not overwrite host env", () => {
|
|
101
|
+
const prev = process.env.API_TOKEN;
|
|
102
|
+
process.env.API_TOKEN = "from-host";
|
|
103
|
+
const hostEnv = captureMappedHostEnv(program);
|
|
104
|
+
try {
|
|
105
|
+
exportConfigToEnv(program, { apiToken: "from-file" }, hostEnv);
|
|
106
|
+
expect(process.env.API_TOKEN).toBe("from-host");
|
|
107
|
+
} finally {
|
|
108
|
+
if (prev === undefined) delete process.env.API_TOKEN;
|
|
109
|
+
else process.env.API_TOKEN = prev;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
88
112
|
});
|
package/src/config/resolve.ts
CHANGED
|
@@ -20,8 +20,26 @@ function isPresent(value: unknown): boolean {
|
|
|
20
20
|
return true;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** Snapshot mapped host env at bootstrap entry (before file exports mutate process.env). */
|
|
24
|
+
export function captureMappedHostEnv(program: CliProgram): Record<string, string | undefined> {
|
|
25
|
+
const out: Record<string, string | undefined> = {};
|
|
26
|
+
const entries = program.appConfig?.entries;
|
|
27
|
+
if (!entries) {
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of Object.values(entries)) {
|
|
31
|
+
if (entry.env) {
|
|
32
|
+
out[entry.env] = process.env[entry.env];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envOverrideValue(
|
|
39
|
+
envName: string,
|
|
40
|
+
hostEnv?: Record<string, string | undefined>,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
const val = hostEnv && envName in hostEnv ? hostEnv[envName] : process.env[envName];
|
|
25
43
|
if (val === undefined || val.length === 0) {
|
|
26
44
|
return undefined;
|
|
27
45
|
}
|
|
@@ -55,15 +73,19 @@ function coerceEnvValue(program: CliProgram, key: string, raw: string): unknown
|
|
|
55
73
|
return raw;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
|
-
/** Resolve all schema keys from file data and
|
|
59
|
-
export function resolveAppConfig(
|
|
76
|
+
/** Resolve all schema keys from file data and mapped host env (env wins over file). */
|
|
77
|
+
export function resolveAppConfig(
|
|
78
|
+
program: CliProgram,
|
|
79
|
+
fileData: AppConfigFileData,
|
|
80
|
+
hostEnv?: Record<string, string | undefined>,
|
|
81
|
+
): ResolvedConfig {
|
|
60
82
|
const appConfig = program.appConfig;
|
|
61
83
|
if (!appConfig) {
|
|
62
84
|
return {};
|
|
63
85
|
}
|
|
64
86
|
const out: ResolvedConfig = {};
|
|
65
87
|
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
66
|
-
const value = resolveConfigKey(program, key, entry, fileData);
|
|
88
|
+
const value = resolveConfigKey(program, key, entry, fileData, hostEnv);
|
|
67
89
|
if (value !== undefined) {
|
|
68
90
|
out[key] = value;
|
|
69
91
|
}
|
|
@@ -76,9 +98,10 @@ function resolveConfigKey(
|
|
|
76
98
|
key: string,
|
|
77
99
|
entry: CliAppConfigEntry,
|
|
78
100
|
fileData: AppConfigFileData,
|
|
101
|
+
hostEnv?: Record<string, string | undefined>,
|
|
79
102
|
): unknown {
|
|
80
103
|
if (entry.env) {
|
|
81
|
-
const fromEnv = envOverrideValue(entry.env);
|
|
104
|
+
const fromEnv = envOverrideValue(entry.env, hostEnv);
|
|
82
105
|
if (fromEnv !== undefined) {
|
|
83
106
|
return coerceEnvValue(program, key, fromEnv);
|
|
84
107
|
}
|
|
@@ -93,8 +116,12 @@ function resolveConfigKey(
|
|
|
93
116
|
return undefined;
|
|
94
117
|
}
|
|
95
118
|
|
|
96
|
-
/** Write mapped config values to process.env for subprocess inheritance. */
|
|
97
|
-
export function exportConfigToEnv(
|
|
119
|
+
/** Write mapped config values to process.env for subprocess inheritance (never overwrites host env). */
|
|
120
|
+
export function exportConfigToEnv(
|
|
121
|
+
program: CliProgram,
|
|
122
|
+
resolved: ResolvedConfig,
|
|
123
|
+
hostEnv?: Record<string, string | undefined>,
|
|
124
|
+
): void {
|
|
98
125
|
const appConfig = program.appConfig;
|
|
99
126
|
if (!appConfig) {
|
|
100
127
|
return;
|
|
@@ -103,6 +130,14 @@ export function exportConfigToEnv(program: CliProgram, resolved: ResolvedConfig)
|
|
|
103
130
|
if (!entry.env) {
|
|
104
131
|
continue;
|
|
105
132
|
}
|
|
133
|
+
const captured = hostEnv?.[entry.env];
|
|
134
|
+
if (captured !== undefined && captured.length > 0) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const existing = process.env[entry.env];
|
|
138
|
+
if (existing !== undefined && existing.length > 0) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
106
141
|
const value = resolved[key];
|
|
107
142
|
if (!isPresent(value)) {
|
|
108
143
|
continue;
|
|
@@ -3,16 +3,17 @@ App config bootstrap and MCP config enforcement regressions.
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { expect, test } from "bun:test";
|
|
6
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
9
|
import { bootstrapAppConfig } from "./config/bootstrap.ts";
|
|
10
|
+
import { resolveAppConfigPath } from "./config/file.ts";
|
|
10
11
|
import { mcpRequest, testProgram } from "./test-fixtures.ts";
|
|
11
12
|
|
|
12
13
|
test("bootstrapAppConfig prefers host env over config file", () => {
|
|
13
14
|
const dir = mkdtempSync(join(tmpdir(), "argsbarg-env-"));
|
|
14
|
-
const
|
|
15
|
-
|
|
15
|
+
const prevHome = process.env.HOME;
|
|
16
|
+
process.env.HOME = dir;
|
|
16
17
|
process.env.FOO = "original";
|
|
17
18
|
try {
|
|
18
19
|
const p = testProgram({
|
|
@@ -20,7 +21,6 @@ test("bootstrapAppConfig prefers host env over config file", () => {
|
|
|
20
21
|
version: "0",
|
|
21
22
|
description: "",
|
|
22
23
|
appConfig: {
|
|
23
|
-
path: configFile,
|
|
24
24
|
entries: {
|
|
25
25
|
foo: { description: "x", env: "FOO" },
|
|
26
26
|
bar: { description: "y", env: "BAR" },
|
|
@@ -28,10 +28,15 @@ test("bootstrapAppConfig prefers host env over config file", () => {
|
|
|
28
28
|
},
|
|
29
29
|
handler: () => {},
|
|
30
30
|
});
|
|
31
|
+
const configFile = resolveAppConfigPath(p);
|
|
32
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
33
|
+
writeFileSync(configFile, `${JSON.stringify({ foo: "fromfile", bar: "bar" })}\n`, "utf8");
|
|
31
34
|
bootstrapAppConfig(p, { validateFile: true });
|
|
32
35
|
expect(process.env.FOO).toBe("original");
|
|
33
36
|
expect(process.env.BAR).toBe("bar");
|
|
34
37
|
} finally {
|
|
38
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
39
|
+
else process.env.HOME = prevHome;
|
|
35
40
|
delete process.env.FOO;
|
|
36
41
|
delete process.env.BAR;
|
|
37
42
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -74,7 +79,8 @@ test("MCP program.appConfig succeeds when env present", async () => {
|
|
|
74
79
|
|
|
75
80
|
test("MCP config file loads and exports vars for tool handlers", async () => {
|
|
76
81
|
const dir = mkdtempSync(join(tmpdir(), "argsbarg-mcp-"));
|
|
77
|
-
const configFile = join(dir, "config");
|
|
82
|
+
const configFile = join(dir, ".local", "lib", "mcp_test", "config");
|
|
83
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
78
84
|
writeFileSync(
|
|
79
85
|
configFile,
|
|
80
86
|
`${JSON.stringify({ argsTestSecret: "file-value" }, null, 2)}\n`,
|
|
@@ -91,17 +97,19 @@ test("MCP config file loads and exports vars for tool handlers", async () => {
|
|
|
91
97
|
],
|
|
92
98
|
{
|
|
93
99
|
script: "examples/mcp-test.ts",
|
|
94
|
-
env: {
|
|
100
|
+
env: { HOME: dir, ARGS_TEST_SECRET: "present" },
|
|
95
101
|
},
|
|
96
102
|
);
|
|
97
103
|
const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
|
|
98
104
|
expect(res.result.isError).toBe(false);
|
|
99
105
|
expect(res.result.content[0]?.text.trim()).toBe("present");
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
107
|
});
|
|
101
108
|
|
|
102
109
|
test("Cli.run docs api skips required appConfig exit", async () => {
|
|
103
110
|
const dir = mkdtempSync(join(tmpdir(), "argsbarg-docs-skip-"));
|
|
104
|
-
const configFile = join(dir, "config");
|
|
111
|
+
const configFile = join(dir, ".local", "lib", "docs_skip_test", "config");
|
|
112
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
105
113
|
writeFileSync(configFile, "{}\n");
|
|
106
114
|
const entry = join(import.meta.dir, "index.ts");
|
|
107
115
|
const mainPath = join(dir, "run-docs.ts");
|
|
@@ -114,7 +122,6 @@ const program = {
|
|
|
114
122
|
description: "test",
|
|
115
123
|
docs: { enabled: true, topics: { readme: { text: "# readme\\n" } } },
|
|
116
124
|
appConfig: {
|
|
117
|
-
path: ${JSON.stringify(configFile)},
|
|
118
125
|
entries: { token: { description: "Token.", env: "DOCS_SKIP_RUN_TOKEN" } },
|
|
119
126
|
},
|
|
120
127
|
handler: () => {},
|
|
@@ -122,7 +129,7 @@ const program = {
|
|
|
122
129
|
await new Cli(program).run(process.argv.slice(2));
|
|
123
130
|
`,
|
|
124
131
|
);
|
|
125
|
-
const env = { ...process.env }
|
|
132
|
+
const env = { ...process.env, HOME: dir } as Record<string, string | undefined>;
|
|
126
133
|
delete env.DOCS_SKIP_RUN_TOKEN;
|
|
127
134
|
try {
|
|
128
135
|
const proc = Bun.spawn(["bun", "run", mainPath, "docs", "api"], {
|
|
@@ -71,7 +71,7 @@ test("generateApiGuide resolves program key in install notes", () => {
|
|
|
71
71
|
};
|
|
72
72
|
const md = generateApiGuide(fixture);
|
|
73
73
|
expect(md).not.toContain("{argsbarg:program}");
|
|
74
|
-
expect(md).toContain("myapp install --
|
|
74
|
+
expect(md).toContain("myapp install --yes");
|
|
75
75
|
expect(md).not.toContain("Upgrade to latest release");
|
|
76
76
|
});
|
|
77
77
|
|
package/src/docs/docs.test.ts
CHANGED
|
@@ -217,7 +217,7 @@ test("generateMcpGuide includes schema URI and install targets", () => {
|
|
|
217
217
|
expect(guide).toContain("claude_desktop_config.json");
|
|
218
218
|
expect(guide).toContain("## Installation");
|
|
219
219
|
expect(guide).toContain("## Running directly");
|
|
220
|
-
expect(guide).toContain("install --
|
|
220
|
+
expect(guide).toContain("install --app");
|
|
221
221
|
expect(guide).toContain("OpenAI Codex");
|
|
222
222
|
expect(guide).toContain("ChatGPT");
|
|
223
223
|
});
|
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "../mcp/tools.ts";
|
|
11
11
|
import { collectOptionDefs } from "../parse.ts";
|
|
12
12
|
import { CliOptionKind, type CliProgram } from "../types.ts";
|
|
13
|
+
import { resolveDocsTopicResourceUri } from "./mcp-resources.ts";
|
|
14
|
+
import { docsEnabled, docsUserTopicKeys } from "./resolve.ts";
|
|
13
15
|
|
|
14
16
|
/** Extra host notes for generated `docs mcp` (manual fallbacks and ChatGPT Connectors). */
|
|
15
17
|
function appendManualHostSetup(lines: string[], root: CliProgram, serverId: string): void {
|
|
@@ -109,7 +111,7 @@ export function generateMcpGuide(root: CliProgram): string {
|
|
|
109
111
|
|
|
110
112
|
if (caps.install) {
|
|
111
113
|
lines.push(
|
|
112
|
-
`Install the CLI first so \`${root.key}\` is on your PATH (e.g. \`${root.key} install --
|
|
114
|
+
`Install the CLI first so \`${root.key}\` is on your PATH (e.g. \`${root.key} install --app --yes\` or \`install --all --yes\`). Host configs reference the app by name.`,
|
|
113
115
|
"",
|
|
114
116
|
);
|
|
115
117
|
} else {
|
|
@@ -210,10 +212,19 @@ export function generateMcpGuide(root: CliProgram): string {
|
|
|
210
212
|
"| `tools/list` | Callable tools for exposed leaf commands |",
|
|
211
213
|
"| `tools/call` | Runs handlers headlessly; JSON stdout becomes `structuredContent` when valid |",
|
|
212
214
|
`| Schema resource | \`${schemaUri}\` — same JSON as \`${root.key} docs schema\` |`,
|
|
213
|
-
"",
|
|
214
|
-
"## Exposed tools",
|
|
215
|
-
"",
|
|
216
215
|
);
|
|
216
|
+
if (docsEnabled(root)) {
|
|
217
|
+
const docs = root.docs;
|
|
218
|
+
if (docs) {
|
|
219
|
+
for (const key of docsUserTopicKeys(docs)) {
|
|
220
|
+
const uri = resolveDocsTopicResourceUri(root, key);
|
|
221
|
+
lines.push(
|
|
222
|
+
`| Docs topic \`${key}\` | \`${uri}\` — same markdown as \`${root.key} docs ${key}\` |`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
lines.push("", "## Exposed tools", "");
|
|
217
228
|
|
|
218
229
|
if (tools.length === 0) {
|
|
219
230
|
lines.push("(No MCP tools exposed.)", "");
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { CliProgram } from "../types.ts";
|
|
3
|
+
import {
|
|
4
|
+
defaultDocsTopicResourceUri,
|
|
5
|
+
docsMcpResources,
|
|
6
|
+
reservedDocsTopicResourceUris,
|
|
7
|
+
resolveDocsTopicResourceUri,
|
|
8
|
+
} from "./mcp-resources.ts";
|
|
9
|
+
|
|
10
|
+
function fixture(opts?: { docs?: boolean; mcp?: boolean }): CliProgram {
|
|
11
|
+
const docs = opts?.docs !== false;
|
|
12
|
+
const mcp = opts?.mcp !== false;
|
|
13
|
+
return {
|
|
14
|
+
key: "my-app",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
description: "Test.",
|
|
17
|
+
...(docs
|
|
18
|
+
? {
|
|
19
|
+
docs: {
|
|
20
|
+
enabled: true,
|
|
21
|
+
topics: {
|
|
22
|
+
readme: { text: "# Readme\n", description: "User guide." },
|
|
23
|
+
arch: { text: "# Arch\n" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
: {}),
|
|
28
|
+
...(mcp ? { mcpServer: { enabled: true } } : {}),
|
|
29
|
+
handler: () => {},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("defaultDocsTopicResourceUri", () => {
|
|
34
|
+
expect(defaultDocsTopicResourceUri("my_app", "readme")).toBe("my_app://docs/readme");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("resolveDocsTopicResourceUri sanitizes program key", () => {
|
|
38
|
+
expect(resolveDocsTopicResourceUri(fixture(), "readme")).toBe("my_app://docs/readme");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("docsMcpResources when docs and MCP enabled", () => {
|
|
42
|
+
const resources = docsMcpResources(fixture());
|
|
43
|
+
expect(resources.map((r) => r.uri)).toEqual(["my_app://docs/readme", "my_app://docs/arch"]);
|
|
44
|
+
expect(resources[0]?.name).toBe("readme");
|
|
45
|
+
expect(resources[0]?.mimeType).toBe("text/markdown");
|
|
46
|
+
expect(resources[0]?.description).toBe("User guide.");
|
|
47
|
+
expect(resources[0]?.load()).toBe("# Readme\n");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("docsMcpResources empty when docs disabled", () => {
|
|
51
|
+
expect(docsMcpResources(fixture({ docs: false }))).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("docsMcpResources empty when MCP disabled", () => {
|
|
55
|
+
expect(docsMcpResources(fixture({ mcp: false }))).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reservedDocsTopicResourceUris matches docsMcpResources URIs", () => {
|
|
59
|
+
const program = fixture();
|
|
60
|
+
expect(reservedDocsTopicResourceUris(program)).toEqual(
|
|
61
|
+
docsMcpResources(program).map((r) => r.uri),
|
|
62
|
+
);
|
|
63
|
+
});
|