@phren/cli 0.0.10 → 0.0.11

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 (63) hide show
  1. package/README.md +2 -8
  2. package/mcp/dist/cli-actions.js +5 -5
  3. package/mcp/dist/cli-config.js +334 -127
  4. package/mcp/dist/cli-govern.js +35 -63
  5. package/mcp/dist/cli-graph.js +3 -2
  6. package/mcp/dist/cli-hooks-globs.js +2 -1
  7. package/mcp/dist/cli-hooks-output.js +3 -3
  8. package/mcp/dist/cli-hooks.js +39 -32
  9. package/mcp/dist/cli-namespaces.js +15 -5
  10. package/mcp/dist/cli-search.js +2 -2
  11. package/mcp/dist/content-archive.js +2 -2
  12. package/mcp/dist/content-dedup.js +9 -9
  13. package/mcp/dist/embedding.js +7 -7
  14. package/mcp/dist/entrypoint.js +129 -102
  15. package/mcp/dist/governance-locks.js +6 -5
  16. package/mcp/dist/governance-policy.js +155 -2
  17. package/mcp/dist/governance-scores.js +3 -3
  18. package/mcp/dist/hooks.js +39 -18
  19. package/mcp/dist/index.js +4 -4
  20. package/mcp/dist/init-config.js +3 -24
  21. package/mcp/dist/init-setup.js +5 -5
  22. package/mcp/dist/init.js +170 -23
  23. package/mcp/dist/link-checksums.js +3 -2
  24. package/mcp/dist/link-context.js +1 -1
  25. package/mcp/dist/link-doctor.js +3 -3
  26. package/mcp/dist/link-skills.js +98 -12
  27. package/mcp/dist/link.js +17 -27
  28. package/mcp/dist/machine-identity.js +1 -9
  29. package/mcp/dist/mcp-config.js +247 -42
  30. package/mcp/dist/mcp-data.js +9 -9
  31. package/mcp/dist/mcp-extract-facts.js +1 -1
  32. package/mcp/dist/mcp-extract.js +2 -2
  33. package/mcp/dist/mcp-finding.js +6 -6
  34. package/mcp/dist/mcp-graph.js +11 -11
  35. package/mcp/dist/mcp-ops.js +18 -18
  36. package/mcp/dist/mcp-search.js +8 -8
  37. package/mcp/dist/memory-ui-page.js +23 -0
  38. package/mcp/dist/memory-ui-scripts.js +210 -27
  39. package/mcp/dist/memory-ui-server.js +115 -3
  40. package/mcp/dist/phren-paths.js +7 -7
  41. package/mcp/dist/profile-store.js +2 -2
  42. package/mcp/dist/project-config.js +63 -16
  43. package/mcp/dist/session-utils.js +3 -2
  44. package/mcp/dist/shared-fragment-graph.js +22 -21
  45. package/mcp/dist/shared-index.js +144 -105
  46. package/mcp/dist/shared-retrieval.js +19 -13
  47. package/mcp/dist/shared-search-fallback.js +13 -13
  48. package/mcp/dist/shared-sqljs.js +3 -2
  49. package/mcp/dist/shared.js +3 -3
  50. package/mcp/dist/shell-input.js +1 -1
  51. package/mcp/dist/shell-state-store.js +1 -1
  52. package/mcp/dist/shell-view.js +3 -2
  53. package/mcp/dist/shell.js +1 -1
  54. package/mcp/dist/skill-files.js +4 -10
  55. package/mcp/dist/skill-registry.js +3 -0
  56. package/mcp/dist/status.js +41 -13
  57. package/mcp/dist/task-hygiene.js +1 -1
  58. package/mcp/dist/telemetry.js +5 -4
  59. package/mcp/dist/update.js +1 -1
  60. package/mcp/dist/utils.js +3 -3
  61. package/package.json +2 -2
  62. package/starter/global/skills/audit.md +106 -0
  63. package/mcp/dist/shared-paths.js +0 -1
package/mcp/dist/index.js CHANGED
@@ -48,13 +48,13 @@ function cleanStaleLocks(phrenPath) {
48
48
  }
49
49
  }
50
50
  catch (err) {
51
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
51
+ if ((process.env.PHREN_DEBUG))
52
52
  process.stderr.write(`[phren] cleanStaleLocks statFile: ${errorMessage(err)}\n`);
53
53
  }
54
54
  }
55
55
  }
56
56
  catch (err) {
57
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
57
+ if ((process.env.PHREN_DEBUG))
58
58
  process.stderr.write(`[phren] cleanStaleLocks readdir: ${errorMessage(err)}\n`);
59
59
  }
60
60
  }
@@ -88,7 +88,7 @@ async function main() {
88
88
  db?.close();
89
89
  }
90
90
  catch (err) {
91
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
91
+ if ((process.env.PHREN_DEBUG))
92
92
  process.stderr.write(`[phren] rebuildIndex dbClose: ${errorMessage(err)}\n`);
93
93
  }
94
94
  db = await buildIndex(phrenPath, profile);
@@ -158,7 +158,7 @@ async function main() {
158
158
  trackToolCall(phrenPath, registeredName);
159
159
  }
160
160
  catch (err) {
161
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
161
+ if ((process.env.PHREN_DEBUG))
162
162
  process.stderr.write(`[phren] trackToolCall: ${errorMessage(err)}\n`);
163
163
  }
164
164
  return handler(...args);
@@ -4,27 +4,16 @@
4
4
  */
5
5
  import * as fs from "fs";
6
6
  import * as path from "path";
7
- import { randomUUID } from "crypto";
8
- import { execFileSync } from "child_process";
9
- import { buildLifecycleCommands } from "./hooks.js";
10
- import { EXEC_TIMEOUT_QUICK_MS, isRecord, hookConfigPath, homePath, readRootManifest, } from "./shared.js";
7
+ import { buildLifecycleCommands, commandExists } from "./hooks.js";
8
+ import { isRecord, hookConfigPath, homePath, readRootManifest, atomicWriteText, } from "./shared.js";
11
9
  import { isFeatureEnabled, errorMessage } from "./utils.js";
12
10
  import { probeVsCodeConfig, resolveCodexMcpConfig, resolveCopilotMcpConfig, resolveCursorMcpConfig, } from "./provider-adapters.js";
13
11
  import { getMcpEnabledPreference, getHooksEnabledPreference } from "./init-preferences.js";
14
- import { resolveEntryScript, VERSION } from "./init-shared.js";
15
- function log(msg) {
16
- process.stdout.write(msg + "\n");
17
- }
12
+ import { resolveEntryScript, log, VERSION } from "./init-shared.js";
18
13
  function getObjectProp(value, key) {
19
14
  const candidate = value[key];
20
15
  return isRecord(candidate) ? candidate : undefined;
21
16
  }
22
- function atomicWriteText(filePath, content) {
23
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
24
- const tmpPath = `${filePath}.tmp-${randomUUID()}`;
25
- fs.writeFileSync(tmpPath, content);
26
- fs.renameSync(tmpPath, filePath);
27
- }
28
17
  export function patchJsonFile(filePath, patch) {
29
18
  let data = {};
30
19
  if (fs.existsSync(filePath)) {
@@ -44,16 +33,6 @@ export function patchJsonFile(filePath, patch) {
44
33
  patch(data);
45
34
  atomicWriteText(filePath, JSON.stringify(data, null, 2) + "\n");
46
35
  }
47
- function commandExists(cmd) {
48
- try {
49
- const whichCmd = process.platform === "win32" ? "where.exe" : "which";
50
- execFileSync(whichCmd, [cmd], { stdio: ["ignore", "ignore", "ignore"], timeout: EXEC_TIMEOUT_QUICK_MS });
51
- return true;
52
- }
53
- catch {
54
- return false;
55
- }
56
- }
57
36
  function buildMcpServerConfig(phrenPath) {
58
37
  const entryScript = resolveEntryScript();
59
38
  if (entryScript && fs.existsSync(entryScript)) {
@@ -1087,14 +1087,14 @@ export function updateMachinesYaml(phrenPath, machine, profile) {
1087
1087
  }
1088
1088
  }
1089
1089
  catch (err) {
1090
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1091
- process.stderr.write(`[phren] updateMachinesYaml parse: ${err instanceof Error ? err.message : String(err)}\n`);
1090
+ if ((process.env.PHREN_DEBUG))
1091
+ process.stderr.write(`[phren] updateMachinesYaml parse: ${errorMessage(err)}\n`);
1092
1092
  }
1093
1093
  // Passive init/link refreshes should keep an existing mapping; explicit overrides can remap.
1094
1094
  if (hasExistingMapping && !machine && !profile)
1095
1095
  return;
1096
1096
  const mapping = setMachineProfile(phrenPath, machineName, profileName);
1097
- if (!mapping.ok && (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG)) {
1097
+ if (!mapping.ok && (process.env.PHREN_DEBUG)) {
1098
1098
  process.stderr.write(`[phren] updateMachinesYaml setMachineProfile: ${mapping.error}\n`);
1099
1099
  }
1100
1100
  }
@@ -1289,8 +1289,8 @@ export function runPostInitVerify(phrenPath) {
1289
1289
  ftsOk = entries.some(d => d.isDirectory() && !d.name.startsWith("."));
1290
1290
  }
1291
1291
  catch (err) {
1292
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1293
- process.stderr.write(`[phren] runPostInitVerify projectScan: ${err instanceof Error ? err.message : String(err)}\n`);
1292
+ if ((process.env.PHREN_DEBUG))
1293
+ process.stderr.write(`[phren] runPostInitVerify projectScan: ${errorMessage(err)}\n`);
1294
1294
  ftsOk = false;
1295
1295
  }
1296
1296
  checks.push({
package/mcp/dist/init.js CHANGED
@@ -8,7 +8,7 @@ import * as crypto from "crypto";
8
8
  import { execFileSync } from "child_process";
9
9
  import { configureAllHooks } from "./hooks.js";
10
10
  import { getMachineName, machineFilePath, persistMachineName } from "./machine-identity.js";
11
- import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, expandHomePath, findPhrenPath, readRootManifest, writeRootManifest, } from "./shared.js";
11
+ import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, expandHomePath, findPhrenPath, getProjectDirs, readRootManifest, writeRootManifest, } from "./shared.js";
12
12
  import { isValidProjectName, errorMessage } from "./utils.js";
13
13
  import { codexJsonCandidates, copilotMcpCandidates, cursorMcpCandidates, vscodeMcpCandidates, } from "./provider-adapters.js";
14
14
  export { configureClaude, configureVSCode, configureCursorMcp, configureCopilotMcp, configureCodexMcp, logMcpTargetStatus, resetVSCodeProbeCache, patchJsonFile, } from "./init-config.js";
@@ -406,7 +406,7 @@ async function runWalkthrough(phrenPath) {
406
406
  }
407
407
  }
408
408
  catch (err) {
409
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
409
+ if ((process.env.PHREN_DEBUG))
410
410
  process.stderr.write(`[phren] init ollamaCheck: ${errorMessage(err)}\n`);
411
411
  }
412
412
  printSection("Auto-Capture (Optional)");
@@ -910,32 +910,36 @@ export async function runInit(opts = {}) {
910
910
  }
911
911
  let phrenPath = resolveInitPhrenPath(opts);
912
912
  const dryRun = Boolean(opts.dryRun);
913
- // Migrate ~/.cortex ~/.phren when upgrading from the old name.
914
- // Only runs when the resolved phrenPath doesn't exist yet but ~/.cortex does.
913
+ // Migrate the legacy hidden store directory into ~/.phren when upgrading
914
+ // from the previous product name. Only runs when the resolved phrenPath
915
+ // doesn't exist yet but the legacy directory does.
915
916
  if (!opts._walkthroughStoragePath && !fs.existsSync(phrenPath)) {
916
- const cortexPath = path.resolve(homePath(".cortex"));
917
- if (cortexPath !== phrenPath && fs.existsSync(cortexPath) && hasInstallMarkers(cortexPath)) {
917
+ // Pre-rebrand directory name — kept as literal for migration
918
+ const legacyPath = path.resolve(homePath(".cortex"));
919
+ if (legacyPath !== phrenPath && fs.existsSync(legacyPath) && hasInstallMarkers(legacyPath)) {
918
920
  if (!dryRun) {
919
- fs.renameSync(cortexPath, phrenPath);
921
+ fs.renameSync(legacyPath, phrenPath);
920
922
  }
921
- console.log(`Migrated ~/.cortex → ~/.phren`);
923
+ console.log(`Migrated legacy store → ~/.phren`);
922
924
  }
923
925
  }
924
- // Rename stale cortex-*.md skill files left over from the rebrand.
925
- // Runs on every init so users who already migrated the directory still get the fix.
926
+ // Rename stale legacy skill names left over from the rebrand. Runs on every
927
+ // init so users who already migrated the directory still get the fix.
926
928
  const skillsMigrateDir = path.join(phrenPath, "global", "skills");
927
929
  if (!dryRun && fs.existsSync(skillsMigrateDir)) {
930
+ const legacySkillName = "cortex.md";
931
+ const legacySkillPrefix = "cortex-";
928
932
  for (const entry of fs.readdirSync(skillsMigrateDir)) {
929
933
  if (!entry.endsWith(".md"))
930
934
  continue;
931
- if (entry === "cortex.md") {
935
+ if (entry === legacySkillName) {
932
936
  const dest = path.join(skillsMigrateDir, "phren.md");
933
937
  if (!fs.existsSync(dest)) {
934
938
  fs.renameSync(path.join(skillsMigrateDir, entry), dest);
935
939
  }
936
940
  }
937
- else if (entry.startsWith("cortex-")) {
938
- const newName = entry.replace(/^cortex-/, "phren-");
941
+ else if (entry.startsWith(legacySkillPrefix)) {
942
+ const newName = `phren-${entry.slice(legacySkillPrefix.length)}`;
939
943
  const dest = path.join(skillsMigrateDir, newName);
940
944
  if (!fs.existsSync(dest)) {
941
945
  fs.renameSync(path.join(skillsMigrateDir, entry), dest);
@@ -1381,7 +1385,7 @@ export async function runInit(opts = {}) {
1381
1385
  }
1382
1386
  }
1383
1387
  catch (err) {
1384
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1388
+ if ((process.env.PHREN_DEBUG))
1385
1389
  process.stderr.write(`[phren] init ollamaInstallHint: ${errorMessage(err)}\n`);
1386
1390
  }
1387
1391
  }
@@ -1554,7 +1558,122 @@ export async function runHooksMode(modeArg) {
1554
1558
  log(`Claude status: ${claudeStatus}`);
1555
1559
  log(`Restart your agent to apply changes.`);
1556
1560
  }
1557
- export async function runUninstall() {
1561
+ // Agent skill directories to sweep for symlinks during uninstall
1562
+ function agentSkillDirs() {
1563
+ const home = homeDir();
1564
+ return [
1565
+ homePath(".claude", "skills"),
1566
+ path.join(home, ".cursor", "skills"),
1567
+ path.join(home, ".copilot", "skills"),
1568
+ path.join(home, ".codex", "skills"),
1569
+ ];
1570
+ }
1571
+ // Remove skill symlinks that resolve inside phrenPath. Only touches symlinks, never regular files.
1572
+ function sweepSkillSymlinks(phrenPath) {
1573
+ const resolvedPhren = path.resolve(phrenPath);
1574
+ for (const dir of agentSkillDirs()) {
1575
+ if (!fs.existsSync(dir))
1576
+ continue;
1577
+ let entries;
1578
+ try {
1579
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1580
+ }
1581
+ catch (err) {
1582
+ debugLog(`sweepSkillSymlinks: readdirSync failed for ${dir}: ${errorMessage(err)}`);
1583
+ continue;
1584
+ }
1585
+ for (const entry of entries) {
1586
+ if (!entry.isSymbolicLink())
1587
+ continue;
1588
+ const fullPath = path.join(dir, entry.name);
1589
+ try {
1590
+ const target = fs.realpathSync(fullPath);
1591
+ if (target.startsWith(resolvedPhren + path.sep) || target === resolvedPhren) {
1592
+ fs.unlinkSync(fullPath);
1593
+ log(` Removed skill symlink: ${fullPath}`);
1594
+ }
1595
+ }
1596
+ catch (err) {
1597
+ debugLog(`sweepSkillSymlinks: could not check/remove ${fullPath}: ${errorMessage(err)}`);
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ // Filter phren hook entries from an agent hooks file. Returns true if the file was changed.
1603
+ // Deletes the file if no hooks remain. `commandField` is the JSON key holding the command
1604
+ // string in each hook entry (e.g. "bash" for Copilot, "command" for Codex).
1605
+ function filterAgentHooks(filePath, commandField) {
1606
+ if (!fs.existsSync(filePath))
1607
+ return false;
1608
+ try {
1609
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
1610
+ if (!isRecord(raw) || !isRecord(raw.hooks))
1611
+ return false;
1612
+ const hooks = raw.hooks;
1613
+ let changed = false;
1614
+ for (const event of Object.keys(hooks)) {
1615
+ const entries = hooks[event];
1616
+ if (!Array.isArray(entries))
1617
+ continue;
1618
+ const filtered = entries.filter((e) => !(isRecord(e) && typeof e[commandField] === "string" && isPhrenCommand(e[commandField])));
1619
+ if (filtered.length !== entries.length) {
1620
+ hooks[event] = filtered;
1621
+ changed = true;
1622
+ }
1623
+ }
1624
+ if (!changed)
1625
+ return false;
1626
+ // Remove empty hook event keys
1627
+ for (const event of Object.keys(hooks)) {
1628
+ if (Array.isArray(hooks[event]) && hooks[event].length === 0) {
1629
+ delete hooks[event];
1630
+ }
1631
+ }
1632
+ if (Object.keys(hooks).length === 0) {
1633
+ fs.unlinkSync(filePath);
1634
+ }
1635
+ else {
1636
+ atomicWriteText(filePath, JSON.stringify(raw, null, 2));
1637
+ }
1638
+ return true;
1639
+ }
1640
+ catch (err) {
1641
+ debugLog(`filterAgentHooks: failed for ${filePath}: ${errorMessage(err)}`);
1642
+ return false;
1643
+ }
1644
+ }
1645
+ async function promptUninstallConfirm(phrenPath) {
1646
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1647
+ return true;
1648
+ // Show summary of what will be deleted
1649
+ try {
1650
+ const projectDirs = getProjectDirs(phrenPath);
1651
+ const projectCount = projectDirs.length;
1652
+ let findingCount = 0;
1653
+ for (const dir of projectDirs) {
1654
+ const findingsFile = path.join(dir, "FINDINGS.md");
1655
+ if (fs.existsSync(findingsFile)) {
1656
+ const content = fs.readFileSync(findingsFile, "utf8");
1657
+ findingCount += content.split("\n").filter((l) => l.startsWith("- ")).length;
1658
+ }
1659
+ }
1660
+ log(`\n Will delete: ${phrenPath}`);
1661
+ log(` Contains: ${projectCount} project(s), ~${findingCount} finding(s)`);
1662
+ }
1663
+ catch (err) {
1664
+ debugLog(`promptUninstallConfirm: summary failed: ${errorMessage(err)}`);
1665
+ log(`\n Will delete: ${phrenPath}`);
1666
+ }
1667
+ const readline = await import("readline");
1668
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1669
+ return new Promise((resolve) => {
1670
+ rl.question(`\nThis will permanently delete ${phrenPath} and all phren data. Type 'yes' to confirm: `, (answer) => {
1671
+ rl.close();
1672
+ resolve(answer.trim().toLowerCase() === "yes");
1673
+ });
1674
+ });
1675
+ }
1676
+ export async function runUninstall(opts = {}) {
1558
1677
  const phrenPath = findPhrenPath();
1559
1678
  const manifest = phrenPath ? readRootManifest(phrenPath) : null;
1560
1679
  if (manifest?.installMode === "project-local" && phrenPath) {
@@ -1575,6 +1694,27 @@ export async function runUninstall() {
1575
1694
  return;
1576
1695
  }
1577
1696
  log("\nUninstalling phren...\n");
1697
+ // Confirmation prompt (shared-mode only — project-local is low-stakes)
1698
+ if (!opts.yes) {
1699
+ const confirmed = phrenPath
1700
+ ? await promptUninstallConfirm(phrenPath)
1701
+ : (process.stdin.isTTY && process.stdout.isTTY
1702
+ ? await (async () => {
1703
+ const readline = await import("readline");
1704
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1705
+ return new Promise((resolve) => {
1706
+ rl.question("This will remove all phren config and hooks. Type 'yes' to confirm: ", (answer) => {
1707
+ rl.close();
1708
+ resolve(answer.trim().toLowerCase() === "yes");
1709
+ });
1710
+ });
1711
+ })()
1712
+ : true);
1713
+ if (!confirmed) {
1714
+ log("Uninstall cancelled.");
1715
+ return;
1716
+ }
1717
+ }
1578
1718
  const home = homeDir();
1579
1719
  const machineFile = machineFilePath();
1580
1720
  const settingsPath = hookConfigPath("claude");
@@ -1677,12 +1817,11 @@ export async function runUninstall() {
1677
1817
  debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
1678
1818
  }
1679
1819
  }
1680
- // Remove Copilot hooks file (written by configureAllHooks)
1820
+ // Remove phren entries from Copilot hooks file (filter, don't bulk-delete)
1681
1821
  const copilotHooksFile = hookConfigPath("copilot", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1682
1822
  try {
1683
- if (fs.existsSync(copilotHooksFile)) {
1684
- fs.unlinkSync(copilotHooksFile);
1685
- log(` Removed Copilot hooks file (${copilotHooksFile})`);
1823
+ if (filterAgentHooks(copilotHooksFile, "bash")) {
1824
+ log(` Removed phren entries from Copilot hooks (${copilotHooksFile})`);
1686
1825
  }
1687
1826
  }
1688
1827
  catch (err) {
@@ -1709,13 +1848,12 @@ export async function runUninstall() {
1709
1848
  catch (err) {
1710
1849
  debugLog(`uninstall: cleanup failed for ${cursorHooksFile}: ${errorMessage(err)}`);
1711
1850
  }
1712
- // Remove Codex hooks file in phren path
1851
+ // Remove phren entries from Codex hooks file (filter, don't bulk-delete)
1713
1852
  const uninstallPhrenPath = (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1714
1853
  const codexHooksFile = hookConfigPath("codex", uninstallPhrenPath);
1715
1854
  try {
1716
- if (fs.existsSync(codexHooksFile)) {
1717
- fs.unlinkSync(codexHooksFile);
1718
- log(` Removed Codex hooks file (${codexHooksFile})`);
1855
+ if (filterAgentHooks(codexHooksFile, "command")) {
1856
+ log(` Removed phren entries from Codex hooks (${codexHooksFile})`);
1719
1857
  }
1720
1858
  }
1721
1859
  catch (err) {
@@ -1748,6 +1886,15 @@ export async function runUninstall() {
1748
1886
  catch (err) {
1749
1887
  debugLog(`uninstall: cleanup failed for ${machineFile}: ${errorMessage(err)}`);
1750
1888
  }
1889
+ // Sweep agent skill directories for symlinks pointing into the phren store
1890
+ if (phrenPath) {
1891
+ try {
1892
+ sweepSkillSymlinks(phrenPath);
1893
+ }
1894
+ catch (err) {
1895
+ debugLog(`uninstall: skill symlink sweep failed: ${errorMessage(err)}`);
1896
+ }
1897
+ }
1751
1898
  if (phrenPath && fs.existsSync(phrenPath)) {
1752
1899
  try {
1753
1900
  fs.rmSync(phrenPath, { recursive: true, force: true });
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import * as crypto from "crypto";
4
4
  import { getProjectDirs } from "./shared.js";
5
5
  import { TASK_FILE_ALIASES } from "./data-tasks.js";
6
+ import { errorMessage } from "./utils.js";
6
7
  function fileChecksum(filePath) {
7
8
  const content = fs.readFileSync(filePath);
8
9
  return crypto.createHash("sha256").update(content).digest("hex");
@@ -18,8 +19,8 @@ function loadChecksums(phrenPath) {
18
19
  return JSON.parse(fs.readFileSync(file, "utf8"));
19
20
  }
20
21
  catch (err) {
21
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
22
- process.stderr.write(`[phren] loadChecksums: ${err instanceof Error ? err.message : String(err)}\n`);
22
+ if ((process.env.PHREN_DEBUG))
23
+ process.stderr.write(`[phren] loadChecksums: ${errorMessage(err)}\n`);
23
24
  return {};
24
25
  }
25
26
  }
@@ -4,7 +4,7 @@ import * as yaml from "js-yaml";
4
4
  import { isValidProjectName } from "./utils.js";
5
5
  import { homeDir, homePath } from "./shared.js";
6
6
  import { resolveTaskFilePath } from "./data-tasks.js";
7
- function log(msg) { process.stdout.write(msg + "\n"); }
7
+ import { log } from "./init-shared.js";
8
8
  function contextFilePath() {
9
9
  return homePath(".phren-context.md");
10
10
  }
@@ -199,14 +199,14 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
199
199
  fsMs = Date.now() - t0;
200
200
  }
201
201
  catch (err) {
202
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
202
+ if ((process.env.PHREN_DEBUG))
203
203
  process.stderr.write(`[phren] doctor fsBenchmark: ${errorMessage(err)}\n`);
204
204
  fsMs = -1;
205
205
  try {
206
206
  fs.unlinkSync(fsBenchFile);
207
207
  }
208
208
  catch (e2) {
209
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
209
+ if ((process.env.PHREN_DEBUG))
210
210
  process.stderr.write(`[phren] doctor fsBenchmarkCleanup: ${e2 instanceof Error ? e2.message : String(e2)}\n`);
211
211
  }
212
212
  }
@@ -329,7 +329,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
329
329
  runtime = JSON.parse(fs.readFileSync(runtimeHealthPath, "utf8"));
330
330
  }
331
331
  catch (err) {
332
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
332
+ if ((process.env.PHREN_DEBUG))
333
333
  process.stderr.write(`[phren] doctor runtimeHealth: ${errorMessage(err)}\n`);
334
334
  runtime = null;
335
335
  }
@@ -108,7 +108,75 @@ export function readSkillManifestHooks(phrenPath) {
108
108
  }
109
109
  return Object.keys(result).length > 0 ? result : null;
110
110
  }
111
- // ── Skill linking helpers ───────────────────────────────────────────────────
111
+ /**
112
+ * Returns true if `destPath` is a symlink whose resolved target lives under
113
+ * `managedRoot`. Used to decide whether phren owns a symlink.
114
+ */
115
+ export function isManagedSymlink(destPath, managedRoot) {
116
+ try {
117
+ const stat = fs.lstatSync(destPath);
118
+ if (!stat.isSymbolicLink())
119
+ return false;
120
+ const target = fs.readlinkSync(destPath);
121
+ const resolvedTarget = path.resolve(path.dirname(destPath), target);
122
+ const managedPrefix = path.resolve(managedRoot) + path.sep;
123
+ return resolvedTarget.startsWith(managedPrefix);
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * Returns true if `destPath` exists and is NOT a symlink that points into
131
+ * managedRoot — i.e. it's a file/dir the user owns.
132
+ */
133
+ function isUserOwnedFile(destPath, managedRoot) {
134
+ try {
135
+ fs.lstatSync(destPath);
136
+ }
137
+ catch {
138
+ return false; // doesn't exist
139
+ }
140
+ return !isManagedSymlink(destPath, managedRoot);
141
+ }
142
+ /**
143
+ * Scan destDir for files that phren would want to link (based on srcDir) but
144
+ * can't because a user-owned file already occupies the destination slot.
145
+ */
146
+ export function detectSkillCollisions(srcDir, destDir, managedRoot) {
147
+ if (!fs.existsSync(srcDir) || !fs.existsSync(destDir))
148
+ return [];
149
+ const collisions = [];
150
+ for (const entry of fs.readdirSync(srcDir)) {
151
+ const srcPath = path.join(srcDir, entry);
152
+ const stat = fs.statSync(srcPath);
153
+ if (stat.isFile() && entry.endsWith(".md")) {
154
+ const destPath = path.join(destDir, entry);
155
+ if (isUserOwnedFile(destPath, managedRoot)) {
156
+ const skillName = entry.replace(/\.md$/, "");
157
+ collisions.push({
158
+ skillName,
159
+ destPath,
160
+ message: `Skill '${skillName}' — user file already exists at ${destPath}. Rename or remove it to use phren's version.`,
161
+ });
162
+ }
163
+ }
164
+ else if (stat.isDirectory()) {
165
+ const skillFile = path.join(srcPath, "SKILL.md");
166
+ if (fs.existsSync(skillFile)) {
167
+ const destPath = path.join(destDir, entry);
168
+ if (isUserOwnedFile(destPath, managedRoot)) {
169
+ collisions.push({
170
+ skillName: entry,
171
+ destPath,
172
+ message: `Skill '${entry}' — user directory already exists at ${destPath}. Rename or remove it to use phren's version.`,
173
+ });
174
+ }
175
+ }
176
+ }
177
+ }
178
+ return collisions;
179
+ }
112
180
  function cleanupManagedSkillLinks(destDir, expectedNames, managedRoot) {
113
181
  if (!fs.existsSync(destDir))
114
182
  return;
@@ -117,27 +185,22 @@ function cleanupManagedSkillLinks(destDir, expectedNames, managedRoot) {
117
185
  continue;
118
186
  const destPath = path.join(destDir, entry);
119
187
  try {
120
- const stat = fs.lstatSync(destPath);
121
- if (!stat.isSymbolicLink())
122
- continue;
123
- const target = fs.readlinkSync(destPath);
124
- const resolvedTarget = path.resolve(path.dirname(destPath), target);
125
- const managedPrefix = path.resolve(managedRoot) + path.sep;
126
- if (!resolvedTarget.startsWith(managedPrefix))
188
+ if (!isManagedSymlink(destPath, managedRoot))
127
189
  continue;
128
190
  fs.unlinkSync(destPath);
129
191
  }
130
192
  catch (err) {
131
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
193
+ if ((process.env.PHREN_DEBUG))
132
194
  process.stderr.write(`[phren] cleanupManagedSkillLinks: ${errorMessage(err)}\n`);
133
195
  }
134
196
  }
135
197
  }
136
198
  export function linkSkillsDir(srcDir, destDir, managedRoot, symlinkFile, opts) {
137
199
  if (!fs.existsSync(srcDir))
138
- return;
200
+ return [];
139
201
  fs.mkdirSync(destDir, { recursive: true });
140
202
  const expectedNames = new Set();
203
+ const collisions = [];
141
204
  for (const entry of fs.readdirSync(srcDir)) {
142
205
  const srcPath = path.join(srcDir, entry);
143
206
  const stat = fs.statSync(srcPath);
@@ -146,20 +209,43 @@ export function linkSkillsDir(srcDir, destDir, managedRoot, symlinkFile, opts) {
146
209
  continue;
147
210
  }
148
211
  if (stat.isFile() && entry.endsWith(".md")) {
212
+ const destPath = path.join(destDir, entry);
213
+ if (isUserOwnedFile(destPath, managedRoot)) {
214
+ const collision = {
215
+ skillName,
216
+ destPath,
217
+ message: `Skipping skill '${skillName}' — user skill already exists at ${destPath}. To use phren's version, rename or remove your skill first.`,
218
+ };
219
+ collisions.push(collision);
220
+ process.stderr.write(`[phren] ${collision.message}\n`);
221
+ continue;
222
+ }
149
223
  expectedNames.add(entry);
150
- symlinkFile(srcPath, path.join(destDir, entry), managedRoot);
224
+ symlinkFile(srcPath, destPath, managedRoot);
151
225
  }
152
226
  else if (stat.isDirectory()) {
153
227
  const skillFile = path.join(srcPath, "SKILL.md");
154
228
  if (fs.existsSync(skillFile)) {
229
+ const destPath = path.join(destDir, entry);
230
+ if (isUserOwnedFile(destPath, managedRoot)) {
231
+ const collision = {
232
+ skillName,
233
+ destPath,
234
+ message: `Skipping skill '${skillName}' — user skill already exists at ${destPath}. To use phren's version, rename or remove your skill first.`,
235
+ };
236
+ collisions.push(collision);
237
+ process.stderr.write(`[phren] ${collision.message}\n`);
238
+ continue;
239
+ }
155
240
  expectedNames.add(entry);
156
241
  // Symlink the entire skill directory so bundled scripts and assets are accessible.
157
242
  // Relative paths in the skill body remain valid because the directory structure is preserved.
158
- symlinkFile(srcPath, path.join(destDir, entry), managedRoot);
243
+ symlinkFile(srcPath, destPath, managedRoot);
159
244
  }
160
245
  }
161
246
  }
162
247
  cleanupManagedSkillLinks(destDir, expectedNames, managedRoot);
248
+ return collisions;
163
249
  }
164
250
  export function writeSkillMd(phrenPath) {
165
251
  const lifecycle = buildSharedLifecycleCommands();