@phren/cli 0.0.32 → 0.0.34

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 (59) hide show
  1. package/mcp/dist/cli/actions.js +3 -0
  2. package/mcp/dist/cli/config.js +3 -3
  3. package/mcp/dist/cli/govern.js +18 -8
  4. package/mcp/dist/cli/hooks-context.js +1 -1
  5. package/mcp/dist/cli/hooks-session.js +18 -62
  6. package/mcp/dist/cli/namespaces.js +1 -1
  7. package/mcp/dist/cli/search.js +5 -5
  8. package/mcp/dist/cli-hooks-prompt.js +7 -3
  9. package/mcp/dist/cli-hooks-session-handlers.js +3 -15
  10. package/mcp/dist/cli-hooks-stop.js +10 -48
  11. package/mcp/dist/content/archive.js +8 -20
  12. package/mcp/dist/content/learning.js +29 -8
  13. package/mcp/dist/data/access.js +13 -4
  14. package/mcp/dist/finding/lifecycle.js +9 -3
  15. package/mcp/dist/governance/audit.js +13 -5
  16. package/mcp/dist/governance/policy.js +13 -0
  17. package/mcp/dist/governance/rbac.js +1 -1
  18. package/mcp/dist/governance/scores.js +2 -1
  19. package/mcp/dist/hooks.js +52 -6
  20. package/mcp/dist/index.js +1 -1
  21. package/mcp/dist/init/init.js +66 -45
  22. package/mcp/dist/init/shared.js +1 -1
  23. package/mcp/dist/init-bootstrap.js +0 -47
  24. package/mcp/dist/init-fresh.js +13 -18
  25. package/mcp/dist/init-uninstall.js +22 -0
  26. package/mcp/dist/init-walkthrough.js +19 -24
  27. package/mcp/dist/link/doctor.js +9 -0
  28. package/mcp/dist/package-metadata.js +1 -1
  29. package/mcp/dist/phren-art.js +4 -120
  30. package/mcp/dist/proactivity.js +1 -1
  31. package/mcp/dist/project-topics.js +16 -46
  32. package/mcp/dist/provider-adapters.js +1 -1
  33. package/mcp/dist/runtime-profile.js +1 -1
  34. package/mcp/dist/shared/data-utils.js +25 -0
  35. package/mcp/dist/shared/fragment-graph.js +4 -18
  36. package/mcp/dist/shared/index.js +14 -10
  37. package/mcp/dist/shared/ollama.js +23 -5
  38. package/mcp/dist/shared/process.js +24 -0
  39. package/mcp/dist/shared/retrieval.js +7 -4
  40. package/mcp/dist/shared/search-fallback.js +1 -0
  41. package/mcp/dist/shared.js +2 -1
  42. package/mcp/dist/shell/render.js +1 -1
  43. package/mcp/dist/skill/registry.js +1 -1
  44. package/mcp/dist/skill/state.js +0 -3
  45. package/mcp/dist/task/github.js +1 -0
  46. package/mcp/dist/task/lifecycle.js +1 -6
  47. package/mcp/dist/tools/config.js +415 -400
  48. package/mcp/dist/tools/finding.js +390 -373
  49. package/mcp/dist/tools/ops.js +372 -365
  50. package/mcp/dist/tools/search.js +495 -487
  51. package/mcp/dist/tools/session.js +3 -2
  52. package/mcp/dist/tools/skills.js +9 -0
  53. package/mcp/dist/ui/page.js +1 -1
  54. package/mcp/dist/ui/server.js +645 -1040
  55. package/mcp/dist/utils.js +12 -8
  56. package/package.json +1 -1
  57. package/mcp/dist/init-dryrun.js +0 -55
  58. package/mcp/dist/init-migrate.js +0 -51
  59. package/mcp/dist/init-walkthrough-merge.js +0 -90
@@ -123,7 +123,8 @@ export function autoDetectFindingType(text) {
123
123
  return 'context';
124
124
  return null;
125
125
  }
126
- function prepareFinding(learning, project, fullHistory, extraAnnotations, citationInput, source, nowIso, inferredRepo, headCommit, phrenPath) {
126
+ function prepareFinding(opts) {
127
+ const { finding: learning, project, fullHistory, extraAnnotations, citationInput, source, nowIso, inferredRepo, headCommit, phrenPath } = opts;
127
128
  const secretType = scanForSecrets(learning);
128
129
  if (secretType) {
129
130
  return { status: "rejected", reason: `Contains ${secretType}` };
@@ -208,7 +209,11 @@ function insertFindingIntoContent(content, today, bullet, citationComment) {
208
209
  // Use positional insertion (not String.replace) to avoid: (1) special $& replacement patterns
209
210
  // if bullet contains $ chars, and (2) inserting inside an archived <details> block when a
210
211
  // duplicate date header exists from a prior consolidation run.
211
- const idx = content.indexOf(todayHeader);
212
+ // Search for todayHeader only after the last </details> close tag so we never
213
+ // insert into an archived block whose date happens to match today.
214
+ const lastDetailsClose = content.lastIndexOf("</details>");
215
+ const searchFrom = lastDetailsClose >= 0 ? lastDetailsClose : 0;
216
+ const idx = content.indexOf(todayHeader, searchFrom);
212
217
  if (idx !== -1) {
213
218
  const insertAt = idx + todayHeader.length;
214
219
  return content.slice(0, insertAt) + `\n\n${bullet}\n${citationComment}` + content.slice(insertAt);
@@ -288,7 +293,10 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
288
293
  if (!fs.existsSync(resolvedDir))
289
294
  return phrenErr(`Project "${project}" does not exist.`, PhrenError.INVALID_PROJECT_NAME);
290
295
  const result = withFileLock(learningsPath, () => {
291
- const preparedForNewFile = prepareFinding(learning, project, "", opts?.extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
296
+ const preparedForNewFile = prepareFinding({
297
+ finding: learning, project, fullHistory: "", extraAnnotations: opts?.extraAnnotations,
298
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
299
+ });
292
300
  if (!fs.existsSync(learningsPath)) {
293
301
  if (preparedForNewFile.status === "rejected") {
294
302
  return phrenErr(`Rejected: finding appears to contain a secret (${preparedForNewFile.reason.replace(/^Contains /, "")}). Strip credentials before saving.`, PhrenError.VALIDATION_ERROR);
@@ -297,7 +305,9 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
297
305
  return phrenOk(`Skipped duplicate finding for "${project}": already exists with similar wording.`);
298
306
  }
299
307
  const newContent = `# ${project} Findings\n\n## ${today}\n\n${preparedForNewFile.finding.bullet}\n${preparedForNewFile.finding.citationComment}\n`;
300
- fs.writeFileSync(learningsPath, newContent);
308
+ const tmpPath = learningsPath + ".tmp." + process.pid;
309
+ fs.writeFileSync(tmpPath, newContent);
310
+ fs.renameSync(tmpPath, learningsPath);
301
311
  return phrenOk({
302
312
  content: newContent,
303
313
  citation: buildFindingCitation(resolvedCitationInput, nowIso, inferredRepo, headCommit),
@@ -316,7 +326,10 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
316
326
  .filter(line => !line.startsWith("- ") || !line.toLowerCase().includes(supersedesText.slice(0, 40).toLowerCase()))
317
327
  .join("\n")
318
328
  : content;
319
- const prepared = prepareFinding(learning, project, historyForDedup, opts?.extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
329
+ const prepared = prepareFinding({
330
+ finding: learning, project, fullHistory: historyForDedup, extraAnnotations: opts?.extraAnnotations,
331
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
332
+ });
320
333
  if (prepared.status === "rejected") {
321
334
  return phrenErr(`Rejected: finding appears to contain a secret (${prepared.reason.replace(/^Contains /, "")}). Strip credentials before saving.`, PhrenError.VALIDATION_ERROR);
322
335
  }
@@ -430,7 +443,10 @@ export function addFindingsToFile(phrenPath, project, learnings, opts) {
430
443
  rejected.push({ text: learning, reason: lengthError });
431
444
  continue;
432
445
  }
433
- const prepared = prepareFinding(learning, project, content, extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
446
+ const prepared = prepareFinding({
447
+ finding: learning, project, fullHistory: content, extraAnnotations,
448
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
449
+ });
434
450
  if (prepared.status === "rejected") {
435
451
  rejected.push({ text: learning, reason: prepared.reason });
436
452
  continue;
@@ -445,7 +461,9 @@ export function addFindingsToFile(phrenPath, project, learnings, opts) {
445
461
  added.push(learning);
446
462
  }
447
463
  if (added.length > 0) {
448
- fs.writeFileSync(learningsPath, content.endsWith("\n") ? content : `${content}\n`);
464
+ const tmpPath = learningsPath + ".tmp." + process.pid;
465
+ fs.writeFileSync(tmpPath, content.endsWith("\n") ? content : `${content}\n`);
466
+ fs.renameSync(tmpPath, learningsPath);
449
467
  }
450
468
  return phrenOk({ content, wrote: added.length > 0 });
451
469
  }
@@ -460,7 +478,10 @@ export function addFindingsToFile(phrenPath, project, learnings, opts) {
460
478
  rejected.push({ text: learning, reason: lengthError });
461
479
  continue;
462
480
  }
463
- const prepared = prepareFinding(learning, project, content, extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
481
+ const prepared = prepareFinding({
482
+ finding: learning, project, fullHistory: content, extraAnnotations,
483
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
484
+ });
464
485
  if (prepared.status === "rejected") {
465
486
  rejected.push({ text: learning, reason: prepared.reason });
466
487
  continue;
@@ -275,7 +275,9 @@ export function removeFinding(phrenPath, project, match) {
275
275
  const matched = lines[idx];
276
276
  lines.splice(idx, removeCount);
277
277
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
278
- fs.writeFileSync(filePath, normalized);
278
+ const tmp = filePath + ".tmp." + process.pid;
279
+ fs.writeFileSync(tmp, normalized);
280
+ fs.renameSync(tmp, filePath);
279
281
  return phrenOk(`Removed from ${project}: ${matched}`);
280
282
  });
281
283
  }
@@ -323,7 +325,9 @@ export function removeFindings(phrenPath, project, matches) {
323
325
  if (removed.length > 0) {
324
326
  const filtered = lines.filter((_, i) => !indicesToRemove.has(i));
325
327
  const normalized = filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
326
- fs.writeFileSync(findingsPath, normalized);
328
+ const tmp = findingsPath + ".tmp." + process.pid;
329
+ fs.writeFileSync(tmp, normalized);
330
+ fs.renameSync(tmp, findingsPath);
327
331
  }
328
332
  return phrenOk({ removed, errors });
329
333
  });
@@ -363,7 +367,9 @@ export function editFinding(phrenPath, project, oldText, newText) {
363
367
  const metaSuffix = metaMatch ? " " + metaMatch.join(" ") : "";
364
368
  lines[idx] = `- ${newTextTrimmed}${metaSuffix}`;
365
369
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
366
- fs.writeFileSync(findingsPath, normalized);
370
+ const tmp = findingsPath + ".tmp." + process.pid;
371
+ fs.writeFileSync(tmp, normalized);
372
+ fs.renameSync(tmp, findingsPath);
367
373
  return phrenOk(`Updated finding in ${project}`);
368
374
  });
369
375
  }
@@ -452,7 +458,10 @@ function withQueueLineOp(phrenPath, project, lineText, op) {
452
458
  });
453
459
  }
454
460
  function writeQueueLines(file, lines) {
455
- fs.writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n");
461
+ const content = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
462
+ const tmp = file + ".tmp." + process.pid;
463
+ fs.writeFileSync(tmp, content);
464
+ fs.renameSync(tmp, file);
456
465
  }
457
466
  /** Remove a queue item's line from review.md (finding stays in FINDINGS.md). */
458
467
  export function approveQueueItem(phrenPath, project, lineText) {
@@ -219,7 +219,9 @@ export function supersedeFinding(phrenPath, project, findingText, supersededBy)
219
219
  const today = new Date().toISOString().slice(0, 10);
220
220
  lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "superseded", status_updated: today, status_reason: "superseded_by", status_ref: ref }, today, { supersededBy: ref });
221
221
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
222
- fs.writeFileSync(findingsPath, normalized);
222
+ const tmpPath = findingsPath + ".tmp." + process.pid;
223
+ fs.writeFileSync(tmpPath, normalized);
224
+ fs.renameSync(tmpPath, findingsPath);
223
225
  return phrenOk({ finding: matched.data.text, superseded_by: ref, status: "superseded" });
224
226
  });
225
227
  }
@@ -239,7 +241,9 @@ export function retractFinding(phrenPath, project, findingText, reason) {
239
241
  const today = new Date().toISOString().slice(0, 10);
240
242
  lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "retracted", status_updated: today, status_reason: reasonText }, today);
241
243
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
242
- fs.writeFileSync(findingsPath, normalized);
244
+ const tmpPath = findingsPath + ".tmp." + process.pid;
245
+ fs.writeFileSync(tmpPath, normalized);
246
+ fs.renameSync(tmpPath, findingsPath);
243
247
  return phrenOk({ finding: matched.data.text, reason: reasonText, status: "retracted" });
244
248
  });
245
249
  }
@@ -289,7 +293,9 @@ export function resolveFindingContradiction(phrenPath, project, findingA, findin
289
293
  statusB = "retracted";
290
294
  }
291
295
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
292
- fs.writeFileSync(findingsPath, normalized);
296
+ const tmpPath = findingsPath + ".tmp." + process.pid;
297
+ fs.writeFileSync(tmpPath, normalized);
298
+ fs.renameSync(tmpPath, findingsPath);
293
299
  return phrenOk({
294
300
  resolution,
295
301
  finding_a: { text: matchedA.data.text, status: statusA },
@@ -2,18 +2,26 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { debugLog } from "../shared.js";
4
4
  import { errorMessage } from "../utils.js";
5
+ const MAX_LOG_LINES = 1000;
5
6
  export function recordRetrieval(phrenPath, file, section) {
6
7
  const dir = path.join(phrenPath, ".runtime");
7
- fs.mkdirSync(dir, { recursive: true });
8
- const logPath = path.join(dir, "retrieval-log.jsonl");
9
- const entry = { file, section, retrievedAt: new Date().toISOString() };
10
- fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
8
+ let logPath;
9
+ try {
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ logPath = path.join(dir, "retrieval-log.jsonl");
12
+ const entry = { file, section, retrievedAt: new Date().toISOString() };
13
+ fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
14
+ }
15
+ catch (err) {
16
+ debugLog(`recordRetrieval write failed: ${errorMessage(err)}`);
17
+ return;
18
+ }
11
19
  try {
12
20
  const stat = fs.statSync(logPath);
13
21
  if (stat.size > 500_000) {
14
22
  const content = fs.readFileSync(logPath, "utf8");
15
23
  const lines = content.split("\n").filter(Boolean);
16
- fs.writeFileSync(logPath, lines.slice(-1000).join("\n") + "\n");
24
+ fs.writeFileSync(logPath, lines.slice(-MAX_LOG_LINES).join("\n") + "\n");
17
25
  }
18
26
  }
19
27
  catch (err) {
@@ -8,7 +8,20 @@ import { readProjectConfig } from "../project-config.js";
8
8
  import { getActiveProfileDefaults } from "../profile-store.js";
9
9
  import { runCustomHooks } from "../hooks.js";
10
10
  import { METADATA_REGEX, isCitationLine, isArchiveStart as isArchiveStartMeta, isArchiveEnd as isArchiveEndMeta, stripLifecycleMetadata as stripLifecycleMetadataMeta, } from "../content/metadata.js";
11
+ /** @internal Exported for tests. */
11
12
  export const MAX_QUEUE_ENTRY_LENGTH = 500;
13
+ export function buildSyncStatus(opts) {
14
+ return {
15
+ ...(opts.pullAt !== undefined ? { lastPullAt: opts.pullAt } : {}),
16
+ ...(opts.pullStatus !== undefined ? { lastPullStatus: opts.pullStatus } : {}),
17
+ ...(opts.pullDetail !== undefined ? { lastPullDetail: opts.pullDetail } : {}),
18
+ ...(opts.successfulPullAt !== undefined ? { lastSuccessfulPullAt: opts.successfulPullAt } : {}),
19
+ lastPushAt: opts.now,
20
+ lastPushStatus: opts.pushStatus,
21
+ ...(opts.pushDetail !== undefined ? { lastPushDetail: opts.pushDetail } : {}),
22
+ ...(opts.unsyncedCommits !== undefined ? { unsyncedCommits: opts.unsyncedCommits } : {}),
23
+ };
24
+ }
12
25
  export const GOVERNANCE_SCHEMA_VERSION = 1;
13
26
  const DEFAULT_POLICY = {
14
27
  schemaVersion: GOVERNANCE_SCHEMA_VERSION,
@@ -104,7 +104,7 @@ function rolePermits(role, action) {
104
104
  *
105
105
  * Returns `{ allowed: true }` when permitted, `{ allowed: false, reason }` when denied.
106
106
  */
107
- export function checkPermission(phrenPath, action, project) {
107
+ function checkPermission(phrenPath, action, project) {
108
108
  const actor = (process.env.PHREN_ACTOR ?? "").trim() || null;
109
109
  const globalAc = readGlobalAccessControl(phrenPath);
110
110
  const projectAccess = project
@@ -5,6 +5,7 @@ import { appendAuditLog, debugLog, isRecord, memoryScoresFile, memoryUsageLogFil
5
5
  import { withFileLock, isFiniteNumber, hasValidSchemaVersion } from "../shared/governance.js";
6
6
  import { errorMessage } from "../utils.js";
7
7
  import { logger } from "../logger.js";
8
+ const MAX_LOG_LINES = 1000;
8
9
  const GOVERNANCE_SCHEMA_VERSION = 1;
9
10
  const DEFAULT_MEMORY_SCORES_FILE = {
10
11
  schemaVersion: GOVERNANCE_SCHEMA_VERSION,
@@ -255,7 +256,7 @@ export function recordInjection(phrenPath, key, sessionId) {
255
256
  if (stat.size > 1_000_000) {
256
257
  const content = fs.readFileSync(logFile, "utf8");
257
258
  const lines = content.split("\n");
258
- fs.writeFileSync(logFile, lines.slice(-500).join("\n"));
259
+ fs.writeFileSync(logFile, lines.slice(-MAX_LOG_LINES).join("\n"));
259
260
  }
260
261
  }
261
262
  catch (err) {
package/mcp/dist/hooks.js CHANGED
@@ -200,6 +200,44 @@ exit $status
200
200
  return false;
201
201
  }
202
202
  }
203
+ /**
204
+ * Install a lightweight `phren` CLI wrapper at ~/.local/bin/phren so the bare
205
+ * `phren` command works without a global npm install. The wrapper simply execs
206
+ * `node <entry_script> "$@"`.
207
+ */
208
+ export function installPhrenCliWrapper(phrenPath) {
209
+ const entry = resolveCliEntryScript();
210
+ if (!entry)
211
+ return false;
212
+ const localBinDir = homePath(".local", "bin");
213
+ const wrapperPath = path.join(localBinDir, "phren");
214
+ // Don't overwrite a real global install — only our own wrapper
215
+ if (fs.existsSync(wrapperPath)) {
216
+ try {
217
+ const existing = fs.readFileSync(wrapperPath, "utf8");
218
+ if (!existing.includes("PHREN_CLI_WRAPPER"))
219
+ return false;
220
+ }
221
+ catch { /* unreadable — skip */ }
222
+ }
223
+ const content = `#!/bin/sh
224
+ # PHREN_CLI_WRAPPER — managed by phren init; safe to delete
225
+ set -u
226
+ PHREN_PATH="\${PHREN_PATH:-${shellEscape(phrenPath)}}"
227
+ export PHREN_PATH
228
+ exec node ${shellEscape(entry)} "$@"
229
+ `;
230
+ try {
231
+ fs.mkdirSync(localBinDir, { recursive: true });
232
+ atomicWriteText(wrapperPath, content);
233
+ fs.chmodSync(wrapperPath, 0o755);
234
+ return true;
235
+ }
236
+ catch (err) {
237
+ debugLog(`installPhrenCliWrapper: failed: ${errorMessage(err)}`);
238
+ return false;
239
+ }
240
+ }
203
241
  function validateCopilotConfig(config) {
204
242
  return (typeof config.version === "number" &&
205
243
  Array.isArray(config.hooks?.sessionStart) &&
@@ -236,7 +274,14 @@ function cachedReadInstallPrefsJson(phrenPath) {
236
274
  if (cached && cached.mtimeMs === mtimeMs) {
237
275
  return cached.parsed;
238
276
  }
239
- const parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
277
+ let parsed;
278
+ try {
279
+ parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
280
+ }
281
+ catch {
282
+ _installPrefsJsonCache.delete(prefsPath);
283
+ return null;
284
+ }
240
285
  _installPrefsJsonCache.set(prefsPath, { mtimeMs, parsed });
241
286
  return parsed;
242
287
  }
@@ -272,6 +317,7 @@ export const HOOK_EVENT_VALUES = [
272
317
  "post-session-end", "post-consolidate",
273
318
  ];
274
319
  const VALID_HOOK_EVENTS = new Set(HOOK_EVENT_VALUES);
320
+ const MAX_HOOK_COMMAND_LENGTH = 1000;
275
321
  /** Return the target (URL or shell command) for display or matching. */
276
322
  export function getHookTarget(h) {
277
323
  return "webhook" in h ? h.webhook : h.command;
@@ -280,8 +326,8 @@ export function validateCustomHookCommand(command) {
280
326
  const trimmed = command.trim();
281
327
  if (!trimmed)
282
328
  return "Command cannot be empty.";
283
- if (trimmed.length > 1000)
284
- return "Command too long (max 1000 characters).";
329
+ if (trimmed.length > MAX_HOOK_COMMAND_LENGTH)
330
+ return `Command too long (max ${MAX_HOOK_COMMAND_LENGTH} characters).`;
285
331
  if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) {
286
332
  return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < > # \\n \\r";
287
333
  }
@@ -566,7 +612,7 @@ export function configureAllHooks(phrenPath, options = {}) {
566
612
  configured.push("Copilot CLI");
567
613
  }
568
614
  catch (err) {
569
- debugLog(`configureAllHooks: copilot failed: ${errorMessage(err)}`);
615
+ console.warn(`configureAllHooks: copilot hook config failed: ${errorMessage(err)}`);
570
616
  }
571
617
  if (isToolHookEnabled(phrenPath, "copilot"))
572
618
  installSessionWrapper("copilot", phrenPath);
@@ -598,7 +644,7 @@ export function configureAllHooks(phrenPath, options = {}) {
598
644
  configured.push("Cursor");
599
645
  }
600
646
  catch (err) {
601
- debugLog(`configureAllHooks: cursor failed: ${errorMessage(err)}`);
647
+ console.warn(`configureAllHooks: cursor hook config failed: ${errorMessage(err)}`);
602
648
  }
603
649
  if (isToolHookEnabled(phrenPath, "cursor"))
604
650
  installSessionWrapper("cursor", phrenPath);
@@ -629,7 +675,7 @@ export function configureAllHooks(phrenPath, options = {}) {
629
675
  configured.push("Codex");
630
676
  }
631
677
  catch (err) {
632
- debugLog(`configureAllHooks: codex failed: ${errorMessage(err)}`);
678
+ console.warn(`configureAllHooks: codex hook config failed: ${errorMessage(err)}`);
633
679
  }
634
680
  if (isToolHookEnabled(phrenPath, "codex"))
635
681
  installSessionWrapper("codex", phrenPath);
package/mcp/dist/index.js CHANGED
@@ -72,7 +72,7 @@ async function main() {
72
72
  catch (error) {
73
73
  const msg = error instanceof Error ? error.message : String(error);
74
74
  structuredLog("error", "startup", `Failed to build phren index: ${msg}`);
75
- console.error("Failed to build phren index at startup:", error);
75
+ console.error("Failed to build phren index at startup:", msg);
76
76
  process.exit(1);
77
77
  }
78
78
  let writeQueue = Promise.resolve();
@@ -6,7 +6,7 @@ import * as fs from "fs";
6
6
  import * as path from "path";
7
7
  import * as crypto from "crypto";
8
8
  import { execFileSync, spawnSync } from "child_process";
9
- import { configureAllHooks } from "../hooks.js";
9
+ import { configureAllHooks, installPhrenCliWrapper } from "../hooks.js";
10
10
  import { getMachineName, machineFilePath, persistMachineName } from "../machine-identity.js";
11
11
  import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, expandHomePath, findPhrenPath, getProjectDirs, readRootManifest, writeRootManifest, } from "../shared.js";
12
12
  import { isValidProjectName, errorMessage } from "../utils.js";
@@ -435,31 +435,26 @@ async function runWalkthrough(phrenPath) {
435
435
  log(" Change later: set PHREN_OLLAMA_URL=off to disable");
436
436
  let ollamaEnabled = false;
437
437
  try {
438
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("../shared/ollama.js");
439
- if (getOllamaUrl()) {
440
- const ollamaUp = await checkOllamaAvailable();
441
- if (ollamaUp) {
442
- const modelReady = await checkModelAvailable();
443
- if (modelReady) {
444
- log(" Ollama detected with nomic-embed-text ready.");
445
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
446
- }
447
- else {
448
- log(" Ollama detected, but nomic-embed-text is not pulled yet.");
449
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
450
- if (ollamaEnabled) {
451
- log(" Run after init: ollama pull nomic-embed-text");
452
- }
453
- }
438
+ const { checkOllamaStatus } = await import("../shared/ollama.js");
439
+ const status = await checkOllamaStatus();
440
+ if (status === "ready") {
441
+ log(" Ollama detected with nomic-embed-text ready.");
442
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
443
+ }
444
+ else if (status === "no_model") {
445
+ log(" Ollama detected, but nomic-embed-text is not pulled yet.");
446
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
447
+ if (ollamaEnabled) {
448
+ log(" Run after init: ollama pull nomic-embed-text");
454
449
  }
455
- else {
456
- log(" Ollama not detected. Install it to enable semantic search:");
457
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
458
- ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
459
- if (ollamaEnabled) {
460
- log(style.success(" Semantic search enabled — will activate once Ollama is running."));
461
- log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
462
- }
450
+ }
451
+ else if (status === "not_running") {
452
+ log(" Ollama not detected. Install it to enable semantic search:");
453
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
454
+ ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
455
+ if (ollamaEnabled) {
456
+ log(style.success(" Semantic search enabled will activate once Ollama is running."));
457
+ log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
463
458
  }
464
459
  }
465
460
  }
@@ -974,6 +969,15 @@ function configureHooksIfEnabled(phrenPath, hooksEnabled, verb) {
974
969
  else {
975
970
  log(` Hooks are disabled by preference (run: npx phren hooks-mode on)`);
976
971
  }
972
+ // Install phren CLI wrapper at ~/.local/bin/phren so the bare command works
973
+ try {
974
+ if (installPhrenCliWrapper(phrenPath)) {
975
+ log(` ${verb} CLI wrapper: ~/.local/bin/phren`);
976
+ }
977
+ }
978
+ catch (err) {
979
+ debugLog(`installPhrenCliWrapper failed: ${errorMessage(err)}`);
980
+ }
977
981
  }
978
982
  export async function runInit(opts = {}) {
979
983
  if ((opts.mode || "shared") === "project-local") {
@@ -1450,24 +1454,19 @@ export async function runInit(opts = {}) {
1450
1454
  const walkthroughCoveredOllama = Boolean(process.env._PHREN_WALKTHROUGH_OLLAMA_SKIP) || (!hasExistingInstall && !opts.yes);
1451
1455
  if (!walkthroughCoveredOllama) {
1452
1456
  try {
1453
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("../shared/ollama.js");
1454
- if (getOllamaUrl()) {
1455
- const ollamaUp = await checkOllamaAvailable();
1456
- if (ollamaUp) {
1457
- const modelReady = await checkModelAvailable();
1458
- if (modelReady) {
1459
- log("\n Semantic search: Ollama + nomic-embed-text ready.");
1460
- }
1461
- else {
1462
- log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
1463
- log(" Run: ollama pull nomic-embed-text");
1464
- }
1465
- }
1466
- else {
1467
- log("\n Tip: Install Ollama for semantic search (optional).");
1468
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
1469
- log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
1470
- }
1457
+ const { checkOllamaStatus } = await import("../shared/ollama.js");
1458
+ const status = await checkOllamaStatus();
1459
+ if (status === "ready") {
1460
+ log("\n Semantic search: Ollama + nomic-embed-text ready.");
1461
+ }
1462
+ else if (status === "no_model") {
1463
+ log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
1464
+ log(" Run: ollama pull nomic-embed-text");
1465
+ }
1466
+ else if (status === "not_running") {
1467
+ log("\n Tip: Install Ollama for semantic search (optional).");
1468
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
1469
+ log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
1471
1470
  }
1472
1471
  }
1473
1472
  catch (err) {
@@ -1966,9 +1965,9 @@ export async function runUninstall(opts = {}) {
1966
1965
  catch (err) {
1967
1966
  debugLog(`uninstall: cleanup failed for ${codexHooksFile}: ${errorMessage(err)}`);
1968
1967
  }
1969
- // Remove session wrapper scripts (written by installSessionWrapper)
1968
+ // Remove session wrapper scripts (written by installSessionWrapper) and CLI wrapper
1970
1969
  const localBinDir = path.join(home, ".local", "bin");
1971
- for (const tool of ["copilot", "cursor", "codex"]) {
1970
+ for (const tool of ["copilot", "cursor", "codex", "phren"]) {
1972
1971
  const wrapperPath = path.join(localBinDir, tool);
1973
1972
  try {
1974
1973
  if (fs.existsSync(wrapperPath)) {
@@ -2003,6 +2002,28 @@ export async function runUninstall(opts = {}) {
2003
2002
  catch (err) {
2004
2003
  debugLog(`uninstall: cleanup failed for ${contextFile}: ${errorMessage(err)}`);
2005
2004
  }
2005
+ // Remove global CLAUDE.md symlink (created by linkGlobal -> ~/.claude/CLAUDE.md)
2006
+ const globalClaudeLink = homePath(".claude", "CLAUDE.md");
2007
+ try {
2008
+ if (fs.lstatSync(globalClaudeLink).isSymbolicLink()) {
2009
+ fs.unlinkSync(globalClaudeLink);
2010
+ log(` Removed global CLAUDE.md symlink (${globalClaudeLink})`);
2011
+ }
2012
+ }
2013
+ catch {
2014
+ // Does not exist or not a symlink — nothing to do
2015
+ }
2016
+ // Remove copilot-instructions.md symlink (created by linkGlobal -> ~/.github/copilot-instructions.md)
2017
+ const copilotInstrLink = homePath(".github", "copilot-instructions.md");
2018
+ try {
2019
+ if (fs.lstatSync(copilotInstrLink).isSymbolicLink()) {
2020
+ fs.unlinkSync(copilotInstrLink);
2021
+ log(` Removed copilot-instructions.md symlink (${copilotInstrLink})`);
2022
+ }
2023
+ }
2024
+ catch {
2025
+ // Does not exist or not a symlink — nothing to do
2026
+ }
2006
2027
  // Sweep agent skill directories for symlinks pointing into the phren store
2007
2028
  if (phrenPath) {
2008
2029
  try {
@@ -33,7 +33,7 @@ export function commandVersion(cmd, args = ["--version"]) {
33
33
  return null;
34
34
  }
35
35
  }
36
- export function parseSemverTriple(raw) {
36
+ function parseSemverTriple(raw) {
37
37
  const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
38
38
  if (!match)
39
39
  return null;
@@ -1,56 +1,9 @@
1
1
  /**
2
2
  * Bootstrap-current-project prompting and execution for init.
3
3
  */
4
- import * as path from "path";
5
4
  import { debugLog } from "./shared.js";
6
5
  import { bootstrapFromExisting, } from "./init/setup.js";
7
- import { PROJECT_OWNERSHIP_MODES, } from "./project-config.js";
8
- import { createWalkthroughPrompts, createWalkthroughStyle, } from "./init-walkthrough.js";
9
- import { getPendingBootstrapTarget } from "./init-detect.js";
10
6
  import { log } from "./init/shared.js";
11
- /**
12
- * Decide whether to bootstrap the CWD project and with what ownership.
13
- * May prompt the user interactively.
14
- */
15
- export async function resolveBootstrapDecision(phrenPath, opts, ownershipDefault, dryRun) {
16
- const pendingBootstrap = getPendingBootstrapTarget(phrenPath, opts);
17
- let shouldBootstrap = opts._walkthroughBootstrapCurrentProject === true;
18
- let ownership = opts._walkthroughBootstrapOwnership ?? ownershipDefault;
19
- if (pendingBootstrap && !dryRun) {
20
- const walkthroughAlreadyHandled = opts._walkthroughBootstrapCurrentProject !== undefined;
21
- if (walkthroughAlreadyHandled) {
22
- shouldBootstrap = opts._walkthroughBootstrapCurrentProject === true;
23
- ownership = opts._walkthroughBootstrapOwnership ?? ownershipDefault;
24
- }
25
- else if (opts.yes || !process.stdin.isTTY || !process.stdout.isTTY) {
26
- shouldBootstrap = true;
27
- ownership = ownershipDefault;
28
- }
29
- else {
30
- const prompts = await createWalkthroughPrompts();
31
- const style = await createWalkthroughStyle();
32
- const detectedProjectName = path.basename(pendingBootstrap.path);
33
- log("");
34
- log(style.header("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
35
- log(style.header("Current Project"));
36
- log(style.header("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
37
- log(`Detected project: ${detectedProjectName}`);
38
- shouldBootstrap = await prompts.confirm("Add this project to phren now?", true);
39
- if (!shouldBootstrap) {
40
- log(style.warning(` Skipped. Later: cd ${pendingBootstrap.path} && npx phren add`));
41
- }
42
- else {
43
- ownership = await prompts.select("Ownership for detected project", [
44
- { value: ownershipDefault, name: `${ownershipDefault} (default)` },
45
- ...PROJECT_OWNERSHIP_MODES
46
- .filter((mode) => mode !== ownershipDefault)
47
- .map((mode) => ({ value: mode, name: mode })),
48
- ], ownershipDefault);
49
- }
50
- }
51
- }
52
- return { shouldBootstrap, ownership };
53
- }
54
7
  /**
55
8
  * Bootstrap a project from an existing directory into phren.
56
9
  */
@@ -162,24 +162,19 @@ export async function runFreshInstall(phrenPath, opts, params) {
162
162
  const walkthroughCoveredOllama = Boolean(process.env._PHREN_WALKTHROUGH_OLLAMA_SKIP) || !opts.yes;
163
163
  if (!walkthroughCoveredOllama) {
164
164
  try {
165
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("./shared/ollama.js");
166
- if (getOllamaUrl()) {
167
- const ollamaUp = await checkOllamaAvailable();
168
- if (ollamaUp) {
169
- const modelReady = await checkModelAvailable();
170
- if (modelReady) {
171
- log("\n Semantic search: Ollama + nomic-embed-text ready.");
172
- }
173
- else {
174
- log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
175
- log(" Run: ollama pull nomic-embed-text");
176
- }
177
- }
178
- else {
179
- log("\n Tip: Install Ollama for semantic search (optional).");
180
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
181
- log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
182
- }
165
+ const { checkOllamaStatus } = await import("./shared/ollama.js");
166
+ const status = await checkOllamaStatus();
167
+ if (status === "ready") {
168
+ log("\n Semantic search: Ollama + nomic-embed-text ready.");
169
+ }
170
+ else if (status === "no_model") {
171
+ log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
172
+ log(" Run: ollama pull nomic-embed-text");
173
+ }
174
+ else if (status === "not_running") {
175
+ log("\n Tip: Install Ollama for semantic search (optional).");
176
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
177
+ log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
183
178
  }
184
179
  }
185
180
  catch (err) {