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.
Files changed (99) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +6 -6
  3. package/docs/ai-skills.md +9 -4
  4. package/docs/bundled-docs.md +1 -0
  5. package/docs/cli-program.md +4 -4
  6. package/docs/config-schema.md +1 -4
  7. package/docs/developing.md +1 -1
  8. package/docs/install.md +160 -39
  9. package/docs/mcp.md +38 -11
  10. package/docs/templates/cursor/rules/cli-program.mdc +1 -1
  11. package/examples/config-app/program.ts +0 -3
  12. package/examples/consumer-app/README.md +1 -1
  13. package/examples/consumer-app/src/program.ts +5 -13
  14. package/examples/mcp-test.ts +14 -24
  15. package/index.d.ts +60 -5
  16. package/package.json +1 -1
  17. package/src/builtins/builtins.test.ts +20 -4
  18. package/src/builtins/config.test.ts +31 -25
  19. package/src/builtins/config.ts +4 -3
  20. package/src/builtins/install.ts +50 -57
  21. package/src/builtins/mcp.ts +1 -1
  22. package/src/capabilities.ts +4 -4
  23. package/src/cli.ts +2 -0
  24. package/src/config/bootstrap.ts +167 -66
  25. package/src/config/context.test.ts +22 -36
  26. package/src/config/context.ts +5 -4
  27. package/src/config/file.test.ts +66 -56
  28. package/src/config/file.ts +33 -25
  29. package/src/config/resolve.test.ts +25 -1
  30. package/src/config/resolve.ts +43 -8
  31. package/src/config.integration.test.ts +17 -10
  32. package/src/docs/api-guide.test.ts +1 -1
  33. package/src/docs/docs.test.ts +1 -1
  34. package/src/docs/mcp-guide.ts +15 -4
  35. package/src/docs/mcp-resources.test.ts +63 -0
  36. package/src/docs/mcp-resources.ts +68 -0
  37. package/src/hidden-mcpb.test.ts +41 -1
  38. package/src/index.ts +4 -0
  39. package/src/install/{binary.ts → app.ts} +13 -13
  40. package/src/install/bootstrap.ts +22 -0
  41. package/src/install/detect-installed.ts +2 -97
  42. package/src/install/index.ts +187 -110
  43. package/src/install/install-validate.test.ts +61 -0
  44. package/src/install/install.test.ts +138 -42
  45. package/src/install/mcp-openclaw.test.ts +40 -0
  46. package/src/install/mcp-openclaw.ts +106 -0
  47. package/src/install/normalize.ts +35 -0
  48. package/src/install/paths.ts +27 -13
  49. package/src/install/plan.ts +30 -259
  50. package/src/install/shell.ts +2 -2
  51. package/src/install/status.test.ts +85 -0
  52. package/src/install/status.ts +22 -9
  53. package/src/install/target-base.ts +93 -0
  54. package/src/install/target-detect.ts +20 -0
  55. package/src/install/target-effective.ts +131 -0
  56. package/src/install/target-mcp-cli.ts +149 -0
  57. package/src/install/target-mcp-json.ts +130 -0
  58. package/src/install/target-plan-build.ts +67 -0
  59. package/src/install/target-registry.ts +57 -0
  60. package/src/install/target-scope.ts +266 -0
  61. package/src/install/target-skill.ts +104 -0
  62. package/src/install/target-types.ts +145 -0
  63. package/src/install/targets/app.ts +69 -0
  64. package/src/install/targets/chatgpt-mcp.ts +12 -0
  65. package/src/install/targets/claude-code-mcp.ts +15 -0
  66. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  67. package/src/install/targets/claude-skill.ts +16 -0
  68. package/src/install/targets/codex-mcp.ts +25 -0
  69. package/src/install/targets/codex-skill.ts +14 -0
  70. package/src/install/targets/completions.ts +133 -0
  71. package/src/install/targets/configure.ts +59 -0
  72. package/src/install/targets/cursor-mcp.ts +15 -0
  73. package/src/install/targets/cursor-skill.ts +16 -0
  74. package/src/install/targets/index.ts +53 -0
  75. package/src/install/targets/openclaw-mcp.ts +25 -0
  76. package/src/install/targets/openclaw-skill.ts +17 -0
  77. package/src/install/targets/opencode-mcp.ts +101 -0
  78. package/src/install/targets/opencode-skill.ts +15 -0
  79. package/src/install/targets.test.ts +136 -0
  80. package/src/install/uninstall.ts +16 -152
  81. package/src/install/update.test.ts +17 -2
  82. package/src/install/update.ts +2 -5
  83. package/src/invoke.test.ts +7 -1
  84. package/src/mcp/bundle.ts +16 -4
  85. package/src/mcp/claude.test.ts +23 -4
  86. package/src/mcp/claude.ts +18 -13
  87. package/src/mcp/tools.ts +4 -1
  88. package/src/mcp/zip.test.ts +17 -0
  89. package/src/mcp/zip.ts +62 -9
  90. package/src/mcp.integration.test.ts +18 -1
  91. package/src/parse.test.ts +57 -4
  92. package/src/paths/host.ts +11 -11
  93. package/src/paths/remove-empty-dir.ts +13 -0
  94. package/src/skill/generate.ts +89 -5
  95. package/src/skill/hint.ts +5 -0
  96. package/src/skill/install.ts +33 -6
  97. package/src/skill/naming.ts +28 -0
  98. package/src/types.ts +65 -4
  99. package/src/validate.ts +79 -4
@@ -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 { readAppConfigFile, resolveAppConfigPath, writeAppConfigFile } from "./file.ts";
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
- const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
38
- const prevHome = process.env.HOME;
39
- process.env.HOME = dir;
40
- const prevToken = process.env.API_TOKEN;
41
- process.env.API_TOKEN = "from-host";
42
- try {
43
- const p: CliProgram = {
44
- ...program,
45
- appConfig: {
46
- ...program.appConfig!,
47
- path: join(dir, ".config", "myapp-test", "config"),
48
- },
49
- };
50
- mkdirSync(join(dir, ".config", "myapp-test"), { recursive: true });
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
- const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
80
- try {
81
- const p: CliProgram = {
82
- ...program,
83
- appConfig: {
84
- ...program.appConfig!,
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
- const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
97
- try {
98
- const p: CliProgram = {
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
- } finally {
109
- rmSync(dir, { recursive: true, force: true });
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
  });
@@ -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 { appConfigHome, expandTilde } from "../paths/host.ts";
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(appConfigHome(), dirName, "config");
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 (`~` when under home). */
26
+ /** Human-readable config path for error messages (`~/…` when under home). */
31
27
  export function displayAppConfigPath(program: CliProgram): string {
32
- const resolved = resolveAppConfigPath(program);
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 ?? "config";
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 ?? "config";
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
- /** Removes the app config file when present. Returns true if removed. */
116
- export function uninstallAppConfig(program: CliProgram, dry: boolean): boolean {
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 (!existsSync(path)) {
119
- return false;
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 true;
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
  });
@@ -20,8 +20,26 @@ function isPresent(value: unknown): boolean {
20
20
  return true;
21
21
  }
22
22
 
23
- function envOverrideValue(envName: string): string | undefined {
24
- const val = process.env[envName];
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 process.env. */
59
- export function resolveAppConfig(program: CliProgram, fileData: AppConfigFileData): ResolvedConfig {
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(program: CliProgram, resolved: ResolvedConfig): void {
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 configFile = join(dir, "config");
15
- writeFileSync(configFile, `${JSON.stringify({ foo: "fromfile", bar: "bar" })}\n`, "utf8");
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: { ARGS_TEST_CONFIG_FILE: configFile, ARGS_TEST_SECRET: "present" },
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 --all --yes");
74
+ expect(md).toContain("myapp install --yes");
75
75
  expect(md).not.toContain("Upgrade to latest release");
76
76
  });
77
77
 
@@ -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 --bin");
220
+ expect(guide).toContain("install --app");
221
221
  expect(guide).toContain("OpenAI Codex");
222
222
  expect(guide).toContain("ChatGPT");
223
223
  });
@@ -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 --bin --yes\` or \`install --all --yes\`). Host configs reference the binary by name.`,
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
+ });