agent-sh 0.12.23 → 0.12.24

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/README.md CHANGED
@@ -19,7 +19,7 @@ So I built agent-sh. Under the hood it's a normal shell on top of node-pty — y
19
19
  ~ $ > draft a commit message # agent reads your diff and shell history
20
20
  ```
21
21
 
22
- I still use a proper coding harness for serious work — this doesn't replace that. But for the quick stuff in the terminal, I reach for agent-sh almost every day now. The built-in agent is lightweight and good enough for most of what I throw at it, and when it isn't, you can swap in [pi](examples/extensions/pi-bridge/) as the backend via a bridge extension.
22
+ I still use a proper coding harness for serious work — this doesn't replace that. But for the quick stuff in the terminal, I reach for agent-sh almost every day now. The built-in agent is lightweight and good enough for most of what I throw at it, and when it isn't, you can swap in [pi](examples/extensions/pi-bridge/) as the backend `agent-sh install pi-bridge` followed by `agent-sh --backend pi`.
23
23
 
24
24
  ## Quick Start
25
25
 
@@ -95,7 +95,7 @@ Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fis
95
95
 
96
96
  **Context that just works.** Every query includes your cwd, recent commands, and their output. Run a failing test, type `> fix this`, and agent-sh knows exactly what happened. Context management works like shell history — continuous, persistent across restarts, no sessions to manage. See [Context Management](docs/context-management.md).
97
97
 
98
- **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — [pi](examples/extensions/pi-bridge/) runs as a drop-in backend extension.
98
+ **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — `agent-sh install pi-bridge && agent-sh --backend pi` runs [pi](examples/extensions/pi-bridge/) as a drop-in backend.
99
99
 
100
100
  **Extensible by design.** The entire system is built on a typed event bus. Extensions can add custom input modes, content transforms (render LaTeX as images, Mermaid as diagrams), themes, slash commands, or replace the agent backend entirely. The built-in TUI renderer is itself just an extension.
101
101
 
package/dist/core.d.ts CHANGED
@@ -37,7 +37,7 @@ export interface AgentShellCore {
37
37
  /** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
38
38
  instanceId: string;
39
39
  /** Activate the agent backend (call after extensions load). */
40
- activateBackend(): Promise<void>;
40
+ activateBackend(override?: string): Promise<void>;
41
41
  /** Convenience: emit agent:submit and await the response. */
42
42
  query(text: string): Promise<string>;
43
43
  /** Convenience: emit agent:cancel-request. */
package/dist/core.js CHANGED
@@ -102,10 +102,10 @@ export function createCore(config) {
102
102
  bus,
103
103
  handlers,
104
104
  instanceId,
105
- async activateBackend() {
105
+ async activateBackend(override) {
106
106
  if (backends.size === 0)
107
107
  return;
108
- const preferred = settings.defaultBackend;
108
+ const preferred = override ?? settings.defaultBackend;
109
109
  const name = preferred && backends.has(preferred) ? preferred : backends.keys().next().value;
110
110
  await activateByName(name);
111
111
  },
@@ -367,6 +367,8 @@ export interface ShellEvents {
367
367
  label: string;
368
368
  items: string[];
369
369
  }>;
370
+ /** Name of the backend being launched. Extensions should gate per-backend sections on this rather than settings.defaultBackend. */
371
+ activeBackend?: string;
370
372
  };
371
373
  "autocomplete:request": {
372
374
  buffer: string;
@@ -293,8 +293,7 @@ export default function agentBackend(ctx) {
293
293
  bus.emit("config:changed", {});
294
294
  });
295
295
  bus.onPipe("banner:collect", (e) => {
296
- const settings = getSettings();
297
- if (settings.defaultBackend && settings.defaultBackend !== "ash")
296
+ if (e.activeBackend && e.activeBackend !== "ash")
298
297
  return e;
299
298
  if (loadedExtensionNames.length > 0) {
300
299
  e.sections.push({ label: "Extensions", items: [...loadedExtensionNames] });
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { loadBuiltinExtensions } from "./extensions/index.js";
8
8
  import { loadExtensions } from "./extension-loader.js";
9
9
  import { getSettings } from "./settings.js";
10
10
  import { runInit } from "./init.js";
11
+ import { runInstall, runUninstall, runList, suggestBridgeFor } from "./install.js";
11
12
  import { PACKAGE_VERSION } from "./utils/package-version.js";
12
13
  /**
13
14
  * Capture the user's full shell environment.
@@ -78,7 +79,8 @@ function parseArgs(argv) {
78
79
  let model;
79
80
  let extensions;
80
81
  let provider;
81
- const shell = process.env.SHELL || "/bin/bash";
82
+ let backend;
83
+ let shell = process.env.SHELL || "/bin/bash";
82
84
  let apiKey = process.env.OPENAI_API_KEY;
83
85
  let baseURL = process.env.OPENAI_BASE_URL;
84
86
  for (let i = 0; i < argv.length; i++) {
@@ -95,8 +97,11 @@ function parseArgs(argv) {
95
97
  else if (arg === "--provider" && argv[i + 1]) {
96
98
  provider = argv[++i];
97
99
  }
100
+ else if (arg === "--backend" && argv[i + 1]) {
101
+ backend = argv[++i];
102
+ }
98
103
  else if (arg === "--shell" && argv[i + 1]) {
99
- return { shell: argv[++i], model, extensions, apiKey, baseURL, provider };
104
+ shell = argv[++i];
100
105
  }
101
106
  else if ((arg === "--extensions" || arg === "-e") && argv[i + 1]) {
102
107
  const exts = argv[++i].split(",").map(s => s.trim());
@@ -110,7 +115,10 @@ function parseArgs(argv) {
110
115
  console.log(`agent-sh — a shell-first terminal where AI is one keystroke away
111
116
 
112
117
  Usage: agent-sh [options]
113
- agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
118
+ agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
119
+ agent-sh install <spec> [--force] Install an extension (bundled name, file:, npm:, github:)
120
+ agent-sh uninstall <name> Remove an installed extension
121
+ agent-sh list List installed extensions
114
122
 
115
123
  Provider Profiles:
116
124
  --provider <name> Use a provider from ~/.agent-sh/settings.json
@@ -121,6 +129,7 @@ Direct LLM API:
121
129
  --base-url <url> Base URL for API (or set OPENAI_BASE_URL)
122
130
 
123
131
  General Options:
132
+ --backend <name> Agent backend to launch (e.g. ash, pi); overrides settings.defaultBackend for this session
124
133
  --shell <path> Shell to use (default: $SHELL or /bin/bash)
125
134
  -e, --extensions Extensions to load (comma-separated, repeatable)
126
135
  -h, --help Show this help
@@ -149,7 +158,7 @@ Inside the shell:
149
158
  process.exit(0);
150
159
  }
151
160
  }
152
- return { shell, model, extensions, apiKey, baseURL, provider };
161
+ return { shell, model, extensions, apiKey, baseURL, provider, backend };
153
162
  }
154
163
  async function main() {
155
164
  // Subcommands — handled before the shell-launch path.
@@ -158,6 +167,18 @@ async function main() {
158
167
  runInit({ force: rawArgs.includes("--force") });
159
168
  return;
160
169
  }
170
+ if (rawArgs[0] === "install") {
171
+ await runInstall(rawArgs[1] ?? "", { force: rawArgs.includes("--force") });
172
+ return;
173
+ }
174
+ if (rawArgs[0] === "uninstall") {
175
+ await runUninstall(rawArgs[1] ?? "");
176
+ return;
177
+ }
178
+ if (rawArgs[0] === "list") {
179
+ runList();
180
+ return;
181
+ }
161
182
  if (process.env.AGENT_SH) {
162
183
  console.error("agent-sh: already running inside an agent-sh session (nested sessions are not supported).");
163
184
  process.exit(1);
@@ -280,24 +301,37 @@ async function main() {
280
301
  console.error("\nagent-sh: no agent backend available.\n\n" +
281
302
  " Export OPENROUTER_API_KEY or OPENAI_API_KEY for zero-config launch, or\n" +
282
303
  " pass --api-key on the command line, or\n" +
283
- " run `agent-sh init` for a settings.json template.\n" +
284
- " Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
304
+ " run `agent-sh init` for a settings.json template, or\n" +
305
+ " run `agent-sh install <bridge>` (e.g. pi-bridge, claude-code-bridge) to use a non-ash backend.\n");
306
+ process.exit(1);
307
+ }
308
+ if (config.backend && !backendNames.includes(config.backend)) {
309
+ shell?.kill();
310
+ const bridge = suggestBridgeFor(config.backend);
311
+ const hint = bridge
312
+ ? ` Try: agent-sh install ${bridge}\n`
313
+ : ` Run \`agent-sh install\` to see bundled bridge extensions.\n`;
314
+ console.error(`\nagent-sh: backend "${config.backend}" is not available.\n\n` +
315
+ ` Available backends: ${backendNames.join(", ")}\n` +
316
+ hint);
285
317
  process.exit(1);
286
318
  }
287
319
  // No await: banner must out-race the shell's PS1 arriving via PTY.
288
- core.activateBackend();
320
+ core.activateBackend(config.backend);
289
321
  // ── Startup banner ───────────────────────────────────────────
290
322
  const settings = getSettings();
291
323
  if (settings.startupBanner !== false) {
292
324
  const termW = process.stdout.columns || 80;
293
325
  const bannerW = Math.min(termW, 60);
294
326
  const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
295
- const backendName = settings.defaultBackend && backendNames.includes(settings.defaultBackend)
296
- ? settings.defaultBackend
297
- : backendNames[0];
327
+ const backendName = config.backend && backendNames.includes(config.backend)
328
+ ? config.backend
329
+ : settings.defaultBackend && backendNames.includes(settings.defaultBackend)
330
+ ? settings.defaultBackend
331
+ : backendNames[0];
298
332
  let sections = "";
299
333
  sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
300
- const extSections = bus.emitPipe("banner:collect", { sections: [] }).sections;
334
+ const extSections = bus.emitPipe("banner:collect", { sections: [], activeBackend: backendName }).sections;
301
335
  for (const sec of extSections) {
302
336
  sections += `\n\n ${p.muted}${sec.label}:${p.reset}`;
303
337
  for (const item of sec.items) {
@@ -0,0 +1,10 @@
1
+ interface InstallOpts {
2
+ force?: boolean;
3
+ }
4
+ export declare function listBundled(): string[];
5
+ /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
6
+ export declare function suggestBridgeFor(backend: string): string | null;
7
+ export declare function runInstall(spec: string, opts?: InstallOpts): Promise<void>;
8
+ export declare function runUninstall(name: string): Promise<void>;
9
+ export declare function runList(): void;
10
+ export {};
@@ -0,0 +1,205 @@
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 "./settings.js";
6
+ // Kept in sync with extension-loader.ts SCRIPT_EXTS.
7
+ const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
8
+ function hasIndexFile(dir) {
9
+ return SCRIPT_EXTS.some((ext) => fs.existsSync(path.join(dir, `index${ext}`)));
10
+ }
11
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../");
12
+ const BUNDLED_DIR = path.join(PACKAGE_ROOT, "examples/extensions");
13
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
14
+ export function listBundled() {
15
+ if (!fs.existsSync(BUNDLED_DIR))
16
+ return [];
17
+ return fs.readdirSync(BUNDLED_DIR).map((n) => n.replace(/\.(ts|js|mjs)$/, ""));
18
+ }
19
+ /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
20
+ export function suggestBridgeFor(backend) {
21
+ const candidate = `${backend}-bridge`;
22
+ return listBundled().includes(candidate) ? candidate : null;
23
+ }
24
+ const bundledResolver = {
25
+ resolve: async (spec) => {
26
+ const candidates = [
27
+ { p: path.join(BUNDLED_DIR, spec), name: spec },
28
+ { p: path.join(BUNDLED_DIR, `${spec}.ts`), name: `${spec}.ts` },
29
+ { p: path.join(BUNDLED_DIR, `${spec}.js`), name: `${spec}.js` },
30
+ ];
31
+ for (const c of candidates) {
32
+ if (fs.existsSync(c.p)) {
33
+ const isDirectory = fs.statSync(c.p).isDirectory();
34
+ return { sourcePath: c.p, name: c.name, isDirectory };
35
+ }
36
+ }
37
+ const available = listBundled();
38
+ throw new Error(`No bundled extension named "${spec}".\n\n` +
39
+ `Available:\n${available.map((n) => ` ${n}`).join("\n")}`);
40
+ },
41
+ };
42
+ const npmResolver = {
43
+ canHandle: (spec) => spec.startsWith("npm:"),
44
+ resolve: async () => {
45
+ throw new Error("npm: source is not yet implemented");
46
+ },
47
+ };
48
+ const githubResolver = {
49
+ canHandle: (spec) => spec.startsWith("github:") || spec.startsWith("https://github.com/"),
50
+ resolve: async () => {
51
+ throw new Error("github: source is not yet implemented");
52
+ },
53
+ };
54
+ const fileResolver = {
55
+ canHandle: (spec) => spec.startsWith("file:") || spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../"),
56
+ resolve: async (spec) => {
57
+ const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
58
+ const abs = path.resolve(raw);
59
+ if (!fs.existsSync(abs))
60
+ throw new Error(`Path does not exist: ${abs}`);
61
+ const isDirectory = fs.statSync(abs).isDirectory();
62
+ return { sourcePath: abs, name: path.basename(abs), isDirectory };
63
+ },
64
+ };
65
+ const PREFIX_RESOLVERS = [npmResolver, githubResolver, fileResolver];
66
+ function pickResolver(spec) {
67
+ for (const r of PREFIX_RESOLVERS)
68
+ if (r.canHandle?.(spec))
69
+ return r;
70
+ return bundledResolver;
71
+ }
72
+ function maybeNpmInstall(target) {
73
+ const pkgJson = path.join(target, "package.json");
74
+ if (!fs.existsSync(pkgJson))
75
+ return;
76
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
77
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) };
78
+ if (Object.keys(deps).length === 0)
79
+ return;
80
+ if (fs.existsSync(path.join(target, "node_modules")))
81
+ return;
82
+ console.log(`Running npm install in ${target}...`);
83
+ const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], {
84
+ cwd: target,
85
+ stdio: "inherit",
86
+ });
87
+ if (result.status !== 0) {
88
+ throw new Error(`npm install failed in ${target}; run it manually.`);
89
+ }
90
+ }
91
+ export async function runInstall(spec, opts = {}) {
92
+ if (!spec) {
93
+ console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force]\n\n" +
94
+ "Bundled extensions:\n" +
95
+ listBundled()
96
+ .map((n) => ` ${n}`)
97
+ .join("\n"));
98
+ process.exit(1);
99
+ }
100
+ fs.mkdirSync(EXT_DIR, { recursive: true });
101
+ let resolved;
102
+ try {
103
+ resolved = await pickResolver(spec).resolve(spec);
104
+ }
105
+ catch (err) {
106
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
107
+ process.exit(1);
108
+ }
109
+ const target = path.join(EXT_DIR, resolved.name);
110
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) {
111
+ if (!opts.force) {
112
+ console.error(`agent-sh: ${target} already exists (pass --force to overwrite)`);
113
+ process.exit(1);
114
+ }
115
+ fs.rmSync(target, { recursive: true, force: true });
116
+ }
117
+ if (resolved.isDirectory) {
118
+ fs.cpSync(resolved.sourcePath, target, { recursive: true });
119
+ try {
120
+ maybeNpmInstall(target);
121
+ }
122
+ catch (err) {
123
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
124
+ process.exit(1);
125
+ }
126
+ }
127
+ else {
128
+ fs.copyFileSync(resolved.sourcePath, target);
129
+ }
130
+ console.log(`Installed: ${resolved.name} -> ${target}`);
131
+ }
132
+ export async function runUninstall(name) {
133
+ if (!name) {
134
+ console.error("Usage: agent-sh uninstall <name>");
135
+ process.exit(1);
136
+ }
137
+ const target = path.join(EXT_DIR, name);
138
+ // Refuse path-traversal: target must sit directly under EXT_DIR.
139
+ const resolvedTarget = path.resolve(target);
140
+ const resolvedExtDir = path.resolve(EXT_DIR);
141
+ if (!resolvedTarget.startsWith(resolvedExtDir + path.sep)) {
142
+ console.error(`agent-sh: refusing to uninstall outside ${EXT_DIR}`);
143
+ process.exit(1);
144
+ }
145
+ if (!fs.lstatSync(target, { throwIfNoEntry: false })) {
146
+ console.error(`agent-sh: not installed: ${name}`);
147
+ process.exit(1);
148
+ }
149
+ fs.rmSync(target, { recursive: true, force: true });
150
+ console.log(`Uninstalled: ${name}`);
151
+ }
152
+ function listFromExtDir(disabled) {
153
+ if (!fs.existsSync(EXT_DIR))
154
+ return [];
155
+ const dirents = fs.readdirSync(EXT_DIR, { withFileTypes: true });
156
+ const out = [];
157
+ for (const d of dirents) {
158
+ if (d.name.startsWith("."))
159
+ continue;
160
+ const nameForDisable = d.name.replace(/\.[^.]+$/, "");
161
+ if (disabled.has(nameForDisable))
162
+ continue;
163
+ const full = path.join(EXT_DIR, d.name);
164
+ let isDir = d.isDirectory();
165
+ if (d.isSymbolicLink()) {
166
+ try {
167
+ isDir = fs.statSync(full).isDirectory();
168
+ }
169
+ catch {
170
+ continue;
171
+ }
172
+ }
173
+ if (isDir) {
174
+ if (!hasIndexFile(full))
175
+ continue;
176
+ }
177
+ else if (!SCRIPT_EXTS.some((ext) => d.name.endsWith(ext))) {
178
+ continue;
179
+ }
180
+ const detail = d.isSymbolicLink() ? `-> ${fs.readlinkSync(full)}` : undefined;
181
+ out.push({ name: d.name, source: "extensions dir", detail });
182
+ }
183
+ return out;
184
+ }
185
+ function listFromSettings(disabled) {
186
+ const specs = getSettings().extensions ?? [];
187
+ return specs
188
+ .filter((s) => !disabled.has(s.replace(/\.[^.]+$/, "")))
189
+ .map((s) => ({ name: s, source: "settings.json" }));
190
+ }
191
+ export function runList() {
192
+ const disabled = new Set(getSettings().disabledExtensions ?? []);
193
+ const items = [...listFromExtDir(disabled), ...listFromSettings(disabled)];
194
+ if (items.length === 0) {
195
+ console.log("No extensions installed.");
196
+ return;
197
+ }
198
+ const nameWidth = Math.max(...items.map((i) => i.name.length));
199
+ console.log("Installed extensions:");
200
+ for (const item of items) {
201
+ const padded = item.name.padEnd(nameWidth);
202
+ const detail = item.detail ? ` ${item.detail}` : "";
203
+ console.log(` ${padded} (${item.source})${detail}`);
204
+ }
205
+ }
package/dist/types.d.ts CHANGED
@@ -100,6 +100,8 @@ export interface AgentShellConfig {
100
100
  baseURL?: string;
101
101
  /** Named provider to use from settings.json. */
102
102
  provider?: string;
103
+ /** Override settings.defaultBackend for this session only (does not persist). */
104
+ backend?: string;
103
105
  /** Conversation history backend. Defaults to the on-disk HistoryFile. */
104
106
  history?: HistoryAdapter;
105
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.23",
3
+ "version": "0.12.24",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",