@holt-os/holt 0.15.0 → 0.16.0

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 (3) hide show
  1. package/README.md +19 -10
  2. package/dist/cli.js +185 -77
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -50,7 +50,7 @@ cd ~/where-you-want-to-work
50
50
  holt # start your assistant (or use your custom command, e.g. `ai`)
51
51
  ```
52
52
 
53
- That is it. Bare `holt` sets the folder up the first time (trust, brain, memory hooks) and then launches the **real interactive brain** (Claude Code, Codex, or Gemini), branded as Holt. Run `holt help` for the full command list, and `holt chat` for the lightweight REPL described further down.
53
+ That is it. Bare `holt` sets the folder up the first time (trust, brain, memory hooks) and then launches the **real interactive brain** (Claude Code, Codex, or Gemini), branded as Holt. Run `holt help` for the full command list.
54
54
 
55
55
  Prefer to step through setup on its own first? `holt init` still does that, then `holt` starts the session.
56
56
 
@@ -59,10 +59,12 @@ Prefer to step through setup on its own first? `holt init` still does that, then
59
59
  `holt` (with no command) is the front door: **your assistant, with memory of you.**
60
60
 
61
61
  1. **Sets up if needed.** In a fresh folder it runs onboarding (trust, pick a brain, memory). In an already-configured folder it just makes sure the folder is trusted, the memory directory exists, and the [ambient memory hooks](#ambient-memory-for-claude-code-holt-hook) are installed.
62
- 2. **Launches the real interactive brain.** It hands the terminal to the brain's own interactive session (`claude`, `codex`, or `gemini` with no `-p` and no prompt), so you keep the brain's full power: agentic edits, tool use, the permission UI, MCP. This is different from `holt chat`, which is a lightweight REPL that shells out to the brain once per turn.
62
+ > **Gemini note.** Personal Google accounts can no longer sign in to Gemini CLI (Google retired that login on 18 June 2026 in favour of Antigravity). Gemini still works in Holt with an API key from <https://aistudio.google.com/apikey>; `holt login gemini` walks you through connecting it.
63
+
64
+ 2. **Launches the real interactive brain.** It hands the terminal to the brain's own interactive session (`claude`, `codex`, or `gemini` with no `-p` and no prompt), so you keep the brain's full power: agentic edits, tool use, the permission UI, MCP. That is different from Holt's own built-in REPL, which shells out to the brain once per turn and is used for API brains that have no TUI of their own.
63
65
  3. **Brands the session as Holt.** See [Branding](#branding-holt-forward) below.
64
66
 
65
- If your default brain is a **direct API brain** (no interactive TUI), bare `holt` prints a one-line note and starts `holt chat` instead, since that path supports API brains.
67
+ If your default brain is a **direct API brain** (no interactive TUI of its own), bare `holt` opens Holt's own built-in REPL instead, branded the same way. Same command, same intro, no second thing to learn.
66
68
 
67
69
  ### Branding (Holt-forward)
68
70
 
@@ -96,7 +98,7 @@ During `holt init` you:
96
98
 
97
99
  ## Using it
98
100
 
99
- Inside `holt chat`:
101
+ Inside a Holt session:
100
102
 
101
103
  ```
102
104
  /brain list your brains and see which is active
@@ -127,7 +129,7 @@ Holt runs the brain in the folder you launched it from, so by default the brain
127
129
  - `/allow <path>` grants a folder up front with no prompt.
128
130
  - `/allowed` lists what you have granted.
129
131
 
130
- Grants are **in-memory and session-scoped**: nothing is written to disk, and they reset the next time you run `holt chat`. Access is **read-oriented** and only works with a **Claude Code** brain: Holt passes `--add-dir=<dir>` for each granted folder plus `--allowedTools=Read,Glob,Grep` so Claude Code can read (but not write) those files without an interactive permission prompt. With a Codex/Gemini or API brain, external file access is unavailable and Holt says so instead of adding flags. (If you need to tune the flags, they live in one place: `claudeAccessArgs` in `src/access.ts`.)
132
+ Grants are **in-memory and session-scoped**: nothing is written to disk, and they reset the next time you run `holt`. Access is **read-oriented** and only works with a **Claude Code** brain: Holt passes `--add-dir=<dir>` for each granted folder plus `--allowedTools=Read,Glob,Grep` so Claude Code can read (but not write) those files without an interactive permission prompt. With a Codex/Gemini or API brain, external file access is unavailable and Holt says so instead of adding flags. (If you need to tune the flags, they live in one place: `claudeAccessArgs` in `src/access.ts`.)
131
133
 
132
134
  ***
133
135
 
@@ -248,7 +250,7 @@ holt wiki setup # recommend a local model for this machine's RAM
248
250
 
249
251
  **How sync works.** `holt wiki sync` gathers facts added since the last sync (a marker in `.holt/wiki/.state.json`), embeds each one locally, and routes it to the nearest existing page by cosine similarity. Related facts land on the same page; a fact that matches nothing new starts a fresh page. Facts are grouped by target page and the maintainer is called **once per changed page** (not once per fact), so it only rewrites the few pages that actually changed. Routing is always local and free, which is what keeps the maintainer's cost minimal. `holt wiki rebuild` folds every fact from scratch.
250
252
 
251
- **Self-maintaining (auto-sync).** Turn on `holt wiki auto on` (persists `wiki.autoSync: true` for the folder) and the wiki keeps itself current with no manual command. It syncs **after fact distillation** at the end of a `holt chat` session, and ambiently when the Claude Code **Stop** hook fires (`holt hook capture`), so newly captured facts fold straight into pages. Auto-sync is silent, best-effort, and never blocks exit: it reuses the exact same sync engine as `holt wiki sync`, so there is no behavior drift. Left off (the default), nothing changes and you sync by hand. `holt wiki auto` with no argument shows the current state; `holt wiki status` surfaces it too.
253
+ **Self-maintaining (auto-sync).** Turn on `holt wiki auto on` (persists `wiki.autoSync: true` for the folder) and the wiki keeps itself current with no manual command. It syncs **after fact distillation** at the end of a Holt session, and ambiently when the Claude Code **Stop** hook fires (`holt hook capture`), so newly captured facts fold straight into pages. Auto-sync is silent, best-effort, and never blocks exit: it reuses the exact same sync engine as `holt wiki sync`, so there is no behavior drift. Left off (the default), nothing changes and you sync by hand. `holt wiki auto` with no argument shows the current state; `holt wiki status` surfaces it too.
252
254
 
253
255
  **Lint that can fix.** `holt wiki lint` audits pages for contradictions, duplicates, and gaps and prints a report only (files untouched). Add `--fix` and Holt asks the maintainer to return corrected page bodies and **applies them**, rewriting the affected pages (provenance/`sources` is preserved). Because pages are derived and regenerable, applying is safe: a bad `--fix` is fully recoverable with `holt wiki rebuild`, and the command prints a git/backup nudge before writing. Only pages that actually change are rewritten.
254
256
 
@@ -378,7 +380,7 @@ Point the client at the folder whose memory you want it to use; Holt serves what
378
380
 
379
381
  ## Ambient memory for Claude Code (`holt hook`)
380
382
 
381
- The MCP server above lets Claude Code recall and remember **when it decides to**. `holt hook` goes further: it makes Holt's per-folder memory work **ambiently**, with no `holt chat` and no manual tool call. Two directions, both wired as [Claude Code hooks](https://docs.claude.com/en/docs/claude-code/hooks):
383
+ The MCP server above lets Claude Code recall and remember **when it decides to**. `holt hook` goes further: it makes Holt's per-folder memory work **ambiently**, with no Holt session open and no manual tool call. Two directions, both wired as [Claude Code hooks](https://docs.claude.com/en/docs/claude-code/hooks):
382
384
 
383
385
  - **Inject** (`UserPromptSubmit`): before each prompt, Holt quietly recalls the most relevant remembered notes for the current folder and adds them to the model's context.
384
386
  - **Capture** (`Stop`): when a session ends, Holt distills durable facts from the transcript (via this folder's configured brain) and saves them to the folder's memory.
@@ -394,7 +396,7 @@ holt hook remove # remove ONLY Holt's hooks, leaving everything
394
396
 
395
397
  Install **merges** into any existing `hooks` config, never clobbers unrelated settings or other hooks, is **idempotent** (re-running does not duplicate), and backs the file up to `settings.json.holt-bak` before writing. `remove` deletes only the entries whose command is `holt hook inject` / `holt hook capture`.
396
398
 
397
- **Trusted-folder guard (important).** The two runtime hooks (`holt hook inject` and `holt hook capture`, invoked by Claude Code, not you) no-op **silently** unless the folder is a trusted Holt workspace with an existing `.holt/memory`. So Holt never injects your private notes into an unrelated project, and never creates memory in a folder you never set up. To make a folder ambient, run `holt init` (or `holt chat`) there once so it becomes trusted and gets its `.holt/memory`. The inject hook keeps stdout clean (only the context block is printed, since Claude Code injects it verbatim); all diagnostics go to stderr, and both hooks always exit 0 so a memory step never blocks or slows your prompt.
399
+ **Trusted-folder guard (important).** The two runtime hooks (`holt hook inject` and `holt hook capture`, invoked by Claude Code, not you) no-op **silently** unless the folder is a trusted Holt workspace with an existing `.holt/memory`. So Holt never injects your private notes into an unrelated project, and never creates memory in a folder you never set up. To make a folder ambient, run `holt init` there once so it becomes trusted and gets its `.holt/memory`. The inject hook keeps stdout clean (only the context block is printed, since Claude Code injects it verbatim); all diagnostics go to stderr, and both hooks always exit 0 so a memory step never blocks or slows your prompt.
398
400
 
399
401
  **Seeing what the hooks did (`~/.holt/hooks.log`).** Since the hooks stay silent, every `holt hook capture` run appends **one** line to `~/.holt/hooks.log` with the outcome or the exact skip reason (untrusted folder, no config, `extractFacts` off, no usable brain, missing or empty transcript, too few exchanges, or `saved N facts`), preceded by a line of the raw hook fields it received. Check this file first if capture seems to save nothing. `inject` uses the same log but stays quiet by default; set `HOLT_HOOK_DEBUG=1` to make it log too.
400
402
 
@@ -418,7 +420,7 @@ Run a single task non-interactively. It recalls relevant memory, injects your sk
418
420
  holt run "summarize the open items in this folder"
419
421
  ```
420
422
 
421
- Options: `--brain <id>` pick a brain, `--out <file>` also write the reply to a file, `--no-store` skip saving, `--no-recall` skip memory, `--quiet` suppress streaming. The folder must be trusted first (`holt init` or `holt chat` once); in a non-interactive context an untrusted folder exits non-zero instead of prompting.
423
+ Options: `--brain <id>` pick a brain, `--out <file>` also write the reply to a file, `--no-store` skip saving, `--no-recall` skip memory, `--quiet` suppress streaming. The folder must be trusted first (`holt init` once); in a non-interactive context an untrusted folder exits non-zero instead of prompting.
422
424
 
423
425
  ### holt schedule
424
426
 
@@ -494,6 +496,14 @@ It reports, section by section:
494
496
  - **Always-on / Telegram**: whether a Telegram bot is set up, and a note on hosting the bot on a low-power always-on machine while keeping heavy local models on a bigger one.
495
497
  - **Recommended next steps**: a short checklist built from whatever gaps it found.
496
498
 
499
+ And when you would rather not do the steps yourself:
500
+
501
+ ```bash
502
+ holt doctor --fix
503
+ ```
504
+
505
+ `--fix` repairs the common problems in one go: starts Ollama when it is installed but not running, downloads the missing embed model, upgrades any memories saved while embeddings were off, and reports a damaged-config repair if one happened. It ends with a "Repairs" section listing exactly what it did (or "Nothing needed repair"). Every Holt error message points here, so the answer to "something is off" is always the same one command.
506
+
497
507
  It is read-only advice: it changes nothing, needs no trust, and always exits cleanly even when a probe fails.
498
508
 
499
509
  The RAM-to-model table lives in `src/specs.ts` (`LOCAL_MODEL_RECS` / `recommendLocalModel`), the single source of truth the wiki maintainer reads too.
@@ -549,7 +559,6 @@ holt start your assistant: set up if needed, then launch the rea
549
559
  interactive brain (Claude Code/Codex/Gemini), branded as Holt
550
560
  holt launch same as bare "holt"
551
561
  holt init set up (trust, brains, sign-in, defaults) for this folder
552
- holt chat lightweight REPL that remembers past ones (used for API brains)
553
562
  holt run <task> run one task non-interactively (recall, brain, remember)
554
563
  holt schedule run a task on a timer: add | list | remove
555
564
  holt routine named, reusable, scheduled jobs: add | run | list | show | remove
package/dist/cli.js CHANGED
@@ -98,6 +98,18 @@ function createStatusBar() {
98
98
  detach
99
99
  };
100
100
  }
101
+ async function askYesNo(ask, question, def) {
102
+ for (let tries = 0; tries < 3; tries++) {
103
+ const raw = await ask(question);
104
+ if (raw === null) return def;
105
+ const a = raw.trim().toLowerCase();
106
+ if (a === "") return def;
107
+ if (a === "y" || a === "yes") return true;
108
+ if (a === "n" || a === "no") return false;
109
+ console.log(c.dim(` Just "y" or "n" please (enter = ${def ? "yes" : "no"}). You typed "${raw.trim()}".`));
110
+ }
111
+ return def;
112
+ }
101
113
  function createReader() {
102
114
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
103
115
  const buffer = [];
@@ -155,8 +167,7 @@ async function ensureTrusted(ask) {
155
167
  console.log("\n" + c.accent("Trust this folder?"));
156
168
  console.log(" " + ws);
157
169
  console.log(c.dim(" Holt will read and write here: its config, memory, and any files you ask a brain to touch."));
158
- const ans = (await ask(" Trust and continue? [y/N] ") ?? "").trim().toLowerCase();
159
- if (ans === "y" || ans === "yes") {
170
+ if (await askYesNo(ask, " Trust and continue? [y/N] ", false)) {
160
171
  trustDir(ws);
161
172
  console.log(c.green(" Trusted.") + c.dim(" (remembered for next time)") + "\n");
162
173
  return true;
@@ -194,12 +205,22 @@ var BRAIN_SETUP = {
194
205
  codex: { install: ["npm", "install", "-g", "@openai/codex"], login: ["codex", "login"] },
195
206
  gemini: { install: ["npm", "install", "-g", "@google/gemini-cli"], login: ["gemini"] }
196
207
  };
208
+ var BRAIN_LOGIN_UNAVAILABLE = {
209
+ gemini: {
210
+ reason: 'Google ended "Login with Google" for Gemini Code Assist individual accounts on 18 June 2026. Personal Google accounts (free, AI Pro, AI Ultra) can no longer sign in to Gemini CLI.',
211
+ provider: "gemini",
212
+ keyUrl: "https://aistudio.google.com/apikey"
213
+ }
214
+ };
215
+ function loginUnavailable(id) {
216
+ return BRAIN_LOGIN_UNAVAILABLE[id] ?? null;
217
+ }
197
218
  var BRAIN_IDS = ["claude", "codex", "gemini"];
198
219
  var PROVIDERS = ["anthropic", "openai", "gemini", "kimi", "grok"];
199
220
  var PROVIDER_MODEL_SUGGESTION = {
200
- anthropic: "claude-sonnet-4-6",
221
+ anthropic: "claude-sonnet-5",
201
222
  openai: "gpt-5",
202
- gemini: "gemini-2.5-flash",
223
+ gemini: "gemini-flash-latest",
203
224
  kimi: "kimi-k2-turbo-preview",
204
225
  grok: "grok-4"
205
226
  };
@@ -210,6 +231,11 @@ var PROVIDER_ENV = {
210
231
  kimi: "MOONSHOT_API_KEY",
211
232
  grok: "XAI_API_KEY"
212
233
  };
234
+ function cliBrainUsable(id) {
235
+ const gone = loginUnavailable(id);
236
+ if (!gone) return true;
237
+ return Boolean(process.env[PROVIDER_ENV[gone.provider]]);
238
+ }
213
239
  function defaultConfig() {
214
240
  const brains = {};
215
241
  for (const id of BRAIN_IDS) {
@@ -1369,9 +1395,9 @@ async function setupTelegram(ask) {
1369
1395
  console.log(c.dim(" Option A: open your new bot in Telegram and send it any message,"));
1370
1396
  console.log(c.dim(" then I will auto-detect the chat id."));
1371
1397
  console.log(c.dim(" Option B: paste a numeric chat id yourself."));
1372
- const mode = (await ask("\n Auto-detect from a message you just sent? [Y/n] ") ?? "").trim().toLowerCase();
1398
+ const autoDetect = await askYesNo(ask, "\n Auto-detect from a message you just sent? [Y/n] ", true);
1373
1399
  let allowedChatId = null;
1374
- if (mode === "" || mode === "y" || mode === "yes") {
1400
+ if (autoDetect) {
1375
1401
  console.log(c.dim("\n Checking for a recent message..."));
1376
1402
  const updates = await getUpdates(0, token);
1377
1403
  allowedChatId = latestChatId(updates);
@@ -1498,7 +1524,10 @@ function printStatus(cfg) {
1498
1524
  const b = cfg.brains[id];
1499
1525
  const tags = [
1500
1526
  b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
1501
- cfg.defaultBrain === id ? c.accent("default") : ""
1527
+ cfg.defaultBrain === id ? c.accent("default") : "",
1528
+ // A retired sign-in with no key in the environment is the real reason a
1529
+ // brain will not answer, so name it here rather than at first failure.
1530
+ loginUnavailable(id) && !cliBrainUsable(id) ? c.red("no sign-in, needs API key") : ""
1502
1531
  ].filter(Boolean).join(" ");
1503
1532
  console.log(` ${id.padEnd(10)} ${b.label.padEnd(16)} ${tags}`);
1504
1533
  }
@@ -1517,8 +1546,9 @@ function printStatus(cfg) {
1517
1546
  console.log("\n " + c.dim("[d] default brain [t] toggle brain [c] connect API brain [x] remove API brain"));
1518
1547
  console.log(" " + c.dim("[a] launch command [m] connect Telegram [enter] done"));
1519
1548
  }
1520
- async function connectApiBrain(ask, cfg) {
1521
- let provider = null;
1549
+ async function connectApiBrain(ask, cfg, preset) {
1550
+ let provider = preset ?? null;
1551
+ if (provider) console.log(c.dim(` provider: ${provider}`));
1522
1552
  for (let tries = 0; tries < 3 && !provider; tries++) {
1523
1553
  const provRaw = (await ask(` provider [${PROVIDERS.join("/")}] (enter for ${PROVIDERS[0]}, or "skip"): `) ?? "skip").trim().toLowerCase();
1524
1554
  if (provRaw === "skip" || provRaw === "q" || provRaw === "n" || provRaw === "no") {
@@ -1536,7 +1566,9 @@ async function connectApiBrain(ask, cfg) {
1536
1566
  const suggestion = PROVIDER_MODEL_SUGGESTION[provider];
1537
1567
  const modelRaw = (await ask(` model (enter for ${suggestion}): `) ?? "").trim();
1538
1568
  const model = modelRaw || suggestion;
1539
- const idRaw = (await ask(" short name for this brain (e.g. sonnet): ") ?? "").trim();
1569
+ const idHint = preset ? `${preset}-api` : "";
1570
+ const idPrompt = idHint ? ` short name for this brain (enter for ${idHint}): ` : " short name for this brain (e.g. sonnet): ";
1571
+ const idRaw = (await ask(idPrompt) ?? "").trim() || idHint;
1540
1572
  if (!idRaw) {
1541
1573
  console.log(c.dim(" cancelled (no name)."));
1542
1574
  return null;
@@ -1592,8 +1624,12 @@ async function runSettings(ask) {
1592
1624
  } else if (choice === "t") {
1593
1625
  const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
1594
1626
  if (BRAIN_IDS.includes(pick)) {
1627
+ const goneLogin = loginUnavailable(pick);
1595
1628
  if (!cfg.brains[pick].enabled && !isInstalled(cfg.brains[pick].command)) {
1596
1629
  console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
1630
+ } else if (!cfg.brains[pick].enabled && goneLogin && !cliBrainUsable(pick)) {
1631
+ console.log(c.dim(` ${cfg.brains[pick].label} has no sign-in any more and no ${goneLogin.provider.toUpperCase()}_API_KEY set.`));
1632
+ console.log(c.dim(` Get a key at ${goneLogin.keyUrl}, then connect it here with "c".`));
1597
1633
  } else {
1598
1634
  cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
1599
1635
  if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
@@ -1912,8 +1948,7 @@ async function runQuestions(ask, depth) {
1912
1948
  return out;
1913
1949
  }
1914
1950
  async function askKeepExcerpt(ask) {
1915
- const a = (await ask(" Store a short excerpt so Holt can match your tone directly? [y/N] ") ?? "").trim().toLowerCase();
1916
- return a === "y" || a === "yes";
1951
+ return askYesNo(ask, " Store a short excerpt so Holt can match your tone directly? [y/N] ", false);
1917
1952
  }
1918
1953
  async function addFileSample(ask, file, v) {
1919
1954
  const path = resolve(process.cwd(), file);
@@ -2129,6 +2164,75 @@ async function voiceClear() {
2129
2164
  console.log(had ? c.green("\n Voice profile removed.\n") : c.dim("\n No voice profile to remove.\n"));
2130
2165
  }
2131
2166
 
2167
+ // src/commands/login.ts
2168
+ function printLoginUnavailable(id, info) {
2169
+ console.log("\n" + c.red(`${BRAIN_DEFS[id].label} sign-in is no longer available.`));
2170
+ console.log(c.dim(" " + info.reason));
2171
+ console.log("\n It still works on an API key. Two ways:");
2172
+ console.log(" 1. " + c.accent(`Get a key at ${info.keyUrl}`));
2173
+ console.log(" 2. Either export it as " + c.accent(`${info.provider.toUpperCase()}_API_KEY`) + c.dim(` (the ${BRAIN_DEFS[id].command} CLI reads it too), or connect it to Holt as a direct API brain.`));
2174
+ }
2175
+ async function offerKeyInstead(ask, id, info) {
2176
+ const cfg = loadConfig();
2177
+ if (!cfg) {
2178
+ console.log(c.dim('\n This folder is not set up yet. Run "holt init" and connect it as a direct API brain.\n'));
2179
+ return false;
2180
+ }
2181
+ if (!await askYesNo(ask, `
2182
+ Connect ${BRAIN_DEFS[id].label} to Holt with an API key now? [Y/n] `, true)) {
2183
+ console.log(c.dim(' Okay. Run "holt setting" then "c" whenever you have the key.\n'));
2184
+ return false;
2185
+ }
2186
+ const brain = await connectApiBrain(ask, cfg, info.provider);
2187
+ if (!brain) return false;
2188
+ if (!cfg.defaultBrain || cfg.defaultBrain === id) {
2189
+ cfg.defaultBrain = brain.id;
2190
+ console.log(c.dim(` default brain for this folder is now "${brain.id}".`));
2191
+ }
2192
+ if (cfg.brains[id]?.enabled) {
2193
+ cfg.brains[id].enabled = false;
2194
+ console.log(c.dim(` turned off the ${BRAIN_DEFS[id].label} brain (no sign-in available).`));
2195
+ }
2196
+ saveConfig(cfg);
2197
+ console.log(c.green("\n Done. Start Holt: holt\n"));
2198
+ return true;
2199
+ }
2200
+ async function login(which) {
2201
+ const id = (which || "").toLowerCase();
2202
+ if (!BRAIN_IDS.includes(id)) {
2203
+ if (which) {
2204
+ console.error(`
2205
+ Unknown brain "${which}". Use one of: ${BRAIN_IDS.join(", ")}
2206
+ `);
2207
+ process.exitCode = 1;
2208
+ } else {
2209
+ console.log(c.dim(`
2210
+ Usage: holt login <${BRAIN_IDS.join("|")}>
2211
+ `));
2212
+ }
2213
+ return;
2214
+ }
2215
+ const gone = loginUnavailable(id);
2216
+ if (gone) {
2217
+ printLoginUnavailable(id, gone);
2218
+ if (cliBrainUsable(id)) {
2219
+ console.log(c.green(`
2220
+ ${PROVIDER_ENV[gone.provider]} is already set, so ${BRAIN_DEFS[id].label} works as it is. Nothing to do.
2221
+ `));
2222
+ return;
2223
+ }
2224
+ const { ask, close } = createReader();
2225
+ await offerKeyInstead(ask, id, gone);
2226
+ close();
2227
+ return;
2228
+ }
2229
+ const s = BRAIN_SETUP[id];
2230
+ console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
2231
+ console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool.
2232
+ `));
2233
+ await runInteractive(s.login[0], s.login.slice(1));
2234
+ }
2235
+
2132
2236
  // src/commands/init.ts
2133
2237
  function parseBrains(raw, found) {
2134
2238
  const s = raw.trim().toLowerCase();
@@ -2149,7 +2253,8 @@ async function init() {
2149
2253
  const found = [];
2150
2254
  for (const id of BRAIN_IDS) {
2151
2255
  const ok2 = isInstalled(BRAIN_DEFS[id].command);
2152
- console.log(` ${ok2 ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
2256
+ const note2 = loginUnavailable(id) && !cliBrainUsable(id) ? c.dim(" (needs an API key, no sign-in)") : "";
2257
+ console.log(` ${ok2 ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})${note2}`);
2153
2258
  if (ok2) found.push(id);
2154
2259
  }
2155
2260
  console.log("");
@@ -2161,13 +2266,24 @@ async function init() {
2161
2266
  const toInstall = chosen.filter((id) => !isInstalled(BRAIN_DEFS[id].command));
2162
2267
  const loginWanted = /* @__PURE__ */ new Set();
2163
2268
  for (const id of toInstall) {
2164
- const a = (await ask(` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `) ?? "").trim().toLowerCase();
2165
- if (a !== "n" && a !== "no") loginWanted.add(id);
2269
+ if (loginUnavailable(id)) continue;
2270
+ if (await askYesNo(ask, ` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `, true)) loginWanted.add(id);
2166
2271
  }
2167
2272
  const connectedApiBrains = [];
2168
- const apiAns = (await ask("\nAlso connect a direct API brain (raw key, no CLI needed)? [y/N] ") ?? "").trim().toLowerCase();
2169
- if (apiAns === "y" || apiAns === "yes") {
2170
- const holder = defaultConfig();
2273
+ const holder = defaultConfig();
2274
+ for (const id of chosen) {
2275
+ const gone = loginUnavailable(id);
2276
+ if (!gone || cliBrainUsable(id)) continue;
2277
+ printLoginUnavailable(id, gone);
2278
+ if (await askYesNo(ask, `
2279
+ Connect ${BRAIN_DEFS[id].label} with an API key now? [Y/n] `, true)) {
2280
+ const brain = await connectApiBrain(ask, holder, gone.provider);
2281
+ if (brain) connectedApiBrains.push(brain);
2282
+ } else {
2283
+ console.log(c.dim(' Okay. Run "holt setting" then "c" whenever you have the key.'));
2284
+ }
2285
+ }
2286
+ if (await askYesNo(ask, "\nAlso connect a direct API brain (raw key, no CLI needed)? [y/N] ", false)) {
2171
2287
  const brain = await connectApiBrain(ask, holder);
2172
2288
  if (brain) connectedApiBrains.push(brain);
2173
2289
  }
@@ -2195,13 +2311,11 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
2195
2311
  } else {
2196
2312
  const ollamaHere = isInstalled("ollama");
2197
2313
  const q = ollamaHere ? `Semantic memory needs a local embed model. Pull ${EMBED_MODEL} with Ollama now? [Y/n] ` : "Enable private semantic memory? Installs Ollama plus a small local embed model. Everything stays on your machine. [Y/n] ";
2198
- const a = (await ask("\n" + q) ?? "").trim().toLowerCase();
2199
- wantMemorySetup = a !== "n" && a !== "no";
2314
+ wantMemorySetup = await askYesNo(ask, "\n" + q, true);
2200
2315
  if (!wantMemorySetup) console.log(c.dim(' Okay. Memory still works with keyword recall; run "holt init" again anytime.'));
2201
2316
  }
2202
2317
  if (!loadTelegramConfig()) {
2203
- const tgAns = (await ask("\nChat with Holt from your phone over Telegram? [y/N] ") ?? "").trim().toLowerCase();
2204
- if (tgAns === "y" || tgAns === "yes") await setupTelegram(ask);
2318
+ if (await askYesNo(ask, "\nChat with Holt from your phone over Telegram? [y/N] ", false)) await setupTelegram(ask);
2205
2319
  }
2206
2320
  close();
2207
2321
  for (const id of toInstall) {
@@ -2240,7 +2354,7 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
2240
2354
  }
2241
2355
  }
2242
2356
  const cfg = loadConfig() ?? defaultConfig();
2243
- for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
2357
+ for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command) && cliBrainUsable(id);
2244
2358
  for (const b of connectedApiBrains) if (!cfg.apiBrains.some((a) => a.id === b.id)) cfg.apiBrains.push(b);
2245
2359
  cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
2246
2360
  saveConfig(cfg);
@@ -2253,17 +2367,17 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
2253
2367
  console.log("\nStart chatting:");
2254
2368
  console.log(" " + c.accent(`source ${aliasNeedsSource}`) + c.dim(" (once; new terminals will not need it)"));
2255
2369
  console.log(" " + c.accent(aliasAns) + "\n");
2256
- console.log(c.dim(" Or right now, without sourcing: holt chat\n"));
2370
+ console.log(c.dim(" Or right now, without sourcing: holt\n"));
2257
2371
  } else {
2258
- console.log("Start chatting: " + c.accent("holt chat") + "\n");
2372
+ console.log("Start chatting: " + c.accent("holt") + "\n");
2259
2373
  }
2260
2374
  } else {
2261
2375
  console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
2262
2376
  }
2263
2377
  const voiceReader = createReader();
2264
- const voiceAns = (await voiceReader.ask("Want Holt to learn how you write, so it can draft in your voice? [y/N] ") ?? "").trim().toLowerCase();
2378
+ const voiceYes = await askYesNo(voiceReader.ask, "Want Holt to learn how you write, so it can draft in your voice? [y/N] ", false);
2265
2379
  voiceReader.close();
2266
- if (voiceAns === "y" || voiceAns === "yes") {
2380
+ if (voiceYes) {
2267
2381
  await voiceInterview();
2268
2382
  } else {
2269
2383
  console.log(c.dim(' Skipped. Run "holt voice" anytime to set up your writing voice.\n'));
@@ -4067,29 +4181,6 @@ function saveReply(reply, format, name) {
4067
4181
  return path;
4068
4182
  }
4069
4183
 
4070
- // src/commands/login.ts
4071
- async function login(which) {
4072
- const id = (which || "").toLowerCase();
4073
- if (!BRAIN_IDS.includes(id)) {
4074
- if (which) {
4075
- console.error(`
4076
- Unknown brain "${which}". Use one of: ${BRAIN_IDS.join(", ")}
4077
- `);
4078
- process.exitCode = 1;
4079
- } else {
4080
- console.log(c.dim(`
4081
- Usage: holt login <${BRAIN_IDS.join("|")}>
4082
- `));
4083
- }
4084
- return;
4085
- }
4086
- const s = BRAIN_SETUP[id];
4087
- console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
4088
- console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool.
4089
- `));
4090
- await runInteractive(s.login[0], s.login.slice(1));
4091
- }
4092
-
4093
4184
  // src/access.ts
4094
4185
  import { existsSync as existsSync11, realpathSync as realpathSync3, statSync as statSync6 } from "fs";
4095
4186
  import { homedir as homedir6 } from "os";
@@ -4222,14 +4313,14 @@ async function chat() {
4222
4313
  }
4223
4314
  let cfg = loadConfig();
4224
4315
  if (!cfg || !cfg.defaultBrain) {
4225
- const a = (await ask(c.dim("No Holt setup in this folder. Set it up now? [Y/n] ")) ?? "").trim().toLowerCase();
4316
+ const setupYes = await askYesNo(ask, c.dim("No Holt setup in this folder. Set it up now? [Y/n] "), true);
4226
4317
  close();
4227
- if (a === "n" || a === "no") {
4318
+ if (!setupYes) {
4228
4319
  console.log(c.dim(' Run "holt init" here when ready.\n'));
4229
4320
  return;
4230
4321
  }
4231
4322
  await init();
4232
- console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
4323
+ console.log(c.dim('\nSetup done. Run "holt" to start talking.\n'));
4233
4324
  return;
4234
4325
  }
4235
4326
  let active = resolveActive2(cfg, cfg.defaultBrain);
@@ -4357,7 +4448,7 @@ async function chat() {
4357
4448
  sb.detach();
4358
4449
  close();
4359
4450
  await login(targetId);
4360
- console.log(c.dim('\n Signed in. Run "holt chat" again to continue.\n'));
4451
+ console.log(c.dim(loginUnavailable(targetId) ? '\n Run "holt" again to continue.\n' : '\n Signed in. Run "holt" again to continue.\n'));
4361
4452
  return;
4362
4453
  }
4363
4454
  if (cmd === "brain") {
@@ -4426,8 +4517,7 @@ async function chat() {
4426
4517
  const outside = findOutsidePaths(line, workspace());
4427
4518
  for (const dir of outside) {
4428
4519
  if (grantedDirs.has(dir)) continue;
4429
- const ans = (await ask(c.dim(` Allow this session to access ${dir} (outside this folder)? [y/N] `)) ?? "").trim().toLowerCase();
4430
- if (ans === "y" || ans === "yes") {
4520
+ if (await askYesNo(ask, c.dim(` Allow this session to access ${dir} (outside this folder)? [y/N] `), false)) {
4431
4521
  grantedDirs.add(dir);
4432
4522
  console.log(c.green(` granted this session: ${dir}`));
4433
4523
  } else {
@@ -4456,7 +4546,8 @@ async function chat() {
4456
4546
  };
4457
4547
  const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk, extraArgs) : await runApiBrain(active.brain, prompt, onChunk);
4458
4548
  if (looksLikeAuthError(res.text)) {
4459
- const hint = active.kind === "cli" ? ` ${active.label} looks signed out. Run "holt login ${active.id}" in a terminal, or type /login here.` : ` ${active.label} has no working key. Run "holt setting" to add or fix it.`;
4549
+ const goneLogin = active.kind === "cli" ? loginUnavailable(active.id) : null;
4550
+ const hint = goneLogin ? ` ${active.label} has no sign-in any more and no ${PROVIDER_ENV[goneLogin.provider]} to fall back on. Get a key at ${goneLogin.keyUrl}, then run "holt setting" to connect it.` : active.kind === "cli" ? ` ${active.label} looks signed out. Run "holt login ${active.id}" in a terminal, or type /login here.` : ` ${active.label} has no working key. Run "holt setting" to add or fix it.`;
4460
4551
  if (!streamed || !res.text.endsWith("\n")) console.log("");
4461
4552
  console.log(c.red(hint) + "\n");
4462
4553
  showStatus();
@@ -4595,11 +4686,12 @@ function resolveTarget(cfg) {
4595
4686
  if (!id) return null;
4596
4687
  if (BRAIN_IDS.includes(id)) {
4597
4688
  const bid = id;
4598
- return { kind: "cli", id: bid, label: cfg.brains[bid].label };
4689
+ if (cliBrainUsable(bid)) return { kind: "cli", id: bid, label: cfg.brains[bid].label };
4690
+ } else {
4691
+ const api = findApiBrain(cfg, id);
4692
+ if (api) return { kind: "api", label: `${id} (api: ${api.provider}/${api.model})` };
4599
4693
  }
4600
- const api = findApiBrain(cfg, id);
4601
- if (api) return { kind: "api", label: `${id} (api: ${api.provider}/${api.model})` };
4602
- const fallback = BRAIN_IDS.find((b) => cfg.brains[b].enabled);
4694
+ const fallback = BRAIN_IDS.find((b) => cfg.brains[b].enabled && cliBrainUsable(b));
4603
4695
  if (fallback) return { kind: "cli", id: fallback, label: cfg.brains[fallback].label };
4604
4696
  return null;
4605
4697
  }
@@ -4639,13 +4731,23 @@ async function launch() {
4639
4731
  await ensureSetup();
4640
4732
  const target = resolveTarget(cfg);
4641
4733
  if (!target) {
4734
+ const def = cfg.defaultBrain;
4735
+ const gone = def && BRAIN_IDS.includes(def) ? loginUnavailable(def) : null;
4736
+ if (gone) {
4737
+ console.log("\n " + c.red(`${cfg.brains[def].label} can no longer sign in.`));
4738
+ console.log(c.dim(" " + gone.reason));
4739
+ console.log(c.dim(` Get a key at ${gone.keyUrl}, then run "holt setting" and press "c" to connect it.`));
4740
+ if (cfg.apiBrains.length) {
4741
+ console.log(c.dim(` Or switch this folder to an API brain you already have (${cfg.apiBrains.map((b) => b.id).join(", ")}): "holt setting" then "d".`));
4742
+ }
4743
+ console.log("");
4744
+ return;
4745
+ }
4642
4746
  console.log(c.dim('\n No brain is ready. Run "holt setting" or "holt init".\n'));
4643
4747
  return;
4644
4748
  }
4645
4749
  if (target.kind === "api") {
4646
- console.log(c.dim(`
4647
- launch needs a CLI brain; starting chat instead (default is ${target.label}).
4648
- `));
4750
+ console.log(renderIntroBox());
4649
4751
  await chat();
4650
4752
  return;
4651
4753
  }
@@ -7061,7 +7163,7 @@ async function graph(args = []) {
7061
7163
  if (skipNotes.length) console.log(c.dim(` ingest skipped: ${skipNotes.join(", ")}`));
7062
7164
  }
7063
7165
  if (turns.length === 0 && wikiPages.length === 0 && !codeGraph) {
7064
- console.log(c.dim('\n No memory in this folder yet. Have a chat first with "holt chat", then come back.\n'));
7166
+ console.log(c.dim('\n No memory in this folder yet. Talk to Holt here first with "holt", then come back.\n'));
7065
7167
  console.log(c.dim(` Or ingest this folder's code/docs: "holt graph --code" / "holt graph --docs".
7066
7168
  `));
7067
7169
  return;
@@ -7384,7 +7486,7 @@ function holtTools() {
7384
7486
  }
7385
7487
 
7386
7488
  // src/version.ts
7387
- var VERSION = "0.15.0";
7489
+ var VERSION = "0.16.0";
7388
7490
 
7389
7491
  // src/commands/mcp.ts
7390
7492
  function printSetup() {
@@ -7568,7 +7670,7 @@ async function run(args) {
7568
7670
  close();
7569
7671
  if (!ok2) return;
7570
7672
  } else {
7571
- process.stderr.write("This folder is not trusted. Run holt init or holt chat here once, then schedule/run.\n");
7673
+ process.stderr.write("This folder is not trusted. Run holt init here once, then schedule/run.\n");
7572
7674
  process.exitCode = 1;
7573
7675
  return;
7574
7676
  }
@@ -8075,7 +8177,7 @@ async function runCmd(rest) {
8075
8177
  close();
8076
8178
  if (!ok2) return;
8077
8179
  } else {
8078
- process.stderr.write("This folder is not trusted. Run holt init or holt chat here once, then run the routine.\n");
8180
+ process.stderr.write("This folder is not trusted. Run holt init here once, then run the routine.\n");
8079
8181
  process.exitCode = 1;
8080
8182
  return;
8081
8183
  }
@@ -8478,8 +8580,14 @@ async function doctor(args = []) {
8478
8580
  for (const id of BRAIN_IDS) {
8479
8581
  const def = BRAIN_DEFS[id];
8480
8582
  const here = isInstalled(def.command);
8481
- if (here) installed.push(def.label);
8583
+ const gone = loginUnavailable(id);
8584
+ const usable = cliBrainUsable(id);
8585
+ if (here && usable) installed.push(def.label);
8482
8586
  console.log(` ${here ? c.green("installed") : c.dim("missing ")} ${def.label} ${c.dim(`(${def.command})`)}`);
8587
+ if (here && gone && !usable) {
8588
+ warn(`${def.label} cannot sign in any more. ${gone.reason}`);
8589
+ steps.push(`Get a ${gone.provider} key at ${gone.keyUrl}, then connect it: holt setting -> "c".`);
8590
+ }
8483
8591
  }
8484
8592
  const cfg = loadConfig();
8485
8593
  const apiBrains = cfg?.apiBrains ?? [];
@@ -8560,7 +8668,7 @@ async function doctor(args = []) {
8560
8668
  steps.unshift('Set up this folder: run "holt init".');
8561
8669
  }
8562
8670
  if (steps.length === 0) {
8563
- good('You are in good shape. Run "holt chat" to start.');
8671
+ good('You are in good shape. Run "holt" to start.');
8564
8672
  } else {
8565
8673
  let n = 1;
8566
8674
  for (const s of steps) console.log(` ${c.accent(String(n++) + ".")} ${s}`);
@@ -8695,8 +8803,7 @@ async function update() {
8695
8803
  }
8696
8804
  console.log(` Newer version available: ${c.bold(latest)}`);
8697
8805
  if (isBrewInstall()) {
8698
- const a2 = (await ask(" Update now via Homebrew? [Y/n] ") ?? "").trim().toLowerCase();
8699
- if (a2 === "n" || a2 === "no") {
8806
+ if (!await askYesNo(ask, " Update now via Homebrew? [Y/n] ", true)) {
8700
8807
  console.log(c.dim(" Later, then. The command is: brew upgrade holt\n"));
8701
8808
  return;
8702
8809
  }
@@ -8706,8 +8813,7 @@ async function update() {
8706
8813
  else console.log(c.dim("\n Homebrew reported a problem above. You can retry with: brew upgrade holt\n"));
8707
8814
  return;
8708
8815
  }
8709
- const a = (await ask(" Update now? [Y/n] ") ?? "").trim().toLowerCase();
8710
- if (a === "n" || a === "no") {
8816
+ if (!await askYesNo(ask, " Update now? [Y/n] ", true)) {
8711
8817
  console.log(c.dim(" Later, then. The command is: npm install -g @holt-os/holt\n"));
8712
8818
  return;
8713
8819
  }
@@ -8872,7 +8978,7 @@ async function write(args) {
8872
8978
  close();
8873
8979
  if (!ok2) return;
8874
8980
  } else {
8875
- process.stderr.write("This folder is not trusted. Run holt init or holt chat here once first.\n");
8981
+ process.stderr.write("This folder is not trusted. Run holt init here once first.\n");
8876
8982
  process.exitCode = 1;
8877
8983
  return;
8878
8984
  }
@@ -8986,12 +9092,12 @@ From your phone, while you sleep:
8986
9092
 
8987
9093
  Housekeeping:
8988
9094
  setting Change brains, API connections, and your launch word (per folder)
8989
- login <brain> Sign in to a brain: claude, codex, or gemini
9095
+ login <brain> Sign in to a brain: claude or codex
9096
+ (Gemini has no sign-in left; it routes you to an API key)
8990
9097
  update Get the latest Holt, in one word
8991
9098
  reset Start this folder over (asks first; your own files are untouched)
8992
9099
  hook Ambient memory inside plain Claude Code: holt hook [install | status]
8993
9100
  mcp Share Holt's memory with other AI apps (Claude Code, Cursor, Codex)
8994
- chat Lightweight built-in chat, used for API brains
8995
9101
  init Full setup by hand (bare "holt" runs this for you when needed)
8996
9102
  launch Same as bare "holt": start your assistant
8997
9103
  version | help Print the version / show this help
@@ -9029,6 +9135,8 @@ async function main() {
9029
9135
  case "init":
9030
9136
  await init();
9031
9137
  break;
9138
+ // Undocumented: bare `holt` reaches the REPL by itself for API brains.
9139
+ // Kept as a working alias so older scripts and aliases do not break.
9032
9140
  case "chat":
9033
9141
  await chat();
9034
9142
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "An open-source personal agent OS: any LLM, private memory you can see and walk.",
5
5
  "type": "module",
6
6
  "license": "MIT",