agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,439 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { CONFIG_DIR, getSettings } from "../core/settings.js";
6
+
7
+ // Kept in sync with extension-loader.ts SCRIPT_EXTS.
8
+ const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
9
+
10
+ function hasIndexFile(dir: string): boolean {
11
+ return SCRIPT_EXTS.some((ext) => fs.existsSync(path.join(dir, `index${ext}`)));
12
+ }
13
+
14
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../");
15
+ const BUNDLED_DIR = path.join(PACKAGE_ROOT, "examples/extensions");
16
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
17
+
18
+ interface InstallOpts {
19
+ force?: boolean;
20
+ syncDeps?: boolean;
21
+ dev?: boolean;
22
+ }
23
+
24
+ interface ResolvedSource {
25
+ sourcePath: string;
26
+ /** Filesystem name used under ~/.agent-sh/extensions/. Includes the file
27
+ * extension for single-file resolves so the loader's SCRIPT_EXTS check matches. */
28
+ name: string;
29
+ isDirectory: boolean;
30
+ }
31
+
32
+ interface Resolver {
33
+ canHandle?(spec: string): boolean;
34
+ resolve(spec: string): Promise<ResolvedSource>;
35
+ }
36
+
37
+ export function listBundled(): string[] {
38
+ if (!fs.existsSync(BUNDLED_DIR)) return [];
39
+ const out: string[] = [];
40
+ for (const d of fs.readdirSync(BUNDLED_DIR, { withFileTypes: true })) {
41
+ if (d.name.startsWith(".")) continue;
42
+ if (d.isDirectory()) out.push(d.name);
43
+ else if (SCRIPT_EXTS.some((ext) => d.name.endsWith(ext))) out.push(d.name.replace(/\.[^.]+$/, ""));
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
49
+ export function suggestBridgeFor(backend: string): string | null {
50
+ const candidate = `${backend}-bridge`;
51
+ return listBundled().includes(candidate) ? candidate : null;
52
+ }
53
+
54
+ const bundledResolver: Resolver = {
55
+ resolve: async (spec) => {
56
+ const candidates = [
57
+ { p: path.join(BUNDLED_DIR, spec), name: spec },
58
+ { p: path.join(BUNDLED_DIR, `${spec}.ts`), name: `${spec}.ts` },
59
+ { p: path.join(BUNDLED_DIR, `${spec}.js`), name: `${spec}.js` },
60
+ ];
61
+ for (const c of candidates) {
62
+ if (fs.existsSync(c.p)) {
63
+ const isDirectory = fs.statSync(c.p).isDirectory();
64
+ return { sourcePath: c.p, name: c.name, isDirectory };
65
+ }
66
+ }
67
+ const available = listBundled();
68
+ throw new Error(
69
+ `No bundled extension named "${spec}".\n\n` +
70
+ `Available:\n${available.map((n) => ` ${n}`).join("\n")}`,
71
+ );
72
+ },
73
+ };
74
+
75
+ const npmResolver: Resolver = {
76
+ canHandle: (spec) => spec.startsWith("npm:"),
77
+ resolve: async () => {
78
+ throw new Error("npm: source is not yet implemented");
79
+ },
80
+ };
81
+
82
+ const githubResolver: Resolver = {
83
+ canHandle: (spec) => spec.startsWith("github:") || spec.startsWith("https://github.com/"),
84
+ resolve: async () => {
85
+ throw new Error("github: source is not yet implemented");
86
+ },
87
+ };
88
+
89
+ const fileResolver: Resolver = {
90
+ canHandle: (spec) =>
91
+ spec.startsWith("file:") || spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../"),
92
+ resolve: async (spec) => {
93
+ const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
94
+ const abs = path.resolve(raw);
95
+ if (!fs.existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
96
+ const isDirectory = fs.statSync(abs).isDirectory();
97
+ return { sourcePath: abs, name: path.basename(abs), isDirectory };
98
+ },
99
+ };
100
+
101
+ const PREFIX_RESOLVERS: Resolver[] = [npmResolver, githubResolver, fileResolver];
102
+
103
+ function pickResolver(spec: string): Resolver {
104
+ for (const r of PREFIX_RESOLVERS) if (r.canHandle?.(spec)) return r;
105
+ return bundledResolver;
106
+ }
107
+
108
+ interface PackageJson {
109
+ scripts?: Record<string, string>;
110
+ dependencies?: Record<string, string>;
111
+ peerDependencies?: Record<string, string>;
112
+ bin?: string | Record<string, string>;
113
+ name?: string;
114
+ }
115
+
116
+ function readPackageJson(target: string): PackageJson | null {
117
+ const pkgJson = path.join(target, "package.json");
118
+ if (!fs.existsSync(pkgJson)) return null;
119
+ return JSON.parse(fs.readFileSync(pkgJson, "utf-8")) as PackageJson;
120
+ }
121
+
122
+ function hostAgentShVersion(): string | null {
123
+ try {
124
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"));
125
+ return typeof pkg.version === "string" ? pkg.version : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function satisfies(version: string, spec: string): boolean {
132
+ if (spec === version || spec === "*" || spec === "latest") return true;
133
+ const [vMaj, vMin, vPatch] = version.split(/[.-]/, 3).map(Number);
134
+ if ([vMaj, vMin, vPatch].some(Number.isNaN)) return true;
135
+ const m = spec.match(/^([\^~]?)(\d+)\.(\d+)\.(\d+)/);
136
+ if (!m) return true;
137
+ const op = m[1];
138
+ const sMaj = Number(m[2]);
139
+ const sMin = Number(m[3]);
140
+ const sPatch = Number(m[4]);
141
+ if (op === "") return vMaj === sMaj && vMin === sMin && vPatch === sPatch;
142
+ if (op === "~") return vMaj === sMaj && vMin === sMin && vPatch >= sPatch;
143
+ // ^x.y.z: zero-major treats minor as the breaking boundary (npm rule).
144
+ if (sMaj > 0) return vMaj === sMaj && (vMin > sMin || (vMin === sMin && vPatch >= sPatch));
145
+ if (sMin > 0) return vMaj === 0 && vMin === sMin && vPatch >= sPatch;
146
+ return vMaj === 0 && vMin === 0 && vPatch === sPatch;
147
+ }
148
+
149
+ /** Warn when the extension's `agent-sh` pin can't admit the host version;
150
+ * only rewrite when --sync-deps is set. */
151
+ function syncAgentShVersion(target: string, syncDeps: boolean): void {
152
+ const hostVersion = hostAgentShVersion();
153
+ if (!hostVersion) return;
154
+ // Prerelease hosts aren't on npm; rewriting would leave npm install unable to resolve.
155
+ if (hostVersion.includes("-")) return;
156
+ const pkgJson = path.join(target, "package.json");
157
+ if (!fs.existsSync(pkgJson)) return;
158
+ const raw = fs.readFileSync(pkgJson, "utf-8");
159
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
160
+ const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const;
161
+ const name = path.basename(target);
162
+ let changed = false;
163
+ let warned = false;
164
+ for (const section of sections) {
165
+ const deps = pkg[section];
166
+ if (!deps || typeof deps !== "object") continue;
167
+ const d = deps as Record<string, string>;
168
+ const current = d["agent-sh"];
169
+ if (typeof current !== "string") continue;
170
+ if (current.startsWith("file:")) continue;
171
+ if (satisfies(hostVersion, current)) continue;
172
+ if (syncDeps) {
173
+ console.log(`agent-sh: rewriting ${name} agent-sh ${current} -> ${hostVersion}.`);
174
+ d["agent-sh"] = hostVersion;
175
+ changed = true;
176
+ } else if (!warned) {
177
+ console.warn(
178
+ `agent-sh: ${name} pins agent-sh ${current}, which doesn't admit host ${hostVersion}. ` +
179
+ `npm install will land an older agent-sh inside the extension and drift from the running host. ` +
180
+ `Re-run with --sync-deps to rewrite the pin to ${hostVersion}, or update the bridge's source pin.`,
181
+ );
182
+ warned = true;
183
+ }
184
+ }
185
+ if (changed) fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
186
+ }
187
+
188
+ /** Relative `file:` deps in bundled extensions (e.g. `"agent-sh": "file:../../.."`)
189
+ * point at the wrong location after the source is copied into ~/.agent-sh/extensions/.
190
+ * Resolve them against the original source dir so npm install in the target succeeds. */
191
+ function rewriteFileDeps(target: string, sourcePath: string): void {
192
+ const pkgJson = path.join(target, "package.json");
193
+ if (!fs.existsSync(pkgJson)) return;
194
+ const raw = fs.readFileSync(pkgJson, "utf-8");
195
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
196
+ const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const;
197
+ let changed = false;
198
+ for (const section of sections) {
199
+ const deps = pkg[section];
200
+ if (!deps || typeof deps !== "object") continue;
201
+ for (const [name, spec] of Object.entries(deps as Record<string, string>)) {
202
+ if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
203
+ const rel = spec.slice("file:".length);
204
+ if (path.isAbsolute(rel)) continue;
205
+ (deps as Record<string, string>)[name] = `file:${path.resolve(sourcePath, rel)}`;
206
+ changed = true;
207
+ }
208
+ }
209
+ if (changed) fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
210
+ }
211
+
212
+ /** --dev: repoint the extension's agent-sh dep at the running host's package
213
+ * root, so the install builds and runs against the local (possibly unreleased)
214
+ * core instead of the published registry version. npm links the file: path, so
215
+ * later core rebuilds flow through without reinstalling. */
216
+ function pinHostCore(target: string): void {
217
+ const pkgJson = path.join(target, "package.json");
218
+ if (!fs.existsSync(pkgJson)) return;
219
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8")) as Record<string, unknown>;
220
+ const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const;
221
+ let changed = false;
222
+ for (const section of sections) {
223
+ const deps = pkg[section];
224
+ if (!deps || typeof deps !== "object") continue;
225
+ const d = deps as Record<string, string>;
226
+ if (typeof d["agent-sh"] !== "string") continue;
227
+ d["agent-sh"] = `file:${PACKAGE_ROOT}`;
228
+ changed = true;
229
+ }
230
+ if (!changed) return;
231
+ fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
232
+ console.log(`agent-sh: --dev — linking ${path.basename(target)} against host core at ${PACKAGE_ROOT}`);
233
+ }
234
+
235
+ function maybeNpmInstall(target: string, pkg: PackageJson): void {
236
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) };
237
+ if (Object.keys(deps).length === 0) return;
238
+ if (fs.existsSync(path.join(target, "node_modules"))) return;
239
+ console.log(`Running npm install in ${target}...`);
240
+ const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], {
241
+ cwd: target,
242
+ stdio: "inherit",
243
+ });
244
+ if (result.status !== 0) {
245
+ throw new Error(`npm install failed in ${target}; run it manually.`);
246
+ }
247
+ }
248
+
249
+ function normalizeBin(pkg: PackageJson): Record<string, string> {
250
+ if (!pkg.bin) return {};
251
+ if (typeof pkg.bin === "string") {
252
+ const name = pkg.name?.startsWith("@") ? pkg.name.split("/")[1]! : pkg.name;
253
+ return name ? { [name]: pkg.bin } : {};
254
+ }
255
+ return pkg.bin;
256
+ }
257
+
258
+ function maybeNpmBuild(target: string, pkg: PackageJson): void {
259
+ if (!pkg.scripts?.build) return;
260
+ console.log(`Running npm run build in ${target}...`);
261
+ const result = spawnSync("npm", ["run", "build"], { cwd: target, stdio: "inherit" });
262
+ if (result.status !== 0) {
263
+ throw new Error(`npm run build failed in ${target}; run it manually.`);
264
+ }
265
+ }
266
+
267
+ function linkBins(target: string, pkg: PackageJson): string[] {
268
+ const bins = normalizeBin(pkg);
269
+ if (Object.keys(bins).length === 0) return [];
270
+ const binDir = path.join(CONFIG_DIR, "bin");
271
+ fs.mkdirSync(binDir, { recursive: true });
272
+ const linked: string[] = [];
273
+ for (const [name, relPath] of Object.entries(bins)) {
274
+ const src = path.resolve(target, relPath);
275
+ if (!fs.existsSync(src)) {
276
+ console.error(`agent-sh: skipping bin "${name}" — ${src} not found`);
277
+ continue;
278
+ }
279
+ try { fs.chmodSync(src, 0o755); } catch { /* ignore */ }
280
+ const linkPath = path.join(binDir, name);
281
+ try { fs.unlinkSync(linkPath); } catch { /* ignore */ }
282
+ fs.symlinkSync(src, linkPath);
283
+ linked.push(name);
284
+ }
285
+ return linked;
286
+ }
287
+
288
+ export async function runInstall(spec: string, opts: InstallOpts = {}): Promise<void> {
289
+ if (!spec) {
290
+ console.error(
291
+ "Usage: agent-sh install <name|file:|npm:|github:> [--force] [--sync-deps] [--dev]\n\n" +
292
+ "Bundled extensions:\n" +
293
+ listBundled()
294
+ .map((n) => ` ${n}`)
295
+ .join("\n"),
296
+ );
297
+ process.exit(1);
298
+ }
299
+
300
+ fs.mkdirSync(EXT_DIR, { recursive: true });
301
+
302
+ let resolved: ResolvedSource;
303
+ try {
304
+ resolved = await pickResolver(spec).resolve(spec);
305
+ } catch (err) {
306
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
307
+ process.exit(1);
308
+ }
309
+
310
+ const target = path.join(EXT_DIR, resolved.name);
311
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) {
312
+ if (!opts.force) {
313
+ console.error(`agent-sh: ${target} already exists (pass --force to overwrite)`);
314
+ process.exit(1);
315
+ }
316
+ fs.rmSync(target, { recursive: true, force: true });
317
+ }
318
+
319
+ let linkedBins: string[] = [];
320
+ if (resolved.isDirectory) {
321
+ fs.cpSync(resolved.sourcePath, target, {
322
+ recursive: true,
323
+ // Skip source node_modules: maybeNpmInstall short-circuits on
324
+ // existing node_modules, silently leaving the bridge's deps stale.
325
+ filter: (src) => path.basename(src) !== "node_modules",
326
+ });
327
+ try {
328
+ rewriteFileDeps(target, resolved.sourcePath);
329
+ if (opts.dev) pinHostCore(target);
330
+ syncAgentShVersion(target, opts.syncDeps ?? false);
331
+ const pkg = readPackageJson(target);
332
+ if (pkg) {
333
+ maybeNpmInstall(target, pkg);
334
+ maybeNpmBuild(target, pkg);
335
+ linkedBins = linkBins(target, pkg);
336
+ }
337
+ } catch (err) {
338
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
339
+ process.exit(1);
340
+ }
341
+ } else {
342
+ fs.copyFileSync(resolved.sourcePath, target);
343
+ }
344
+
345
+ console.log(`Installed: ${resolved.name} -> ${target}`);
346
+ if (linkedBins.length > 0) {
347
+ const binDir = path.join(CONFIG_DIR, "bin");
348
+ console.log(`Linked bins: ${linkedBins.join(", ")} -> ${binDir}`);
349
+ console.log(`Add to PATH: export PATH="${binDir}:$PATH"`);
350
+ }
351
+ }
352
+
353
+ export async function runUninstall(name: string): Promise<void> {
354
+ if (!name) {
355
+ console.error("Usage: agent-sh uninstall <name>");
356
+ process.exit(1);
357
+ }
358
+ const target = path.join(EXT_DIR, name);
359
+ // Refuse path-traversal: target must sit directly under EXT_DIR.
360
+ const resolvedTarget = path.resolve(target);
361
+ const resolvedExtDir = path.resolve(EXT_DIR);
362
+ if (!resolvedTarget.startsWith(resolvedExtDir + path.sep)) {
363
+ console.error(`agent-sh: refusing to uninstall outside ${EXT_DIR}`);
364
+ process.exit(1);
365
+ }
366
+ if (!fs.lstatSync(target, { throwIfNoEntry: false })) {
367
+ console.error(`agent-sh: not installed: ${name}`);
368
+ process.exit(1);
369
+ }
370
+ const pkg = readPackageJson(target);
371
+ if (pkg) {
372
+ const binDir = path.join(CONFIG_DIR, "bin");
373
+ const targetPrefix = path.resolve(target) + path.sep;
374
+ for (const binName of Object.keys(normalizeBin(pkg))) {
375
+ const linkPath = path.join(binDir, binName);
376
+ try {
377
+ const stat = fs.lstatSync(linkPath, { throwIfNoEntry: false });
378
+ if (!stat?.isSymbolicLink()) continue;
379
+ const dest = path.resolve(binDir, fs.readlinkSync(linkPath));
380
+ if (dest.startsWith(targetPrefix)) fs.unlinkSync(linkPath);
381
+ } catch { /* ignore */ }
382
+ }
383
+ }
384
+ fs.rmSync(target, { recursive: true, force: true });
385
+ console.log(`Uninstalled: ${name}`);
386
+ }
387
+
388
+ interface ListedExtension {
389
+ name: string;
390
+ source: "extensions dir" | "settings.json";
391
+ detail?: string;
392
+ }
393
+
394
+ function listFromExtDir(disabled: Set<string>): ListedExtension[] {
395
+ if (!fs.existsSync(EXT_DIR)) return [];
396
+ const dirents = fs.readdirSync(EXT_DIR, { withFileTypes: true });
397
+ const out: ListedExtension[] = [];
398
+ for (const d of dirents) {
399
+ if (d.name.startsWith(".")) continue;
400
+ const nameForDisable = d.name.replace(/\.[^.]+$/, "");
401
+ if (disabled.has(nameForDisable)) continue;
402
+ const full = path.join(EXT_DIR, d.name);
403
+ let isDir = d.isDirectory();
404
+ if (d.isSymbolicLink()) {
405
+ try { isDir = fs.statSync(full).isDirectory(); } catch { continue; }
406
+ }
407
+ if (isDir) {
408
+ if (!hasIndexFile(full)) continue;
409
+ } else if (!SCRIPT_EXTS.some((ext) => d.name.endsWith(ext))) {
410
+ continue;
411
+ }
412
+ const detail = d.isSymbolicLink() ? `-> ${fs.readlinkSync(full)}` : undefined;
413
+ out.push({ name: d.name, source: "extensions dir", detail });
414
+ }
415
+ return out;
416
+ }
417
+
418
+ function listFromSettings(disabled: Set<string>): ListedExtension[] {
419
+ const specs = getSettings().extensions ?? [];
420
+ return specs
421
+ .filter((s) => !disabled.has(s.replace(/\.[^.]+$/, "")))
422
+ .map((s) => ({ name: s, source: "settings.json" as const }));
423
+ }
424
+
425
+ export function runList(): void {
426
+ const disabled = new Set(getSettings().disabledExtensions ?? []);
427
+ const items = [...listFromExtDir(disabled), ...listFromSettings(disabled)];
428
+ if (items.length === 0) {
429
+ console.log("No extensions installed.");
430
+ return;
431
+ }
432
+ const nameWidth = Math.max(...items.map((i) => i.name.length));
433
+ console.log("Installed extensions:");
434
+ for (const item of items) {
435
+ const padded = item.name.padEnd(nameWidth);
436
+ const detail = item.detail ? ` ${item.detail}` : "";
437
+ console.log(` ${padded} (${item.source})${detail}`);
438
+ }
439
+ }
@@ -0,0 +1,68 @@
1
+ import { spawn } from "node:child_process";
2
+ import { pickStrategy, FALLBACK_STRATEGY } from "../shell/strategies/index.js";
3
+
4
+ export async function captureShellEnvAsync(shell: string): Promise<Record<string, string>> {
5
+ if (process.env.AGENT_SH_SKIP_SHELL_ENV) return {};
6
+ return new Promise((resolve) => {
7
+ let settled = false;
8
+ const done = (result: Record<string, string>): void => {
9
+ if (settled) return;
10
+ settled = true;
11
+ resolve(result);
12
+ };
13
+
14
+ try {
15
+ const strategy = pickStrategy(shell) ?? FALLBACK_STRATEGY;
16
+ const captureCmd = strategy.envCaptureCommand();
17
+
18
+ const child = spawn(shell, ["-l", "-c", captureCmd], {
19
+ stdio: ["ignore", "pipe", "ignore"],
20
+ timeout: 5000,
21
+ });
22
+
23
+ let output = "";
24
+ child.stdout?.on("data", (data) => {
25
+ output += data.toString("utf-8");
26
+ });
27
+
28
+ child.on("close", (code) => {
29
+ clearTimeout(timer);
30
+ if (code !== 0 || !output) {
31
+ done({});
32
+ return;
33
+ }
34
+ const env: Record<string, string> = {};
35
+ for (const entry of output.split("\0")) {
36
+ const eq = entry.indexOf("=");
37
+ if (eq > 0) env[entry.slice(0, eq)] = entry.slice(eq + 1);
38
+ }
39
+ done(env);
40
+ });
41
+
42
+ child.on("error", () => {
43
+ clearTimeout(timer);
44
+ done({});
45
+ });
46
+
47
+ const timer = setTimeout(() => {
48
+ child.kill("SIGTERM");
49
+ done({});
50
+ }, 5000);
51
+ } catch {
52
+ done({});
53
+ }
54
+ });
55
+ }
56
+
57
+ export function mergeShellEnv(
58
+ baseEnv: Record<string, string>,
59
+ shellEnv: Record<string, string>,
60
+ ): Record<string, string> {
61
+ const merged = { ...baseEnv };
62
+ for (const [key, value] of Object.entries(shellEnv)) {
63
+ if (!(key in merged) || !merged[key]) {
64
+ merged[key] = value;
65
+ }
66
+ }
67
+ return merged;
68
+ }
@@ -0,0 +1,24 @@
1
+ import { runInit } from "./init.js";
2
+ import { runInstall, runUninstall, runList } from "./install.js";
3
+ import { runAuth } from "./auth/cli.js";
4
+
5
+ type Subcommand = (args: string[]) => void | Promise<void>;
6
+
7
+ const SUBCOMMANDS: Record<string, Subcommand> = {
8
+ init: (args) => runInit({ force: args.includes("--force") }),
9
+ install: (args) => runInstall(args[0] ?? "", {
10
+ force: args.includes("--force"),
11
+ syncDeps: args.includes("--sync-deps"),
12
+ dev: args.includes("--dev"),
13
+ }),
14
+ uninstall: (args) => runUninstall(args[0] ?? ""),
15
+ list: () => runList(),
16
+ auth: (args) => runAuth(args),
17
+ };
18
+
19
+ export async function dispatchSubcommand(argv: string[]): Promise<boolean> {
20
+ const handler = SUBCOMMANDS[argv[0] ?? ""];
21
+ if (!handler) return false;
22
+ await handler(argv.slice(1));
23
+ return true;
24
+ }