primcli 1.43.0 → 1.44.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/README.md CHANGED
@@ -923,6 +923,7 @@ session on this machine register itself and answer Primitive mail, and
923
923
  primitive machine doctor --json
924
924
  primitive machine doctor --fix --json
925
925
  primitive machine doctor --fix --check claude.hook.stop --json
926
+ primitive machine doctor --fix --check claude.hook.stop --profile my-agent
926
927
  ```
927
928
 
928
929
  Each check reports `{id, title, status, detail, fixable, fixed?, action?, path?}`
@@ -942,6 +943,17 @@ content) and `~/.omp/agent/AGENTS.md`, the bundled primitive-connect skill, and
942
943
  saved agent profiles that were disconnected in Primitive (moved aside locally;
943
944
  nothing changes server side).
944
945
 
946
+ `claude.hook.stop` names every per-session receive hook a repair would change
947
+ in `items: [{profile, session, hook, state}]`, where `hook` is the Claude event
948
+ (`Stop`, `SessionStart` or `PostToolUse`) and `state` is `missing`, `stale`,
949
+ `duplicate`, `outdated` or `old_address`; `profile` and `session` are null for
950
+ hooks written by old CLI versions that did not record them. After `--fix` it
951
+ also reports `changes`, the same fields plus `action` (`added`, `removed` or
952
+ `updated`), for each hook it changed. `--profile <name>` (repeatable, with
953
+ `--fix`) limits those per-session hook changes to the named profiles; combine it
954
+ with `--check claude.hook.stop` so nothing else is repaired. With `--profile`,
955
+ each item also carries `selected: false` when this run leaves it unchanged.
956
+
945
957
  Repairs only touch Primitive-owned hooks, blocks and skill copies. Any existing
946
958
  file is backed up beside itself as `<file>.primitive-bak-<timestamp>` before it
947
959
  changes, writes are atomic, and a second `--fix` with nothing drifted changes
@@ -35617,6 +35617,36 @@ function writeClaudeSettings(params) {
35617
35617
  release();
35618
35618
  }
35619
35619
  }
35620
+ const STATE_PHRASES = {
35621
+ missing: "missing",
35622
+ stale: "session disconnected or removed",
35623
+ duplicate: "duplicate",
35624
+ outdated: "old CLI path",
35625
+ old_address: "old agent address"
35626
+ };
35627
+ /** "profile my-agent, session 1234...": who a session hook serves. */
35628
+ function describeSessionHookOwner(item) {
35629
+ const profile = item.profile ? `profile ${item.profile}` : "a profile an old CLI did not record";
35630
+ return item.session ? `${profile}, session ${item.session}` : profile;
35631
+ }
35632
+ /** One short line naming a hook, its owner and its state. */
35633
+ function describeSessionHookItem(item) {
35634
+ return `${item.hook} hook for ${describeSessionHookOwner(item)}: ${STATE_PHRASES[item.state]}`;
35635
+ }
35636
+ /** One short line naming what a repair did to a hook. */
35637
+ function describeSessionHookChange(change) {
35638
+ return `${change.action === "added" ? "Added" : change.action === "removed" ? "Removed" : "Rewrote"} ${change.hook} hook for ${describeSessionHookOwner(change)} (${STATE_PHRASES[change.state]})`;
35639
+ }
35640
+ /** Group items by owner, at most `limit` owners, for a one-line detail. */
35641
+ function summarizeOwners(items, limit = 3) {
35642
+ const owners = [];
35643
+ for (const item of items) {
35644
+ const owner = describeSessionHookOwner(item);
35645
+ if (!owners.includes(owner)) owners.push(owner);
35646
+ }
35647
+ const shown = owners.slice(0, limit).join("; ");
35648
+ return owners.length > limit ? `${shown}; and ${owners.length - limit} more` : shown;
35649
+ }
35620
35650
  const GLOBAL_HOOKS = {
35621
35651
  "claude.hook.session_start": {
35622
35652
  event: "SessionStart",
@@ -35804,6 +35834,57 @@ function boundSessionProfiles(configDir) {
35804
35834
  } catch {}
35805
35835
  return bound;
35806
35836
  }
35837
+ function boundKey(item) {
35838
+ return [
35839
+ resolve(item.configDir),
35840
+ item.profile,
35841
+ item.session
35842
+ ].join("\0");
35843
+ }
35844
+ /**
35845
+ * Bound profiles whose receive hooks are entirely absent while the same
35846
+ * session receives through another connected profile, or while several profiles are
35847
+ * bound to it and none receives. Such a profile is not restored without
35848
+ * being named: its hooks were removed on purpose, or it would give the
35849
+ * session a second address. A profile that still has any receive hook for
35850
+ * the session, or the only profile bound to a session, is restored as usual.
35851
+ */
35852
+ function heldSessionProfiles(hooks, bound, restore) {
35853
+ const receiving = /* @__PURE__ */ new Map();
35854
+ for (const event of SESSION_EVENTS) {
35855
+ const entries = hooks[event];
35856
+ if (!Array.isArray(entries)) continue;
35857
+ for (const entry of entries) {
35858
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) continue;
35859
+ for (const hook of entry.hooks) {
35860
+ const parsed = parseSessionHook(hook);
35861
+ if (!parsed?.profile || !parsed.session) continue;
35862
+ if (!existsSync(parsed.configDir) || !profileStillConnected(parsed.configDir, parsed.profile)) continue;
35863
+ const session = parsed.session.toLowerCase();
35864
+ const profiles = receiving.get(session) ?? /* @__PURE__ */ new Set();
35865
+ profiles.add([
35866
+ resolve(parsed.configDir),
35867
+ parsed.profile,
35868
+ session
35869
+ ].join("\0"));
35870
+ receiving.set(session, profiles);
35871
+ }
35872
+ }
35873
+ }
35874
+ const held = /* @__PURE__ */ new Map();
35875
+ for (const item of bound) {
35876
+ const key = boundKey(item);
35877
+ const present = receiving.get(item.session) ?? /* @__PURE__ */ new Set();
35878
+ if (present.has(key) || restore?.has(item.profile)) continue;
35879
+ const others = [...present].map((other) => other.split("\0")[1] ?? "");
35880
+ const siblings = bound.filter((candidate) => candidate.session === item.session);
35881
+ if (others.length > 0 || siblings.length > 1) held.set(key, {
35882
+ item,
35883
+ receivingAs: others.sort()
35884
+ });
35885
+ }
35886
+ return held;
35887
+ }
35807
35888
  /** Receive hooks each bound profile should have and does not. */
35808
35889
  function missingSessionHooks(hooks, bound, node) {
35809
35890
  return bound.flatMap((item) => SESSION_EVENTS.filter((event) => !hasSessionReceiveHook(hooks, event, item, node)).map((event) => ({
@@ -35811,6 +35892,34 @@ function missingSessionHooks(hooks, bound, node) {
35811
35892
  bound: item
35812
35893
  })));
35813
35894
  }
35895
+ /** Every hook judged for repair, in settings order, then every missing one. */
35896
+ function sessionHookItems(hooks, verdicts, missing) {
35897
+ const items = missing.map(({ event, bound }) => ({
35898
+ profile: bound.profile,
35899
+ session: bound.session,
35900
+ hook: event,
35901
+ state: "missing"
35902
+ }));
35903
+ for (const event of SESSION_EVENTS) {
35904
+ const entries = hooks[event];
35905
+ if (!Array.isArray(entries)) continue;
35906
+ for (const entry of entries) {
35907
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) continue;
35908
+ for (const hook of entry.hooks) {
35909
+ const verdict = verdicts.get(hook);
35910
+ const parsed = parseSessionHook(hook);
35911
+ if (!verdict || verdict === "keep" || !parsed) continue;
35912
+ items.push({
35913
+ profile: parsed.profile,
35914
+ session: parsed.session?.toLowerCase() ?? null,
35915
+ hook: event,
35916
+ state: verdict
35917
+ });
35918
+ }
35919
+ }
35920
+ }
35921
+ return items;
35922
+ }
35814
35923
  /** The exact entry `agent connect` and `session-register` install. */
35815
35924
  function sessionHookEntry(event, item, cli) {
35816
35925
  const pending = SESSION_HOOK_KIND[event] === "pending";
@@ -35884,7 +35993,9 @@ function inspectClaudeHooks(settings, context) {
35884
35993
  };
35885
35994
  return findings;
35886
35995
  }
35887
- const bound = context.bound ?? [];
35996
+ const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
35997
+ const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
35998
+ const heldNote = held.size ? ` Left alone: ${[...held.values()].map(({ item, receivingAs }) => `profile ${item.profile} is bound to session ${item.session}, which ${receivingAs.length ? `receives as ${receivingAs.join(", ")}` : "has several bound profiles and receives as none"}`).join("; ")}. Run \`primitive machine doctor --fix --profile <profile>\` to restore one deliberately.` : "";
35888
35999
  const counts = {
35889
36000
  keep: 0,
35890
36001
  stale: 0,
@@ -35892,8 +36003,11 @@ function inspectClaudeHooks(settings, context) {
35892
36003
  outdated: 0,
35893
36004
  old_address: 0
35894
36005
  };
35895
- for (const verdict of judgeSessionHooks(hooks, context.cli, bound).values()) counts[verdict]++;
35896
- const missing = missingSessionHooks(hooks, bound, context.cli?.node ?? process.execPath).length;
36006
+ const verdicts = judgeSessionHooks(hooks, context.cli, bound);
36007
+ for (const verdict of verdicts.values()) counts[verdict]++;
36008
+ const missingHooks = missingSessionHooks(hooks, bound, context.cli?.node ?? process.execPath);
36009
+ const missing = missingHooks.length;
36010
+ const items = sessionHookItems(hooks, verdicts, missingHooks);
35897
36011
  const problems = [
35898
36012
  missing ? `${missing} missing for connected sessions` : "",
35899
36013
  counts.old_address ? `${counts.old_address} naming an old agent address` : "",
@@ -35903,11 +36017,12 @@ function inspectClaudeHooks(settings, context) {
35903
36017
  ].filter(Boolean);
35904
36018
  findings["claude.hook.stop"] = problems.length ? {
35905
36019
  status: "fail",
35906
- detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}.${blockedNote}`,
35907
- fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null
36020
+ detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}. Affected: ${summarizeOwners(items)}.${blockedNote}${heldNote}`,
36021
+ fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null,
36022
+ items
35908
36023
  } : {
35909
36024
  status: "ok",
35910
- detail: counts.keep ? `${counts.keep} per-session receive hooks, each present once and current.` : "No per-session receive hooks; each registered session adds its own exact-session hooks.",
36025
+ detail: `${counts.keep ? `${counts.keep} per-session receive hooks, each present once and current.` : "No per-session receive hooks; each registered session adds its own exact-session hooks."}${heldNote}`,
35911
36026
  fixable: false
35912
36027
  };
35913
36028
  return findings;
@@ -35939,8 +36054,10 @@ function rewriteEntries(entries, transform) {
35939
36054
  * Primitive-owned hooks are added, rewritten or removed; every other key,
35940
36055
  * entry and sibling hook is carried over unchanged.
35941
36056
  */
35942
- function repairClaudeHooks(settings, context, checks) {
36057
+ function repairClaudeHooks(settings, context, checks, options = {}) {
35943
36058
  const changed = /* @__PURE__ */ new Set();
36059
+ const changes = [];
36060
+ const selected = (profile) => !options.profiles || profile !== null && options.profiles.has(profile);
35944
36061
  const hooks = record$4(settings.hooks) ? { ...settings.hooks } : {};
35945
36062
  const cli = context.blocked === null ? context.cli : null;
35946
36063
  for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
@@ -35973,19 +36090,38 @@ function repairClaudeHooks(settings, context, checks) {
35973
36090
  }
35974
36091
  if (checks.has("claude.hook.stop") && context.blocked === null) {
35975
36092
  if (!SESSION_EVENTS.some((event) => eventEntries(hooks, event) === "invalid")) {
35976
- const bound = context.bound ?? [];
36093
+ const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
36094
+ const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
35977
36095
  const verdicts = judgeSessionHooks(hooks, context.cli, bound);
35978
36096
  for (const event of SESSION_EVENTS) {
35979
36097
  const entries = eventEntries(hooks, event);
35980
36098
  if (entries === "invalid" || entries.length === 0) continue;
35981
36099
  const result = rewriteEntries(entries, (hook) => {
35982
36100
  const verdict = verdicts.get(hook);
35983
- if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") return void 0;
35984
- if (verdict !== "outdated" || !cli || !record$4(hook)) return hook;
35985
36101
  const parsed = parseSessionHook(hook);
36102
+ if (!verdict || verdict === "keep" || !parsed) return hook;
36103
+ if (!selected(parsed.profile)) return hook;
36104
+ const item = {
36105
+ profile: parsed.profile,
36106
+ session: parsed.session?.toLowerCase() ?? null,
36107
+ hook: event,
36108
+ state: verdict
36109
+ };
36110
+ if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") {
36111
+ changes.push({
36112
+ ...item,
36113
+ action: "removed"
36114
+ });
36115
+ return;
36116
+ }
36117
+ if (!cli || !record$4(hook)) return hook;
35986
36118
  const args = [...hook.args];
35987
- args[0] = parsed?.kind === "pending" ? cli.pending : cli.wake;
36119
+ args[0] = parsed.kind === "pending" ? cli.pending : cli.wake;
35988
36120
  args[1] = cli.entry;
36121
+ changes.push({
36122
+ ...item,
36123
+ action: "updated"
36124
+ });
35989
36125
  return {
35990
36126
  ...hook,
35991
36127
  command: cli.node,
@@ -35998,8 +36134,16 @@ function repairClaudeHooks(settings, context, checks) {
35998
36134
  }
35999
36135
  }
36000
36136
  if (cli) for (const missing of missingSessionHooks(hooks, bound, cli.node)) {
36137
+ if (!selected(missing.bound.profile)) continue;
36001
36138
  const entries = eventEntries(hooks, missing.event);
36002
36139
  if (entries === "invalid") continue;
36140
+ changes.push({
36141
+ profile: missing.bound.profile,
36142
+ session: missing.bound.session,
36143
+ hook: missing.event,
36144
+ state: "missing",
36145
+ action: "added"
36146
+ });
36003
36147
  hooks[missing.event] = [...entries, sessionHookEntry(missing.event, missing.bound, cli)];
36004
36148
  changed.add("claude.hook.stop");
36005
36149
  }
@@ -36007,14 +36151,16 @@ function repairClaudeHooks(settings, context, checks) {
36007
36151
  }
36008
36152
  if (!changed.size) return {
36009
36153
  settings,
36010
- changed
36154
+ changed,
36155
+ changes
36011
36156
  };
36012
36157
  return {
36013
36158
  settings: {
36014
36159
  ...settings,
36015
36160
  hooks
36016
36161
  },
36017
- changed
36162
+ changed,
36163
+ changes
36018
36164
  };
36019
36165
  }
36020
36166
  //#endregion
@@ -47192,7 +47338,7 @@ const PRIMITIVE_TEAM_AUTHOR = {
47192
47338
  url: "https://primitive.dev"
47193
47339
  };
47194
47340
  const SDK_VERSION_RANGE = "^1.34.0";
47195
- const CLI_VERSION_RANGE = "^1.43.0";
47341
+ const CLI_VERSION_RANGE = "^1.44.0";
47196
47342
  const ESBUILD_VERSION_RANGE = "^0.27.0";
47197
47343
  function renderHandler() {
47198
47344
  return `// env.PRIMITIVE_API_KEY, env.PRIMITIVE_WEBHOOK_SECRET, and
@@ -54258,13 +54404,15 @@ async function runMachineDoctor(options) {
54258
54404
  "claude.hook.stop"
54259
54405
  ];
54260
54406
  const repairedAhead = /* @__PURE__ */ new Set();
54407
+ const sessionHookChanges = [];
54261
54408
  let settingsRead = readClaudeSettings(paths.claudeDir);
54262
54409
  const hookContext = {
54263
54410
  cli,
54264
54411
  blocked: hookBlocked,
54265
54412
  get bound() {
54266
54413
  return boundSessionProfiles(options.configDir);
54267
- }
54414
+ },
54415
+ restoreProfiles: options.profiles
54268
54416
  };
54269
54417
  const hookCheck = (id) => ({
54270
54418
  inspect: () => {
@@ -54275,22 +54423,28 @@ async function runMachineDoctor(options) {
54275
54423
  return check(id, finding.status, finding.detail, {
54276
54424
  fixable: finding.fixable,
54277
54425
  path: join(paths.claudeDir, "settings.json"),
54278
- ...finding.status === "fail" && !finding.fixable && hookBlocked ? { action: "install_cli" } : {}
54426
+ ...finding.status === "fail" && !finding.fixable && hookBlocked ? { action: "install_cli" } : {},
54427
+ ...finding.items?.length ? { items: finding.items.map((item) => options.fix && options.profiles ? {
54428
+ ...item,
54429
+ selected: item.profile !== null && options.profiles.has(item.profile)
54430
+ } : item) } : {},
54431
+ ...id === "claude.hook.stop" && sessionHookChanges.length ? { changes: [...sessionHookChanges] } : {}
54279
54432
  });
54280
54433
  },
54281
54434
  repair: () => {
54282
54435
  if (!settingsRead.ok) return false;
54283
54436
  const findings = inspectClaudeHooks(settingsRead.settings, hookContext);
54284
54437
  const ids = HOOK_CHECK_IDS.filter((hook) => (hook === id || !options.only || options.only.has(hook)) && findings[hook].status === "fail" && findings[hook].fixable);
54285
- const repaired = repairClaudeHooks(settingsRead.settings, hookContext, new Set(ids));
54438
+ const repaired = repairClaudeHooks(settingsRead.settings, hookContext, new Set(ids), { profiles: options.profiles });
54286
54439
  if (!repaired.changed.size) return false;
54287
- for (const hook of repaired.changed) repairedAhead.add(hook);
54288
54440
  writeClaudeSettings({
54289
54441
  claudeDir: paths.claudeDir,
54290
54442
  read: settingsRead.read,
54291
54443
  settings: repaired.settings,
54292
54444
  now
54293
54445
  });
54446
+ sessionHookChanges.push(...repaired.changes);
54447
+ for (const hook of repaired.changed) repairedAhead.add(hook);
54294
54448
  settingsRead = readClaudeSettings(paths.claudeDir);
54295
54449
  return repaired.changed.has(id);
54296
54450
  }
@@ -54537,7 +54691,7 @@ async function runMachineDoctor(options) {
54537
54691
  ...after,
54538
54692
  fixed: changed && after.status !== "fail"
54539
54693
  };
54540
- if (changed && after.status === "fail") result.detail = `${after.detail} (A repair was applied but did not resolve this.)`;
54694
+ if (changed && after.status === "fail") result.detail = id === "claude.hook.stop" && options.profiles ? `${after.detail} (Only the selected profiles were repaired.)` : `${after.detail} (A repair was applied but did not resolve this.)`;
54541
54695
  } catch (error) {
54542
54696
  result = {
54543
54697
  ...result,
@@ -54569,10 +54723,25 @@ async function runMachineDoctor(options) {
54569
54723
  }
54570
54724
  //#endregion
54571
54725
  //#region src/oclif/commands/machine-doctor.ts
54726
+ const ITEM_LINES = 10;
54727
+ /** At most `limit` indented lines, then "and N more". */
54728
+ function capped(lines, limit = ITEM_LINES) {
54729
+ const shown = lines.slice(0, limit).map((line) => ` ${line}`);
54730
+ if (lines.length > limit) shown.push(` and ${lines.length - limit} more`);
54731
+ return shown;
54732
+ }
54572
54733
  function renderCheck(check) {
54573
54734
  const tag = check.status === "ok" ? "[OK] " : check.status === "warn" ? "[WARN]" : check.status === "skip" ? "[SKIP]" : "[FAIL]";
54574
54735
  const fixed = check.fixed ? " (fixed)" : "";
54575
- return `${tag} ${check.id}${fixed}: ${check.detail}`;
54736
+ const lines = [`${tag} ${check.id}${fixed}: ${check.detail}`];
54737
+ if (check.changes?.length) lines.push(" Changed:", ...capped(check.changes.map(describeSessionHookChange)));
54738
+ if (check.status !== "ok" && check.items?.length) {
54739
+ const selected = check.items.filter((item) => item.selected !== false);
54740
+ const excluded = check.items.filter((item) => item.selected === false);
54741
+ if (selected.length) lines.push(check.changes?.length ? " Still needing repair:" : " Would change:", ...capped(selected.map(describeSessionHookItem)));
54742
+ if (excluded.length) lines.push(" Not selected (left unchanged by --profile):", ...capped(excluded.map(describeSessionHookItem)));
54743
+ }
54744
+ return lines.join("\n");
54576
54745
  }
54577
54746
  var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54578
54747
  static summary = "Check, and optionally repair, this machine's Primitive setup for coding agents";
@@ -54582,6 +54751,7 @@ var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54582
54751
  "<%= config.bin %> machine doctor --json",
54583
54752
  "<%= config.bin %> machine doctor --fix --json",
54584
54753
  "<%= config.bin %> machine doctor --fix --check claude.hook.stop --json",
54754
+ "<%= config.bin %> machine doctor --fix --check claude.hook.stop --profile my-agent",
54585
54755
  "<%= config.bin %> machine doctor --runtime claude,codex --min-cli-version 1.38.0 --json"
54586
54756
  ];
54587
54757
  static flags = {
@@ -54591,6 +54761,10 @@ var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54591
54761
  multiple: true,
54592
54762
  options: [...DOCTOR_CHECK_IDS]
54593
54763
  }),
54764
+ profile: Flags.string({
54765
+ description: "With --fix, change per-session receive hooks only for this saved agent profile; repeatable. Naming a profile is also the only way --fix restores hooks for a profile bound to a session that already receives as another profile. Other checks are unaffected, so combine with --check claude.hook.stop to repair nothing else",
54766
+ multiple: true
54767
+ }),
54594
54768
  runtime: Flags.string({ description: "Comma-separated runtimes to check: claude, codex, omp. Defaults to every runtime found on this machine" }),
54595
54769
  "min-cli-version": Flags.string({ description: "Report cli.version as failing when this CLI is older than the given version" }),
54596
54770
  json: Flags.boolean({ description: "Print the report as JSON" })
@@ -54605,9 +54779,11 @@ var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54605
54779
  runtimes = new Set(names);
54606
54780
  }
54607
54781
  if (flags.check?.length && !flags.fix) this.error("--check selects which checks --fix repairs; add --fix.", { exit: 2 });
54782
+ if (flags.profile?.length && !flags.fix) this.error("--profile narrows what --fix repairs; add --fix.", { exit: 2 });
54608
54783
  const report = await runMachineDoctor({
54609
54784
  fix: flags.fix,
54610
54785
  only: flags.check?.length ? new Set(flags.check) : void 0,
54786
+ profiles: flags.profile?.length ? new Set(flags.profile) : void 0,
54611
54787
  runtimes,
54612
54788
  configDir: this.config.configDir,
54613
54789
  home: homedir(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primcli",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,