argsbarg 4.0.4 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/README.md +91 -85
  3. package/docs/README.md +6 -6
  4. package/docs/ai-skills.md +8 -5
  5. package/docs/bundled-docs.md +1 -1
  6. package/docs/cli-program.md +9 -7
  7. package/docs/config-schema.md +37 -13
  8. package/docs/developing.md +8 -8
  9. package/docs/distribution-homebrew.md +103 -0
  10. package/docs/install.md +143 -106
  11. package/docs/mcp.md +23 -12
  12. package/docs/output-schema.md +1 -1
  13. package/examples/full-example/Formula/.gitkeep +0 -0
  14. package/examples/full-example/README.md +98 -0
  15. package/examples/full-example/biome.json +22 -0
  16. package/examples/{consumer-app → full-example}/bun.lock +2 -0
  17. package/examples/full-example/justfile +134 -0
  18. package/examples/{consumer-app → full-example}/package.json +10 -3
  19. package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
  20. package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
  21. package/examples/full-example/scripts/create-identity.ts +11 -0
  22. package/examples/full-example/scripts/formula-shared.ts +73 -0
  23. package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
  24. package/examples/full-example/scripts/print-identity.ts +27 -0
  25. package/examples/full-example/src/commands/echo/command.ts +21 -0
  26. package/examples/full-example/src/commands/status/command.test.ts +10 -0
  27. package/examples/full-example/src/commands/status/command.ts +36 -0
  28. package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
  29. package/examples/full-example/src/index.ts +10 -0
  30. package/examples/full-example/src/program.ts +57 -0
  31. package/examples/{consumer-app → full-example}/src/types.ts +1 -1
  32. package/examples/mcp-test.ts +8 -24
  33. package/examples/nested.ts +1 -3
  34. package/index.d.ts +81 -65
  35. package/package.json +2 -2
  36. package/src/builtins/builtins.test.ts +37 -22
  37. package/src/builtins/completion-group.ts +17 -15
  38. package/src/builtins/config.test.ts +31 -25
  39. package/src/builtins/config.ts +4 -3
  40. package/src/builtins/dispatch.ts +25 -1
  41. package/src/builtins/install.ts +45 -82
  42. package/src/builtins/mcp.ts +1 -1
  43. package/src/builtins/registry.ts +2 -0
  44. package/src/builtins/uninstall.ts +80 -0
  45. package/src/capabilities.ts +5 -7
  46. package/src/cli-tool/cli-smoke.test.ts +19 -0
  47. package/src/cli-tool/create.test.ts +119 -0
  48. package/src/cli-tool/create.ts +380 -0
  49. package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
  50. package/src/cli-tool/main.ts +8 -0
  51. package/src/cli-tool/post-create.ts +111 -0
  52. package/src/cli-tool/program.ts +82 -0
  53. package/src/cli-tool/prompt.ts +28 -0
  54. package/src/cli-tool/run-create.ts +149 -0
  55. package/src/config/bootstrap.ts +174 -66
  56. package/src/config/context.test.ts +22 -36
  57. package/src/config/context.ts +5 -4
  58. package/src/config/file.test.ts +66 -56
  59. package/src/config/file.ts +33 -25
  60. package/src/config/resolve.test.ts +192 -1
  61. package/src/config/resolve.ts +92 -13
  62. package/src/config.integration.test.ts +17 -10
  63. package/src/docs/api-guide.test.ts +4 -5
  64. package/src/docs/docs.test.ts +2 -1
  65. package/src/docs/mcp-guide.ts +7 -8
  66. package/src/hidden-mcpb.test.ts +41 -1
  67. package/src/index.ts +7 -10
  68. package/src/install/binary-placement.test.ts +101 -0
  69. package/src/install/binary-placement.ts +47 -0
  70. package/src/install/detect-installed.ts +2 -97
  71. package/src/install/index.ts +239 -168
  72. package/src/install/install-validate.test.ts +61 -0
  73. package/src/install/install.test.ts +170 -90
  74. package/src/install/mcp-openclaw.test.ts +40 -0
  75. package/src/install/mcp-openclaw.ts +106 -0
  76. package/src/install/normalize-uninstall.ts +11 -0
  77. package/src/install/normalize.ts +20 -0
  78. package/src/install/paths.ts +18 -26
  79. package/src/install/plan.ts +40 -261
  80. package/src/install/shell.ts +0 -14
  81. package/src/install/status.test.ts +85 -0
  82. package/src/install/status.ts +22 -15
  83. package/src/install/target-base.ts +93 -0
  84. package/src/install/target-detect.ts +20 -0
  85. package/src/install/target-effective.ts +129 -0
  86. package/src/install/target-mcp-cli.ts +149 -0
  87. package/src/install/target-mcp-json.ts +130 -0
  88. package/src/install/target-plan-build.ts +67 -0
  89. package/src/install/target-registry.ts +57 -0
  90. package/src/install/target-scope.ts +253 -0
  91. package/src/install/target-skill.ts +104 -0
  92. package/src/install/target-types.ts +129 -0
  93. package/src/install/targets/app.ts +60 -0
  94. package/src/install/targets/chatgpt-mcp.ts +12 -0
  95. package/src/install/targets/claude-code-mcp.ts +15 -0
  96. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  97. package/src/install/targets/claude-skill.ts +16 -0
  98. package/src/install/targets/codex-mcp.ts +25 -0
  99. package/src/install/targets/codex-skill.ts +14 -0
  100. package/src/install/targets/configure.ts +63 -0
  101. package/src/install/targets/cursor-mcp.ts +15 -0
  102. package/src/install/targets/cursor-skill.ts +16 -0
  103. package/src/install/targets/index.ts +50 -0
  104. package/src/install/targets/openclaw-mcp.ts +25 -0
  105. package/src/install/targets/openclaw-skill.ts +17 -0
  106. package/src/install/targets/opencode-mcp.ts +101 -0
  107. package/src/install/targets/opencode-skill.ts +15 -0
  108. package/src/install/targets.test.ts +118 -0
  109. package/src/install/uninstall.ts +16 -152
  110. package/src/invoke.test.ts +7 -1
  111. package/src/mcp/bundle.ts +16 -4
  112. package/src/mcp/claude.test.ts +14 -1
  113. package/src/mcp/claude.ts +11 -4
  114. package/src/mcp/env.test.ts +92 -0
  115. package/src/mcp/env.ts +15 -14
  116. package/src/mcp/zip.test.ts +17 -0
  117. package/src/mcp/zip.ts +62 -9
  118. package/src/mcp.integration.test.ts +1 -1
  119. package/src/parse.test.ts +14 -2
  120. package/src/paths/host.ts +11 -11
  121. package/src/paths/remove-empty-dir.ts +13 -0
  122. package/src/prompt.ts +10 -0
  123. package/src/schema.ts +9 -1
  124. package/src/skill/generate.ts +18 -4
  125. package/src/skill/install.ts +33 -6
  126. package/src/skill/naming.ts +28 -0
  127. package/src/types.ts +86 -7
  128. package/src/validate.ts +73 -9
  129. package/docs/templates/cursor/rules/cli-program.mdc +0 -31
  130. package/examples/config-app/main.ts +0 -20
  131. package/examples/config-app/program.ts +0 -81
  132. package/examples/config-app/schema.ts +0 -37
  133. package/examples/config-app/types.ts +0 -19
  134. package/examples/consumer-app/README.md +0 -57
  135. package/examples/consumer-app/src/main.ts +0 -15
  136. package/examples/consumer-app/src/program.ts +0 -108
  137. package/src/install/binary.ts +0 -94
  138. package/src/install/completions.ts +0 -56
  139. package/src/install/update.test.ts +0 -108
  140. package/src/install/update.ts +0 -57
  141. /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
  142. /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
  143. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
  144. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
  145. /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
  146. /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
  147. /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
@@ -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,21 +71,20 @@ 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("brew install");
75
75
  expect(md).not.toContain("Upgrade to latest release");
76
76
  });
77
77
 
78
- test("generateApiGuide includes upgrade section when updateGetLatest is set", () => {
78
+ test("generateApiGuide mentions Homebrew upgrade", () => {
79
79
  const fixture: CliProgram = {
80
80
  key: "myapp",
81
81
  version: "1.0.0",
82
82
  description: "Demo app.",
83
- install: { updateGetLatest: async () => ({ path: process.execPath }) },
84
83
  commands: [{ key: "run", description: "Run.", handler: () => {} }],
85
84
  };
86
85
  const md = generateApiGuide(fixture);
87
- expect(md).toContain("Upgrade to latest release");
88
- expect(md).toContain("myapp install --update");
86
+ expect(md).toContain("brew upgrade");
87
+ expect(md).not.toContain("install --update");
89
88
  });
90
89
 
91
90
  test("generateApiGuide resolves {argsbarg:program} in consumer notes", () => {
@@ -217,7 +217,8 @@ 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 --mcp");
221
+ expect(guide).toContain("brew install myapp");
221
222
  expect(guide).toContain("OpenAI Codex");
222
223
  expect(guide).toContain("ChatGPT");
223
224
  });
@@ -111,7 +111,7 @@ export function generateMcpGuide(root: CliProgram): string {
111
111
 
112
112
  if (caps.install) {
113
113
  lines.push(
114
- `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. \`brew install ${root.key}\`). Host configs reference the app by name.`,
115
115
  "",
116
116
  );
117
117
  } else {
@@ -167,13 +167,12 @@ export function generateMcpGuide(root: CliProgram): string {
167
167
  "",
168
168
  );
169
169
 
170
- if (mcp.shellEnv) {
171
- lines.push("## Environment", "");
172
- lines.push(
173
- "- **`shellEnv`** — captures login-shell environment at MCP startup (PATH, toolchain shims, exports).",
174
- );
175
- lines.push("");
176
- }
170
+ lines.push(
171
+ "## Environment",
172
+ "",
173
+ "- **`shellEnv`** — on by default; captures login-shell environment at MCP startup (PATH, toolchain shims, exports). Opt out with `shellEnv: false`.",
174
+ "",
175
+ );
177
176
 
178
177
  if (root.appConfig?.entries && Object.keys(root.appConfig.entries).length > 0) {
179
178
  lines.push("## Configuration", "");
@@ -19,7 +19,7 @@ const hiddenFixture: CliProgram = {
19
19
  key: "myapp",
20
20
  version: "1.0.0",
21
21
  description: "Hidden demo.",
22
- mcpServer: { enabled: true },
22
+ mcpServer: { enabled: true, mcpd: true, claudePlugin: true },
23
23
  commands: [
24
24
  {
25
25
  key: "public",
@@ -187,4 +187,44 @@ describe("mcp bundle", () => {
187
187
  rmSync(work, { recursive: true, force: true });
188
188
  }
189
189
  });
190
+
191
+ test("runMcpBundle with claudePlugin only prints plugin path", () => {
192
+ const work = mkdtempSync(join(tmpdir(), "mcpb-run-"));
193
+ const stdout: string[] = [];
194
+ const orig = process.stdout.write.bind(process.stdout);
195
+ process.stdout.write = ((chunk: string | Uint8Array) => {
196
+ stdout.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
197
+ return true;
198
+ }) as typeof process.stdout.write;
199
+ const fixture: CliProgram = {
200
+ ...hiddenFixture,
201
+ mcpServer: { enabled: true, claudePlugin: true },
202
+ };
203
+ try {
204
+ const dist = join(work, "dist");
205
+ mkdirSync(dist, { recursive: true });
206
+ writeFileSync(join(dist, "myapp"), "#!/bin/sh\n", { mode: 0o755 });
207
+ const prevCwd = process.cwd();
208
+ process.chdir(work);
209
+ try {
210
+ runMcpBundle(fixture);
211
+ } finally {
212
+ process.chdir(prevCwd);
213
+ }
214
+ const lines = stdout.join("").trim().split("\n");
215
+ expect(lines).toHaveLength(1);
216
+ expect(lines[0]).toContain("claude-plugin");
217
+ } finally {
218
+ process.stdout.write = orig;
219
+ rmSync(work, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ test("runMcpBundle errors when no bundle flags enabled", () => {
224
+ const fixture: CliProgram = {
225
+ ...hiddenFixture,
226
+ mcpServer: { enabled: true },
227
+ };
228
+ expect(() => runMcpBundle(fixture)).toThrow(/mcpd/);
229
+ });
190
230
  });
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ module layout.
9
9
 
10
10
  export { Cli, type CliInvokeKind, type CliInvokeResult } from "./cli.ts";
11
11
  export { cliErrWithHelp } from "./cli-errors.ts";
12
+ export { displayAppConfigPath, resolveAppConfigPath } from "./config/file.ts";
12
13
  export type { CliLeafInputs } from "./context.ts";
13
14
  export { CliContext } from "./context.ts";
14
15
  export {
@@ -26,23 +27,18 @@ export {
26
27
  shouldRunHeadlessWithYes,
27
28
  wantsExplicitJson,
28
29
  } from "./headless.ts";
29
- export type { GhReleaseUpdateConfig, GhVersionCheckConfig } from "./install/gh-release-update.ts";
30
- export {
31
- createGhFetchLatest,
32
- createGhVersionCheck,
33
- ghReleaseUpdateGetLatest,
34
- isAlreadyCurrent,
35
- parseReleaseTag,
36
- } from "./install/gh-release-update.ts";
37
30
  export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
38
31
  export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
39
32
  export type {
40
33
  CliAppConfig,
41
34
  CliAppConfigEntry,
35
+ CliAppConfigResolveContext,
36
+ CliAppConfigResolveFn,
42
37
  CliDocsConfig,
43
38
  CliDocsTopic,
44
39
  CliHandler,
45
40
  CliInstallConfig,
41
+ CliInstallTargets,
46
42
  CliInvocation,
47
43
  CliMcpBundleConfig,
48
44
  CliMcpResource,
@@ -51,8 +47,9 @@ export type {
51
47
  CliOption,
52
48
  CliPositional,
53
49
  CliProgram,
54
- CliUpdateArtifact,
55
- CliUpdateGetLatest,
50
+ InstallAgentIntegration,
51
+ InstallTargetSpec,
52
+ ResolvedInstallTarget,
56
53
  } from "./types.ts";
57
54
  export {
58
55
  CliFallbackMode,
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { CliProgram } from "../types.ts";
6
+ import {
7
+ isAppInstalled,
8
+ isExternallyManagedBinary,
9
+ resolvePathCommand,
10
+ } from "./binary-placement.ts";
11
+
12
+ const program: CliProgram = {
13
+ key: "placementapp",
14
+ version: "1.0.0",
15
+ description: "x",
16
+ handler: () => {},
17
+ };
18
+
19
+ let tmp: string;
20
+ let prevPath: string | undefined;
21
+
22
+ beforeEach(() => {
23
+ tmp = mkdtempSync(join(tmpdir(), "argsbarg-placement-"));
24
+ prevPath = process.env.PATH;
25
+ });
26
+
27
+ afterEach(() => {
28
+ if (prevPath === undefined) delete process.env.PATH;
29
+ else process.env.PATH = prevPath;
30
+ rmSync(tmp, { recursive: true, force: true });
31
+ });
32
+
33
+ describe("isExternallyManagedBinary", () => {
34
+ test("false when command is not on PATH", () => {
35
+ process.env.PATH = tmp;
36
+ expect(isExternallyManagedBinary("placementapp-not-on-path")).toBe(false);
37
+ });
38
+
39
+ test("true when PATH resolves to execPath", () => {
40
+ const bin = join(tmp, "placementapp");
41
+ writeFileSync(bin, "#!/bin/sh\n", "utf8");
42
+ chmodSync(bin, 0o755);
43
+ process.env.PATH = tmp;
44
+ expect(isExternallyManagedBinary("placementapp", bin)).toBe(true);
45
+ });
46
+
47
+ test("true when PATH entry is a symlink to execPath", () => {
48
+ const bin = join(tmp, "placementapp");
49
+ const target = join(tmp, "real-binary");
50
+ writeFileSync(target, "fake", "utf8");
51
+ symlinkSync(target, bin);
52
+ process.env.PATH = tmp;
53
+ expect(isExternallyManagedBinary("placementapp", target)).toBe(true);
54
+ });
55
+
56
+ test("false when PATH points at a different binary", () => {
57
+ const bin = join(tmp, "placementapp");
58
+ const other = join(tmp, "other");
59
+ writeFileSync(bin, "a", "utf8");
60
+ writeFileSync(other, "b", "utf8");
61
+ process.env.PATH = tmp;
62
+ expect(isExternallyManagedBinary("placementapp", other)).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe("isAppInstalled", () => {
67
+ test("true when externally managed", () => {
68
+ const bin = join(tmp, program.key);
69
+ const prevExec = process.execPath;
70
+ writeFileSync(bin, "x", "utf8");
71
+ chmodSync(bin, 0o755);
72
+ process.env.PATH = tmp;
73
+ process.execPath = bin;
74
+ try {
75
+ expect(isAppInstalled(program)).toBe(true);
76
+ } finally {
77
+ process.execPath = prevExec;
78
+ }
79
+ });
80
+
81
+ test("false when not on PATH and no local copy", () => {
82
+ const home = mkdtempSync(join(tmpdir(), "argsbarg-placement-home-"));
83
+ const prevHome = process.env.HOME;
84
+ process.env.HOME = home;
85
+ process.env.PATH = tmp;
86
+ try {
87
+ expect(isAppInstalled(program)).toBe(false);
88
+ } finally {
89
+ if (prevHome === undefined) delete process.env.HOME;
90
+ else process.env.HOME = prevHome;
91
+ rmSync(home, { recursive: true, force: true });
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("resolvePathCommand", () => {
97
+ test("returns undefined when missing", () => {
98
+ process.env.PATH = tmp;
99
+ expect(resolvePathCommand("missing-cmd")).toBeUndefined();
100
+ });
101
+ });
@@ -0,0 +1,47 @@
1
+ import { accessSync, constants, realpathSync } from "node:fs";
2
+ import { delimiter, join } from "node:path";
3
+ import type { CliProgram } from "../types.ts";
4
+
5
+ /** Resolves a command name on PATH to its real path, if present. */
6
+ export function resolvePathCommand(key: string): string | undefined {
7
+ const pathEnv = process.env.PATH ?? "";
8
+ for (const dir of pathEnv.split(delimiter)) {
9
+ if (!dir) continue;
10
+ const candidate = join(dir, key);
11
+ try {
12
+ accessSync(candidate, constants.F_OK);
13
+ return realpathSync(candidate);
14
+ } catch {}
15
+ }
16
+
17
+ const found = Bun.which(key);
18
+ if (found === null) return undefined;
19
+ try {
20
+ return realpathSync(found);
21
+ } catch {
22
+ return found;
23
+ }
24
+ }
25
+
26
+ function realpathOrSelf(path: string): string {
27
+ try {
28
+ return realpathSync(path);
29
+ } catch {
30
+ return path;
31
+ }
32
+ }
33
+
34
+ /** True when PATH resolves to the running executable (e.g. Homebrew Cellar). */
35
+ export function isExternallyManagedBinary(
36
+ key: string,
37
+ execPath: string = process.execPath,
38
+ ): boolean {
39
+ const resolved = resolvePathCommand(key);
40
+ if (!resolved) return false;
41
+ return resolved === realpathOrSelf(execPath);
42
+ }
43
+
44
+ /** True when the app binary is available on PATH (e.g. Homebrew). */
45
+ export function isAppInstalled(program: CliProgram): boolean {
46
+ return isExternallyManagedBinary(program.key);
47
+ }
@@ -1,97 +1,2 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { codexMcpHasServer } from "./mcp-codex.ts";
3
- import { detectOpenCodeMcpConfigPath } from "./mcp-opencode.ts";
4
- import { type InstallPaths, userHome } from "./paths.ts";
5
-
6
- export interface InstalledArtifacts {
7
- binary: boolean;
8
- bashCompletion: boolean;
9
- zshCompletion: boolean;
10
- fishCompletion: boolean;
11
- cursorSkill: boolean;
12
- claudeSkill: boolean;
13
- cursorMcp: boolean;
14
- claudeMcp: boolean;
15
- claudeDesktopMcp: boolean;
16
- opencodeMcp: boolean;
17
- codexMcp: boolean;
18
- chatGptMcp: boolean;
19
- bashRcPath: boolean;
20
- zshRcFpath: boolean;
21
- }
22
-
23
- function mcpConfigHasServer(path: string, name: string): boolean {
24
- if (!existsSync(path)) return false;
25
- try {
26
- const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, unknown> };
27
- return data.mcpServers?.[name] !== undefined;
28
- } catch {
29
- return false;
30
- }
31
- }
32
-
33
- /** Detects which install artifacts are currently present. */
34
- export function detectInstalledArtifacts(paths: InstallPaths): InstalledArtifacts {
35
- return {
36
- binary: existsSync(paths.binaryPath),
37
- bashCompletion: existsSync(paths.bashCompletion),
38
- zshCompletion: existsSync(paths.zshCompletion),
39
- fishCompletion: existsSync(paths.fishCompletion),
40
- cursorSkill: existsSync(paths.cursorSkillDir),
41
- claudeSkill: existsSync(paths.claudeSkillDir),
42
- cursorMcp: mcpConfigHasServer(paths.cursorMcpPath, paths.mcpName),
43
- claudeMcp: mcpConfigHasServer(paths.claudeMcpPath, paths.mcpName),
44
- claudeDesktopMcp: mcpConfigHasServer(paths.claudeDesktopMcpPath, paths.mcpName),
45
- opencodeMcp: detectOpenCodeMcpConfigPath(userHome(), paths.mcpName) !== undefined,
46
- codexMcp: codexMcpHasServer(userHome(), paths.mcpName),
47
- chatGptMcp: mcpConfigHasServer(paths.chatGptMcpPath, paths.mcpName),
48
- bashRcPath: false,
49
- zshRcFpath: false,
50
- };
51
- }
52
-
53
- export interface InstallStatus {
54
- binary?: string;
55
- bashCompletion?: string;
56
- zshCompletion?: string;
57
- fishCompletion?: string;
58
- cursorSkill?: string;
59
- claudeSkill?: string;
60
- cursorMcp?: string;
61
- claudeMcp?: string;
62
- claudeDesktopMcp?: string;
63
- opencodeMcp?: string;
64
- codexMcp?: string;
65
- chatGptMcp?: string;
66
- }
67
-
68
- /** Builds a status inventory from detected artifacts. */
69
- export function buildInstallStatus(
70
- paths: InstallPaths,
71
- detected: InstalledArtifacts,
72
- ): InstallStatus {
73
- const status: InstallStatus = {};
74
- if (detected.binary) status.binary = paths.binaryPath;
75
- if (detected.bashCompletion) status.bashCompletion = paths.bashCompletion;
76
- if (detected.zshCompletion) status.zshCompletion = paths.zshCompletion;
77
- if (detected.fishCompletion) status.fishCompletion = paths.fishCompletion;
78
- if (detected.cursorSkill) status.cursorSkill = `${paths.cursorSkillDir}/`;
79
- if (detected.claudeSkill) status.claudeSkill = `${paths.claudeSkillDir}/`;
80
- if (detected.cursorMcp) status.cursorMcp = `${paths.cursorMcpPath} (server "${paths.mcpName}")`;
81
- if (detected.claudeMcp) status.claudeMcp = `${paths.claudeMcpPath} (server "${paths.mcpName}")`;
82
- if (detected.claudeDesktopMcp) {
83
- status.claudeDesktopMcp = `${paths.claudeDesktopMcpPath} (server "${paths.mcpName}")`;
84
- }
85
- if (detected.opencodeMcp) {
86
- const openCodePath =
87
- detectOpenCodeMcpConfigPath(userHome(), paths.mcpName) ?? paths.opencodeMcpPath;
88
- status.opencodeMcp = `${openCodePath} (server "${paths.mcpName}")`;
89
- }
90
- if (detected.codexMcp) {
91
- status.codexMcp = `${paths.codexConfigPath} (server "${paths.mcpName}")`;
92
- }
93
- if (detected.chatGptMcp) {
94
- status.chatGptMcp = `${paths.chatGptMcpPath} (server "${paths.mcpName}")`;
95
- }
96
- return status;
97
- }
1
+ export { buildInstallStatus, detectInstalledArtifacts } from "./target-detect.ts";
2
+ export type { InstalledArtifacts, InstallStatus } from "./target-types.ts";