ashlr-plugin 1.36.4 → 1.37.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 (81) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +49 -8
  5. package/commands/ashlr-doctor.md +29 -0
  6. package/commands/ashlr-status.md +1 -1
  7. package/docs/PHANTOM-USAGE.md +58 -0
  8. package/docs/architecture.md +5 -5
  9. package/docs/ashlr-handshake.md +36 -0
  10. package/docs/assets/og.svg +1 -1
  11. package/docs/benchmarks-v2.json +226 -177
  12. package/docs/benchmarks.md +20 -10
  13. package/docs/competitive-wozcode.md +2 -2
  14. package/docs/embeddings-gateway.md +39 -0
  15. package/docs/index.html +3 -3
  16. package/docs/legal.md +1 -1
  17. package/docs/llm-providers.md +15 -1
  18. package/docs/multi-host-mcp.md +25 -3
  19. package/docs/phantom-preflight.md +23 -0
  20. package/docs/team-genome.md +2 -1
  21. package/hooks/_hook-timing-ledger.ts +2 -9
  22. package/hooks/hooks.json +3 -106
  23. package/hooks/posttooluse-stale-result.ts +3 -0
  24. package/hooks/pretooluse-common.ts +1 -1
  25. package/hooks/pretooluse-notebookedit.ts +1 -1
  26. package/hooks/pretooluse-write.ts +1 -1
  27. package/hooks/session-start.ts +48 -26
  28. package/package.json +9 -4
  29. package/ports/cursor/mcp.json +1 -1
  30. package/scripts/_read-thresholds.ts +119 -0
  31. package/scripts/ashlr-mcp.mjs +15 -0
  32. package/scripts/ashlr-mcp.ts +3 -0
  33. package/scripts/ashlr-plugin.mjs +59 -0
  34. package/scripts/bench-read-real.ts +90 -0
  35. package/scripts/bootstrap.mjs +14 -4
  36. package/scripts/cli.ts +35 -10
  37. package/scripts/context-status.ts +7 -2
  38. package/scripts/deploy-smoke.sh +8 -4
  39. package/scripts/doctor.ts +171 -42
  40. package/scripts/genome-auto-consolidate.ts +4 -2
  41. package/scripts/genome-init.ts +3 -3
  42. package/scripts/genome-key.ts +64 -7
  43. package/scripts/handshake.mjs +51 -0
  44. package/scripts/hook-bootstrap.mjs +170 -17
  45. package/scripts/mcp-entrypoint.ts +3 -0
  46. package/scripts/ollama-setup.ts +5 -4
  47. package/scripts/run-benchmark.ts +28 -18
  48. package/scripts/upgrade-flow.ts +9 -14
  49. package/servers/_base-urls.ts +48 -0
  50. package/servers/_companions.ts +258 -0
  51. package/servers/_cwd-clamp.ts +38 -1
  52. package/servers/_edit-match.ts +12 -2
  53. package/servers/_embedding-model.ts +77 -0
  54. package/servers/_gateway-target.ts +179 -0
  55. package/servers/_llm-providers/anthropic.ts +10 -4
  56. package/servers/_llm-providers/cloud.ts +9 -7
  57. package/servers/_llm-providers/index.ts +8 -6
  58. package/servers/_llm-providers/local.ts +9 -5
  59. package/servers/_mcp-host.ts +6 -0
  60. package/servers/_miss-tracker.ts +121 -0
  61. package/servers/_phantom-exec.ts +135 -0
  62. package/servers/_phantom-preflight.ts +130 -0
  63. package/servers/_pid-alive.ts +40 -0
  64. package/servers/_pro-token-keychain.ts +149 -0
  65. package/servers/_redact-keys.ts +75 -0
  66. package/servers/_rg-text.ts +111 -0
  67. package/servers/_router.ts +20 -2
  68. package/servers/_run-with-timeout.ts +56 -12
  69. package/servers/_test-watch.ts +1 -10
  70. package/servers/_token-measure.ts +3 -1
  71. package/servers/_tool-base.ts +6 -0
  72. package/servers/bash-server.ts +1 -10
  73. package/servers/edit-server.ts +24 -1
  74. package/servers/efficiency-server.ts +12 -2
  75. package/servers/genome-server-handlers.ts +2 -2
  76. package/servers/genome-server.ts +17 -17
  77. package/servers/grep-server.ts +6 -2
  78. package/servers/orient-server-handlers.ts +3 -2
  79. package/servers/orient-server.ts +16 -9
  80. package/servers/read-server.ts +1 -1
  81. package/servers/write-server-handlers.ts +10 -2
@@ -27,11 +27,22 @@
27
27
  * Usage (wired from hooks/hooks.json):
28
28
  * node "${CODEX_PLUGIN_ROOT}/scripts/hook-bootstrap.mjs" \
29
29
  * "${CODEX_PLUGIN_ROOT}/hooks/<hook>.ts" [args...]
30
+ *
31
+ * Group mode runs several hooks for one event from a single node process:
32
+ * node hook-bootstrap.mjs --group [--match <tools>] <hook>.ts [--match <tools>] <hook>.ts ...
33
+ * `--match` takes a Claude Code matcher (`Read|Grep`) and applies to the next
34
+ * hook only; a hook without one always runs. Each hook still runs as its own
35
+ * `bun run` process, in parallel, with the same stdin. Their outputs are
36
+ * merged: `additionalContext` strings are joined, plain-text output is kept,
37
+ * and exit code 2 from any hook wins. Starting node once per event instead of
38
+ * once per hook is what saves the time.
30
39
  */
31
40
 
32
- import { spawnSync } from "node:child_process";
33
- import { hasBun, prependBunToPath } from "./bun-resolve.mjs";
41
+ import { spawn, spawnSync } from "node:child_process";
42
+ import { readFileSync } from "node:fs";
43
+ import { prependBunToPath } from "./bun-resolve.mjs";
34
44
 
45
+ const groupMode = process.argv[2] === "--group";
35
46
  const hookPath = process.argv[2];
36
47
  if (!hookPath) {
37
48
  process.stderr.write("[ashlr:hook-bootstrap] missing hook path argument\n");
@@ -42,6 +53,10 @@ const looksLikeCodexPluginHook =
42
53
  Boolean(process.env.CODEX_PLUGIN_ROOT) ||
43
54
  Boolean(process.env.ASHLR_CODEX_HOOK_MODE) ||
44
55
  (Boolean(process.env.CLAUDE_PLUGIN_ROOT) &&
56
+ // Claude Code sets CLAUDECODE=1 for its child processes but neither of
57
+ // the two variables below, so without this check every Claude Code hook
58
+ // ran in Codex nudge mode and suggested bare `ashlr__*` tool names.
59
+ !process.env.CLAUDECODE &&
45
60
  !process.env.CLAUDE_SESSION_ID &&
46
61
  !process.env.CLAUDE_CODE_MODEL);
47
62
 
@@ -52,24 +67,162 @@ if (process.env.ASHLR_MCP_HOST === "codex-cli" && !process.env.ASHLR_HOOK_MODE)
52
67
  process.env.ASHLR_HOOK_MODE = process.env.ASHLR_CODEX_HOOK_MODE || "nudge";
53
68
  }
54
69
 
55
- if (!hasBun()) {
56
- prependBunToPath();
57
- if (!hasBun()) {
58
- // Bun genuinely unreachable. Exit 0 — hooks must never fail the harness.
59
- // The MCP server's bootstrap.mjs will install bun on its next spawn if it
60
- // hasn't already; this hook simply no-ops for now.
70
+ /** True when the error means the `bun` executable was not found. */
71
+ function bunMissing(error) {
72
+ return Boolean(error) && error.code === "ENOENT";
73
+ }
74
+
75
+ if (groupMode) {
76
+ await runGroup(process.argv.slice(3));
77
+ } else {
78
+ runSingle();
79
+ }
80
+
81
+ function runSingle() {
82
+ const forwarded = process.argv.slice(3);
83
+ const run = () => spawnSync("bun", ["run", hookPath, ...forwarded], { stdio: "inherit", env: process.env });
84
+ let result = run();
85
+ if (bunMissing(result.error)) {
86
+ // Not on PATH: try ~/.bun/bin (a prior bootstrap.mjs install). If bun is
87
+ // still unreachable, exit 0. Hooks must never fail the harness; the MCP
88
+ // server's bootstrap.mjs installs bun on its next spawn.
89
+ prependBunToPath();
90
+ result = run();
91
+ if (bunMissing(result.error)) process.exit(0);
92
+ }
93
+ if (result.error) {
94
+ process.stderr.write(`[ashlr:hook-bootstrap] ${result.error.message}\n`);
61
95
  process.exit(0);
62
96
  }
97
+ process.exit(result.status ?? 0);
63
98
  }
64
99
 
65
- const forwarded = process.argv.slice(3);
66
- const result = spawnSync("bun", ["run", hookPath, ...forwarded], {
67
- stdio: "inherit",
68
- env: process.env,
69
- });
100
+ /** Claude Code matcher semantics: empty or `*` matches all, else a regex over the whole tool name. */
101
+ function matches(matcher, toolName) {
102
+ if (!matcher || matcher === "*") return true;
103
+ if (!toolName) return true;
104
+ try {
105
+ return new RegExp(`^(?:${matcher})$`).test(toolName);
106
+ } catch {
107
+ return matcher === toolName;
108
+ }
109
+ }
110
+
111
+ function parseGroup(args) {
112
+ const hooks = [];
113
+ let matcher = null;
114
+ for (let i = 0; i < args.length; i++) {
115
+ if (args[i] === "--match") {
116
+ matcher = args[++i] ?? null;
117
+ continue;
118
+ }
119
+ hooks.push({ path: args[i], matcher });
120
+ matcher = null;
121
+ }
122
+ return hooks;
123
+ }
70
124
 
71
- if (result.error) {
72
- process.stderr.write(`[ashlr:hook-bootstrap] ${result.error.message}\n`);
73
- process.exit(0);
125
+ function runHook(path, input) {
126
+ return new Promise((resolveRun) => {
127
+ let child;
128
+ try {
129
+ child = spawn("bun", ["run", path], { stdio: ["pipe", "pipe", "pipe"], env: process.env });
130
+ } catch (error) {
131
+ resolveRun({ error, status: 0, stdout: "", stderr: "" });
132
+ return;
133
+ }
134
+ const out = [];
135
+ const err = [];
136
+ child.stdout.on("data", (c) => out.push(c));
137
+ child.stderr.on("data", (c) => err.push(c));
138
+ child.on("error", (error) => resolveRun({ error, status: 0, stdout: "", stderr: "" }));
139
+ child.on("close", (status) =>
140
+ resolveRun({
141
+ error: null,
142
+ status: status ?? 0,
143
+ stdout: Buffer.concat(out).toString("utf8"),
144
+ stderr: Buffer.concat(err).toString("utf8"),
145
+ }),
146
+ );
147
+ child.stdin.on("error", () => {}); // a hook that exits without reading stdin
148
+ child.stdin.end(input);
149
+ });
150
+ }
151
+
152
+ async function runGroup(args) {
153
+ let input = Buffer.alloc(0);
154
+ try {
155
+ input = readFileSync(0);
156
+ } catch {
157
+ // No stdin (manual run): hooks get an empty payload.
158
+ }
159
+ let payload = {};
160
+ try {
161
+ payload = JSON.parse(input.toString("utf8") || "{}");
162
+ } catch {
163
+ // Unparseable payload: run every hook and let each decide.
164
+ }
165
+ const toolName = typeof payload.tool_name === "string" ? payload.tool_name : "";
166
+ const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
167
+ const selected = parseGroup(args).filter((h) => matches(h.matcher, toolName));
168
+ if (selected.length === 0) process.exit(0);
169
+
170
+ let results = await Promise.all(selected.map((h) => runHook(h.path, input)));
171
+ if (results.some((r) => bunMissing(r.error))) {
172
+ prependBunToPath();
173
+ results = await Promise.all(selected.map((h) => runHook(h.path, input)));
174
+ if (results.some((r) => bunMissing(r.error))) process.exit(0);
175
+ }
176
+
177
+ const contexts = [];
178
+ const plain = [];
179
+ let merged = null;
180
+ let blocking = null;
181
+ let failure = 0;
182
+ for (const r of results) {
183
+ if (r.stderr) process.stderr.write(r.stderr);
184
+ if (r.error) {
185
+ process.stderr.write(`[ashlr:hook-bootstrap] ${r.error.message}\n`);
186
+ continue;
187
+ }
188
+ if (r.status === 2 && blocking === null) blocking = r;
189
+ else if (r.status !== 0 && failure === 0) failure = r.status;
190
+ const text = r.stdout.trim();
191
+ if (!text) continue;
192
+ let parsed = null;
193
+ try {
194
+ parsed = JSON.parse(text);
195
+ } catch {
196
+ // Plain-text output.
197
+ }
198
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
199
+ plain.push(text);
200
+ continue;
201
+ }
202
+ const { hookSpecificOutput, ...rest } = parsed;
203
+ merged = { ...(merged ?? {}), ...rest };
204
+ if (hookSpecificOutput && typeof hookSpecificOutput.additionalContext === "string" && hookSpecificOutput.additionalContext) {
205
+ contexts.push(hookSpecificOutput.additionalContext);
206
+ }
207
+ if (hookSpecificOutput && !event && typeof hookSpecificOutput.hookEventName === "string") {
208
+ merged.__event = hookSpecificOutput.hookEventName;
209
+ }
210
+ }
211
+
212
+ if (blocking) process.exit(2);
213
+
214
+ if (merged === null && contexts.length === 0) {
215
+ // Every hook printed plain text (or nothing): keep it as plain text.
216
+ if (plain.length > 0) process.stdout.write(plain.join("\n\n") + "\n");
217
+ process.exit(failure);
218
+ }
219
+ const { __event, ...top } = merged ?? {};
220
+ // Mixed output: plain text joins the JSON's context so nothing is lost.
221
+ const allContext = [...contexts, ...plain];
222
+ const output = { ...top };
223
+ if (allContext.length > 0) {
224
+ output.hookSpecificOutput = { hookEventName: event || __event, additionalContext: allContext.join("\n\n") };
225
+ }
226
+ process.stdout.write(JSON.stringify(output));
227
+ process.exit(failure);
74
228
  }
75
- process.exit(result.status ?? 0);
@@ -27,6 +27,9 @@ import { fileURLToPath } from "node:url";
27
27
 
28
28
  const __filename = fileURLToPath(import.meta.url);
29
29
  const PLUGIN_ROOT = resolve(dirname(__filename), "..");
30
+ // Record the folder the host launched us in (the user's project) before
31
+ // moving to PLUGIN_ROOT; the router adopts it once dependencies resolve.
32
+ process.env.ASHLR_LAUNCH_CWD ||= process.cwd();
30
33
  process.chdir(PLUGIN_ROOT);
31
34
 
32
35
  // 1. Self-install deps if missing. Idempotent — `bun install` is a no-op once
@@ -22,6 +22,7 @@
22
22
  /* eslint-disable no-console */
23
23
 
24
24
  import { spawn } from "bun";
25
+ import { DEFAULT_OLLAMA_BASE_URL, ollamaBaseUrl } from "../servers/_base-urls.ts";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Colored output (zero deps, respects NO_COLOR / FORCE_COLOR / !isTTY)
@@ -79,7 +80,7 @@ function printFix(cmd: string): void {
79
80
  // Types + constants
80
81
  // ---------------------------------------------------------------------------
81
82
 
82
- export const OLLAMA_BASE_URL = "http://localhost:11434";
83
+ export const OLLAMA_BASE_URL = DEFAULT_OLLAMA_BASE_URL;
83
84
  const TAGS_TIMEOUT_MS = 3000;
84
85
  const SMOKE_TIMEOUT_MS = 30000;
85
86
 
@@ -192,7 +193,7 @@ export interface TagsResult {
192
193
 
193
194
  /** Fetch /api/tags. Returns `running: false` if the daemon is unreachable. */
194
195
  export async function fetchTags(
195
- baseUrl = OLLAMA_BASE_URL,
196
+ baseUrl = ollamaBaseUrl(),
196
197
  timeoutMs = TAGS_TIMEOUT_MS,
197
198
  fetchImpl: typeof fetch = fetch,
198
199
  ): Promise<TagsResult> {
@@ -241,7 +242,7 @@ export interface SmokeTestResult {
241
242
  */
242
243
  export async function smokeTest(
243
244
  model: string,
244
- baseUrl = OLLAMA_BASE_URL,
245
+ baseUrl = ollamaBaseUrl(),
245
246
  timeoutMs = SMOKE_TIMEOUT_MS,
246
247
  fetchImpl: typeof fetch = fetch,
247
248
  ): Promise<SmokeTestResult> {
@@ -368,7 +369,7 @@ export async function run(args: CliArgs): Promise<number> {
368
369
  if (!tags.running) {
369
370
  printOk("Ollama CLI found on PATH");
370
371
  printFail(
371
- `Ollama daemon not reachable at ${OLLAMA_BASE_URL}${tags.error ? ` (${tags.error})` : ""}`,
372
+ `Ollama daemon not reachable at ${ollamaBaseUrl()}${tags.error ? ` (${tags.error})` : ""}`,
372
373
  );
373
374
  console.log("");
374
375
  for (const l of startInstructions()) console.log(` ${l}`);
@@ -14,9 +14,11 @@
14
14
  * ashlr__edit by calling the handler functions directly — no MCP layer.
15
15
  */
16
16
 
17
+ import { describePhantomExec, detectPhantomExec, providerKeyUsable } from "../servers/_phantom-exec.ts";
17
18
  import { readFileSync, writeFileSync, existsSync } from "fs";
18
19
  import { resolve, relative } from "path";
19
20
  import { spawnSync } from "child_process";
21
+ import { rgTextArgs, truncateRgText } from "../servers/_rg-text";
20
22
 
21
23
  import {
22
24
  estimateTokensFromString,
@@ -345,10 +347,11 @@ function resolveRg(): string {
345
347
  function measureGrep(pattern: string, repoPath: string): GrepSample | null {
346
348
  try {
347
349
  const rgBin = resolveRg();
348
- const res = spawnSync(rgBin, ["--json", "-n", "--", pattern, repoPath], {
350
+ // Baseline: what a plain grep returns (`path:line:text`, no column cap).
351
+ const res = spawnSync(rgBin, ["-n", "--no-heading", "--color", "never", "--", pattern, repoPath], {
349
352
  encoding: "utf-8",
350
353
  timeout: 15_000,
351
- maxBuffer: 32 * 1024 * 1024,
354
+ maxBuffer: 64 * 1024 * 1024,
352
355
  });
353
356
  const raw = res.stdout ?? "";
354
357
  if (!raw.trim()) return null;
@@ -356,11 +359,13 @@ function measureGrep(pattern: string, repoPath: string): GrepSample | null {
356
359
  const rawBytes = Buffer.byteLength(raw, "utf-8");
357
360
  const rawTokens = estimateTokensFromString(raw);
358
361
 
359
- // Replicate rg-fallback path in ashlrGrep: truncate to 4000 chars head+tail
360
- const truncated =
361
- raw.length > 4000
362
- ? raw.slice(0, 2000) + "\n\n[... truncated ...]\n\n" + raw.slice(-1000)
363
- : raw;
362
+ // Replicate the rg-fallback path in ashlrGrep (servers/_rg-text.ts).
363
+ const ashlrRes = spawnSync(rgBin, rgTextArgs(pattern, repoPath), {
364
+ encoding: "utf-8",
365
+ timeout: 15_000,
366
+ maxBuffer: 64 * 1024 * 1024,
367
+ });
368
+ const truncated = truncateRgText(ashlrRes.stdout ?? "");
364
369
 
365
370
  const ashlrBytes = Buffer.byteLength(truncated, "utf-8");
366
371
  const ashlrTokens = estimateTokensFromString(truncated);
@@ -402,13 +407,10 @@ function summarizeEditForBench(
402
407
  replace: string,
403
408
  fileSize: number,
404
409
  ): string {
405
- // Replicate the summarizeEdit output from efficiency-server.ts
406
- const first = (s: string) => s.split("\n")[0]?.slice(0, 72) ?? "";
407
- return [
408
- `[ashlr__edit] file.ts · 1 of 1 hunks applied`,
409
- ` - removed (${estimateTokensFromString(search)} tok): ${first(search)}${search.length > 72 ? "…" : ""}`,
410
- ` + added (${estimateTokensFromString(replace)} tok): ${first(replace)}${replace.length > 72 ? "…" : ""}`,
411
- ].join("\n");
410
+ // Mirrors servers/edit-server.ts: an exact, unique match (every benchmark edit
411
+ // is one) returns the header only, because the caller already has the text it sent.
412
+ void search; void replace; void fileSize;
413
+ return `[ashlr__edit] file.ts · 1 of 1 hunks applied`;
412
414
  }
413
415
 
414
416
  function measureEdit(size: EditSize): EditSample {
@@ -548,9 +550,10 @@ size buckets (2–5 KB, 5–15 KB, 15–50 KB, 50+ KB). Re-running on the same
548
550
  commit always picks the same files.
549
551
 
550
552
  **ashlr__grep**: Five common patterns (function, import, TODO, class, interface)
551
- are run via rg --json against the repo root. Raw output bytes are measured
552
- directly. The ashlr__grep fallback path (no genome) truncates output to 4 000
553
- chars (head 2 000 + tail 1 000). The ratio is truncated/raw.
553
+ are run via plain \`rg -n\` (path:line:text, the shape a native grep returns)
554
+ against the repo root; that output is the raw size. The ashlr__grep fallback
555
+ path (no genome) caps lines at 300 columns and, above 4 000 chars, keeps whole
556
+ lines from the head and tail. The ratio is ashlr/raw.
554
557
 
555
558
  Note: when a .ashlrcode/genome/ index is present, real-world grep savings are
556
559
  substantially higher. This benchmark measures only the conservative
@@ -705,7 +708,14 @@ export async function runBenchmark(opts: {
705
708
 
706
709
  // --validate-tokenizer: sample 5% through measureTokens
707
710
  let heuristicError: number | null = null;
708
- if (validateTokenizer && process.env.ANTHROPIC_API_KEY) {
711
+ const phantomExec = detectPhantomExec();
712
+ if (validateTokenizer && phantomExec.inSession) {
713
+ console.log(`[run-benchmark] ${describePhantomExec(phantomExec)}`);
714
+ }
715
+ if (validateTokenizer && process.env.ANTHROPIC_API_KEY && !providerKeyUsable("ANTHROPIC_API_KEY")) {
716
+ for (const w of phantomExec.warnings) console.log(`[run-benchmark] --validate-tokenizer: ${w}`);
717
+ console.log("[run-benchmark] --validate-tokenizer: ANTHROPIC_API_KEY is not usable here, skipping");
718
+ } else if (validateTokenizer && process.env.ANTHROPIC_API_KEY) {
709
719
  console.log("[run-benchmark] --validate-tokenizer: sampling API token counts...");
710
720
  const { measureTokens } = await import("../servers/_token-measure.ts");
711
721
  const sampleN = Math.max(1, Math.ceil(readSamples.length * 0.05));
@@ -29,6 +29,7 @@ import * as readline from "readline";
29
29
  import { randomBytes } from "crypto";
30
30
  import { recordNudgeClicked, maybeSyncToCloud as maybeSyncNudgeEvents } from "../servers/_nudge-events.ts";
31
31
  import { validateProToken } from "../servers/_pro.ts";
32
+ import { PLAINTEXT_FALLBACK_WARNING, storeProToken } from "../servers/_pro-token-keychain.ts";
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Config
@@ -186,19 +187,10 @@ function saveToken(token: string): void {
186
187
  try { chmodSync(TOKEN_FILE, 0o600); } catch { /* best-effort */ }
187
188
  }
188
189
 
189
- async function writeEnvFile(token: string): Promise<void> {
190
+ async function writeEnvFile(token: string): Promise<"keychain" | "file"> {
190
191
  ensureAshlrDir();
191
- let existing = "";
192
- try { existing = await readFile(ENV_FILE, "utf8"); } catch { /* new file */ }
193
-
194
- // Remove any prior ASHLR_PRO_TOKEN line, then append the fresh one
195
- const lines = existing
196
- .split("\n")
197
- .filter((l) => !l.startsWith("export ASHLR_PRO_TOKEN="));
198
- lines.push(`export ASHLR_PRO_TOKEN=${token}`);
199
-
200
- await writeFile(ENV_FILE, lines.join("\n").trimStart() + "\n", { encoding: "utf8", mode: 0o600 });
201
- try { chmodSync(ENV_FILE, 0o600); } catch { /* best-effort */ }
192
+ // OS keychain first; the 0600 `export ASHLR_PRO_TOKEN=` line only as fallback.
193
+ return storeProToken(token, ENV_FILE);
202
194
  }
203
195
 
204
196
  function readStoredToken(): string | null {
@@ -690,9 +682,12 @@ async function main(): Promise<number> {
690
682
 
691
683
  // Persist token
692
684
  saveToken(token);
693
- await writeEnvFile(token);
685
+ const where = await writeEnvFile(token);
694
686
  process.env["ASHLR_PRO_TOKEN"] = token;
695
- ok("Token saved to ~/.ashlr/pro-token and ~/.ashlr/env (auto-loaded on next session).");
687
+ if (where === "file") warn(PLAINTEXT_FALLBACK_WARNING);
688
+ ok(where === "keychain"
689
+ ? "Token saved to ~/.ashlr/pro-token and the OS keychain (auto-loaded on next session)."
690
+ : "Token saved to ~/.ashlr/pro-token and ~/.ashlr/env (auto-loaded on next session).");
696
691
  } else {
697
692
  step(2, totalSteps, "Sign in");
698
693
  ok("Already authenticated — skipping sign-in.");
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Base URLs for the provider hosts the plugin's scripts and servers call.
3
+ *
4
+ * Each helper reads the same environment variable the provider's own tooling
5
+ * uses and falls back to the host the plugin always used, so nothing changes
6
+ * unless a variable is set. A gateway (for example LiteLLM) or a local proxy
7
+ * can then sit in front by setting the variable.
8
+ *
9
+ * ANTHROPIC_BASE_URL root without /v1 (default https://api.anthropic.com)
10
+ * OPENAI_BASE_URL includes /v1 (default https://api.openai.com/v1)
11
+ * OLLAMA_HOST host, host:port, :port or a full URL (default http://localhost:11434)
12
+ *
13
+ * Empty or whitespace-only values count as unset. Trailing slashes are trimmed.
14
+ */
15
+
16
+ export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
17
+ export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
18
+ export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
19
+
20
+ type Env = Record<string, string | undefined>;
21
+
22
+ function trimmed(value: string | undefined): string {
23
+ return (value ?? "").trim().replace(/\/+$/, "");
24
+ }
25
+
26
+ /** Anthropic API root, no trailing slash. Append `/v1/...`. */
27
+ export function anthropicBaseUrl(env: Env = process.env): string {
28
+ return trimmed(env["ANTHROPIC_BASE_URL"]) || DEFAULT_ANTHROPIC_BASE_URL;
29
+ }
30
+
31
+ /** OpenAI API root including `/v1`, no trailing slash. Append `/models` etc. */
32
+ export function openaiBaseUrl(env: Env = process.env): string {
33
+ return trimmed(env["OPENAI_BASE_URL"]) || DEFAULT_OPENAI_BASE_URL;
34
+ }
35
+
36
+ /**
37
+ * Ollama root, no trailing slash. OLLAMA_HOST may omit the scheme (`host:port`,
38
+ * `host`, `:port`); `http://` is assumed and port 11434 when none is given.
39
+ */
40
+ export function ollamaBaseUrl(env: Env = process.env): string {
41
+ const raw = trimmed(env["OLLAMA_HOST"]);
42
+ if (!raw) return DEFAULT_OLLAMA_BASE_URL;
43
+ if (/^https?:\/\//i.test(raw)) return raw;
44
+ let hostPort = raw;
45
+ if (hostPort.startsWith(":")) hostPort = `localhost${hostPort}`;
46
+ if (!/:\d+$/.test(hostPort)) hostPort = `${hostPort}:11434`;
47
+ return `http://${hostPort}`;
48
+ }