@hizliemre/horse-code 0.2.0 → 0.3.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.
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  restoreTerminal,
16
16
  runJob,
17
17
  sttySane
18
- } from "./chunk-23CLQ2KO.js";
18
+ } from "./chunk-XYZVZPAY.js";
19
19
  import "./chunk-M2RKCIGV.js";
20
20
  import {
21
21
  CODE_TEAM,
@@ -30,14 +30,14 @@ import {
30
30
  SHORT_CALL_MS,
31
31
  SPEC_TEAM,
32
32
  WorktreeManager,
33
+ ZAI_MODELS,
33
34
  buildCouncilRegistry,
34
35
  buildTeamRegistry,
35
- checkProfile,
36
36
  cliCatalog,
37
37
  mainWorktreeRoot,
38
- runLogin,
38
+ modelsFor,
39
39
  toSlug
40
- } from "./chunk-7JMWPTJ5.js";
40
+ } from "./chunk-UEWVVN5L.js";
41
41
  import "./chunk-QF4MP6BS.js";
42
42
  import "./chunk-UGESK765.js";
43
43
  import "./chunk-ZSQ24YDJ.js";
@@ -48,7 +48,12 @@ import {
48
48
  UNSET_MODEL,
49
49
  loadConfig,
50
50
  saveSkillSource
51
- } from "./chunk-KKWZBZYK.js";
51
+ } from "./chunk-EAF22QIG.js";
52
+ import {
53
+ CLI_KINDS,
54
+ checkProfile,
55
+ runLogin
56
+ } from "./chunk-G45RWL7S.js";
52
57
  import "./chunk-H2FDGPVW.js";
53
58
  import {
54
59
  buildBrief,
@@ -93,8 +98,8 @@ import {
93
98
 
94
99
  // src/cli.ts
95
100
  import { execFileSync } from "child_process";
96
- import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, realpathSync } from "fs";
97
- import { join as join10, dirname as dirname5 } from "path";
101
+ import { readFileSync as readFileSync5, existsSync as existsSync3, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, realpathSync, rmSync } from "fs";
102
+ import { join as join11, dirname as dirname5 } from "path";
98
103
  import { pathToFileURL } from "url";
99
104
 
100
105
  // src/agents/cli-accounts.ts
@@ -181,6 +186,26 @@ var AccountPool = class {
181
186
  return kind ? this.accounts.filter((a) => a.kind === kind).length : this.accounts.length;
182
187
  }
183
188
  };
189
+ function modelsPanel(usage2, models, kinds, inUse = [], now = Date.now()) {
190
+ const used = new Set(inUse);
191
+ const rows = [];
192
+ for (const kind of kinds) {
193
+ const mine = usage2.filter((u) => u.account.kind === kind);
194
+ const who = mine.length ? mine.map(({ account, reading }) => {
195
+ const name = account.email ?? account.plan ?? account.name;
196
+ return `${name}${reading ? ` \xB7 ${Math.round(reading.spent * 100)}% used ${ageOf(reading.at, now)} ago` : ""}`;
197
+ }).join(" \xB7 ") : "_not connected \u2014 these will fail every call_";
198
+ rows.push(`**${kind}** \u2014 ${who}`);
199
+ rows.push(` ${models(kind).map((m) => `${used.has(m) ? "\u25CF" : "\xB7"} ${m}`).join(" ")}`);
200
+ }
201
+ const idle = kinds.filter((k) => usage2.some((u) => u.account.kind === k)).filter((k) => !models(k).some((m) => used.has(m)));
202
+ const note = idle.length ? `
203
+
204
+ _\u25CF = a role is running it. ${idle.join(" and ")} ${idle.length > 1 ? "are" : "is"} connected but in no chain \u2014 \`/roles adjust\` assigns them._` : "\n\n_\u25CF = a role is running it._";
205
+ return `**Models** \u2014 what your connected subscriptions serve
206
+
207
+ ${rows.join("\n")}${note}`;
208
+ }
184
209
  function ageOf(at, now) {
185
210
  const mins = Math.max(0, Math.round((now - at) / 6e4));
186
211
  if (mins < 60) return `${mins}m`;
@@ -207,9 +232,11 @@ function freshDir(kind, home, taken) {
207
232
  }
208
233
  }
209
234
  function nameFor(kind, status, dir) {
210
- return status.email ?? `${kind}-${dir.split("/").pop() ?? "profile"}`;
235
+ const base = dir.split("/").pop() ?? "profile";
236
+ return status.email ?? (base.startsWith(`${kind}-`) ? base : `${kind}-${base}`);
211
237
  }
212
238
  function withAmbient(kind, existing, ambient) {
239
+ if (kind === "zai") return [...existing];
213
240
  if (existing.some((a) => a.kind === kind)) return [...existing];
214
241
  if (!ambient.loggedIn) return [...existing];
215
242
  return [
@@ -228,11 +255,11 @@ function accountsIn(config) {
228
255
  return raw.filter((a) => {
229
256
  const e = a;
230
257
  if (typeof a !== "object" || a === null) return false;
231
- if (e.kind !== "claude" && e.kind !== "codex") return false;
258
+ if (!CLI_KINDS.includes(e.kind)) return false;
232
259
  return e.configDir === void 0 || typeof e.configDir === "string";
233
260
  });
234
261
  }
235
- function addProvider(kind, io) {
262
+ async function addProvider(kind, io) {
236
263
  const config = io.readConfig();
237
264
  const existing = accountsIn(config);
238
265
  const accounts = withAmbient(kind, existing, io.check(kind));
@@ -242,12 +269,26 @@ function addProvider(kind, io) {
242
269
  io.log(`It stays first in line, so runs keep spending it before anything added now.`);
243
270
  }
244
271
  const dir = freshDir(kind, io.home, accounts);
245
- io.log(`
272
+ if (kind === "zai") {
273
+ io.log(`
274
+ z.ai has no sign-in \u2014 an account is an API key, and the profile at ${dir} will hold it.`);
275
+ io.log(`Take the key from your z.ai dashboard; it is written to that profile alone and nowhere else.
276
+ `);
277
+ } else {
278
+ io.log(`
246
279
  Opening ${kind}'s own sign-in, for a NEW profile at ${dir}.`);
247
- io.log(`Sign in with the account you want to add \u2014 not the one already connected.
280
+ io.log(`Sign in with the account you want to add \u2014 not the one already connected.
248
281
  `);
249
- const r = io.login(kind, dir);
282
+ }
283
+ const r = await io.login(kind, dir);
250
284
  if (!r.ok) {
285
+ if (kind === "zai") {
286
+ io.log(`
287
+ z.ai did not accept that key${r.error ? `: ${r.error}` : ""}. Nothing was changed.`);
288
+ io.log(`The key is checked by making one real call, because \`claude auth status\` reports any token as connected \u2014 valid or not.`);
289
+ io.log(`Nothing needs cleaning up: running this again reuses ${dir}.`);
290
+ return 1;
291
+ }
251
292
  io.log(`
252
293
  ${kind} sign-in did not complete${r.error ? `: ${r.error}` : ""}. Nothing was changed.`);
253
294
  io.log(`If a browser page opened, its address holds the reason \u2014 an \`error=\` there is the account being refused, not this command failing.`);
@@ -277,13 +318,203 @@ That is the account already connected as "${already.name}". Nothing was changed.
277
318
  const mine = [...accounts, entry].filter((a) => a.kind === kind);
278
319
  io.log(`
279
320
  Connected ${entry.email ?? entry.name}${entry.plan ? ` (${entry.plan})` : ""}.`);
280
- io.log(`${mine.length} ${kind} account${mine.length === 1 ? "" : "s"} now connected. Runs use them in order, spilling to the next when one is nearly out.`);
321
+ io.log(`${mine.length} ${kind} account${mine.length === 1 ? "" : "s"} now connected. Runs take turns between them.`);
322
+ if (kind === "claude") io.log(`One nearly out of quota steps aside for the others.`);
323
+ else io.log(`${kind} reports no quota figures, so a spent account is discovered by being refused, not before.`);
324
+ if (kind === "zai" && mine.length > 1) {
325
+ io.log(`z.ai reports no account identity, so this cannot be checked: if that key is one already connected, remove the duplicate from ~/.horsecode/config.json.`);
326
+ }
281
327
  return 0;
282
328
  }
283
329
 
330
+ // src/agents/zai-profile.ts
331
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
332
+ import { join as join3 } from "path";
333
+ var ZAI_BASE_URL = "https://api.z.ai/api/anthropic";
334
+ function zaiSettings(token) {
335
+ return { env: { ANTHROPIC_BASE_URL: ZAI_BASE_URL, ANTHROPIC_AUTH_TOKEN: token } };
336
+ }
337
+ function settingsPath(dir) {
338
+ return join3(dir, "settings.json");
339
+ }
340
+ function writeZaiProfile(dir, token) {
341
+ mkdirSync2(dir, { recursive: true, mode: 448 });
342
+ writeFileSync2(settingsPath(dir), JSON.stringify(zaiSettings(token), null, 2) + "\n", { mode: 384 });
343
+ }
344
+ async function verifyZaiKey(token, model, timeoutMs = 3e4) {
345
+ const ctl = new AbortController();
346
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
347
+ try {
348
+ const res = await fetch(`${ZAI_BASE_URL}/v1/messages`, {
349
+ method: "POST",
350
+ signal: ctl.signal,
351
+ headers: {
352
+ "content-type": "application/json",
353
+ // Both spellings: z.ai accepts the bearer form, and `x-api-key` is what the Anthropic wire format
354
+ // names. Sending each costs nothing and removes a guess about which one this endpoint reads.
355
+ authorization: `Bearer ${token}`,
356
+ "x-api-key": token,
357
+ "anthropic-version": "2023-06-01"
358
+ },
359
+ body: JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "Reply with: ok" }] })
360
+ });
361
+ const body = await res.json().catch(() => void 0);
362
+ if (!res.ok) return { ok: false, error: zaiErrorText(body) ?? `the endpoint answered HTTP ${res.status}` };
363
+ const served = body?.model;
364
+ return { ok: true, ...served ? { served } : {} };
365
+ } catch (e) {
366
+ const aborted = e instanceof Error && e.name === "AbortError";
367
+ return {
368
+ ok: false,
369
+ error: aborted ? `${ZAI_BASE_URL} did not answer within ${Math.round(timeoutMs / 1e3)}s` : e instanceof Error ? e.message : String(e)
370
+ };
371
+ } finally {
372
+ clearTimeout(timer);
373
+ }
374
+ }
375
+ function zaiErrorText(body) {
376
+ const msg = body?.error?.message;
377
+ return typeof msg === "string" && msg.trim() ? msg.trim() : void 0;
378
+ }
379
+
380
+ // src/agents/prompt.ts
381
+ import { spawnSync } from "child_process";
382
+ import { closeSync, openSync, readSync } from "fs";
383
+ function promptLine() {
384
+ return readTty(false);
385
+ }
386
+ function promptSecret() {
387
+ return readTty(true);
388
+ }
389
+ function confirm(question) {
390
+ process.stdout.write(`${question} [y/N] `);
391
+ const answer = promptLine();
392
+ process.stdout.write("\n");
393
+ return /^y(es)?$/i.test((answer ?? "").trim());
394
+ }
395
+ function chooseFrom(question, options) {
396
+ process.stdout.write(`${question}
397
+ `);
398
+ options.forEach((o, i) => process.stdout.write(` ${i + 1}) ${o}
399
+ `));
400
+ process.stdout.write(`Number (anything else cancels): `);
401
+ const line = promptLine();
402
+ process.stdout.write("\n");
403
+ const n = Number((line ?? "").trim());
404
+ return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : void 0;
405
+ }
406
+ function readTty(hidden) {
407
+ let fd;
408
+ try {
409
+ fd = openSync("/dev/tty", "r");
410
+ } catch {
411
+ return void 0;
412
+ }
413
+ const hushed = hidden && spawnSync("stty", ["-echo"], { stdio: ["inherit", "ignore", "ignore"] }).status === 0;
414
+ try {
415
+ const buf = Buffer.alloc(1);
416
+ let out = "";
417
+ for (; ; ) {
418
+ let n = 0;
419
+ try {
420
+ n = readSync(fd, buf, 0, 1, null);
421
+ } catch {
422
+ break;
423
+ }
424
+ if (n === 0) break;
425
+ const ch = buf.toString("utf8");
426
+ if (ch === "\n" || ch === "\r") break;
427
+ out += ch;
428
+ }
429
+ return out.trim() || void 0;
430
+ } finally {
431
+ if (hushed) spawnSync("stty", ["echo"], { stdio: ["inherit", "ignore", "ignore"] });
432
+ closeSync(fd);
433
+ }
434
+ }
435
+
436
+ // src/agents/remove-provider.ts
437
+ function ownedProfile(home, dir) {
438
+ if (!dir) return false;
439
+ const root = `${home}/.horsecode/profiles/`;
440
+ return dir.startsWith(root) && dir.length > root.length && !dir.slice(root.length).includes("..");
441
+ }
442
+ function describeAccount(a) {
443
+ const who = a.email ?? a.name;
444
+ return `${a.kind} ${who}${a.plan ? ` (${a.plan})` : ""}${a.configDir ? "" : " \u2014 the signed-in default"}`;
445
+ }
446
+ function pickAccount(accounts, kind, name) {
447
+ const mine = accounts.filter((a) => a.kind === kind);
448
+ if (!mine.length) return { error: `no ${kind} account is connected` };
449
+ if (!name) return mine.length === 1 ? { account: mine[0] } : { choices: mine };
450
+ const found = mine.filter((a) => a.name === name || a.email === name);
451
+ if (!found.length) {
452
+ return {
453
+ error: `no ${kind} account called "${name}". Connected:
454
+ ` + mine.map((a) => ` ${a.name}${a.email && a.email !== a.name ? ` (${a.email})` : ""}`).join("\n")
455
+ };
456
+ }
457
+ if (found.length > 1) return { error: `"${name}" matches ${found.length} ${kind} accounts \u2014 remove one by its exact name` };
458
+ return { account: found[0] };
459
+ }
460
+ function removeProvider(kind, name, io) {
461
+ const config = io.readConfig();
462
+ const accounts = accountsIn(config);
463
+ const picked = pickAccount(accounts, kind, name);
464
+ if ("error" in picked) {
465
+ io.log(picked.error);
466
+ const others = accounts.filter((a) => a.kind !== kind);
467
+ if (!accounts.filter((a) => a.kind === kind).length && others.length) {
468
+ io.log(`
469
+ Connected accounts:
470
+ ${others.map((a) => ` ${describeAccount(a)}`).join("\n")}`);
471
+ }
472
+ return { code: 1 };
473
+ }
474
+ let account;
475
+ if ("account" in picked) {
476
+ account = picked.account;
477
+ } else {
478
+ const chosen = io.choose(`Which ${kind} account should be removed?`, picked.choices.map(describeAccount));
479
+ if (chosen === void 0) {
480
+ io.log("Nothing was removed.");
481
+ return { code: 1 };
482
+ }
483
+ account = picked.choices[chosen];
484
+ }
485
+ const kept = accounts.filter((a) => a !== account);
486
+ io.writeConfig({ ...config, accounts: kept });
487
+ if (io.usage) {
488
+ const readings = io.usage.load();
489
+ const { [readingKey(kind, account.name)]: _dropped, ...rest } = readings;
490
+ io.usage.save(rest);
491
+ }
492
+ io.log(`Removed ${describeAccount(account)}.`);
493
+ if (!account.configDir) {
494
+ io.log(`That was the account you are signed into ${kind} with, not a profile horse-code created.`);
495
+ io.log(`It is still signed in, and a run will find it again. To stop using it, sign out of ${kind} itself.`);
496
+ return { code: 0, removed: account };
497
+ }
498
+ if (!ownedProfile(io.home, account.configDir)) {
499
+ io.log(`Its profile at ${account.configDir} was not created by horse-code, so it is left alone.`);
500
+ return { code: 0, removed: account };
501
+ }
502
+ const holds = kind === "zai" ? "the API key" : `the ${kind} sign-in`;
503
+ if (!io.confirm(`Also delete ${account.configDir}, which holds ${holds}?`)) {
504
+ io.log(`Left ${account.configDir} in place. Delete it by hand to remove ${holds}.`);
505
+ return { code: 0, removed: account };
506
+ }
507
+ io.removeDir(account.configDir);
508
+ io.log(`Deleted ${account.configDir}.`);
509
+ return { code: 0, removed: account };
510
+ }
511
+ function removeUsage(given) {
512
+ return `remove-provider needs a CLI to disconnect: ${CLI_KINDS.map((k) => `\`hcode remove-provider ${k}\``).join(", ")}${given ? ` (got "${given}")` : ""}`;
513
+ }
514
+
284
515
  // src/skills/registry.ts
285
516
  import { readdir, readFile } from "fs/promises";
286
- import { join as join3 } from "path";
517
+ import { join as join4 } from "path";
287
518
  var SkillRegistry = class {
288
519
  skills = /* @__PURE__ */ new Map();
289
520
  register(skill) {
@@ -306,7 +537,7 @@ var SkillRegistry = class {
306
537
  if (!e.isDirectory()) continue;
307
538
  let raw;
308
539
  try {
309
- raw = await readFile(join3(dir, e.name, "SKILL.md"), "utf8");
540
+ raw = await readFile(join4(dir, e.name, "SKILL.md"), "utf8");
310
541
  } catch {
311
542
  continue;
312
543
  }
@@ -314,14 +545,14 @@ var SkillRegistry = class {
314
545
  if (!name || !description) {
315
546
  throw new Error(`skill ${e.name}: missing frontmatter (name/description)`);
316
547
  }
317
- this.register({ name, description, content: body, dir: join3(dir, e.name) });
548
+ this.register({ name, description, content: body, dir: join4(dir, e.name) });
318
549
  }
319
550
  }
320
551
  };
321
552
 
322
553
  // src/skills/builtin.ts
323
554
  import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
324
- import { dirname as dirname2, join as join4, resolve } from "path";
555
+ import { dirname as dirname2, join as join5, resolve } from "path";
325
556
  import { fileURLToPath } from "url";
326
557
  function candidates() {
327
558
  const here = dirname2(fileURLToPath(import.meta.url));
@@ -342,14 +573,14 @@ async function registerBuiltinSkills(registry, dirs = candidates()) {
342
573
  if (!e.isDirectory()) continue;
343
574
  let raw;
344
575
  try {
345
- raw = await readFile2(join4(dir, e.name, "SKILL.md"), "utf8");
576
+ raw = await readFile2(join5(dir, e.name, "SKILL.md"), "utf8");
346
577
  } catch {
347
578
  continue;
348
579
  }
349
580
  const { parseFrontmatter: parseFrontmatter2 } = await import("./frontmatter-UNIPNLLO.js");
350
581
  const { name, description, body } = parseFrontmatter2(raw);
351
582
  if (!name || !description) continue;
352
- found.push({ name, description, content: body, dir: join4(dir, e.name) });
583
+ found.push({ name, description, content: body, dir: join5(dir, e.name) });
353
584
  }
354
585
  if (!found.length) continue;
355
586
  for (const s of found) registry.register(s);
@@ -364,12 +595,12 @@ async function registerBuiltinSkills(registry, dirs = candidates()) {
364
595
  // src/skills/external.ts
365
596
  import { mkdir, rm, readFile as readFile3, writeFile, readdir as readdir3, cp } from "fs/promises";
366
597
  import { existsSync } from "fs";
367
- import { join as join5 } from "path";
598
+ import { join as join6 } from "path";
368
599
  import { spawn } from "child_process";
369
600
  import { tmpdir } from "os";
370
601
  var PROVENANCE = ".horsecode-source.json";
371
602
  function externalSkillsDir(home) {
372
- return join5(home, ".horsecode", "skills");
603
+ return join6(home, ".horsecode", "skills");
373
604
  }
374
605
  function validName(name) {
375
606
  return /^[A-Za-z0-9._-]+$/.test(name) && !name.includes("..");
@@ -413,7 +644,7 @@ async function resolveSha(src, deps) {
413
644
  }
414
645
  async function installedSource(home, name) {
415
646
  try {
416
- return JSON.parse(await readFile3(join5(externalSkillsDir(home), name, PROVENANCE), "utf8"));
647
+ return JSON.parse(await readFile3(join6(externalSkillsDir(home), name, PROVENANCE), "utf8"));
417
648
  } catch {
418
649
  return void 0;
419
650
  }
@@ -423,27 +654,27 @@ async function installSkillSource(home, src, deps = {}) {
423
654
  if (!validRepo(src.repo)) throw new Error(`skill source repo must be "owner/repo": ${src.repo}`);
424
655
  if (src.path?.includes("..")) throw new Error(`skill source path may not contain "..": ${src.path}`);
425
656
  const sha = await resolveSha(src, deps);
426
- const dest = join5(externalSkillsDir(home), src.name);
657
+ const dest = join6(externalSkillsDir(home), src.name);
427
658
  const current = await installedSource(home, src.name);
428
- if (current?.sha === sha && existsSync(join5(dest, "SKILL.md"))) {
659
+ if (current?.sha === sha && existsSync(join6(dest, "SKILL.md"))) {
429
660
  return { name: src.name, sha, changed: false };
430
661
  }
431
- const staging = join5(tmpdir(), `hc-skill-${src.name}-${sha.slice(0, 8)}`);
662
+ const staging = join6(tmpdir(), `hc-skill-${src.name}-${sha.slice(0, 8)}`);
432
663
  await rm(staging, { recursive: true, force: true });
433
664
  await mkdir(staging, { recursive: true });
434
665
  try {
435
666
  const url = `https://codeload.github.com/${src.repo}/tar.gz/${sha}`;
436
667
  const dl = await run("sh", ["-c", `curl -fsSL --max-time 120 ${JSON.stringify(url)} | tar xz -C ${JSON.stringify(staging)} --strip-components=1`]);
437
668
  if (dl.code !== 0) throw new Error(`download failed for ${src.repo}@${sha.slice(0, 8)}: ${dl.err.trim().slice(0, 200)}`);
438
- const from = src.path ? join5(staging, src.path) : staging;
439
- if (!existsSync(join5(from, "SKILL.md"))) {
669
+ const from = src.path ? join6(staging, src.path) : staging;
670
+ if (!existsSync(join6(from, "SKILL.md"))) {
440
671
  throw new Error(`${src.repo}${src.path ? `/${src.path}` : ""} has no SKILL.md`);
441
672
  }
442
673
  await rm(dest, { recursive: true, force: true });
443
674
  await mkdir(dest, { recursive: true });
444
675
  await cp(from, dest, { recursive: true });
445
676
  const record = { ...src, sha, installedAt: (deps.now ?? Date.now)() };
446
- await writeFile(join5(dest, PROVENANCE), `${JSON.stringify(record, null, 2)}
677
+ await writeFile(join6(dest, PROVENANCE), `${JSON.stringify(record, null, 2)}
447
678
  `, "utf8");
448
679
  return { name: src.name, sha, changed: true };
449
680
  } finally {
@@ -733,8 +964,8 @@ var ProposalQueue = class {
733
964
  };
734
965
 
735
966
  // src/speckit/templates.ts
736
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
737
- import { join as join6, dirname as dirname3 } from "path";
967
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
968
+ import { join as join7, dirname as dirname3 } from "path";
738
969
  var TEMPLATE_FILES = {
739
970
  spec: "spec-template.md",
740
971
  plan: "plan-template.md",
@@ -757,10 +988,10 @@ async function loadSpecKit(opts) {
757
988
  );
758
989
  }
759
990
  const fetchFn = opts.fetch ?? globalThis.fetch;
760
- const cacheDir = join6(opts.home, ".horsecode", "spec-kit", opts.version, "templates");
991
+ const cacheDir = join7(opts.home, ".horsecode", "spec-kit", opts.version, "templates");
761
992
  const get = async (relPath) => {
762
- const cachePath = join6(cacheDir, relPath);
763
- if (existsSync2(cachePath)) return readFileSync2(cachePath, "utf8");
993
+ const cachePath = join7(cacheDir, relPath);
994
+ if (existsSync2(cachePath)) return readFileSync3(cachePath, "utf8");
764
995
  const url = `${RAW_BASE}/${opts.version}/templates/${relPath}`;
765
996
  const res = await fetchFn(url);
766
997
  if (!res.ok) {
@@ -770,8 +1001,8 @@ Check your network or set specKit.version to a valid tag.`
770
1001
  );
771
1002
  }
772
1003
  const text = await res.text();
773
- mkdirSync2(dirname3(cachePath), { recursive: true });
774
- writeFileSync2(cachePath, text, "utf8");
1004
+ mkdirSync3(dirname3(cachePath), { recursive: true });
1005
+ writeFileSync3(cachePath, text, "utf8");
775
1006
  return text;
776
1007
  };
777
1008
  const templates = {};
@@ -787,7 +1018,7 @@ Check your network or set specKit.version to a valid tag.`
787
1018
 
788
1019
  // src/wiring.ts
789
1020
  import { homedir } from "os";
790
- import { join as join7 } from "path";
1021
+ import { join as join8 } from "path";
791
1022
  async function buildJobDeps(opts) {
792
1023
  const { config } = opts;
793
1024
  const roles = {};
@@ -801,7 +1032,12 @@ async function buildJobDeps(opts) {
801
1032
  const roleRegistry = new RoleRegistry(roles, DEFAULT_PROMPTS, opts.skillRegistry);
802
1033
  if (opts.accounts) {
803
1034
  const pool = opts.accounts;
804
- roleRegistry.setSourceWeights(() => ({ claude: pool.count("claude"), codex: pool.count("codex") }));
1035
+ roleRegistry.setSourceWeights(() => ({
1036
+ claude: pool.count("claude"),
1037
+ codex: pool.count("codex"),
1038
+ grok: pool.count("grok"),
1039
+ zai: pool.count("zai")
1040
+ }));
805
1041
  }
806
1042
  const fillModels = (r) => ({
807
1043
  ...r,
@@ -828,7 +1064,7 @@ async function buildJobDeps(opts) {
828
1064
  const queue = new ProposalQueue();
829
1065
  let kitPromise;
830
1066
  const specKit = () => kitPromise ??= loadSpecKit({ version: config.specKit.version, home: opts.home, fetch: opts.fetch });
831
- const fitness = new RoleFitness(join7(opts.home ?? homedir(), ".horsecode", "model-fitness.json"));
1067
+ const fitness = new RoleFitness(join8(opts.home ?? homedir(), ".horsecode", "model-fitness.json"));
832
1068
  roleRegistry.setFitness(fitness);
833
1069
  return {
834
1070
  provider: opts.provider,
@@ -1020,10 +1256,10 @@ function redactRecord(record) {
1020
1256
  }
1021
1257
 
1022
1258
  // src/obs/sink.ts
1023
- import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
1024
- import { join as join8 } from "path";
1259
+ import { createWriteStream, mkdirSync as mkdirSync4 } from "fs";
1260
+ import { join as join9 } from "path";
1025
1261
  function telemetryDir(home) {
1026
- return join8(home, ".horsecode", "telemetry");
1262
+ return join9(home, ".horsecode", "telemetry");
1027
1263
  }
1028
1264
  var MAX_QUEUE = 1e4;
1029
1265
  var FileSink = class {
@@ -1032,9 +1268,9 @@ var FileSink = class {
1032
1268
  dropped = 0;
1033
1269
  path;
1034
1270
  constructor(home, runId) {
1035
- this.path = join8(telemetryDir(home), `${runId}.jsonl`);
1271
+ this.path = join9(telemetryDir(home), `${runId}.jsonl`);
1036
1272
  try {
1037
- mkdirSync3(telemetryDir(home), { recursive: true });
1273
+ mkdirSync4(telemetryDir(home), { recursive: true });
1038
1274
  this.stream = createWriteStream(this.path, { flags: "a" });
1039
1275
  this.stream.on("error", () => {
1040
1276
  this.stream = void 0;
@@ -1091,13 +1327,13 @@ var FileSink = class {
1091
1327
  };
1092
1328
 
1093
1329
  // src/cli-info.ts
1094
- import { readFileSync as readFileSync3 } from "fs";
1095
- import { dirname as dirname4, join as join9 } from "path";
1330
+ import { readFileSync as readFileSync4 } from "fs";
1331
+ import { dirname as dirname4, join as join10 } from "path";
1096
1332
  import { fileURLToPath as fileURLToPath2 } from "url";
1097
1333
  function hcodeVersion() {
1098
1334
  try {
1099
1335
  const here = dirname4(fileURLToPath2(import.meta.url));
1100
- const pkg = JSON.parse(readFileSync3(join9(here, "..", "package.json"), "utf8"));
1336
+ const pkg = JSON.parse(readFileSync4(join10(here, "..", "package.json"), "utf8"));
1101
1337
  return pkg.version ?? "unknown";
1102
1338
  } catch {
1103
1339
  return "unknown";
@@ -1124,7 +1360,9 @@ function usage() {
1124
1360
  " hcode start the REPL",
1125
1361
  ' hcode "<request>" run one request to completion, then report where the work landed',
1126
1362
  " hcode init write .horsecode/ for this project and ask for the essentials",
1127
- " hcode add-provider <cli> connect one more claude or codex subscription, through that CLI's own sign-in",
1363
+ " hcode add-provider <cli> connect one more claude, codex, grok or zai subscription",
1364
+ " hcode remove-provider <cli> [name]",
1365
+ " disconnect one, and optionally delete the sign-in it holds",
1128
1366
  "",
1129
1367
  "OPTIONS",
1130
1368
  " -b, --branch <name> branch the session's worktree from this ref (default: current branch)",
@@ -1152,6 +1390,18 @@ function nearestFlag(flag) {
1152
1390
 
1153
1391
  // src/cli.ts
1154
1392
  var HEAP_MB = 12288;
1393
+ async function connectZai(dir) {
1394
+ process.stdout.write("z.ai API key (not shown as you type): ");
1395
+ const token = promptSecret();
1396
+ process.stdout.write("\n");
1397
+ if (!token) return { ok: false, error: "no key was entered" };
1398
+ console.log(`Asking ${ZAI_MODELS[0]} one question, to see whether the key works\u2026`);
1399
+ const v = await verifyZaiKey(token, ZAI_MODELS[0]);
1400
+ if (!v.ok) return { ok: false, error: v.error };
1401
+ writeZaiProfile(dir, token);
1402
+ console.log(` answered by ${v.served ?? "the endpoint (which named no model)"}.`);
1403
+ return { ok: true };
1404
+ }
1155
1405
  function parseArgs(argv) {
1156
1406
  let fromBranch;
1157
1407
  let jobName;
@@ -1310,14 +1560,14 @@ async function main(argv) {
1310
1560
  read: read2,
1311
1561
  readFile: (p) => {
1312
1562
  try {
1313
- return readFileSync4(p, "utf8");
1563
+ return readFileSync5(p, "utf8");
1314
1564
  } catch {
1315
1565
  return void 0;
1316
1566
  }
1317
1567
  },
1318
1568
  writeFile: (p, c) => {
1319
- mkdirSync4(dirname5(p), { recursive: true });
1320
- writeFileSync3(p, c);
1569
+ mkdirSync5(dirname5(p), { recursive: true });
1570
+ writeFileSync4(p, c);
1321
1571
  },
1322
1572
  home: process.env.HOME ?? "",
1323
1573
  log: (s) => console.log(s)
@@ -1329,32 +1579,74 @@ async function main(argv) {
1329
1579
  }
1330
1580
  if (argv[0] === "add-provider") {
1331
1581
  const kind = argv[1];
1332
- if (kind !== "claude" && kind !== "codex") {
1333
- console.error(`add-provider needs a CLI to connect: \`hcode add-provider claude\` or \`hcode add-provider codex\`${kind ? ` (got "${kind}")` : ""}`);
1582
+ if (!kind || !CLI_KINDS.includes(kind)) {
1583
+ console.error(`add-provider needs a CLI to connect: ${CLI_KINDS.map((k) => `\`hcode add-provider ${k}\``).join(", ")}${argv[1] ? ` (got "${argv[1]}")` : ""}`);
1334
1584
  process.exitCode = 1;
1335
1585
  return;
1336
1586
  }
1337
1587
  const path = `${process.env.HOME ?? ""}/.horsecode/config.json`;
1338
- process.exitCode = addProvider(kind, {
1588
+ process.exitCode = await addProvider(kind, {
1339
1589
  home: process.env.HOME ?? "",
1340
1590
  readConfig: () => {
1341
1591
  try {
1342
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
1592
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
1343
1593
  return typeof parsed === "object" && parsed !== null ? parsed : {};
1344
1594
  } catch {
1345
1595
  return {};
1346
1596
  }
1347
1597
  },
1348
1598
  writeConfig: (c) => {
1349
- mkdirSync4(dirname5(path), { recursive: true });
1350
- writeFileSync3(path, JSON.stringify(c, null, 2) + "\n");
1599
+ mkdirSync5(dirname5(path), { recursive: true });
1600
+ writeFileSync4(path, JSON.stringify(c, null, 2) + "\n");
1351
1601
  },
1352
- login: runLogin,
1602
+ /**
1603
+ * "Make this profile real" means two different things, and z.ai is the one that is not a sign-in.
1604
+ *
1605
+ * The other three hand the terminal to their own OAuth. z.ai has none — a profile is a settings file
1606
+ * holding an endpoint and a key — so here the step is: ask for the key, write it, and then SPEND one
1607
+ * call proving it works. That last part is not ceremony: `claude auth status` reports any token as
1608
+ * connected, valid or not, so without a real call a mistyped key would be filed as a working account
1609
+ * and fail every call of the next run.
1610
+ */
1611
+ login: (k, dir) => k === "zai" ? connectZai(dir ?? "") : runLogin(k, dir),
1353
1612
  check: checkProfile,
1354
1613
  log: (line) => console.log(line)
1355
1614
  });
1356
1615
  return;
1357
1616
  }
1617
+ if (argv[0] === "remove-provider") {
1618
+ const kind = argv[1];
1619
+ if (!kind || !CLI_KINDS.includes(kind)) {
1620
+ console.error(removeUsage(argv[1]));
1621
+ process.exitCode = 1;
1622
+ return;
1623
+ }
1624
+ const path = `${process.env.HOME ?? ""}/.horsecode/config.json`;
1625
+ const home2 = process.env.HOME ?? "";
1626
+ process.exitCode = removeProvider(kind, argv[2], {
1627
+ home: home2,
1628
+ readConfig: () => {
1629
+ try {
1630
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
1631
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
1632
+ } catch {
1633
+ return {};
1634
+ }
1635
+ },
1636
+ writeConfig: (c) => {
1637
+ mkdirSync5(dirname5(path), { recursive: true });
1638
+ writeFileSync4(path, JSON.stringify(c, null, 2) + "\n");
1639
+ },
1640
+ removeDir: (dir) => {
1641
+ rmSync(dir, { recursive: true, force: true });
1642
+ },
1643
+ confirm,
1644
+ choose: chooseFrom,
1645
+ usage: fileUsageStore(home2),
1646
+ log: (line) => console.log(line)
1647
+ }).code;
1648
+ return;
1649
+ }
1358
1650
  const cwd = process.cwd();
1359
1651
  const config = loadConfig({
1360
1652
  cwd,
@@ -1362,7 +1654,7 @@ async function main(argv) {
1362
1654
  env: process.env,
1363
1655
  readFile: (p) => {
1364
1656
  try {
1365
- return readFileSync4(p, "utf8");
1657
+ return readFileSync5(p, "utf8");
1366
1658
  } catch {
1367
1659
  return void 0;
1368
1660
  }
@@ -1370,13 +1662,13 @@ async function main(argv) {
1370
1662
  });
1371
1663
  const home = process.env.HOME ?? "";
1372
1664
  if (!process.env.HC_HEAP_SET && !process.env.NODE_OPTIONS) {
1373
- const { spawnSync } = await import("child_process");
1665
+ const { spawnSync: spawnSync2 } = await import("child_process");
1374
1666
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
1375
1667
  const ignore = () => {
1376
1668
  };
1377
1669
  const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
1378
1670
  for (const sig of SIGNALS) process.on(sig, ignore);
1379
- const r = spawnSync(process.execPath, [`--max-old-space-size=${HEAP_MB}`, ...process.argv.slice(1)], {
1671
+ const r = spawnSync2(process.execPath, [`--max-old-space-size=${HEAP_MB}`, ...process.argv.slice(1)], {
1380
1672
  stdio: "inherit",
1381
1673
  /**
1382
1674
  * `NODE_ENV=production` is set here because it must be set BEFORE React is imported.
@@ -1412,7 +1704,7 @@ async function main(argv) {
1412
1704
  process.once("SIGTERM", flush);
1413
1705
  }
1414
1706
  const telemetryNote = sink ? `\u{1F4C8} Telemetry \u2192 \`${sink.path}\` (one JSON object per line)` : void 0;
1415
- const ambientLogins = ["claude", "codex"].filter((k) => !config.accounts.some((a) => a.kind === k)).map((kind) => ({ kind, status: checkProfile(kind) }));
1707
+ const ambientLogins = CLI_KINDS.filter((k) => k !== "zai" && !config.accounts.some((a) => a.kind === k)).map((kind) => ({ kind, status: checkProfile(kind) }));
1416
1708
  const accounts = new AccountPool(
1417
1709
  [
1418
1710
  ...config.accounts,
@@ -1427,12 +1719,13 @@ async function main(argv) {
1427
1719
  );
1428
1720
  const notSignedIn = ambientLogins.filter((a) => !a.status.loggedIn).map((a) => a.kind);
1429
1721
  const accountsNote = () => accountsLine(accounts.usage(), notSignedIn);
1722
+ const modelsPanelNote = (inUse) => modelsPanel(accounts.usage(), modelsFor, CLI_KINDS, inUse);
1430
1723
  const raw = new CliProvider({ readOnly: false, accounts });
1431
1724
  const provider = config.telemetry ? telemetryProvider(raw, telemetry()) : raw;
1432
1725
  const skillRegistry = new SkillRegistry();
1433
1726
  await registerBuiltinSkills(skillRegistry);
1434
1727
  await skillRegistry.loadFromDir(externalSkillsDir(home));
1435
- const skillsDir = join10(cwd, ".horsecode", "skills");
1728
+ const skillsDir = join11(cwd, ".horsecode", "skills");
1436
1729
  if (existsSync3(skillsDir)) await skillRegistry.loadFromDir(skillsDir);
1437
1730
  const worktreeHome = await mainWorktreeRoot(defaultGitRunner, cwd);
1438
1731
  if (worktreeHome !== cwd) {
@@ -1465,7 +1758,7 @@ async function main(argv) {
1465
1758
  const useTui = shouldUseTui(!!process.stdin.isTTY, !!process.stdout.isTTY, !!args.noTui);
1466
1759
  if (!args.prompt) {
1467
1760
  if (useTui) {
1468
- const { runTuiRepl } = await import("./app-2GPGCDX6.js");
1761
+ const { runTuiRepl } = await import("./app-5FXHE7GX.js");
1469
1762
  const { fetchCatalog, makeProbe, discoverSources } = await import("./discover-G2Z6XC3O.js");
1470
1763
  const { loadSourceCache, saveSourceCache } = await import("./source-cache-XEK5WN7I.js");
1471
1764
  const manualSources = config.modelSources.length > 0;
@@ -1516,7 +1809,7 @@ _Expected something like \`https://github.com/owner/repo\` or a link to the dire
1516
1809
  _Discoverable by every agent. \`/roles adjust\` assigns it to the roles it fits; \`/skills update\` re-installs it from upstream._`;
1517
1810
  };
1518
1811
  const reloadProjectSkills = async () => {
1519
- const dir = join10(cwd, ".horsecode", "skills");
1812
+ const dir = join11(cwd, ".horsecode", "skills");
1520
1813
  if (existsSync3(dir)) await skillRegistry.loadFromDir(dir);
1521
1814
  };
1522
1815
  const updateSkills = async () => {
@@ -1607,6 +1900,7 @@ _Committed with the repo, so every clone starts with them. Agents read one with
1607
1900
  buildDeps,
1608
1901
  memStore,
1609
1902
  accountsNote,
1903
+ modelsPanel: modelsPanelNote,
1610
1904
  listSkills,
1611
1905
  updateSkills,
1612
1906
  addSkill,
@@ -1644,7 +1938,7 @@ _Committed with the repo, so every clone starts with them. Agents read one with
1644
1938
  ...args.revisionRounds !== void 0 && { revisionRounds: args.revisionRounds }
1645
1939
  };
1646
1940
  if (useTui) {
1647
- const { runTui } = await import("./app-2GPGCDX6.js");
1941
+ const { runTui } = await import("./app-5FXHE7GX.js");
1648
1942
  const res = await runTui({ buildDeps, job });
1649
1943
  console.log(renderResult(res));
1650
1944
  return;