@lorekit/cli 1.15.0 → 1.17.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
@@ -483,6 +483,16 @@ Both files share this schema — all fields optional:
483
483
  // values: "claude" | "cursor" | "codex"
484
484
  // repo wins over user
485
485
 
486
+ "hooks.instructions": {
487
+ "SessionStart": "Focus on migration safety. Treat any lesson tagged 'migration' as high-priority.",
488
+ "PostToolUseFailure": "When recording a failure, always include the exact command and exit code.",
489
+ "Stop": null
490
+ },
491
+ // per-event custom text appended to the hook output.
492
+ // both layers merged: repo instructions first, then user.
493
+ // null (or absent key) means no extra instruction for that event.
494
+ // values: string | null (keys: "SessionStart" | "PostToolUseFailure" | "Stop")
495
+
486
496
  // ── Telemetry ──────────────────────────────────────────────────────────────
487
497
  "telemetry.disabled": true,
488
498
  // team-level opt-out for orgs with a no-telemetry policy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/control.mjs CHANGED
@@ -137,6 +137,27 @@ export function resolveControl({
137
137
  (typeof userConfig['hooks.adapter'] === 'string' && userConfig['hooks.adapter'].trim()) ||
138
138
  null;
139
139
 
140
+ // `hooks.instructions` — per-event custom text appended to the hook output so
141
+ // teams can embed project-specific guidance directly into the injected context.
142
+ // Both layers contribute: repo instructions come first, user instructions follow
143
+ // (same direction as `tags.default` — repo supplements, user personalises).
144
+ // null for a given event means "no custom instruction for that event".
145
+ const HOOK_EVENTS = ['SessionStart', 'PostToolUseFailure', 'Stop'];
146
+ const hooksInstructions = {};
147
+ {
148
+ const repoInstr =
149
+ (repoConfig['hooks.instructions'] && typeof repoConfig['hooks.instructions'] === 'object')
150
+ ? repoConfig['hooks.instructions'] : {};
151
+ const userInstr =
152
+ (userConfig['hooks.instructions'] && typeof userConfig['hooks.instructions'] === 'object')
153
+ ? userConfig['hooks.instructions'] : {};
154
+ for (const ev of HOOK_EVENTS) {
155
+ const parts = [repoInstr[ev], userInstr[ev]]
156
+ .filter((v) => typeof v === 'string' && v.trim().length > 0);
157
+ hooksInstructions[ev] = parts.length > 0 ? parts.join('\n') : null;
158
+ }
159
+ }
160
+
140
161
  return {
141
162
  mode: chosen.mode,
142
163
  storeTarget,
@@ -147,6 +168,7 @@ export function resolveControl({
147
168
  scopeDefaults,
148
169
  hooksDisabled,
149
170
  hooksAdapter,
171
+ hooksInstructions,
150
172
  };
151
173
  }
152
174
 
@@ -51,19 +51,56 @@ export async function fetchLessons(store, cwd) {
51
51
  return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
52
52
  }
53
53
 
54
- // Render lessons as a compact markdown block, or null when there are none.
55
- export function formatLessons(lessons, scope) {
56
- if (!lessons || lessons.length === 0) return null;
54
+ // Cap on a lesson's one-line hook in the injected index. Long enough to jog
55
+ // recognition, short enough that N lessons stay a scannable list, not a wall —
56
+ // the full text is always one `memory.read` away.
57
+ const HOOK_LEN = 80;
58
+
59
+ // A lesson's first meaningful line, cleaned into a short recognisable hook:
60
+ // skips leading HTML-comment metadata (`<!-- ... -->`) and markdown heading
61
+ // marks, collapses whitespace, and truncates on a word boundary with an
62
+ // ellipsis — so nothing is ever cut mid-word into noise like "cascades to GE".
63
+ function lessonHook(value, max = HOOK_LEN) {
64
+ let first = '';
65
+ for (const raw of String(value || '').split('\n')) {
66
+ const line = raw.trim();
67
+ if (!line || line.startsWith('<!--')) continue; // skip blanks + meta comments
68
+ first = line.replace(/^#+\s*/, ''); // strip markdown heading marks
69
+ if (first) break;
70
+ }
71
+ first = first.replace(/\s+/g, ' ').trim();
72
+ if (first.length <= max) return first;
73
+ const clipped = first.slice(0, max);
74
+ const lastSpace = clipped.lastIndexOf(' ');
75
+ return `${(lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
76
+ }
77
+
78
+ // Render the SessionStart block as a compact INDEX — one terse line per lesson
79
+ // (scope, key, and a short hook), never the full bodies. This mirrors the
80
+ // lorekit-memory intake rule ("report briefly") and the MEMORY.md index pattern:
81
+ // surface WHAT is known so the agent can `memory.read` the one lesson that turns
82
+ // out to matter, instead of paying for every body up front. Null when empty.
83
+ // `instruction` — an optional extra line appended after the index, sourced from
84
+ // `hooks.instructions.SessionStart` in the control config. Lets teams inject
85
+ // project-specific guidance (e.g. "focus on migration safety") without touching
86
+ // the hook internals. Visible even when there are no lessons.
87
+ export function formatLessons(lessons, scope, { instruction = null } = {}) {
88
+ const noun = lessons && lessons.length === 1 ? 'memory' : 'memories';
89
+ if (!lessons || lessons.length === 0) {
90
+ // No lessons — only emit if there is a custom instruction to show.
91
+ if (!instruction) return null;
92
+ return (
93
+ `LoreKit: 0 ${noun} loaded · ${scope.repoScope || 'this workspace'} ` +
94
+ `— considerations, not rules; read any in full with memory.read.\n\n` +
95
+ `Project instruction: ${instruction}`
96
+ );
97
+ }
57
98
  const header =
58
- `LoreKit ${lessons.length} shared ${lessons.length === 1 ? 'memory' : 'memories'} for ${scope.repoScope || 'this workspace'}. ` +
59
- `Treat as considerations, not rules; trust the current code if they conflict.`;
60
- const body = lessons
61
- .map((l) => {
62
- const first = String(l.value || '').split('\n')[0].slice(0, 300);
63
- return `- (${l.scope}) ${l.key}: ${first}`;
64
- })
65
- .join('\n');
66
- return `${header}\n${body}`;
99
+ `LoreKit: ${lessons.length} ${noun} loaded · ${scope.repoScope || 'this workspace'} ` +
100
+ `— considerations, not rules; read any in full with memory.read.`;
101
+ const body = lessons.map((l) => `- (${l.scope}) ${l.key} — ${lessonHook(l.value)}`).join('\n');
102
+ const instructionBlock = instruction ? `\n\nProject instruction: ${instruction}` : '';
103
+ return `${header}\n${body}${instructionBlock}`;
67
104
  }
68
105
 
69
106
  // Distil a small set of significant, lowercased search TERMS from a tool
@@ -104,19 +141,16 @@ export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
104
141
  }
105
142
 
106
143
  // Render the relevant-lessons block injected alongside the failure nudge, or
107
- // null when nothing matched. Framed as prior art, not a directive — same
108
- // "considerations, not rules" stance as `formatLessons`.
144
+ // null when nothing matched. Same compact-index shape as `formatLessons`, with a
145
+ // touch more hook per line (there are at most MAX_RELEVANT and they're directly
146
+ // actionable). Framed as prior art, not a directive.
109
147
  export function formatRelevantLessons(lessons) {
110
148
  if (!lessons || lessons.length === 0) return null;
149
+ const noun = lessons.length === 1 ? 'memory' : 'memories';
111
150
  const header =
112
- `LoreKit — you've hit something like this before. ${lessons.length} related ` +
113
- `${lessons.length === 1 ? 'memory' : 'memories'} (considerations, not rules; trust the current code if they conflict):`;
114
- const body = lessons
115
- .map((l) => {
116
- const first = String(l.value || '').split('\n')[0].slice(0, 300);
117
- return `- (${l.scope}) ${l.key}: ${first}`;
118
- })
119
- .join('\n');
151
+ `LoreKit: ${lessons.length} related ${noun} — you've hit something like this before ` +
152
+ `(considerations, not rules; read in full with memory.read):`;
153
+ const body = lessons.map((l) => `- (${l.scope}) ${l.key} — ${lessonHook(l.value, 140)}`).join('\n');
120
154
  return `${header}\n${body}`;
121
155
  }
122
156
 
@@ -146,12 +180,12 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
146
180
  export function retrospectiveNudge(scope, control) {
147
181
  const writeScope = scope.repoScope || 'global';
148
182
  const hint = tagsHint(writeScope, control);
183
+ const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
184
+ ? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
149
185
  return (
150
- 'LoreKit retrospective: if this session hit a stuck loop, a repeated ' +
151
- 'command failure, a surprising gotcha, a near-miss, or a wrong assumption ' +
152
- 'that cost time, record it now via the lorekit-memory skill ' +
153
- `(memory.write to ${writeScope}, phrased as an observation).${hint} ` +
154
- 'If nothing was durable, do nothing.'
186
+ `LoreKit: hit any friction worth remembering — a stuck loop, a repeated ` +
187
+ `failure, a gotcha, a wrong assumption? If so, memory.write to ${writeScope} ` +
188
+ `as an observation; else skip.${hint}${instruction}`
155
189
  );
156
190
  }
157
191
 
@@ -161,11 +195,11 @@ export function retrospectiveNudge(scope, control) {
161
195
  export function failureNudge(toolName, scope, control) {
162
196
  const writeScope = scope.repoScope || 'global';
163
197
  const hint = tagsHint(writeScope, control);
164
- const suffix = hint ? `${hint} So the next run avoids it.` : 'so the next run avoids it.';
198
+ const instruction = control && control.hooksInstructions && control.hooksInstructions.PostToolUseFailure
199
+ ? `\n\nProject instruction: ${control.hooksInstructions.PostToolUseFailure}` : '';
165
200
  return (
166
- `LoreKit: the last ${toolName} call failed. If this is a recurring or ` +
167
- 'non-obvious failure, consider recording the fix as a memory via ' +
168
- `lorekit-memory (memory.write to ${writeScope}) — ${suffix}`
201
+ `LoreKit: the last ${toolName} call failed. If it's recurring or non-obvious, ` +
202
+ `memory.write to ${writeScope} with the fix so the next run avoids it.${hint}${instruction}`
169
203
  );
170
204
  }
171
205
 
package/src/doctor.mjs CHANGED
@@ -88,7 +88,24 @@ export async function doctor(args) {
88
88
  record('warn', 'scope', 'no git remote here — memories fall back to global');
89
89
  }
90
90
 
91
- // 6. doctor.requirecommitted list of checks that MUST pass.
91
+ // 6. Hook instructions show resolved per-event custom instructions when any are set.
92
+ {
93
+ const instr = control.hooksInstructions || {};
94
+ const EVENTS = ['SessionStart', 'PostToolUseFailure', 'Stop'];
95
+ const configured = EVENTS.filter((ev) => instr[ev]);
96
+ if (configured.length > 0) {
97
+ for (const ev of EVENTS) {
98
+ const text = instr[ev];
99
+ if (text) {
100
+ record('info', `hooks.instructions.${ev}`, c.dim(text.length > 80 ? text.slice(0, 77) + '…' : text));
101
+ } else {
102
+ record('info', `hooks.instructions.${ev}`, c.dim('(not set)'));
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ // 7. doctor.require — committed list of checks that MUST pass.
92
109
  // Useful as a CI gate: any check in the list that did not pass causes a failure.
93
110
  const lorekitJson = readLorekitJson(root);
94
111
  const required = (Array.isArray(lorekitJson['doctor.require']) ? lorekitJson['doctor.require'] : [])
package/src/hook.mjs CHANGED
@@ -93,9 +93,19 @@ async function run(args) {
93
93
  if (intent === 'read') {
94
94
  if (!firstTimeThisSession(parsed.sessionId, 'read')) return 0;
95
95
  const store = createStore(control);
96
- if (!store) return 0; // unconfigured/unusable stay silent
96
+ // When there is no usable store, we still want to emit a custom instruction
97
+ // if one is configured — so we don't bail out entirely on a missing store.
98
+ const sessionInstruction = control.hooksInstructions && control.hooksInstructions.SessionStart
99
+ ? control.hooksInstructions.SessionStart : null;
100
+ if (!store) {
101
+ // No store: emit a minimal header + instruction when present, then return.
102
+ if (sessionInstruction) {
103
+ emit(formatLessons(null, { repoScope: null }, { instruction: sessionInstruction }));
104
+ }
105
+ return 0;
106
+ }
97
107
  const { scope: readScope, lessons } = await fetchLessons(store, root);
98
- emit(formatLessons(lessons, readScope));
108
+ emit(formatLessons(lessons, readScope, { instruction: sessionInstruction }));
99
109
  return 0;
100
110
  }
101
111
 
package/src/install.mjs CHANGED
@@ -15,21 +15,55 @@ import {
15
15
  resolveConnection,
16
16
  tokenKind,
17
17
  homeDir,
18
+ mcpConfigPath,
19
+ readJsonIfExists,
18
20
  } from './config.mjs';
19
- import { buildRemoteUrl } from './mcp.mjs';
21
+ import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
20
22
  import { deriveScope } from './scope.mjs';
21
- import { log, err, heading, status, select, c } from './util.mjs';
23
+ import { log, heading, status, select, c } from './util.mjs';
24
+
25
+ // The MCP server URL is fixed — there is only one hosted LoreKit endpoint.
26
+ const LOREKIT_MCP_ENDPOINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
22
27
 
23
28
  function ask(question) {
24
29
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
30
  return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
26
31
  }
27
32
 
28
- const DEFAULT_ENDPOINT_HINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
33
+ // Detect whether lorekit is already installed for a given scope. Returns an
34
+ // object describing what is present so the caller can give precise feedback.
35
+ function detectInstalled(root, scope) {
36
+ // Check if at least one skill is present.
37
+ const skillsPresent = SKILLS.filter((skill) =>
38
+ fs.existsSync(path.join(skillInstallDir(root, scope, skill.name), 'SKILL.md')),
39
+ );
40
+
41
+ // Check if the MCP server entry exists and extract any configured token.
42
+ // Use a try/catch so a corrupt config file degrades to "not installed"
43
+ // instead of throwing — the user sees a clear error when they actually
44
+ // try to write via upsertMcpServer, which uses the throwing readJsonIfExists.
45
+ const mcpFile = mcpConfigPath(root, scope);
46
+ let mcpConfig = null;
47
+ try { mcpConfig = readJsonIfExists(mcpFile); } catch { /* corrupt — treat as absent */ }
48
+ const mcpServer = mcpConfig && mcpConfig.mcpServers && mcpConfig.mcpServers.lorekit;
49
+ const serverArgs = mcpServer && Array.isArray(mcpServer.args) ? mcpServer.args : [];
50
+ const serverUrl = serverArgs.find((a) => typeof a === 'string' && /^https?:\/\//.test(a));
51
+ const existingToken = serverUrl ? splitEndpoint(serverUrl).token : null;
52
+
53
+ return {
54
+ hasSkills: skillsPresent.length > 0,
55
+ skillCount: skillsPresent.length,
56
+ totalSkills: SKILLS.length,
57
+ hasMcp: Boolean(mcpServer),
58
+ existingToken,
59
+ isFullyInstalled: skillsPresent.length === SKILLS.length && Boolean(mcpServer),
60
+ };
61
+ }
29
62
 
30
63
  export async function install(args) {
31
64
  const root = resolveProjectRoot(args.dir);
32
65
  const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
66
+ const force = Boolean(args.force);
33
67
 
34
68
  heading('LoreKit install');
35
69
  log(` project: ${c.dim(root)}`);
@@ -47,44 +81,105 @@ export async function install(args) {
47
81
  ]);
48
82
  }
49
83
  }
84
+
85
+ // 2. Already-installed detection — check both scopes so we can give accurate
86
+ // context ("installed globally but not for this project", etc.).
87
+ const projectState = detectInstalled(root, 'project');
88
+ const globalState = detectInstalled(root, 'global');
89
+ const currentState = scope === 'global' ? globalState : projectState;
90
+
91
+ if (currentState.isFullyInstalled && !force) {
92
+ // Surface a clear, useful already-installed summary.
93
+ log('');
94
+ log(
95
+ ` ${c.green('LoreKit is already installed')} for ${
96
+ scope === 'global'
97
+ ? 'all projects (global)'
98
+ : 'this project'
99
+ }.`,
100
+ );
101
+
102
+ // Cross-scope awareness: tell the user what's installed where.
103
+ if (scope === 'project' && globalState.isFullyInstalled) {
104
+ log(` ${c.dim('Also installed globally — skills and MCP server are active for every project.')}`);
105
+ } else if (scope === 'project' && globalState.hasMcp) {
106
+ log(` ${c.dim('Partially installed globally (MCP server present, but skills may be missing).')}`);
107
+ } else if (scope === 'global' && projectState.isFullyInstalled) {
108
+ log(` ${c.dim('Also installed for this project (.claude, .mcp.json).')}`);
109
+ } else if (scope === 'global' && projectState.hasMcp) {
110
+ log(` ${c.dim('Partially installed for this project (MCP server present, but skills may be missing).')}`);
111
+ }
112
+
113
+ // Surface the configured token state so the user knows what access they have.
114
+ const kind = tokenKind(currentState.existingToken);
115
+ if (kind === 'read-write') {
116
+ log(` ${c.dim('Token: read+write (lk_rw_*)')}`);
117
+ } else if (kind === 'read-only') {
118
+ log(` ${c.dim('Token: read-only (lk_ro_*) — writes will fail until a read+write token is set')}`);
119
+ } else if (kind === 'write-only') {
120
+ log(` ${c.dim('Token: write-only (lk_wo_*) — reads will fail until a read+write token is set')}`);
121
+ } else if (kind === 'unknown') {
122
+ log(` ${c.dim('Token: unrecognized prefix — expected lk_rw_*, lk_ro_*, or lk_wo_*')}`);
123
+ } else {
124
+ log(` ${c.yellow('Token: none configured — reads/writes will fail until a token is set')}`);
125
+ }
126
+
127
+ log('');
128
+ log(` Run ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
129
+ log(` Pass ${c.cyan('--force')} to reinstall and overwrite existing files.`);
130
+ return 0;
131
+ }
132
+
133
+ // Partial install — note what's already there vs what will be added.
134
+ if ((currentState.hasSkills || currentState.hasMcp) && !force) {
135
+ const partialNote =
136
+ currentState.hasSkills && !currentState.hasMcp
137
+ ? `Skills already present (${currentState.skillCount}/${currentState.totalSkills}) — wiring MCP server.`
138
+ : currentState.hasMcp && !currentState.hasSkills
139
+ ? 'MCP server already configured — installing skill files.'
140
+ : `Partially installed (${currentState.skillCount}/${currentState.totalSkills} skills, MCP ${currentState.hasMcp ? 'present' : 'missing'}) — completing setup.`;
141
+ log(`\n ${c.dim(partialNote)}`);
142
+ }
143
+
50
144
  log(
51
145
  ` install: ${c.dim(
52
146
  scope === 'global' ? 'global — ~/.claude, applies to every project' : 'project — this repo only',
53
147
  )}`,
54
148
  );
55
149
 
56
- // 2. Connection details.
57
- let { endpoint, token } = resolveConnection(args);
58
-
59
- if (!endpoint) {
60
- if (nonInteractive) {
61
- err(
62
- `\n${c.red('Missing endpoint.')} Pass --endpoint ${DEFAULT_ENDPOINT_HINT} ` +
63
- `or set LOREKIT_MCP_URL.`,
64
- );
65
- return 1;
66
- }
67
- endpoint = await ask(` LoreKit MCP endpoint [${DEFAULT_ENDPOINT_HINT}]: `);
68
- if (!endpoint) endpoint = DEFAULT_ENDPOINT_HINT;
150
+ // 3. Connection details.
151
+ // The endpoint is always the fixed hosted LoreKit URL — no need to ask.
152
+ // The token is reused from the existing config when already present; the
153
+ // user only needs to supply it on a fresh install (or to replace it).
154
+ const fromArgs = resolveConnection(args);
155
+ const endpoint = fromArgs.endpoint || LOREKIT_MCP_ENDPOINT;
156
+
157
+ // Token resolution order: --token flag → env → existing config → prompt.
158
+ let token = fromArgs.token;
159
+ if (!token && currentState.existingToken) {
160
+ // Reuse the token that's already in the config — don't make the user repeat
161
+ // it just because they're running install again.
162
+ token = currentState.existingToken;
163
+ log(` ${c.dim('Token: reusing existing token from config.')}`);
69
164
  }
70
165
  if (!token && !nonInteractive) {
71
166
  token = await ask(' LoreKit token (lk_rw_… to allow writes, blank to skip): ');
72
167
  token = token || null;
73
168
  }
74
169
 
75
- // 3. Install the skill files — every skill the CLI ships.
170
+ // 4. Install the skill files — every skill the CLI ships.
76
171
  const skillResults = SKILLS.map((skill) => {
77
172
  const dest = skillInstallDir(root, scope, skill.name);
78
173
  const existed = fs.existsSync(path.join(dest, 'SKILL.md'));
79
- const written = copyDir(skill.source, dest, { force: Boolean(args.force) });
174
+ const written = copyDir(skill.source, dest, { force });
80
175
  return { name: skill.name, dest, existed, written };
81
176
  });
82
177
 
83
- // 4. Wire the MCP config for the chosen scope.
178
+ // 5. Wire the MCP config for the chosen scope.
84
179
  const remoteUrl = buildRemoteUrl(endpoint, token);
85
180
  const { file, existed } = upsertMcpServer(root, remoteUrl, scope);
86
181
 
87
- // 4b. Wire the deterministic hooks (unless --no-hooks). This is the layer the
182
+ // 5b. Wire the deterministic hooks (unless --no-hooks). This is the layer the
88
183
  // Claude plugin adds on top of the skill: lessons injected on every
89
184
  // SessionStart, a nudge on tool failure, a retrospective nudge on Stop —
90
185
  // firing the shared `lorekit hook` engine, which reads the same config.
@@ -100,14 +195,14 @@ export async function install(args) {
100
195
  scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
101
196
  const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
102
197
 
103
- // 5. Report.
198
+ // 6. Report.
104
199
  heading('Done');
105
200
  for (const s of skillResults) {
106
201
  const skillState = !s.existed
107
202
  ? 'installed'
108
203
  : s.written > 0
109
204
  ? `updated (${s.written} file(s) written)`
110
- : 'unchanged pass --force to overwrite';
205
+ : 'already up to date';
111
206
  status(s.existed && s.written === 0 ? 'info' : 'pass', `skill ${s.name}`, `${skillState} → ${display(s.dest)}`);
112
207
  }
113
208
  status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
@@ -116,10 +211,11 @@ export async function install(args) {
116
211
  status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but memories are model-invoked only');
117
212
  } else {
118
213
  const n = hooks.added + hooks.updated;
119
- const hookState =
120
- n === 0
121
- ? 'unchanged already wired'
122
- : `${hooks.added ? `${hooks.added} added` : ''}${hooks.added && hooks.updated ? ', ' : ''}${hooks.updated ? `${hooks.updated} updated` : ''}`;
214
+ const hookParts = [
215
+ hooks.added ? `${hooks.added} added` : '',
216
+ hooks.updated ? `${hooks.updated} updated` : '',
217
+ ].filter(Boolean);
218
+ const hookState = n === 0 ? 'already wired' : hookParts.join(', ');
123
219
  status(n === 0 ? 'info' : 'pass', 'hooks', `${hookState} → ${display(hooks.file)} (${CLAUDE_HOOK_EVENTS.join(', ')})`);
124
220
  }
125
221
 
@@ -148,8 +244,8 @@ export async function install(args) {
148
244
  log(
149
245
  ` ${c.dim(
150
246
  scope === 'global'
151
- ? 'Note: your token now lives in ~/.claude.json (used by every project) — keep that file private.'
152
- : 'Note: your token now lives in .mcp.json — keep it out of version control.',
247
+ ? 'Note: your token is stored in ~/.claude.json (used by every project) — keep that file private.'
248
+ : 'Note: your token is stored in .mcp.json — keep it out of version control.',
153
249
  )}`,
154
250
  );
155
251
  }