kenaz 0.3.20 → 0.3.22

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 (60) hide show
  1. package/core/.claude/commands/Ansuz.md +3 -0
  2. package/core/.claude/commands/architect.md +3 -0
  3. package/core/.claude/commands/incident.md +3 -0
  4. package/core/.claude/commands/review.md +3 -0
  5. package/core/.claude/commands/security.md +3 -0
  6. package/core/.claude/commands/spec.md +3 -0
  7. package/core/.claude/hooks/__tests__/peer-session-fill.test.js +37 -0
  8. package/core/.claude/hooks/_ansuz-conversation.js +32 -1
  9. package/core/.claude/hooks/peer-optin.test.js +74 -0
  10. package/core/.claude/hooks/peer-session-fill.js +38 -0
  11. package/core/.claude/hooks/turn-context.js +5 -4
  12. package/core/.claude/settings.json +9 -0
  13. package/core/.claude/skills/architect_agent/SKILL.md +1 -1
  14. package/core/.claude/skills/assessment_agent/SKILL.md +1 -1
  15. package/core/.claude/skills/code_reviewer/SKILL.md +1 -1
  16. package/core/.claude/skills/incident_response/SKILL.md +1 -1
  17. package/core/.claude/skills/security_agent/SKILL.md +1 -1
  18. package/core/.claude/skills/spec_optimizer/SKILL.md +1 -1
  19. package/core/.formation/bifrost/codex-hooks-repair.js +115 -0
  20. package/core/.formation/bifrost/codex-hooks-repair.test.js +94 -0
  21. package/core/.formation/bifrost/openai/codex-hook-shim.js +26 -7
  22. package/core/.formation/bifrost/openai/codex-hook-shim.test.js +2 -0
  23. package/core/.formation/bifrost/openai/codex-peer-pull.test.js +42 -0
  24. package/core/.formation/bifrost/provider-launch.js +2 -1
  25. package/core/.formation/bifrost/setup-codex.js +22 -12
  26. package/core/.specialist/Ansuz/RULES.md +7 -0
  27. package/core/KENAZ_CORE_VERSION +1 -1
  28. package/core/dashboard/scripts/install-kenaz-cli.js +456 -0
  29. package/core/dev/scripts/relay-admin-ui.js +154 -0
  30. package/core/dev/scripts/relay-admin-ui.test.js +112 -0
  31. package/core/dev/scripts/relay-admin.js +19 -10
  32. package/core/dev/scripts/skill-routing-replay.js +80 -0
  33. package/core/dev/scripts/skill-routing-replay.test.js +24 -0
  34. package/core/dev/specs/peer/codex-hook-shim-peer.test.js +17 -5
  35. package/core/plugins/kenaz/INVENTORY.md +1 -1
  36. package/core/plugins/kenaz/commands/Ansuz.md +3 -0
  37. package/core/plugins/kenaz/commands/architect.md +3 -0
  38. package/core/plugins/kenaz/commands/incident.md +3 -0
  39. package/core/plugins/kenaz/commands/review.md +3 -0
  40. package/core/plugins/kenaz/commands/security.md +3 -0
  41. package/core/plugins/kenaz/commands/spec.md +3 -0
  42. package/core/plugins/kenaz/guidance.md +1 -0
  43. package/core/plugins/kenaz/hooks/hooks.json +9 -0
  44. package/core/plugins/kenaz/hooks/kenaz-hook.js +1 -0
  45. package/core/plugins/kenaz/scripts/kenaz-mode.js +5 -0
  46. package/core/plugins/kenaz/scripts/sync.js +1 -1
  47. package/core/plugins/kenaz/scripts/ygg.js +40 -4
  48. package/core/plugins/kenaz/scripts/ygg.test.js +63 -3
  49. package/core/plugins/kenaz/skills/architect_agent/SKILL.md +1 -1
  50. package/core/plugins/kenaz/skills/assessment_agent/SKILL.md +1 -1
  51. package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +1 -1
  52. package/core/plugins/kenaz/skills/incident_response/SKILL.md +1 -1
  53. package/core/plugins/kenaz/skills/security_agent/SKILL.md +1 -1
  54. package/core/plugins/kenaz/skills/spec_optimizer/SKILL.md +1 -1
  55. package/core/plugins/kenaz-peer/hooks/register.test.ts +59 -2
  56. package/core/plugins/kenaz-peer/hooks/register.ts +24 -1
  57. package/lib/cli.js +4 -0
  58. package/package.json +6 -6
  59. package/core/plugins/kenaz/skills/feature_agent/SKILL.md +0 -289
  60. /package/core/{.claude → dev/archive}/skills/feature_agent/SKILL.md +0 -0
@@ -2,6 +2,7 @@
2
2
  const test = require('node:test');
3
3
  const assert = require('node:assert/strict');
4
4
  const fs = require('node:fs');
5
+ const realCrypto = require('node:crypto');
5
6
  const path = require('node:path');
6
7
  const vm = require('node:vm');
7
8
 
@@ -46,6 +47,7 @@ async function fixture({ project, cwd = path.resolve('hook-cwd'), reject = false
46
47
  } };
47
48
  if (id.endsWith('_peer-project-root.js')) return { resolvePeerProjectRoot: dir => dir };
48
49
  if (id === 'child_process') return { execFileSync: () => assert.fail('unexpected child') };
50
+ if (id === 'crypto') return realCrypto;
49
51
  effects.modules.push(id);
50
52
  if (id.endsWith('task-bridge.js')) return { handleHookEvent: async payload => {
51
53
  effects.hooks.push(payload);
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ // TASK_2465: Codex pulls peer mail by default when the server says the project's room is activated.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { wantsPeerPull } = require('./codex-hook-shim.js');
9
+
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-codex-room-'));
11
+ fs.mkdirSync(path.join(dir, '.kenaz'), { recursive: true });
12
+ fs.writeFileSync(path.join(dir, '.kenaz', 'project.yaml'), 'name: t\n');
13
+ // Committed repo config naming a room is NOT the activation.
14
+ fs.writeFileSync(path.join(dir, '.kenaz', 'yggdrasil.yaml'), 'yggdrasil_project: "room-a"\n');
15
+
16
+ const server = (cfg) => async (method, p) => { assert.equal(method, 'GET'); assert.ok(p.startsWith('/api/yggdrasil/config?projectPath=')); return cfg; };
17
+ const active = server({ effective_project_key: 'room-a', collaboration_projects: ['room-a'] });
18
+
19
+ test('activated room -> pulls without the flag', async () => {
20
+ assert.equal(await wantsPeerPull({}, ['node', 'shim'], dir, active), true);
21
+ });
22
+
23
+ test('room key present in repo config but not activated -> no pull', async () => {
24
+ assert.equal(await wantsPeerPull({}, [], dir, server({ effective_project_key: 'room-a', collaboration_projects: [] })), false);
25
+ assert.equal(await wantsPeerPull({}, [], dir, server({ effective_project_key: 'room-a', collaboration_projects: ['other'] })), false);
26
+ });
27
+
28
+ test('server down or odd answer fails closed', async () => {
29
+ assert.equal(await wantsPeerPull({}, [], dir, async () => null), false);
30
+ assert.equal(await wantsPeerPull({}, [], dir, async () => { throw new Error('x'); }), false);
31
+ assert.equal(await wantsPeerPull({}, [], dir, server({})), false);
32
+ });
33
+
34
+ test('KENAZ_PEER_PULL=0 wins over an activated room and --pull', async () => {
35
+ assert.equal(await wantsPeerPull({ KENAZ_PEER_PULL: '0' }, ['--pull'], dir, active), false);
36
+ });
37
+
38
+ test('KENAZ_PEER_PULL=1 and --pull still pull without a room', async () => {
39
+ const none = server({});
40
+ assert.equal(await wantsPeerPull({ KENAZ_PEER_PULL: '1' }, [], dir, none), true);
41
+ assert.equal(await wantsPeerPull({}, ['--pull'], dir, none), true);
42
+ });
@@ -45,7 +45,8 @@ function plan(argv, env = process.env, home = os.homedir(), live = null) {
45
45
  const key = p.key(env, home);
46
46
  if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
47
47
  const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
48
- KENAZ_PROVIDER: provider }; // tools (dispatch.js) tell the session runs on another vendor
48
+ KENAZ_PROVIDER: provider, // tools (dispatch.js) tell the session runs on another vendor
49
+ KENAZ_PEER_OPTIN: env.KENAZ_PEER_OPTIN || '1' }; // TASK_2465: launched sessions are peer collaborators; =0 opts out
49
50
  for (const k of p.unset) delete childEnv[k];
50
51
  return { bin: 'claude', args: rest, env: childEnv, provider, model: id, context: childEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS };
51
52
  }
@@ -138,6 +138,8 @@ function setup(options = {}) {
138
138
  const missingEv = missingHookEvents();
139
139
  log('dry-run: MCP ' + MCP_NAME + ' ' + (isMcpAlreadyRegistered() ? 'already registered' : 'would run codex mcp add ' + MCP_NAME + ' --url ' + MCP_URL));
140
140
  log('dry-run: native hooks ' + (missingEv.length === 0 ? 'already registered' : 'would append [[hooks.*]] for ' + missingEv.join(', ') + ' to ' + getCodexConfigPath()));
141
+ const stale = require('./codex-hooks-repair').describe(require('./codex-hooks-repair').scan(getCodexHome()));
142
+ if (stale) log('dry-run: ' + stale);
141
143
  log('dry-run: /kenaz prompt would be written to ' + path.join(getCodexHome(), 'prompts', 'kenaz.md'));
142
144
  return { success: true, action: 'dry_run' };
143
145
  }
@@ -173,6 +175,12 @@ function setup(options = {}) {
173
175
  }
174
176
 
175
177
 
178
+ // TASK_2460: drop Kenaz hook entries whose script is gone (hooks.json, config.toml) before the current ones are registered.
179
+ try {
180
+ const repairer = require('./codex-hooks-repair'), r = repairer.repair(getCodexHome());
181
+ if (r.json.length + r.toml.length) log('removed ' + (r.json.length + r.toml.length) + ' Kenaz hook entries pointing at missing files (backup: *.bak-<time>)');
182
+ } catch (err) { log('hook repair skipped: ' + err.message); }
183
+
176
184
  // TASK_1733: register native hooks when codex CLI supports them.
177
185
  // Graceful: if feature probe fails or hooks already registered, logs advisory and continues.
178
186
  const projectRoot = options.projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -324,18 +332,15 @@ function scanKenazHooks(text, projectRoot) {
324
332
  });
325
333
  return { segs, found };
326
334
  }
327
- /** config text without the kenaz handlers of `events` (and the empty group each one leaves behind). */
328
- function stripKenazHooks(text, events, projectRoot) {
329
- const { segs, found } = scanKenazHooks(text, projectRoot);
335
+ /** config text of `segs` without the handler segments `indexes` (and the empty group each one leaves behind). */
336
+ function dropHandlerSegments(segs, indexes) {
330
337
  const drop = new Set();
331
- for (const ev of events) {
332
- for (const i of [...(found[ev] ? found[ev].current : []), ...(found[ev] ? found[ev].stale : [])]) {
333
- drop.add(i);
334
- const prev = segs[i - 1], next = segs[i + 1];
335
- const g = prev && prev[0] && prev[0].match(GROUP_HEADER);
336
- if (g && g[1] === ev && !(next && next[0] && HANDLER_HEADER.test(next[0]) && next[0].includes('.' + ev + '.'))
337
- && prev[1].slice(1).every((l) => !l.trim())) drop.add(i - 1);
338
- }
338
+ for (const i of indexes) {
339
+ drop.add(i);
340
+ const ev = segs[i][0].match(HANDLER_HEADER)[1], prev = segs[i - 1], next = segs[i + 1];
341
+ const g = prev && prev[0] && prev[0].match(GROUP_HEADER);
342
+ if (g && g[1] === ev && !(next && next[0] && HANDLER_HEADER.test(next[0]) && next[0].includes('.' + ev + '.'))
343
+ && prev[1].slice(1).every((l) => !l.trim())) drop.add(i - 1);
339
344
  }
340
345
  const out = [];
341
346
  segs.forEach((seg, i) => {
@@ -344,6 +349,11 @@ function stripKenazHooks(text, events, projectRoot) {
344
349
  });
345
350
  return out.join('\n');
346
351
  }
352
+ /** config text without the kenaz handlers of `events`. */
353
+ function stripKenazHooks(text, events, projectRoot) {
354
+ const { segs, found } = scanKenazHooks(text, projectRoot);
355
+ return dropHandlerSegments(segs, events.flatMap((ev) => (found[ev] ? [...found[ev].current, ...found[ev].stale] : [])));
356
+ }
347
357
 
348
358
  function areHooksAlreadyRegistered(projectRoot) {
349
359
  const configPath = getCodexConfigPath();
@@ -743,7 +753,7 @@ function probeHttpMcp(urlStr) {
743
753
  module.exports = {
744
754
  setup, uninstall, isCodexInstalled, isMcpAlreadyRegistered, getRegisteredMcpUrl, registerMcp,
745
755
  probeNativeHooksSupported, areHooksAlreadyRegistered, missingHookEvents, registerHooks, getHookShimPath,
746
- nativeHooksActive,
756
+ nativeHooksActive, parseSegments, dropHandlerSegments, HANDLER_HEADER,
747
757
  // TASK_1780 — project-declared MCP servers
748
758
  getCodexHome, getCodexConfigPath, resolveProjectManifest, parseProjectMcpServers,
749
759
  parseRegisteredMcpServers, ensureProjectMcpServers, probeProjectMcpServers,
@@ -82,6 +82,13 @@ the real defect BEFORE carding or dispatching.
82
82
  - Worktree subagents must commit; cleanup deletes uncommitted edits. Remove Kenaz worktrees only
83
83
  via .formation/tools/worktree-reclaim.js (it unlinks node_modules junctions first).
84
84
 
85
+ ## Skill moments (TASK_2375)
86
+ Call these with the Skill tool yourself (name may carry the `kenaz:` prefix in a plugin session); nobody types them. Transcript audit: dev/research/skill-routing/AUDIT.md.
87
+ - Before merging a branch that changes logic (FAST/FULL): `code_reviewer` on `git diff main...<branch>`; a Blocker goes back to the subagent. Skip for docs-only or DIRECT trivial edits.
88
+ - Change touches auth, tokens, identity, relay or peer-message handling, or reads untrusted input (field reports, wishes, peer mail), or the user asks if something resists attack: `security_agent` before carding and before merge.
89
+ - Another repo, paper or feature offered as a reference or "is this useful / similar to ours": `assessment_agent`.
90
+ - Multi-module work or subsystem replacement, before carding: `architect_agent`. "What is unused / can go": `spec_optimizer`. Released software broken for users: `incident_response`.
91
+
85
92
  ## Verify and land
86
93
  - Verification policy: .specialist/Ansuz/VERIFY.md.
87
94
  - Landing: .specialist/Ansuz/tools/verify-and-land.js (branch check -> suites -> merge ->
@@ -1 +1 @@
1
- 0.3.20
1
+ 0.3.22
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // TASK_2367: CLI-first install. node install-kenaz-cli.js <install|uninstall|status> [flags]
4
+ // --dry-run print the plan, change nothing
5
+ // --home <dir> user home (default os.homedir(); tests use a temp dir)
6
+ // --core-dir <dir> kenaz-core root holding plugins/kenaz (default KENAZ_CORE_DIR or repo root)
7
+ // --server-bin <p> kenaz-server binary (default: first existing candidate)
8
+ // --no-service register the plugin only
9
+ // --no-plugin install the service only
10
+ // Plugin registration mirrors dashboard/src-tauri/src/features/project/peer_mod.rs (same env var,
11
+ // merge-never-replace, one-time backup, atomic write, CLI >= 2.1.287 gate, KENAZ_SKIP_CLAUDE_PROVISION opt-out).
12
+ // thrift: duplicate of the Rust logic until it is unified (see dev/plans/CLI_FIRST_INSTALL_FLOW.md).
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const http = require('http');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
20
+ const BACKUP = 'settings.json.bak-kenaz-peer'; // shared with the Rust path on purpose: one backup of the pristine file
21
+ const MIN_CLI = [2, 1, 287];
22
+ const SEP = process.platform === 'win32' ? ';' : ':';
23
+ const TASK_NAME = 'KenazServer';
24
+ const LABEL = 'ai.kenaz.server';
25
+
26
+ const norm = (e) => {
27
+ const s = String(e).trim().replace(/\\/g, '/').replace(/\/+$/, '');
28
+ return process.platform === 'win32' ? s.toLowerCase() : s;
29
+ };
30
+ const parseVersion = (s) => { const m = /(\d+)\.(\d+)\.(\d+)/.exec(s || ''); return m ? m.slice(1, 4).map(Number) : null; };
31
+ const cmpVersion = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i]; return 0; };
32
+
33
+ function loadSettings(file) {
34
+ let txt;
35
+ try { txt = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return {}; throw new Error('cannot read settings.json: ' + e.message); }
36
+ if (!txt.trim()) return {};
37
+ let v;
38
+ try { v = JSON.parse(txt); } catch (e) { throw new Error('settings.json is not valid JSON (' + e.message + '); left untouched'); }
39
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('settings.json is not a JSON object; left untouched');
40
+ return v;
41
+ }
42
+ function entriesOf(v) {
43
+ if (v.env === undefined) return [];
44
+ if (!v.env || typeof v.env !== 'object' || Array.isArray(v.env)) throw new Error('settings.json `env` is not an object; left untouched');
45
+ const s = v.env[ENV_KEY];
46
+ if (s === undefined) return [];
47
+ if (typeof s !== 'string') throw new Error('env.' + ENV_KEY + ' is not a string; left untouched');
48
+ return s.split(SEP).map((x) => x.trim()).filter(Boolean);
49
+ }
50
+ function saveSettings(file, v) {
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ const bak = path.join(path.dirname(file), BACKUP);
53
+ if (fs.existsSync(file) && !fs.existsSync(bak)) fs.copyFileSync(file, bak);
54
+ const tmp = file + '.kenaz-tmp';
55
+ fs.writeFileSync(tmp, JSON.stringify(v, null, 2));
56
+ fs.renameSync(tmp, file);
57
+ }
58
+ function setEntries(v, list) {
59
+ v.env = v.env || {};
60
+ if (list.length) v.env[ENV_KEY] = list.join(SEP); else delete v.env[ENV_KEY];
61
+ }
62
+ const isWorktreePath = (d) => /(^|\/)\.kenaz-worktrees(\/|$)/.test(norm(d));
63
+ // TASK_2384: a stale copy is another checkout of the same plugin (.../plugins/<name>): release core, worktree.
64
+ const staleCopy = (e, dir) => norm(e) !== norm(dir) && norm(e).endsWith('/plugins/' + norm(dir).split('/').pop());
65
+ /** One path per Kenaz plugin: older copies are replaced in place, never appended. A worktree checkout is
66
+ * never registered in user settings. @returns 'added'|'already'|'replaced' */
67
+ function registerPlugin(file, dir, dry) {
68
+ if (isWorktreePath(dir)) throw new Error('refusing to register a .kenaz-worktrees path in user settings: ' + dir);
69
+ const v = loadSettings(file), list = entriesOf(v);
70
+ const mine = (e) => norm(e) === norm(dir) || staleCopy(e, dir);
71
+ const has = list.some((e) => norm(e) === norm(dir));
72
+ if (has && list.filter(mine).length === 1) return 'already';
73
+ const first = list.findIndex(mine);
74
+ if (first < 0) { if (!dry) { list.push(dir); setEntries(v, list); saveSettings(file, v); } return 'added'; }
75
+ const next = list.map((e, i) => (i === first ? dir : e)).filter((e, i) => i === first || !mine(e));
76
+ if (!dry) { setEntries(v, next); saveSettings(file, v); }
77
+ return 'replaced';
78
+ }
79
+ /** @returns 'removed'|'absent' */
80
+ function unregisterPlugin(file, dir, dry) {
81
+ if (!fs.existsSync(file)) return 'absent';
82
+ const v = loadSettings(file), list = entriesOf(v);
83
+ const kept = list.filter((e) => norm(e) !== norm(dir));
84
+ if (kept.length === list.length) return 'absent';
85
+ if (!dry) { setEntries(v, kept); saveSettings(file, v); }
86
+ return 'removed';
87
+ }
88
+
89
+ // Status line with the project's task counts (plugins/kenaz/scripts/statusline.js). Set only when the user has no
90
+ // statusLine; a user's own is never replaced. Ours is recognised by the script path, so a moved core is updated.
91
+ const STATUSLINE_TAIL = 'plugins/kenaz/scripts/statusline.js';
92
+ const isOurStatusline = (s) => !!(s && typeof s.command === 'string' && norm(s.command).includes(STATUSLINE_TAIL));
93
+ /** @returns 'added'|'already'|'replaced'|'kept-user' */
94
+ function registerStatusline(file, core, dry) {
95
+ const v = loadSettings(file);
96
+ const command = 'node "' + path.join(core, ...STATUSLINE_TAIL.split('/')) + '"';
97
+ if (v.statusLine && !isOurStatusline(v.statusLine)) return 'kept-user';
98
+ if (v.statusLine && norm(v.statusLine.command) === norm(command)) return 'already';
99
+ const r = v.statusLine ? 'replaced' : 'added';
100
+ if (!dry) { v.statusLine = { type: 'command', command }; saveSettings(file, v); }
101
+ return r;
102
+ }
103
+ /** @returns 'removed'|'absent' */
104
+ function unregisterStatusline(file, dry) {
105
+ if (!fs.existsSync(file)) return 'absent';
106
+ const v = loadSettings(file);
107
+ if (!isOurStatusline(v.statusLine)) return 'absent';
108
+ if (!dry) { delete v.statusLine; saveSettings(file, v); }
109
+ return 'removed';
110
+ }
111
+
112
+ // TASK_2384: Dashboard-era user-level hooks (wrapped by .claude/hooks/_user-scope.js, direct .claude/hooks/*.js, or the
113
+ // agent-flow curl/node posts) duplicate what plugins/kenaz/hooks/hooks.json now runs. Single owner = the plugin, so
114
+ // once it is registered these are removed (first write backs up the pristine file to settings.json.bak-kenaz-peer).
115
+ const DASH_HOOK = /_user-scope\.js|[\\/]\.claude[\\/]hooks[\\/][\w.-]+\.js|\/api\/rust\/agent-flow\/hook/;
116
+ /** @returns number of hook commands retired */
117
+ function retireDashboardHooks(file, dry) {
118
+ if (!fs.existsSync(file)) return 0;
119
+ const v = loadSettings(file);
120
+ if (!v.hooks || typeof v.hooks !== 'object' || Array.isArray(v.hooks)) return 0;
121
+ let n = 0;
122
+ for (const ev of Object.keys(v.hooks)) {
123
+ if (!Array.isArray(v.hooks[ev])) continue;
124
+ const groups = [];
125
+ for (const g of v.hooks[ev]) {
126
+ if (!g || !Array.isArray(g.hooks)) { groups.push(g); continue; }
127
+ const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && DASH_HOOK.test(h.command)));
128
+ n += g.hooks.length - kept.length;
129
+ if (kept.length) groups.push(kept.length === g.hooks.length ? g : { ...g, hooks: kept });
130
+ }
131
+ if (groups.length) v.hooks[ev] = groups; else delete v.hooks[ev];
132
+ }
133
+ if (n && !dry) { if (!Object.keys(v.hooks).length) delete v.hooks; saveSettings(file, v); }
134
+ return n;
135
+ }
136
+
137
+ function claudeVersion() {
138
+ const r = process.platform === 'win32' // one string with shell (.cmd shim): args + shell:true is deprecated (DEP0190)
139
+ ? spawnSync('claude --version', { encoding: 'utf8', shell: true, timeout: 15000 })
140
+ : spawnSync('claude', ['--version'], { encoding: 'utf8', timeout: 15000 });
141
+ return r.status === 0 ? parseVersion(r.stdout) : null;
142
+ }
143
+
144
+ // ---- service definitions (pure; the OS calls are in service* below) ----
145
+ function launcherPaths(home) {
146
+ const d = path.join(home, '.kenaz');
147
+ return { dir: d, vbs: path.join(d, 'kenaz-server.vbs'), log: path.join(d, 'kenaz-server.log') };
148
+ }
149
+ const q = (s) => '"' + s + '"';
150
+ function winVbs(bin, core) { // hidden window, env for task tools; server binds loopback by default
151
+ return 'Set sh = WScript.CreateObject("WScript.Shell")\r\nsh.Environment("Process")("KENAZ_SOURCE_DIR") = "' + core + '"\r\n' +
152
+ 'sh.Run """' + bin + '""", 0, False\r\n';
153
+ }
154
+ // launchd and systemd start the server with a bare PATH (/usr/bin:/bin...), so `node` from Homebrew,
155
+ // nvm or /usr/local was not found and every MCP tool that runs a node script failed with
156
+ // "No such file or directory" (field report, macOS 0.3.1). Put the node running this install first.
157
+ function servicePath(nodeExe = process.execPath) {
158
+ return [path.dirname(nodeExe), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']
159
+ .filter((d, i, a) => a.indexOf(d) === i).join(':');
160
+ }
161
+ function launchdPlist(bin, core, log, nodeExe) {
162
+ return '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n' +
163
+ ' <key>Label</key><string>' + LABEL + '</string>\n <key>ProgramArguments</key><array><string>' + bin + '</string></array>\n' +
164
+ ' <key>EnvironmentVariables</key><dict><key>KENAZ_SOURCE_DIR</key><string>' + core + '</string><key>PATH</key><string>' + servicePath(nodeExe) + '</string></dict>\n' +
165
+ ' <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><true/>\n <key>StandardOutPath</key><string>' + log + '</string>\n <key>StandardErrorPath</key><string>' + log + '</string>\n</dict></plist>\n';
166
+ }
167
+ function systemdUnit(bin, core, nodeExe) {
168
+ return '[Unit]\nDescription=Kenaz server (headless backend)\nAfter=network.target\n\n[Service]\nExecStart=' + bin + '\nEnvironment=KENAZ_SOURCE_DIR=' + core + '\nEnvironment=PATH=' + servicePath(nodeExe) + '\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n';
169
+ }
170
+
171
+ // TASK_2413: no usable systemd user session (containers, WSL1, minimal servers) -> `systemctl --user enable --now`
172
+ // silently does nothing. Fall back to an @reboot crontab entry ('cron') or, without crontab, a manual start ('manual').
173
+ const CRON_MARK = '# kenaz-server (managed by kenaz install)';
174
+ const shq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
175
+ function cronStartCmd(bin, core, log, nodeExe) {
176
+ return 'KENAZ_SOURCE_DIR=' + shq(core) + ' PATH=' + shq(servicePath(nodeExe)) + ' ' + shq(bin) + ' >> ' + shq(log) + ' 2>&1';
177
+ }
178
+ function cronLine(bin, core, log, nodeExe) { // % is special in crontab
179
+ return '@reboot ' + cronStartCmd(bin, core, log, nodeExe).replace(/%/g, '\\%') + ' ' + CRON_MARK;
180
+ }
181
+ // stop exactly this binary (never by process name: another install's kenaz-server must survive). An upgraded
182
+ // process still runs the unlinked inode, which /proc reports as "<path> (deleted)".
183
+ function stopCmd(plan) {
184
+ const b = shq(plan.bin);
185
+ return 'for p in $(pgrep -x kenaz-server); do e=$(readlink /proc/$p/exe); ' +
186
+ '[ "$e" = ' + b + ' ] || [ "$e" = ' + shq(plan.bin + ' (deleted)') + ' ] && kill $p; done; true';
187
+ }
188
+ // start now, detached from the installer (new session when setsid exists), output to the log
189
+ function detachedStartCmd(plan) {
190
+ return 'if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi; ' +
191
+ 'KENAZ_SOURCE_DIR=' + shq(plan.core) + ' PATH=' + shq(servicePath()) + ' $S nohup ' + shq(plan.bin) + ' >> ' + shq(plan.log) + ' 2>&1 < /dev/null &';
192
+ }
193
+
194
+ function servicePlan(home, bin, core, platform = process.platform, caps = { systemd: true }) {
195
+ const L = launcherPaths(home);
196
+ if (platform === 'win32') {
197
+ const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
198
+ const startup = path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs');
199
+ return { kind: 'windows', files: [[L.vbs, winVbs(bin, core)]], fallbackFile: startup };
200
+ }
201
+ if (platform === 'darwin') {
202
+ const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
203
+ return { kind: 'launchd', files: [[p, launchdPlist(bin, core, L.log)]], unit: p };
204
+ }
205
+ if (caps.systemd === false) {
206
+ return { kind: caps.crontab ? 'cron' : 'manual', files: [], bin, core, log: L.log, cronLine: cronLine(bin, core, L.log) };
207
+ }
208
+ const p = path.join(home, '.config', 'systemd', 'user', 'kenaz-server.service');
209
+ return { kind: 'systemd', files: [[p, systemdUnit(bin, core)]], unit: p };
210
+ }
211
+ const sh = (cmd, args, opts) => spawnSync(cmd, args, { encoding: 'utf8', ...(opts || {}) });
212
+
213
+ // crontab -l: null = no crontab binary; '' = binary present, no crontab yet. All through the injected `run`.
214
+ function readCron(run) {
215
+ const r = run('crontab', ['-l']);
216
+ if (!r || r.error || r.status === 127) return null;
217
+ return r.status === 0 ? String(r.stdout || '') : '';
218
+ }
219
+ function probeLinux(run) {
220
+ const r = run('systemctl', ['--user', 'show-environment']);
221
+ const systemd = !!r && !r.error && r.status === 0;
222
+ return { systemd, crontab: systemd ? undefined : readCron(run) !== null };
223
+ }
224
+ function writeCron(run, current, line) { // line null = remove our entry; keeps every other line; idempotent
225
+ const keep = current.split('\n').filter((l) => l.trim() && !l.includes(CRON_MARK));
226
+ const next = line ? keep.concat(line) : keep;
227
+ const text = next.length ? next.join('\n') + '\n' : '';
228
+ if (text === current) return false;
229
+ if (text) run('crontab', ['-'], { input: text }); else run('crontab', ['-r']);
230
+ return true;
231
+ }
232
+
233
+ // TASK_2418: loading/enabling a unit that is already loaded/active is a no-op, so an upgrade must restart it
234
+ // explicitly, after the new binary is in place. launchd KeepAlive respawns the OLD file in the window between
235
+ // stopOwnServer() and the copy (the process then keeps the unlinked inode); systemd `enable --now` does not
236
+ // restart an active unit. restartHint is also the command printed when the version check fails.
237
+ const defaultUid = () => (typeof process.getuid === 'function' ? process.getuid() : 501);
238
+ function restartHint(plan, uid = defaultUid()) {
239
+ if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
240
+ if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
241
+ if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
242
+ // the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
243
+ return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
244
+ }
245
+ function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
246
+ if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
247
+ log('start now (detached, log ' + plan.log + ')');
248
+ if (!dry) { fs.mkdirSync(path.dirname(plan.log), { recursive: true }); run('sh', ['-c', detachedStartCmd(plan)]); }
249
+ return;
250
+ }
251
+ if (plan.kind === 'windows') return; // /Create + /Run above start the just-copied exe (stopOwnServer freed it)
252
+ log(restartHint(plan, uid));
253
+ if (dry) return;
254
+ if (plan.kind === 'launchd') {
255
+ const r = run('launchctl', ['kickstart', '-k', 'gui/' + uid + '/' + LABEL]);
256
+ if (r && r.status !== 0) { // old macOS / no GUI session domain: unload + load restarts it the long way
257
+ log('kickstart failed (exit ' + r.status + '), falling back to launchctl unload + load');
258
+ run('launchctl', ['unload', plan.unit]); run('launchctl', ['load', '-w', plan.unit]);
259
+ }
260
+ } else run('systemctl', ['--user', 'restart', 'kenaz-server']);
261
+ }
262
+
263
+ /** One GET /api/health; resolves { ok, version }. */
264
+ function healthOnce(port, getFn = http.get) {
265
+ return new Promise((resolve) => {
266
+ const req = getFn({ host: '127.0.0.1', port, path: '/api/health', timeout: 2000 }, (res) => {
267
+ let b = ''; res.on('data', (d) => { b += d; });
268
+ res.on('end', () => { let v = null; try { v = JSON.parse(b).version || null; } catch (_) { /* body optional */ } resolve({ ok: res.statusCode < 500, version: v }); });
269
+ });
270
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, version: null }); });
271
+ req.on('error', () => resolve({ ok: false, version: null }));
272
+ });
273
+ }
274
+ /**
275
+ * TASK_2418: after the service (re)start, ask the running server for its version and compare with the one just
276
+ * installed. Retries (the server needs a moment to bind). Returns 'ok' | 'mismatch' | 'unreachable'; the last two
277
+ * print a warning with the exact restart command and do not fail the install.
278
+ */
279
+ async function verifyRuntime(plan, expected, log, opts = {}) {
280
+ const port = opts.port || process.env.KENAZ_HTTP_PORT || '18080';
281
+ const tries = opts.tries || 6, delay = opts.delayMs === undefined ? 1000 : opts.delayMs;
282
+ const probe = opts.health || ((p) => healthOnce(p));
283
+ let last = { ok: false, version: null };
284
+ for (let i = 0; i < tries; i++) {
285
+ last = await probe(port);
286
+ if (last.ok && last.version === expected) { log('server: running version ' + expected + ' on :' + port); return 'ok'; }
287
+ if (i < tries - 1 && delay) await new Promise((r) => setTimeout(r, delay));
288
+ }
289
+ const hint = restartHint(plan, opts.uid);
290
+ if (last.ok && last.version) {
291
+ log('WARNING: the server on :' + port + ' still reports version ' + last.version + ' but ' + expected + ' was just installed. Restart it: ' + hint);
292
+ return 'mismatch';
293
+ }
294
+ log('WARNING: could not confirm the new server (' + expected + ') on 127.0.0.1:' + port + (last.ok ? ' (no version in /api/health)' : ' (no answer)') + '. If it is not running: ' + hint);
295
+ return 'unreachable';
296
+ }
297
+
298
+ function serviceInstall(plan, dry, log, run = sh, uid) {
299
+ const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
300
+ for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
301
+ if (plan.kind === 'windows') {
302
+ const tr = 'wscript.exe ' + q(plan.files[0][0]);
303
+ log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + plan.fallbackFile + ')');
304
+ if (dry) return;
305
+ const r = sh('schtasks', ['/Create', '/TN', TASK_NAME, '/SC', 'ONLOGON', '/TR', tr, '/F']);
306
+ if (r.status === 0) sh('schtasks', ['/Run', '/TN', TASK_NAME]);
307
+ else { // ONLOGON tasks can need elevation: a Startup-folder launcher is the per-user equivalent
308
+ fs.mkdirSync(path.dirname(plan.fallbackFile), { recursive: true });
309
+ fs.copyFileSync(plan.files[0][0], plan.fallbackFile);
310
+ sh('wscript.exe', [plan.files[0][0]]);
311
+ // schtasks' stderr is in the console's OEM code page (garbled when echoed); the usual cause is
312
+ // missing admin rights for /SC ONLOGON. The Startup-folder launcher starts at logon the same way.
313
+ log('schtasks needs administrator rights here (exit ' + r.status + '); installed the Startup-folder launcher instead (also starts at logon)');
314
+ }
315
+ } else if (plan.kind === 'launchd') {
316
+ log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
317
+ serviceRestart(plan, dry, log, sh, uid);
318
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
319
+ if (plan.kind === 'cron') {
320
+ log('crontab: @reboot entry ' + plan.cronLine);
321
+ if (!dry) { const cur = readCron(sh); if (cur !== null) writeCron(sh, cur, plan.cronLine); }
322
+ } else {
323
+ log('WARNING: no systemd user session and no crontab here. Add this to your own init/boot script to start the server at boot: ' + cronStartCmd(plan.bin, plan.core, plan.log));
324
+ }
325
+ serviceRestart(plan, dry, log, sh, uid);
326
+ } else {
327
+ log('systemctl --user enable --now kenaz-server');
328
+ if (!dry) { sh('systemctl', ['--user', 'daemon-reload']); sh('systemctl', ['--user', 'enable', '--now', 'kenaz-server']); }
329
+ serviceRestart(plan, dry, log, sh, uid);
330
+ }
331
+ }
332
+ function serviceUninstall(plan, dry, log, run = sh) {
333
+ if (plan.kind === 'windows') {
334
+ log('schtasks /End + /Delete /TN ' + TASK_NAME);
335
+ if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
336
+ if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
337
+ } else if (plan.kind === 'launchd') {
338
+ log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
339
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
340
+ if (plan.kind === 'cron') { log('crontab: remove the kenaz-server @reboot entry'); if (!dry) { const cur = readCron(run); if (cur !== null) writeCron(run, cur, null); } }
341
+ log('stop ' + plan.bin); if (!dry) run('sh', ['-c', stopCmd(plan)]);
342
+ } else {
343
+ log('systemctl --user disable --now kenaz-server'); if (!dry) run('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
344
+ }
345
+ for (const [f] of plan.files) if (fs.existsSync(f)) { log('remove ' + f); if (!dry) fs.rmSync(f, { force: true }); }
346
+ }
347
+ function serviceStatus(plan, run = sh) {
348
+ if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
349
+ if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
350
+ if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
351
+ if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
352
+ return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
353
+ }
354
+
355
+ /** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
356
+ function detectServiceMode(home, core, run = sh, platform = process.platform) {
357
+ if (platform === 'win32') {
358
+ const plan = servicePlan(home, '', core, 'win32');
359
+ if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
360
+ if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
361
+ return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
362
+ }
363
+ if (platform === 'darwin') {
364
+ const p = servicePlan(home, '', core, 'darwin').unit;
365
+ return fs.existsSync(p) ? { mode: 'launchd', detail: p } : { mode: 'manual', detail: 'no launchd agent; run `kenaz install`' };
366
+ }
367
+ const unit = servicePlan(home, '', core, 'linux').unit;
368
+ if (fs.existsSync(unit) && probeLinux(run).systemd) return { mode: 'systemd', detail: unit };
369
+ const c = readCron(run);
370
+ if (c && c.includes(CRON_MARK)) return { mode: 'cron', detail: '@reboot crontab entry (no systemd user session)' };
371
+ return { mode: 'manual', detail: 'no systemd unit or cron entry; run `kenaz install`' };
372
+ }
373
+
374
+ function parseArgs(argv) {
375
+ const o = { cmd: argv[0], flags: {} };
376
+ for (let i = 1; i < argv.length; i++) {
377
+ const a = argv[i];
378
+ if (!a.startsWith('--')) continue;
379
+ const k = a.slice(2);
380
+ if (['home', 'core-dir', 'server-bin'].includes(k)) o.flags[k] = argv[++i]; else o.flags[k] = true;
381
+ }
382
+ return o;
383
+ }
384
+ function findServerBin(core, home) {
385
+ const exe = process.platform === 'win32' ? 'kenaz-server.exe' : 'kenaz-server';
386
+ const c = [path.join(core, '..', exe), path.join(core, 'bin', exe), path.join(core, 'dashboard', 'src-tauri', 'target', 'release', exe), path.join(home, '.kenaz', 'bin', exe)];
387
+ return c.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) || null;
388
+ }
389
+
390
+ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) { // getVersion, deps {sh, platform, uid, onPlan}: injectable for tests
391
+ const { cmd, flags } = parseArgs(argv);
392
+ if (!['install', 'uninstall', 'status'].includes(cmd)) {
393
+ out('usage: install-kenaz-cli.js <install|uninstall|status> [--dry-run] [--home d] [--core-dir d] [--server-bin p] [--no-service] [--no-plugin]');
394
+ return 2;
395
+ }
396
+ const dry = !!flags['dry-run'];
397
+ const home = path.resolve(flags.home || os.homedir());
398
+ const core = path.resolve(flags['core-dir'] || process.env.KENAZ_CORE_DIR || path.join(__dirname, '..', '..'));
399
+ // TASK_2380: kenaz-peer (idle push) rides along; same gate and merge rules as plugins/kenaz.
400
+ const pluginDirs = ['kenaz', 'kenaz-peer'].map((n) => path.join(core, 'plugins', n));
401
+ const settings = path.join(home, '.claude', 'settings.json');
402
+ const log = (m) => out((dry ? '[dry-run] ' : '') + m);
403
+ let rc = 0;
404
+
405
+ if (!flags['no-plugin']) {
406
+ try {
407
+ let ver;
408
+ for (const pluginDir of pluginDirs) {
409
+ const name = path.basename(pluginDir);
410
+ if (cmd === 'status') {
411
+ const list = entriesOf(loadSettings(settings));
412
+ out(name + ': ' + (list.some((e) => norm(e) === norm(pluginDir)) ? 'registered' : 'not registered') + ' (' + pluginDir + ')');
413
+ } else if (cmd === 'uninstall') {
414
+ log(name + ': ' + unregisterPlugin(settings, pluginDir, dry) + ' (' + settings + ')');
415
+ if (name === 'kenaz') log('statusline: ' + unregisterStatusline(settings, dry));
416
+ } else if (process.env.KENAZ_SKIP_CLAUDE_PROVISION === '1') {
417
+ log(name + ': skipped (KENAZ_SKIP_CLAUDE_PROVISION=1)');
418
+ } else if (!fs.existsSync(path.join(pluginDir, 'hooks', 'hooks.json'))) {
419
+ log(name + ': skipped, ' + pluginDir + ' has no hooks/hooks.json'); rc = 1;
420
+ } else {
421
+ if (ver === undefined) ver = getVersion();
422
+ if (!ver) log(name + ': skipped, claude CLI not found (install it, then rerun install)');
423
+ else if (cmpVersion(ver, MIN_CLI) < 0) log(name + ': skipped, claude ' + ver.join('.') + ' < ' + MIN_CLI.join('.') + ' (run `claude update`, then rerun install)');
424
+ else {
425
+ log(name + ': ' + registerPlugin(settings, pluginDir, dry) + ' (' + settings + ')');
426
+ if (name === 'kenaz') {
427
+ const sl = registerStatusline(settings, core, dry);
428
+ log('statusline: ' + (sl === 'kept-user' ? 'kept your own statusLine; to show Kenaz task counts, append the output of: node "' + path.join(core, ...STATUSLINE_TAIL.split('/')) + '"' : sl));
429
+ const n = retireDashboardHooks(settings, dry);
430
+ if (n) log('kenaz: retired ' + n + ' Dashboard-era user-level hook(s), now owned by the plugin (backup: ' + path.join(path.dirname(settings), BACKUP) + ')');
431
+ }
432
+ }
433
+ }
434
+ }
435
+ } catch (e) { out('plugin: ERROR ' + e.message); rc = 1; }
436
+ }
437
+
438
+ if (!flags['no-service']) {
439
+ const bin = flags['server-bin'] ? path.resolve(flags['server-bin']) : findServerBin(core, home);
440
+ const run = deps.sh || sh, platform = deps.platform || process.platform;
441
+ // the probe is read-only but still a system call: skipped on --dry-run (assumes systemd, as before)
442
+ const caps = platform === 'linux' && !dry ? probeLinux(run) : undefined;
443
+ const plan = servicePlan(home, bin || '<kenaz-server not found>', core, platform, caps);
444
+ if (deps.onPlan) deps.onPlan(plan);
445
+ if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan, run)));
446
+ else if (cmd === 'uninstall') serviceUninstall(plan, dry, log, run);
447
+ else if (!bin) { out('service: skipped, kenaz-server binary not found (pass --server-bin)'); rc = 1; }
448
+ else serviceInstall(plan, dry, log, run, deps.uid);
449
+ }
450
+ if (cmd === 'install' && rc === 0) out('Next: cd <your project>; claude; /kenaz:kenaz init; /kenaz:Ansuz (Codex: run /hooks once to trust the kenaz hooks, then $kenaz status)');
451
+ if (cmd === 'uninstall') out('Project .kenaz/ data was left untouched.');
452
+ return rc;
453
+ }
454
+
455
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
456
+ module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };