@join-pantheon/cli 0.28.0 → 0.29.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.
package/dist/install.js CHANGED
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { homedir, platform } from "node:os";
4
4
  import { join, dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { grantToolPermissions, claudeSettingsFile, cursorCliConfigFile } from "./grants.js";
6
+ import { grantClaudeToolPermissions, grantCursorToolPermissions, claudeSettingsFile, cursorCliConfigFile } from "./grants.js";
7
7
  import { PANTHEON_TOOL_NAMES, WRITE_TOOL_NAMES, toolsAllowed } from "./tools.js";
8
8
  import { load } from "./config.js";
9
9
  import { loadProfile } from "./profile.js";
@@ -237,8 +237,17 @@ function claudeLocalScope(entry, projectDir) {
237
237
  return { tool, where: "local scope", status: `failed: ${e.message.split("\n")[0]}` };
238
238
  }
239
239
  }
240
- function cursorUserScope(entry) {
240
+ export function agentPresence(home = homedir(), onPath = onPathNamed) {
241
+ return {
242
+ claude: existsSync(join(home, ".claude")) || onPath("claude"),
243
+ cursor: existsSync(join(home, ".cursor")) || onPath("cursor-agent"),
244
+ codex: existsSync(join(home, ".codex")) || onPath("codex"),
245
+ };
246
+ }
247
+ function cursorUserScope(entry, present = true) {
241
248
  const file = join(homedir(), ".cursor", "mcp.json");
249
+ if (!present)
250
+ return { tool: "cursor", where: "-", status: "skipped (Cursor not found on this machine)" };
242
251
  try {
243
252
  return { tool: "cursor", where: file, status: mergeMcpJson(file, entry) };
244
253
  }
@@ -339,10 +348,94 @@ function isOurToolTable(t) {
339
348
  return keys.length === 1 && /^approval_mode\s*=\s*"prompt"\s*$/.test(keys[0]);
340
349
  }
341
350
  const toolOf = (t) => (t.name && TOOL_TABLE.exec(t.name)?.[1]) ?? null;
351
+ const ENV_TABLE = `${SERVER_TABLE}.env`;
352
+ const isServerSubTable = (t) => !!t.name && t.name.startsWith(`${SERVER_TABLE}.`) && !toolOf(t) && !t.name.startsWith(`${SERVER_TABLE}.tools.`);
353
+ const DOTTED_ENV = /^\s*env\s*\.\s*(.+?)\s*=\s*(.*)$/;
354
+ function tomlValue(raw) {
355
+ const v = raw.trim();
356
+ const q = /^("(?:[^"\\]|\\.)*"|'[^']*')\s*(#.*)?$/.exec(v);
357
+ if (q)
358
+ return q[1];
359
+ return v.replace(/\s+#.*$/, "").trim();
360
+ }
361
+ function splitInline(body) {
362
+ const out = [];
363
+ let cur = "", quote = null, depth = 0;
364
+ for (let i = 0; i < body.length; i++) {
365
+ const ch = body[i];
366
+ if (quote) {
367
+ cur += ch;
368
+ if (ch === "\\" && quote === '"') {
369
+ cur += body[++i] ?? "";
370
+ continue;
371
+ }
372
+ if (ch === quote)
373
+ quote = null;
374
+ continue;
375
+ }
376
+ if (ch === '"' || ch === "'") {
377
+ quote = ch;
378
+ cur += ch;
379
+ continue;
380
+ }
381
+ if (ch === "[" || ch === "{")
382
+ depth++;
383
+ if (ch === "]" || ch === "}")
384
+ depth--;
385
+ if (ch === "," && depth === 0) {
386
+ if (cur.trim())
387
+ out.push(cur.trim());
388
+ cur = "";
389
+ continue;
390
+ }
391
+ cur += ch;
392
+ }
393
+ if (cur.trim())
394
+ out.push(cur.trim());
395
+ return out;
396
+ }
397
+ function hasMultiline(lines) {
398
+ return lines.some((l) => {
399
+ const i = l.indexOf("=");
400
+ if (i <= 0 || l.trim().startsWith("#"))
401
+ return false;
402
+ const v = l.slice(i + 1).trim();
403
+ return ['"""', "'''"].some((d) => v.startsWith(d) && !v.slice(3).includes(d));
404
+ });
405
+ }
406
+ function theirEnv(tables) {
407
+ const out = [];
408
+ for (const t of tables) {
409
+ if (t.name === ENV_TABLE) {
410
+ for (const l of keyLines(t)) {
411
+ const i = l.indexOf("=");
412
+ if (i > 0)
413
+ out.push([l.slice(0, i).trim(), tomlValue(l.slice(i + 1))]);
414
+ }
415
+ }
416
+ if (t.name === SERVER_TABLE) {
417
+ for (const l of t.lines.slice(1)) {
418
+ if (l.trim().startsWith("#"))
419
+ continue;
420
+ const m = DOTTED_ENV.exec(l);
421
+ if (m)
422
+ out.push([m[1].trim(), tomlValue(m[2])]);
423
+ const inline = /^\s*env\s*=\s*\{(.*)\}\s*(#.*)?$/.exec(l);
424
+ if (inline)
425
+ for (const pair of splitInline(inline[1])) {
426
+ const i = pair.indexOf("=");
427
+ if (i > 0)
428
+ out.push([pair.slice(0, i).trim(), pair.slice(i + 1).trim()]);
429
+ }
430
+ }
431
+ }
432
+ }
433
+ return out.filter(([k]) => k.replace(/^["']|["']$/g, "") !== "PANTHEON_AGENT");
434
+ }
342
435
  export function codexKept(current, approval = "approve") {
343
436
  const tables = parseTables(current);
344
437
  const server = tables.find((t) => t.name === SERVER_TABLE);
345
- const serverKeys = server ? keyLines(server).filter((l) => !OUR_SERVER_KEYS.test(l)).map((l) => l.split("=")[0].trim()) : [];
438
+ const serverKeys = server ? keyLines(server).filter((l) => !OUR_SERVER_KEYS.test(l) && !DOTTED_ENV.test(l)).map((l) => l.split("=")[0].trim()) : [];
346
439
  const theirs = tables.filter((t) => toolOf(t) && !isOurToolTable(t));
347
440
  const ownApproval = approval === "ask-before-sending"
348
441
  ? theirs.filter((t) => WRITE_TOOL_NAMES.includes(toolOf(t)) && keyLines(t).some((l) => !marked(l) && /^approval_mode\s*=/.test(l))).map((t) => toolOf(t))
@@ -352,11 +445,21 @@ export function codexKept(current, approval = "approve") {
352
445
  export function upsertCodexToml(current, entry, approval = "approve") {
353
446
  const tables = parseTables(current);
354
447
  const server = tables.find((t) => t.name === SERVER_TABLE);
355
- const kept = server ? server.lines.slice(1).filter((l) => l.trim() && !OUR_SERVER_KEYS.test(l)) : [];
448
+ const envTable = tables.find((t) => t.name === ENV_TABLE);
449
+ const tableMode = !!envTable && hasMultiline(envTable.lines.slice(1));
450
+ const dottedMode = !tableMode && !!server && hasMultiline(server.lines.slice(1).filter((l) => DOTTED_ENV.test(l)));
451
+ const OUR_DOTTED = /^\s*env\s*\.\s*["']?PANTHEON_AGENT["']?\s*=/;
452
+ const kept = server ? server.lines.slice(1).filter((l) => (dottedMode || l.trim()) && !marked(l) && !OUR_SERVER_KEYS.test(l) && (dottedMode ? !OUR_DOTTED.test(l) : !DOTTED_ENV.test(l))) : [];
453
+ while (kept.length && !kept[kept.length - 1].trim())
454
+ kept.pop();
356
455
  const theirsFor = new Map(tables.filter((t) => toolOf(t) && !isOurToolTable(t)).map((t) => [toolOf(t), t]));
357
456
  const wantPrompt = approval === "ask-before-sending" ? WRITE_TOOL_NAMES : [];
457
+ const extraEnv = tableMode || dottedMode ? [] : theirEnv(tables);
458
+ const envLine = `env = { ${[`PANTHEON_AGENT = "codex"`, ...extraEnv.map(([k, v]) => `${k} = ${v}`)].join(", ")} }`;
358
459
  const ourBlock = () => {
359
- const out = [...codexTomlBlock(entry, undefined, "approve").split("\n"), ...kept];
460
+ const block = codexTomlBlock(entry, undefined, "approve").split("\n")
461
+ .flatMap((l) => !/^env = /.test(l) ? [l] : tableMode ? [] : dottedMode ? [`env.PANTHEON_AGENT = "codex" ${OURS_MARK}`] : [envLine]);
462
+ const out = [...block, ...kept];
360
463
  for (const t of wantPrompt)
361
464
  if (!theirsFor.has(t))
362
465
  out.push("", ...ourToolTable(t));
@@ -385,6 +488,13 @@ export function upsertCodexToml(current, entry, approval = "approve") {
385
488
  placed = true;
386
489
  continue;
387
490
  }
491
+ if (t.name === ENV_TABLE) {
492
+ if (!tableMode)
493
+ continue;
494
+ const OUR_KEY = /^\s*["']?PANTHEON_AGENT["']?\s*=/;
495
+ out.push(t.lines[0], `PANTHEON_AGENT = "codex" ${OURS_MARK}`, ...t.lines.slice(1).filter((l) => !marked(l) && !OUR_KEY.test(l)));
496
+ continue;
497
+ }
388
498
  if (toolOf(t)) {
389
499
  if (isOurToolTable(t))
390
500
  continue;
@@ -403,10 +513,16 @@ export function removeCodexToml(current) {
403
513
  for (const t of parseTables(current)) {
404
514
  if (t.name === SERVER_TABLE)
405
515
  continue;
516
+ if (isServerSubTable(t))
517
+ continue;
406
518
  if (toolOf(t)) {
407
519
  if (isOurToolTable(t))
408
520
  continue;
409
- out.push(...t.lines.filter((l) => !marked(l)));
521
+ const lines = t.lines.filter((l) => !marked(l));
522
+ if (!lines.slice(1).some((l) => l.trim() && !l.trim().startsWith("#")))
523
+ continue;
524
+ out.push(`# ${OURS_MARK.slice(2)} leave: kept your setting below, commented out — [mcp_servers.pantheon] is gone, and this table alone would stop Codex starting`);
525
+ out.push(...lines.map((l) => (l.trim() ? `# ${l}` : l)));
410
526
  continue;
411
527
  }
412
528
  out.push(...t.lines);
@@ -437,8 +553,10 @@ export function codexApprovalState(file = codexConfigFile()) {
437
553
  return null;
438
554
  }
439
555
  }
440
- function codexUserScope(entry, approval) {
556
+ function codexUserScope(entry, approval, present = true) {
441
557
  const file = codexConfigFile();
558
+ if (!present)
559
+ return { tool: "codex", where: "-", status: "skipped (Codex not found on this machine)" };
442
560
  const said = approval === "ask-before-sending"
443
561
  ? `${CODEX_APPROVAL_LINE}, with send/open_goal/claim set to prompt`
444
562
  : CODEX_APPROVAL_LINE;
@@ -487,18 +605,100 @@ export function chosenApproval() {
487
605
  return "approve";
488
606
  }
489
607
  }
490
- export function installForUser(approval = chosenApproval()) {
608
+ export function installForUser(approval = chosenApproval(), present = agentPresence()) {
491
609
  const entry = userEntry();
610
+ const said = (s) => /^(granted|already granted)/.test(s)
611
+ ? `${s} — ${approval === "ask-before-sending" ? "reads run without asking; send, open_goal and claim prompt" : `the ${PANTHEON_TOOL_NAMES.length} pantheon tools run without asking`}`
612
+ : s;
492
613
  return [
493
- claudeUserScope(entry), cursorUserScope(entry), codexUserScope(entry, approval),
614
+ claudeUserScope(entry), cursorUserScope(entry, present.cursor), codexUserScope(entry, approval, present.codex),
494
615
  copilotUserScope(entry), vsCodeUserScope(entry), kiroUserScope(entry, approval),
495
- ...grantToolPermissions(approval).map((g) => ({
496
- tool: g.tool,
497
- where: g.tool.startsWith("claude") ? claudeSettingsFile() : cursorCliConfigFile(),
498
- status: /^(granted|already granted)/.test(g.status)
499
- ? `${g.status} — ${approval === "ask-before-sending" ? "reads run without asking; send, open_goal and claim prompt" : `the ${PANTHEON_TOOL_NAMES.length} pantheon tools run without asking`}`
500
- : g.status,
501
- })),
616
+ present.claude
617
+ ? { tool: "claude perms", where: claudeSettingsFile(), status: said(grantClaudeToolPermissions(undefined, approval)) }
618
+ : { tool: "claude perms", where: "-", status: "skipped (Claude Code not found on this machine)" },
619
+ present.cursor
620
+ ? { tool: "cursor perms", where: cursorCliConfigFile(), status: said(grantCursorToolPermissions(undefined, approval)) }
621
+ : { tool: "cursor perms", where: "-", status: "skipped (Cursor not found on this machine)" },
622
+ ];
623
+ }
624
+ export function isOurMcpEntry(e) {
625
+ if (!e || typeof e !== "object")
626
+ return false;
627
+ const { command, args } = e;
628
+ if (typeof command !== "string" || !Array.isArray(args) || args.at(-1) !== "mcp")
629
+ return false;
630
+ const base = command.split(/[\\/]/).pop() ?? "";
631
+ if (/^pantheon(\.cmd|\.exe)?$/i.test(base))
632
+ return true;
633
+ return args.length >= 2 && typeof args[0] === "string" && /[\\/]cli\.js$/.test(args[0]);
634
+ }
635
+ export function removeMcpJsonEntry(file, key = "mcpServers") {
636
+ try {
637
+ if (!existsSync(file))
638
+ return "absent";
639
+ const doc = readConfigJson(file);
640
+ const servers = doc[key];
641
+ if (!servers || !("pantheon" in servers))
642
+ return "absent";
643
+ if (!isOurMcpEntry(servers["pantheon"]))
644
+ return "not ours";
645
+ delete servers["pantheon"];
646
+ writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
647
+ return "removed";
648
+ }
649
+ catch (e) {
650
+ return `failed: ${e.message}`;
651
+ }
652
+ }
653
+ export function removeUserMcpEntries(deps = {}) {
654
+ const removed = [], left = [];
655
+ const home = deps.home ?? homedir();
656
+ const exec = deps.exec ?? ((c, a, o) => execFileSync(c, a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...o }) ?? "");
657
+ const os = deps.os ?? platform();
658
+ const claudeBin = deps.claudeBin === undefined ? which("claude") : deps.claudeBin;
659
+ const claudeRemove = (scope, what, cwd) => {
660
+ const rm = agentSpawn(claudeBin, ["mcp", "remove", "pantheon", "-s", scope], os);
661
+ try {
662
+ exec(rm.command, rm.args, { ...rm.options, ...(cwd ? { cwd } : {}) });
663
+ removed.push(what);
664
+ }
665
+ catch (e) {
666
+ const err = e;
667
+ const text = `${err.message} ${String(err.stderr ?? "")}`;
668
+ if (!/not found|no mcp server|does not exist/i.test(text))
669
+ left.push(`${what} (${text.trim().split("\n")[0]}) — remove it: ${cwd ? `in ${cwd}, ` : ""}claude mcp remove pantheon -s ${scope}`);
670
+ }
671
+ };
672
+ if (claudeBin) {
673
+ claudeRemove("user", "Claude Code's pantheon MCP server (user scope)");
674
+ if (os === "win32" && deps.project)
675
+ claudeRemove("local", `Claude Code's pantheon entry for ${deps.project} (local scope)`, deps.project);
676
+ }
677
+ else if (os === "win32" && deps.project) {
678
+ left.push(`any Claude Code entry for ${deps.project} (local scope) — claude is not on PATH here; once it is: claude mcp remove pantheon -s local, run in that folder`);
679
+ }
680
+ const files = [
681
+ ["Cursor", join(home, ".cursor", "mcp.json"), "mcpServers"],
682
+ ["Copilot", copilotMcpConfigFile(home), "mcpServers"],
683
+ ["Kiro", kiroMcpConfigFile(home), "mcpServers"],
502
684
  ];
685
+ const vs = vsCodeUserDir(home);
686
+ if (vs)
687
+ files.push(["VS Code", join(vs, "mcp.json"), "servers"]);
688
+ for (const [who, file, key] of files) {
689
+ const r = removeMcpJsonEntry(file, key);
690
+ if (r === "removed")
691
+ removed.push(`${who}'s pantheon MCP entry (${file})`);
692
+ else if (r === "not ours")
693
+ left.push(`an entry named "pantheon" in ${file} that Pantheon did not write — yours to keep or delete`);
694
+ else if (r !== "absent")
695
+ left.push(`${who}'s pantheon MCP entry — ${r}; delete the "pantheon" server from ${file} by hand`);
696
+ }
697
+ const codex = removeCodexUserScope(deps.codexFile ?? join(home, ".codex", "config.toml"));
698
+ if (codex && /removed/.test(codex))
699
+ removed.push(codex.trim().replace(/^removed /, "Codex's "));
700
+ else if (codex)
701
+ left.push(codex.trim());
702
+ return { removed, left };
503
703
  }
504
704
  //# sourceMappingURL=install.js.map
@@ -1,15 +1,27 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
+ import { basename, dirname, join } from "node:path";
4
5
  export const kiroDir = (home = homedir(), env = process.env) => env.KIRO_HOME || join(home, ".kiro");
5
6
  export const KIRO_AGENT_NAME = "pantheon";
6
7
  export const kiroAgentFile = (kiroBase) => join(kiroBase, "agents", `${KIRO_AGENT_NAME}.json`);
7
8
  export const isOurKiroHook = (cmd) => !!cmd && /\bhook\s+kiro-(?:prompt|stop)\b/.test(cmd);
8
9
  export const kiroAllowed = (tools) => tools.map((t) => `@pantheon/${t}`);
10
+ export const KIRO_AGENT_MARKER = "[written by pantheon: kiro-agent/1]";
11
+ const KIRO_AGENT_DESCRIPTION = "Kiro's default agent, plus Pantheon: its tools run without asking, and a turn is held open while a teammate is waiting on you";
12
+ export function gitTracks(file) {
13
+ try {
14
+ execFileSync("git", ["-C", dirname(file), "ls-files", "--error-unmatch", "--", basename(file)], { stdio: "ignore", timeout: 5_000 });
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
9
21
  export function kiroAgentDoc(bin, allowed) {
10
22
  return {
11
23
  name: KIRO_AGENT_NAME,
12
- description: "Kiro's default agent, plus Pantheon: its tools run without asking, and a turn is held open while a teammate is waiting on you",
24
+ description: `${KIRO_AGENT_DESCRIPTION} ${KIRO_AGENT_MARKER}`,
13
25
  tools: ["*"],
14
26
  allowedTools: kiroAllowed(allowed),
15
27
  includeMcpJson: true,
@@ -19,16 +31,21 @@ export function kiroAgentDoc(bin, allowed) {
19
31
  },
20
32
  };
21
33
  }
22
- export function readKiroAgent(file) {
34
+ export function readKiroAgent(file, tracked = gitTracks) {
23
35
  if (!existsSync(file))
24
36
  return null;
25
37
  try {
26
38
  const doc = JSON.parse(readFileSync(file, "utf8"));
27
- const events = Object.entries(doc.hooks ?? {}).filter(([, es]) => (es ?? []).some((h) => isOurKiroHook(h.command))).map(([e]) => e);
28
- return { doc, ours: events.length > 0, events };
39
+ const hooks = Object.entries(doc.hooks && typeof doc.hooks === "object" ? doc.hooks : {});
40
+ const events = hooks.filter(([, es]) => Array.isArray(es) && es.some((h) => isOurKiroHook(h?.command))).map(([e]) => e);
41
+ const onlyOurs = hooks.every(([, es]) => Array.isArray(es) && es.every((h) => isOurKiroHook(h?.command)));
42
+ const desc = typeof doc.description === "string" ? doc.description : "";
43
+ const marked = desc.includes(KIRO_AGENT_MARKER) || desc === KIRO_AGENT_DESCRIPTION;
44
+ const isTracked = tracked(file);
45
+ return { doc, ours: marked && onlyOurs && events.length > 0 && doc.name === KIRO_AGENT_NAME && !isTracked, tracked: isTracked, events };
29
46
  }
30
47
  catch {
31
- return { doc: {}, ours: false, events: [] };
48
+ return { doc: {}, ours: false, tracked: tracked(file), events: [] };
32
49
  }
33
50
  }
34
51
  export function writeKiroAgent(file, doc) {
package/dist/ledger.js CHANGED
@@ -9,7 +9,13 @@ export class Ledger {
9
9
  this.file = join(dir, `${space}.jsonl`);
10
10
  mkdirSync(dirname(this.file), { recursive: true });
11
11
  if (existsSync(this.file)) {
12
- for (const line of readFileSync(this.file, "utf8").split("\n")) {
12
+ let text = readFileSync(this.file, "utf8");
13
+ if (text.length && !text.endsWith("\n")) {
14
+ const keep = text.lastIndexOf("\n") + 1;
15
+ text = text.slice(0, keep);
16
+ writeFileSync(this.file, text);
17
+ }
18
+ for (const line of text.split("\n")) {
13
19
  if (!line.trim())
14
20
  continue;
15
21
  try {
@@ -55,7 +61,7 @@ export class Ledger {
55
61
  const cut = Math.max(0, this.records.length - opts.keepRecent);
56
62
  if (cut === 0)
57
63
  return { dropped: 0, kept: this.records.length };
58
- const kept = this.records.filter((r, i) => i >= cut || opts.isLive(r.goal));
64
+ const kept = this.records.filter((r, i) => i >= cut || opts.isLive(r.goal) || (opts.keep?.(r.id) ?? false));
59
65
  const dropped = this.records.length - kept.length;
60
66
  if (dropped === 0)
61
67
  return { dropped: 0, kept: kept.length };