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,91 @@
1
+ import { PACKAGE_VERSION } from "../utils/package-version.js";
2
+ import type { AppConfig } from "../shell/host-types.js";
3
+
4
+ const HELP_TEXT = `agent-sh — a shell-first terminal where AI is one keystroke away
5
+
6
+ Usage: agent-sh [options]
7
+ agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
8
+ agent-sh install <spec> [--force] [--sync-deps] [--dev]
9
+ Install an extension (bundled name, file:, npm:, github:)
10
+ --sync-deps rewrites a stale agent-sh pin to the host version
11
+ --dev links the extension against the running host's core (local development)
12
+ agent-sh uninstall <name> Remove an installed extension
13
+ agent-sh list List installed extensions
14
+ agent-sh auth login [provider] Store an API key for a built-in provider
15
+ agent-sh auth logout <provider> Remove a stored key
16
+ agent-sh auth list Show configured providers
17
+
18
+ Provider Profiles:
19
+ --provider <name> Use a provider from ~/.agent-sh/settings.json
20
+ --model <name> Override default model
21
+
22
+ Direct LLM API:
23
+ --api-key <key> API key for OpenAI-compatible provider (or set OPENAI_API_KEY)
24
+ --base-url <url> Base URL for API (or set OPENAI_BASE_URL)
25
+
26
+ General Options:
27
+ --backend <name> Agent backend to launch (e.g. ash, pi); overrides settings.defaultBackend for this session
28
+ --shell <path> Shell to use (default: $SHELL or /bin/bash)
29
+ -e, --extensions Extensions to load (comma-separated, repeatable)
30
+ -h, --help Show this help
31
+ -V, --version Print version and exit
32
+
33
+ Environment Variables:
34
+ OPENAI_API_KEY API key for LLM provider
35
+ OPENAI_BASE_URL Base URL override (e.g., http://localhost:11434/v1 for Ollama)
36
+
37
+ Examples:
38
+ # Use a configured provider
39
+ agent-sh --provider openai
40
+
41
+ # Direct API access
42
+ agent-sh --api-key "$KEY" --model gpt-4o
43
+
44
+ # Local model via Ollama
45
+ agent-sh --base-url http://localhost:11434/v1 --model llama3
46
+
47
+ Inside the shell:
48
+ Type normally Commands run in your real shell
49
+ > <query> Ask the AI agent (it decides how to help)
50
+ > /help Show available slash commands
51
+ Ctrl-C Cancel agent response (or signal shell as usual)
52
+ `;
53
+
54
+ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): AppConfig {
55
+ let model: string | undefined;
56
+ let extensions: string[] | undefined;
57
+ let provider: string | undefined;
58
+ let backend: string | undefined;
59
+ let shell = env.SHELL || "/bin/bash";
60
+
61
+ let apiKey: string | undefined;
62
+ let baseURL: string | undefined;
63
+
64
+ for (let i = 0; i < argv.length; i++) {
65
+ const arg = argv[i];
66
+ if (arg === "--model" && argv[i + 1]) {
67
+ model = argv[++i]!;
68
+ } else if (arg === "--api-key" && argv[i + 1]) {
69
+ apiKey = argv[++i]!;
70
+ } else if (arg === "--base-url" && argv[i + 1]) {
71
+ baseURL = argv[++i]!;
72
+ } else if (arg === "--provider" && argv[i + 1]) {
73
+ provider = argv[++i]!;
74
+ } else if (arg === "--backend" && argv[i + 1]) {
75
+ backend = argv[++i]!;
76
+ } else if (arg === "--shell" && argv[i + 1]) {
77
+ shell = argv[++i]!;
78
+ } else if ((arg === "--extensions" || arg === "-e") && argv[i + 1]) {
79
+ const exts = argv[++i]!.split(",").map((s) => s.trim());
80
+ extensions = extensions ? [...extensions, ...exts] : exts;
81
+ } else if (arg === "--version" || arg === "-V") {
82
+ console.log(PACKAGE_VERSION);
83
+ process.exit(0);
84
+ } else if (arg === "--help" || arg === "-h") {
85
+ console.log(HELP_TEXT);
86
+ process.exit(0);
87
+ }
88
+ }
89
+
90
+ return { shell, model, extensions, apiKey, baseURL, provider, backend };
91
+ }
@@ -0,0 +1,244 @@
1
+ import * as readline from "node:readline";
2
+ import { palette as p } from "../../utils/palette.js";
3
+ import {
4
+ KNOWN_PROVIDERS,
5
+ KEYS_PATH,
6
+ loadKeysFile,
7
+ saveKeysFile,
8
+ resolveApiKey,
9
+ listAllProvidersWithDiscovery,
10
+ findProvider as findProviderById,
11
+ type ProviderAuthInfo,
12
+ } from "./keys.js";
13
+
14
+ export async function runAuth(args: string[]): Promise<void> {
15
+ const sub = args[0];
16
+ if (!sub || sub === "--help" || sub === "-h") {
17
+ printHelp();
18
+ return;
19
+ }
20
+ if (sub === "login") {
21
+ await runLogin(args[1]);
22
+ return;
23
+ }
24
+ if (sub === "logout") {
25
+ runLogout(args[1]);
26
+ return;
27
+ }
28
+ if (sub === "list" || sub === "ls" || sub === "status") {
29
+ await runList();
30
+ return;
31
+ }
32
+ console.error(`agent-sh auth: unknown subcommand "${sub}"`);
33
+ printHelp();
34
+ process.exit(1);
35
+ }
36
+
37
+ function printHelp(): void {
38
+ const builtins = KNOWN_PROVIDERS.map((p) => p.id).join(" | ");
39
+ console.log(
40
+ `agent-sh auth — manage API keys for providers\n\n` +
41
+ `Usage:\n` +
42
+ ` agent-sh auth login [provider] Store an API key (prompts if omitted)\n` +
43
+ ` agent-sh auth logout <provider> Remove a stored key\n` +
44
+ ` agent-sh auth list Show configured providers and key sources\n\n` +
45
+ `Built-in providers: ${builtins}\n` +
46
+ `Custom providers declared in settings.json are also accepted by id.\n\n` +
47
+ `Keys are stored in ${KEYS_PATH} (chmod 0600).\n` +
48
+ `Resolution order: settings.json > keys.json > env var.\n`,
49
+ );
50
+ }
51
+
52
+ async function runLogin(providerArg: string | undefined): Promise<void> {
53
+ let provider: ProviderAuthInfo | null;
54
+ if (providerArg) {
55
+ const id = providerArg.toLowerCase();
56
+ if (!/^[a-z0-9][a-z0-9_\-./:]*$/.test(id)) {
57
+ console.error(`agent-sh auth: invalid provider id "${providerArg}".`);
58
+ process.exit(1);
59
+ }
60
+ provider = findProviderById(id);
61
+ if (!provider) {
62
+ console.error(
63
+ `${p.warning}Note:${p.reset} no built-in or settings.json provider named "${id}". ` +
64
+ `Storing the key anyway — it will resolve once an extension or settings.json declares the provider.`,
65
+ );
66
+ provider = { id, label: id, unattached: true };
67
+ }
68
+ } else {
69
+ provider = await pickProvider();
70
+ }
71
+ if (!provider) process.exit(1);
72
+
73
+ const key = await promptSecret(`Enter ${provider.label} API key: `);
74
+ const trimmed = key.trim();
75
+ if (!trimmed) {
76
+ console.error("agent-sh auth: empty key, nothing saved.");
77
+ process.exit(1);
78
+ }
79
+ if (/\s/.test(trimmed)) {
80
+ console.error("agent-sh auth: key contains whitespace; aborting.");
81
+ process.exit(1);
82
+ }
83
+
84
+ const keys = { ...loadKeysFile() };
85
+ keys[provider.id] = trimmed;
86
+ saveKeysFile(keys);
87
+
88
+ const resolved = resolveApiKey(provider.id);
89
+ console.log(`${p.success}✓${p.reset} Saved ${provider.label} key to ${KEYS_PATH}`);
90
+ if (resolved.source !== "keys-file") {
91
+ console.log(
92
+ `${p.warning}Note:${p.reset} an existing ${sourceLabel(resolved.source, provider)} entry ` +
93
+ `takes precedence; the stored key is shadowed until you remove it.`,
94
+ );
95
+ }
96
+ }
97
+
98
+ function runLogout(providerArg: string | undefined): void {
99
+ if (!providerArg) {
100
+ console.error("Usage: agent-sh auth logout <provider>");
101
+ process.exit(1);
102
+ }
103
+ const id = providerArg.toLowerCase();
104
+ const keys = { ...loadKeysFile() };
105
+ if (!(id in keys)) {
106
+ console.log(`agent-sh auth: no stored key for ${id}.`);
107
+ return;
108
+ }
109
+ delete keys[id];
110
+ saveKeysFile(keys);
111
+ console.log(`${p.success}✓${p.reset} Removed ${id} key from ${KEYS_PATH}`);
112
+ }
113
+
114
+ async function runList(): Promise<void> {
115
+ const providers = await listAllProvidersWithDiscovery();
116
+ console.log("Provider key status:\n");
117
+ const idWidth = Math.max(...providers.map((p) => p.id.length));
118
+ for (const info of providers) {
119
+ const resolved = resolveApiKey(info.id);
120
+ const padded = info.id.padEnd(idWidth);
121
+ const marker = info.custom
122
+ ? ` ${p.dim}custom${p.reset}`
123
+ : info.unattached
124
+ ? ` ${p.dim}unattached${p.reset}`
125
+ : "";
126
+ if (resolved.key) {
127
+ console.log(` ${p.success}●${p.reset} ${padded} ${p.dim}(${sourceLabel(resolved.source, info)})${p.reset}${marker}`);
128
+ } else if (info.noAuth) {
129
+ console.log(` ${p.success}●${p.reset} ${padded} ${p.dim}(no auth required)${p.reset}${marker}`);
130
+ } else {
131
+ console.log(` ${p.muted}○${p.reset} ${padded} ${p.dim}(not configured)${p.reset}${marker}`);
132
+ }
133
+ }
134
+ const example = providers[0]!.id;
135
+ console.log(`\n${p.muted}Login with:${p.reset} agent-sh auth login <id> ${p.dim}(e.g. agent-sh auth login ${example})${p.reset}`);
136
+ }
137
+
138
+ async function pickProvider(): Promise<ProviderAuthInfo | null> {
139
+ if (!process.stdin.isTTY) {
140
+ console.error("agent-sh auth: no provider specified and stdin is not a TTY.");
141
+ return null;
142
+ }
143
+ const providers = await listAllProvidersWithDiscovery();
144
+ console.log("Select a provider:");
145
+ providers.forEach((info, i) => {
146
+ const resolved = resolveApiKey(info.id);
147
+ const tag = resolved.key
148
+ ? `${p.dim}(currently from ${sourceLabel(resolved.source, info)})${p.reset}`
149
+ : info.noAuth
150
+ ? `${p.dim}(no auth required)${p.reset}`
151
+ : `${p.dim}(not configured)${p.reset}`;
152
+ const labelStr = info.custom
153
+ ? `${p.dim}custom${p.reset}`
154
+ : info.unattached
155
+ ? `${p.dim}unattached${p.reset}`
156
+ : `${p.dim}${info.label}${p.reset}`;
157
+ console.log(` ${i + 1}) ${info.id.padEnd(12)} ${labelStr} ${tag}`);
158
+ });
159
+ const answer = await promptLine("Choice [1]: ");
160
+ const idx = answer.trim() === "" ? 0 : Number(answer.trim()) - 1;
161
+ if (!Number.isInteger(idx) || idx < 0 || idx >= providers.length) {
162
+ console.error("agent-sh auth: invalid selection.");
163
+ return null;
164
+ }
165
+ return providers[idx]!;
166
+ }
167
+
168
+ function promptLine(question: string): Promise<string> {
169
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
170
+ return new Promise((resolve) => {
171
+ rl.question(question, (answer) => {
172
+ rl.close();
173
+ resolve(answer);
174
+ });
175
+ });
176
+ }
177
+
178
+ function promptSecret(question: string): Promise<string> {
179
+ return new Promise((resolve) => {
180
+ if (!process.stdin.isTTY) {
181
+ let buf = "";
182
+ process.stdin.setEncoding("utf-8");
183
+ process.stdin.on("data", (chunk: string) => {
184
+ buf += chunk;
185
+ const nl = buf.indexOf("\n");
186
+ if (nl >= 0) {
187
+ process.stdin.pause();
188
+ resolve(buf.slice(0, nl).replace(/\r$/, ""));
189
+ }
190
+ });
191
+ return;
192
+ }
193
+
194
+ const stdin = process.stdin;
195
+ const stdout = process.stdout;
196
+ const wasRaw = stdin.isRaw;
197
+ stdout.write(question);
198
+ stdin.resume();
199
+ stdin.setRawMode(true);
200
+ stdin.setEncoding("utf-8");
201
+
202
+ let buf = "";
203
+ const finish = (value: string | null): void => {
204
+ stdout.write("\n");
205
+ stdin.removeListener("data", onData);
206
+ stdin.setRawMode(wasRaw);
207
+ stdin.pause();
208
+ if (value === null) process.exit(130);
209
+ resolve(value);
210
+ };
211
+ const onData = (ch: string): void => {
212
+ for (const c of ch) {
213
+ if (c === "\n" || c === "\r" || c === "\x04") {
214
+ finish(buf);
215
+ return;
216
+ }
217
+ if (c === "\x03") {
218
+ finish(null);
219
+ return;
220
+ }
221
+ if (c === "\x7f" || c === "\b") {
222
+ if (buf.length > 0) {
223
+ buf = buf.slice(0, -1);
224
+ stdout.write("\b \b");
225
+ }
226
+ continue;
227
+ }
228
+ if (c < " ") continue;
229
+ buf += c;
230
+ stdout.write("*");
231
+ }
232
+ };
233
+ stdin.on("data", onData);
234
+ });
235
+ }
236
+
237
+ function sourceLabel(source: string, info?: ProviderAuthInfo): string {
238
+ switch (source) {
239
+ case "settings": return "settings.json";
240
+ case "keys-file": return "keys.json";
241
+ case "env": return info ? `$${info.envVar}` : "env var";
242
+ default: return source;
243
+ }
244
+ }
@@ -0,0 +1,52 @@
1
+ /** Bootstrap a throwaway core to enumerate provider ids extensions
2
+ * would register, so `auth list` shows ids the user hasn't keyed yet. */
3
+ import * as path from "node:path";
4
+ import { createCore } from "../../core/index.js";
5
+ import { activateAgent } from "../../agent/index.js";
6
+ import { loadExtensions } from "../../core/extension-loader.js";
7
+ import { loadBuiltinExtensions } from "../../extensions/index.js";
8
+ import { CONFIG_DIR, getSettings } from "../../core/settings.js";
9
+ import type { AppConfig } from "../../shell/host-types.js";
10
+ import type { ProviderRegistration } from "../../agent/host-types.js";
11
+
12
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
13
+ const BARE_IMPORT_RE = /Cannot find (?:package|module) ['"]agent-sh\/[^'"]+['"]/;
14
+
15
+ export interface DiscoveredProvider {
16
+ id: string;
17
+ noAuth?: boolean;
18
+ }
19
+
20
+ let cached: DiscoveredProvider[] | null = null;
21
+
22
+ export async function discoverExtensionProviders(): Promise<DiscoveredProvider[]> {
23
+ if (cached) return cached;
24
+ const core = createCore({} as AppConfig);
25
+ const errors: string[] = [];
26
+ core.bus.on("ui:error", ({ message }) => { errors.push(message); });
27
+ try {
28
+ const ctx = core.extensionContext({ quit: () => {} });
29
+ activateAgent(ctx);
30
+ await loadBuiltinExtensions(ctx, getSettings().disabledBuiltins);
31
+ await loadExtensions(ctx).catch(() => {});
32
+ const { providers } = core.bus.emitPipe("agent:providers", { providers: [] as ProviderRegistration[] });
33
+ cached = providers.map((p) => ({ id: p.id, noAuth: p.noAuth }));
34
+ if (errors.length > 0) {
35
+ process.stderr.write(`\n[agent-sh] extension load errors during provider discovery:\n`);
36
+ for (const msg of errors) {
37
+ process.stderr.write(` - ${msg}\n`);
38
+ if (BARE_IMPORT_RE.test(msg) && msg.includes(EXT_DIR)) {
39
+ process.stderr.write(
40
+ ` ↳ Single-file extensions can't runtime-import agent-sh modules from ${EXT_DIR}.\n` +
41
+ ` Use ctx.call(...) for runtime needs, or convert to a directory extension\n` +
42
+ ` with its own package.json + node_modules.\n`,
43
+ );
44
+ }
45
+ }
46
+ process.stderr.write(`\n`);
47
+ }
48
+ return cached;
49
+ } finally {
50
+ core.kill();
51
+ }
52
+ }
@@ -0,0 +1,143 @@
1
+ // Resolution order: settings.json → keys.json → env var.
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { CONFIG_DIR, getSettings, expandEnvVars } from "../../core/settings.js";
5
+
6
+ export const KEYS_PATH = path.join(CONFIG_DIR, "keys.json");
7
+
8
+ export interface ProviderAuthInfo {
9
+ id: string;
10
+ label: string;
11
+ /** Conventional env var. Absent for user-declared providers. */
12
+ envVar?: string;
13
+ /** True for entries declared in settings.json (vs. a built-in). */
14
+ custom?: boolean;
15
+ /** True for ids only present in keys.json — likely owned by an extension
16
+ * that registers a provider at runtime. */
17
+ unattached?: boolean;
18
+ /** Auth UI shows "no auth required" instead of "not configured". */
19
+ noAuth?: boolean;
20
+ }
21
+
22
+ export const KNOWN_PROVIDERS: ProviderAuthInfo[] = [
23
+ { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
24
+ { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
25
+ { id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY" },
26
+ ];
27
+
28
+ /** Built-ins + settings + keys.json. Sync, no extension load. */
29
+ export function listAllProviders(): ProviderAuthInfo[] {
30
+ const out: ProviderAuthInfo[] = [...KNOWN_PROVIDERS];
31
+ const seen = new Set(out.map((p) => p.id));
32
+ const settingsProviders = getSettings().providers ?? {};
33
+ for (const id of Object.keys(settingsProviders)) {
34
+ if (seen.has(id)) continue;
35
+ out.push({ id, label: id, custom: true });
36
+ seen.add(id);
37
+ }
38
+ for (const id of Object.keys(loadKeysFile())) {
39
+ if (seen.has(id)) continue;
40
+ out.push({ id, label: id, unattached: true });
41
+ seen.add(id);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Augments listAllProviders with extension-registered ids. */
47
+ export async function listAllProvidersWithDiscovery(): Promise<ProviderAuthInfo[]> {
48
+ const out = listAllProviders();
49
+ const byId = new Map(out.map((p) => [p.id, p] as const));
50
+ const { discoverExtensionProviders } = await import("./discover.js");
51
+ try {
52
+ for (const d of await discoverExtensionProviders()) {
53
+ const existing = byId.get(d.id);
54
+ if (existing) {
55
+ if (d.noAuth && !existing.noAuth) existing.noAuth = true;
56
+ continue;
57
+ }
58
+ const entry: ProviderAuthInfo = { id: d.id, label: d.id, custom: true, noAuth: d.noAuth };
59
+ out.push(entry);
60
+ byId.set(d.id, entry);
61
+ }
62
+ } catch {}
63
+ return out;
64
+ }
65
+
66
+ /** Resolve an id against known + settings entries only. Returns null for
67
+ * unattached or unknown ids — callers decide whether to accept them. */
68
+ export function findProvider(id: string): ProviderAuthInfo | null {
69
+ const lower = id.toLowerCase();
70
+ const known = KNOWN_PROVIDERS.find((p) => p.id === lower);
71
+ if (known) return known;
72
+ const settings = getSettings().providers ?? {};
73
+ if (lower in settings) return { id: lower, label: lower, custom: true };
74
+ return null;
75
+ }
76
+
77
+ export type KeySource = "settings" | "keys-file" | "env" | "none";
78
+
79
+ export interface ResolvedKey {
80
+ key: string | null;
81
+ source: KeySource;
82
+ }
83
+
84
+ type KeysFile = Record<string, string>;
85
+
86
+ let cached: KeysFile | null = null;
87
+
88
+ export function loadKeysFile(): KeysFile {
89
+ if (cached) return cached;
90
+ try {
91
+ const raw = fs.readFileSync(KEYS_PATH, "utf-8");
92
+ const parsed = JSON.parse(raw) as unknown;
93
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
94
+ cached = parsed as KeysFile;
95
+ } else {
96
+ cached = {};
97
+ }
98
+ } catch (err) {
99
+ if (err instanceof SyntaxError) {
100
+ console.error(`[agent-sh] Warning: invalid JSON in ${KEYS_PATH}: ${err.message}`);
101
+ }
102
+ cached = {};
103
+ }
104
+ return cached;
105
+ }
106
+
107
+ export function saveKeysFile(keys: KeysFile): void {
108
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
109
+ const tmp = `${KEYS_PATH}.tmp`;
110
+ fs.writeFileSync(tmp, JSON.stringify(keys, null, 2) + "\n", { mode: 0o600 });
111
+ fs.renameSync(tmp, KEYS_PATH);
112
+ try { fs.chmodSync(KEYS_PATH, 0o600); } catch { /* best effort */ }
113
+ cached = { ...keys };
114
+ }
115
+
116
+ export function reloadKeysFile(): void {
117
+ cached = null;
118
+ }
119
+
120
+ export function resolveApiKey(providerId: string): ResolvedKey {
121
+ const settingsKey = getSettings().providers?.[providerId]?.apiKey;
122
+ if (settingsKey) {
123
+ const expanded = expandEnvVars(settingsKey);
124
+ if (expanded) return { key: expanded, source: "settings" };
125
+ }
126
+
127
+ const fileKey = loadKeysFile()[providerId];
128
+ if (fileKey) return { key: fileKey, source: "keys-file" };
129
+
130
+ const info = KNOWN_PROVIDERS.find((p) => p.id === providerId);
131
+ if (info?.envVar) {
132
+ const envKey = process.env[info.envVar];
133
+ if (envKey) return { key: envKey, source: "env" };
134
+ }
135
+
136
+ return { key: null, source: "none" };
137
+ }
138
+
139
+ export function anyProviderConfigured(): boolean {
140
+ // openai-compatible activates on OPENAI_BASE_URL alone (keyless local servers).
141
+ if (process.env.OPENAI_BASE_URL) return true;
142
+ return listAllProviders().some((p) => resolveApiKey(p.id).key);
143
+ }