@phren/cli 0.0.1

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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/mcp/dist/capabilities/cli.js +61 -0
  4. package/mcp/dist/capabilities/index.js +15 -0
  5. package/mcp/dist/capabilities/mcp.js +61 -0
  6. package/mcp/dist/capabilities/types.js +57 -0
  7. package/mcp/dist/capabilities/vscode.js +61 -0
  8. package/mcp/dist/capabilities/web-ui.js +61 -0
  9. package/mcp/dist/cli-actions.js +302 -0
  10. package/mcp/dist/cli-config.js +580 -0
  11. package/mcp/dist/cli-extract.js +305 -0
  12. package/mcp/dist/cli-govern.js +371 -0
  13. package/mcp/dist/cli-graph.js +169 -0
  14. package/mcp/dist/cli-hooks-citations.js +44 -0
  15. package/mcp/dist/cli-hooks-context.js +56 -0
  16. package/mcp/dist/cli-hooks-globs.js +83 -0
  17. package/mcp/dist/cli-hooks-output.js +130 -0
  18. package/mcp/dist/cli-hooks-retrieval.js +2 -0
  19. package/mcp/dist/cli-hooks-session.js +1402 -0
  20. package/mcp/dist/cli-hooks.js +350 -0
  21. package/mcp/dist/cli-namespaces.js +989 -0
  22. package/mcp/dist/cli-ops.js +253 -0
  23. package/mcp/dist/cli-search.js +407 -0
  24. package/mcp/dist/cli.js +108 -0
  25. package/mcp/dist/content-archive.js +278 -0
  26. package/mcp/dist/content-citation.js +391 -0
  27. package/mcp/dist/content-dedup.js +622 -0
  28. package/mcp/dist/content-learning.js +472 -0
  29. package/mcp/dist/content-metadata.js +186 -0
  30. package/mcp/dist/content-validate.js +462 -0
  31. package/mcp/dist/core-finding.js +54 -0
  32. package/mcp/dist/core-project.js +36 -0
  33. package/mcp/dist/core-search.js +50 -0
  34. package/mcp/dist/data-access.js +400 -0
  35. package/mcp/dist/data-tasks.js +821 -0
  36. package/mcp/dist/embedding.js +344 -0
  37. package/mcp/dist/entrypoint.js +387 -0
  38. package/mcp/dist/finding-context.js +172 -0
  39. package/mcp/dist/finding-impact.js +181 -0
  40. package/mcp/dist/finding-journal.js +122 -0
  41. package/mcp/dist/finding-lifecycle.js +259 -0
  42. package/mcp/dist/governance-audit.js +22 -0
  43. package/mcp/dist/governance-locks.js +96 -0
  44. package/mcp/dist/governance-policy.js +648 -0
  45. package/mcp/dist/governance-scores.js +355 -0
  46. package/mcp/dist/hooks.js +449 -0
  47. package/mcp/dist/impact-scoring.js +22 -0
  48. package/mcp/dist/index-query.js +168 -0
  49. package/mcp/dist/index.js +205 -0
  50. package/mcp/dist/init-config.js +336 -0
  51. package/mcp/dist/init-preferences.js +62 -0
  52. package/mcp/dist/init-setup.js +1305 -0
  53. package/mcp/dist/init-shared.js +29 -0
  54. package/mcp/dist/init.js +1730 -0
  55. package/mcp/dist/link-checksums.js +62 -0
  56. package/mcp/dist/link-context.js +257 -0
  57. package/mcp/dist/link-doctor.js +591 -0
  58. package/mcp/dist/link-skills.js +212 -0
  59. package/mcp/dist/link.js +596 -0
  60. package/mcp/dist/logger.js +15 -0
  61. package/mcp/dist/machine-identity.js +38 -0
  62. package/mcp/dist/mcp-config.js +254 -0
  63. package/mcp/dist/mcp-data.js +315 -0
  64. package/mcp/dist/mcp-extract-facts.js +78 -0
  65. package/mcp/dist/mcp-extract.js +133 -0
  66. package/mcp/dist/mcp-finding.js +557 -0
  67. package/mcp/dist/mcp-graph.js +339 -0
  68. package/mcp/dist/mcp-hooks.js +256 -0
  69. package/mcp/dist/mcp-memory.js +58 -0
  70. package/mcp/dist/mcp-ops.js +328 -0
  71. package/mcp/dist/mcp-search.js +628 -0
  72. package/mcp/dist/mcp-session.js +651 -0
  73. package/mcp/dist/mcp-skills.js +189 -0
  74. package/mcp/dist/mcp-tasks.js +551 -0
  75. package/mcp/dist/mcp-types.js +7 -0
  76. package/mcp/dist/memory-ui-assets.js +6 -0
  77. package/mcp/dist/memory-ui-data.js +513 -0
  78. package/mcp/dist/memory-ui-graph.js +1910 -0
  79. package/mcp/dist/memory-ui-page.js +353 -0
  80. package/mcp/dist/memory-ui-scripts.js +1387 -0
  81. package/mcp/dist/memory-ui-server.js +1218 -0
  82. package/mcp/dist/memory-ui-styles.js +555 -0
  83. package/mcp/dist/memory-ui.js +9 -0
  84. package/mcp/dist/package-metadata.js +13 -0
  85. package/mcp/dist/phren-art.js +52 -0
  86. package/mcp/dist/phren-core.js +108 -0
  87. package/mcp/dist/phren-dotenv.js +67 -0
  88. package/mcp/dist/phren-paths.js +476 -0
  89. package/mcp/dist/proactivity.js +172 -0
  90. package/mcp/dist/profile-store.js +228 -0
  91. package/mcp/dist/project-config.js +85 -0
  92. package/mcp/dist/project-locator.js +25 -0
  93. package/mcp/dist/project-topics.js +1134 -0
  94. package/mcp/dist/provider-adapters.js +176 -0
  95. package/mcp/dist/runtime-profile.js +18 -0
  96. package/mcp/dist/session-checkpoints.js +131 -0
  97. package/mcp/dist/session-utils.js +68 -0
  98. package/mcp/dist/shared-content.js +8 -0
  99. package/mcp/dist/shared-embedding-cache.js +143 -0
  100. package/mcp/dist/shared-fragment-graph.js +456 -0
  101. package/mcp/dist/shared-governance.js +4 -0
  102. package/mcp/dist/shared-index.js +1334 -0
  103. package/mcp/dist/shared-ollama.js +192 -0
  104. package/mcp/dist/shared-paths.js +1 -0
  105. package/mcp/dist/shared-retrieval.js +796 -0
  106. package/mcp/dist/shared-search-fallback.js +375 -0
  107. package/mcp/dist/shared-sqljs.js +42 -0
  108. package/mcp/dist/shared-stemmer.js +171 -0
  109. package/mcp/dist/shared-vector-index.js +199 -0
  110. package/mcp/dist/shared.js +114 -0
  111. package/mcp/dist/shell-entry.js +209 -0
  112. package/mcp/dist/shell-input.js +943 -0
  113. package/mcp/dist/shell-palette.js +119 -0
  114. package/mcp/dist/shell-render.js +252 -0
  115. package/mcp/dist/shell-state-store.js +81 -0
  116. package/mcp/dist/shell-types.js +13 -0
  117. package/mcp/dist/shell-view-list.js +14 -0
  118. package/mcp/dist/shell-view.js +707 -0
  119. package/mcp/dist/shell.js +352 -0
  120. package/mcp/dist/skill-files.js +117 -0
  121. package/mcp/dist/skill-registry.js +279 -0
  122. package/mcp/dist/skill-state.js +28 -0
  123. package/mcp/dist/startup-embedding.js +57 -0
  124. package/mcp/dist/status.js +323 -0
  125. package/mcp/dist/synonyms.json +670 -0
  126. package/mcp/dist/task-hygiene.js +251 -0
  127. package/mcp/dist/task-lifecycle.js +347 -0
  128. package/mcp/dist/tasks-github.js +76 -0
  129. package/mcp/dist/telemetry.js +165 -0
  130. package/mcp/dist/test-global-setup.js +37 -0
  131. package/mcp/dist/tool-registry.js +104 -0
  132. package/mcp/dist/update.js +97 -0
  133. package/mcp/dist/utils.js +543 -0
  134. package/package.json +67 -0
  135. package/skills/README.md +7 -0
  136. package/skills/consolidate/SKILL.md +152 -0
  137. package/skills/discover/SKILL.md +175 -0
  138. package/skills/init/SKILL.md +216 -0
  139. package/skills/profiles/SKILL.md +121 -0
  140. package/skills/sync/SKILL.md +261 -0
  141. package/starter/README.md +74 -0
  142. package/starter/global/CLAUDE.md +89 -0
  143. package/starter/global/skills/humanize.md +30 -0
  144. package/starter/global/skills/pipeline.md +35 -0
  145. package/starter/global/skills/release.md +35 -0
  146. package/starter/machines.yaml +8 -0
  147. package/starter/my-api/.claude/skills/README.md +7 -0
  148. package/starter/my-api/CLAUDE.md +33 -0
  149. package/starter/my-api/FINDINGS.md +9 -0
  150. package/starter/my-api/summary.md +7 -0
  151. package/starter/my-api/tasks.md +7 -0
  152. package/starter/my-first-project/.claude/skills/README.md +7 -0
  153. package/starter/my-first-project/CLAUDE.md +49 -0
  154. package/starter/my-first-project/FINDINGS.md +24 -0
  155. package/starter/my-first-project/summary.md +11 -0
  156. package/starter/my-first-project/tasks.md +25 -0
  157. package/starter/my-frontend/.claude/skills/README.md +7 -0
  158. package/starter/my-frontend/CLAUDE.md +33 -0
  159. package/starter/my-frontend/FINDINGS.md +9 -0
  160. package/starter/my-frontend/summary.md +7 -0
  161. package/starter/my-frontend/tasks.md +7 -0
  162. package/starter/profiles/default.yaml +4 -0
  163. package/starter/profiles/personal.yaml +4 -0
  164. package/starter/profiles/work.yaml +4 -0
  165. package/starter/templates/README.md +7 -0
  166. package/starter/templates/frontend/CLAUDE.md +23 -0
  167. package/starter/templates/frontend/FINDINGS.md +7 -0
  168. package/starter/templates/frontend/reference/README.md +4 -0
  169. package/starter/templates/frontend/summary.md +7 -0
  170. package/starter/templates/frontend/tasks.md +11 -0
  171. package/starter/templates/library/CLAUDE.md +22 -0
  172. package/starter/templates/library/FINDINGS.md +7 -0
  173. package/starter/templates/library/reference/README.md +4 -0
  174. package/starter/templates/library/summary.md +7 -0
  175. package/starter/templates/library/tasks.md +11 -0
  176. package/starter/templates/monorepo/CLAUDE.md +21 -0
  177. package/starter/templates/monorepo/FINDINGS.md +7 -0
  178. package/starter/templates/monorepo/reference/README.md +4 -0
  179. package/starter/templates/monorepo/summary.md +7 -0
  180. package/starter/templates/monorepo/tasks.md +11 -0
  181. package/starter/templates/python-project/CLAUDE.md +21 -0
  182. package/starter/templates/python-project/FINDINGS.md +7 -0
  183. package/starter/templates/python-project/reference/README.md +4 -0
  184. package/starter/templates/python-project/summary.md +7 -0
  185. package/starter/templates/python-project/tasks.md +10 -0
@@ -0,0 +1,596 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as crypto from "crypto";
4
+ import * as readline from "readline";
5
+ import * as yaml from "js-yaml";
6
+ import { execFileSync } from "child_process";
7
+ import { fileURLToPath } from "url";
8
+ import { configureClaude, configureCodexMcp, configureCopilotMcp, configureCursorMcp, configureVSCode, ensureGovernanceFiles, getHooksEnabledPreference, getMcpEnabledPreference, isVersionNewer, logMcpTargetStatus, patchJsonFile, setMcpEnabledPreference, } from "./init.js";
9
+ import { configureAllHooks, detectInstalledTools } from "./hooks.js";
10
+ import { getMachineName, persistMachineName } from "./machine-identity.js";
11
+ import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS, isRecord, homePath, hookConfigPath, installPreferencesFile, } from "./shared.js";
12
+ import { errorMessage } from "./utils.js";
13
+ import { listMachines as listMachinesShared, listProfiles as listProfilesShared, setMachineProfile, } from "./profile-store.js";
14
+ import { writeSkillMd } from "./link-skills.js";
15
+ import { syncScopeSkillsToDir } from "./skill-files.js";
16
+ import { renderSkillInstructionsSection } from "./skill-registry.js";
17
+ import { findProjectDir } from "./project-locator.js";
18
+ import { getProjectOwnershipMode, readProjectConfig, } from "./project-config.js";
19
+ import { writeContextDefault, writeContextDebugging, writeContextPlanning, writeContextClean, readBackNativeMemory, rebuildMemory, } from "./link-context.js";
20
+ // Re-export sub-modules so existing imports from "./link.js" continue to work
21
+ export { runDoctor } from "./link-doctor.js";
22
+ export { updateFileChecksums, verifyFileChecksums } from "./link-checksums.js";
23
+ export { findProjectDir } from "./project-locator.js";
24
+ export { parseSkillFrontmatter, validateSkillFrontmatter, validateSkillsDir, readSkillManifestHooks, } from "./link-skills.js";
25
+ // ── Helpers (exported for link-doctor) ──────────────────────────────────────
26
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
27
+ function log(msg) { process.stdout.write(msg + "\n"); }
28
+ function atomicWriteText(filePath, content) {
29
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
30
+ const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
31
+ fs.writeFileSync(tmpPath, content);
32
+ fs.renameSync(tmpPath, filePath);
33
+ }
34
+ export { getMachineName } from "./machine-identity.js";
35
+ export function lookupProfile(phrenPath, machine) {
36
+ const listed = listMachinesShared(phrenPath);
37
+ if (!listed.ok)
38
+ return "";
39
+ return listed.data[machine] || "";
40
+ }
41
+ function listProfiles(phrenPath) {
42
+ const listed = listProfilesShared(phrenPath);
43
+ if (!listed.ok)
44
+ return [];
45
+ return listed.data.map((profile) => ({ name: profile.name, description: "" }));
46
+ }
47
+ export function findProfileFile(phrenPath, profileName) {
48
+ const profilesDir = path.join(phrenPath, "profiles");
49
+ if (!fs.existsSync(profilesDir))
50
+ return null;
51
+ for (const f of fs.readdirSync(profilesDir)) {
52
+ if (!f.endsWith(".yaml"))
53
+ continue;
54
+ const data = yaml.load(fs.readFileSync(path.join(profilesDir, f), "utf8"), { schema: yaml.CORE_SCHEMA });
55
+ if (data?.name === profileName)
56
+ return path.join(profilesDir, f);
57
+ }
58
+ return null;
59
+ }
60
+ export function getProfileProjects(profileFile) {
61
+ const data = yaml.load(fs.readFileSync(profileFile, "utf8"), { schema: yaml.CORE_SCHEMA });
62
+ return Array.isArray(data?.projects) ? data.projects : [];
63
+ }
64
+ function currentPackageVersion() {
65
+ try {
66
+ const pkgPath = path.join(ROOT, "package.json");
67
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
68
+ return pkg.version || null;
69
+ }
70
+ catch (err) {
71
+ debugLog(`currentPackageVersion: failed to read package.json: ${errorMessage(err)}`);
72
+ return null;
73
+ }
74
+ }
75
+ function maybeOfferStarterTemplateUpdate(phrenPath) {
76
+ const current = currentPackageVersion();
77
+ if (!current)
78
+ return;
79
+ const prefsPath = installPreferencesFile(phrenPath);
80
+ if (!fs.existsSync(prefsPath))
81
+ return;
82
+ try {
83
+ const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
84
+ if (isVersionNewer(current, prefs.installedVersion)) {
85
+ log(` Starter template update available: v${prefs.installedVersion} -> v${current}`);
86
+ log(` Run \`npx phren init --apply-starter-update\` to refresh global/CLAUDE.md and global skills.`);
87
+ }
88
+ }
89
+ catch (err) {
90
+ debugLog(`checkStarterVersionUpdate: failed to read preferences: ${errorMessage(err)}`);
91
+ }
92
+ }
93
+ // ── Machine registration ────────────────────────────────────────────────────
94
+ async function registerMachine(phrenPath) {
95
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
96
+ const ask = (q) => new Promise(r => rl.question(q, r));
97
+ log("This machine isn't registered with phren yet.\n");
98
+ const machine = (await ask("What should this machine be called? (e.g. work-desktop): ")).trim();
99
+ if (!machine) {
100
+ rl.close();
101
+ throw new Error("Machine name can't be empty.");
102
+ }
103
+ log("\nAvailable profiles:");
104
+ for (const p of listProfiles(phrenPath))
105
+ log(` ${p.name} (${p.description})`);
106
+ log("");
107
+ const profile = (await ask("Which profile? ")).trim();
108
+ rl.close();
109
+ if (!profile)
110
+ throw new Error("Profile name can't be empty.");
111
+ if (!findProfileFile(phrenPath, profile))
112
+ throw new Error(`No profile named '${profile}' found.`);
113
+ const mapResult = setMachineProfile(phrenPath, machine, profile);
114
+ if (!mapResult.ok)
115
+ throw new Error(mapResult.error);
116
+ persistMachineName(machine);
117
+ log(`\nRegistered ${machine} with profile ${profile}.`);
118
+ return { machine, profile };
119
+ }
120
+ // ── Sparse checkout ─────────────────────────────────────────────────────────
121
+ function setupSparseCheckout(phrenPath, projects) {
122
+ try {
123
+ execFileSync("git", ["rev-parse", "--git-dir"], { cwd: phrenPath, stdio: "ignore", timeout: EXEC_TIMEOUT_QUICK_MS });
124
+ }
125
+ catch (err) {
126
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
127
+ process.stderr.write(`[phren] setupSparseCheckout notAGitRepo: ${errorMessage(err)}\n`);
128
+ return;
129
+ }
130
+ const alwaysInclude = ["profiles", "machines.yaml", "global", "scripts", "link.sh", "README.md", ".gitignore"];
131
+ const paths = [...alwaysInclude, ...projects];
132
+ try {
133
+ execFileSync("git", ["sparse-checkout", "set", ...paths], { cwd: phrenPath, stdio: "ignore", timeout: EXEC_TIMEOUT_MS });
134
+ execFileSync("git", ["pull", "--ff-only"], { cwd: phrenPath, stdio: "ignore", timeout: EXEC_TIMEOUT_MS });
135
+ }
136
+ catch (err) {
137
+ debugLog(`setupSparseCheckout: git sparse-checkout or pull failed: ${errorMessage(err)}`);
138
+ }
139
+ }
140
+ // ── Symlink helpers ─────────────────────────────────────────────────────────
141
+ /** Add entries to .git/info/exclude so phren-managed symlinks don't pollute git status.
142
+ * Skips files already tracked by git to avoid hiding user-owned content. */
143
+ function addGitExcludes(projectDir, entries) {
144
+ const gitDir = path.join(projectDir, ".git");
145
+ if (!fs.existsSync(gitDir))
146
+ return;
147
+ try {
148
+ // Filter out files already tracked by git — exclude only affects untracked files,
149
+ // and adding tracked files could confuse users who version-control their own CLAUDE.md
150
+ let tracked;
151
+ try {
152
+ const out = execFileSync("git", ["ls-files", "--", ...entries], {
153
+ cwd: projectDir,
154
+ timeout: EXEC_TIMEOUT_QUICK_MS,
155
+ encoding: "utf8",
156
+ });
157
+ tracked = new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
158
+ }
159
+ catch {
160
+ tracked = new Set();
161
+ }
162
+ const safe = entries.filter((e) => !tracked.has(e));
163
+ if (safe.length === 0)
164
+ return;
165
+ const excludePath = path.join(gitDir, "info", "exclude");
166
+ fs.mkdirSync(path.join(gitDir, "info"), { recursive: true });
167
+ const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
168
+ const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
169
+ const toAdd = safe.filter((e) => !existingLines.has(e));
170
+ if (toAdd.length === 0)
171
+ return;
172
+ const marker = "# phren-managed";
173
+ const needsMarker = !existingLines.has(marker);
174
+ const suffix = (needsMarker ? `\n${marker}\n` : "\n") + toAdd.join("\n") + "\n";
175
+ fs.appendFileSync(excludePath, suffix);
176
+ }
177
+ catch {
178
+ // git not available or fs issue — silently skip
179
+ }
180
+ }
181
+ function symlinkFile(src, dest, managedRoot) {
182
+ try {
183
+ const stat = fs.lstatSync(dest);
184
+ if (stat.isSymbolicLink()) {
185
+ const currentTarget = fs.readlinkSync(dest);
186
+ const resolvedTarget = path.resolve(path.dirname(dest), currentTarget);
187
+ const managedPrefix = path.resolve(managedRoot) + path.sep;
188
+ if (resolvedTarget === path.resolve(src))
189
+ return true;
190
+ if (!resolvedTarget.startsWith(managedPrefix)) {
191
+ log(` preserve existing symlink: ${dest}`);
192
+ return false;
193
+ }
194
+ fs.unlinkSync(dest);
195
+ }
196
+ else {
197
+ try {
198
+ if (stat.isFile() && fs.readFileSync(dest, "utf8") === fs.readFileSync(src, "utf8")) {
199
+ fs.unlinkSync(dest);
200
+ }
201
+ else {
202
+ const kind = stat.isDirectory() ? "directory" : "file";
203
+ log(` preserve existing ${kind}: ${dest}`);
204
+ return false;
205
+ }
206
+ }
207
+ catch {
208
+ log(` preserve existing file: ${dest}`);
209
+ return false;
210
+ }
211
+ }
212
+ }
213
+ catch (err) {
214
+ if (err.code !== "ENOENT")
215
+ throw err;
216
+ }
217
+ fs.symlinkSync(src, dest);
218
+ return true;
219
+ }
220
+ function addTokenAnnotation(filePath) {
221
+ const content = fs.readFileSync(filePath, "utf8");
222
+ if (content.startsWith("<!-- tokens:"))
223
+ return;
224
+ const tokens = Math.round(content.length / 3.5 + (content.match(/\s+/g) || []).length * 0.1);
225
+ if (tokens <= 500)
226
+ return;
227
+ const rounded = Math.round((tokens + 50) / 100) * 100;
228
+ atomicWriteText(filePath, `<!-- tokens: ~${rounded} -->\n${content}`);
229
+ }
230
+ const GENERATED_AGENTS_MARKER = "<!-- phren:generated-agents -->";
231
+ function writeManagedAgentsFile(src, dest, content, managedRoot) {
232
+ try {
233
+ const stat = fs.lstatSync(dest);
234
+ if (stat.isDirectory()) {
235
+ log(` preserve existing directory: ${dest}`);
236
+ return false;
237
+ }
238
+ if (stat.isSymbolicLink()) {
239
+ const currentTarget = fs.readlinkSync(dest);
240
+ const resolvedTarget = path.resolve(path.dirname(dest), currentTarget);
241
+ const managedPrefix = path.resolve(managedRoot) + path.sep;
242
+ if (resolvedTarget === path.resolve(src) || resolvedTarget.startsWith(managedPrefix)) {
243
+ fs.unlinkSync(dest);
244
+ }
245
+ else {
246
+ log(` preserve existing file: ${dest}`);
247
+ return false;
248
+ }
249
+ }
250
+ else {
251
+ const existing = fs.readFileSync(dest, "utf8");
252
+ if (!existing.includes(GENERATED_AGENTS_MARKER)) {
253
+ log(` preserve existing file: ${dest}`);
254
+ return false;
255
+ }
256
+ fs.unlinkSync(dest);
257
+ }
258
+ }
259
+ catch (err) {
260
+ if (err.code !== "ENOENT")
261
+ throw err;
262
+ }
263
+ atomicWriteText(dest, `${content.trimEnd()}\n`);
264
+ return true;
265
+ }
266
+ // ── Linking operations ──────────────────────────────────────────────────────
267
+ function linkGlobal(phrenPath, tools) {
268
+ log(" global skills -> ~/.claude/skills/");
269
+ const skillsDir = homePath(".claude", "skills");
270
+ syncScopeSkillsToDir(phrenPath, "global", skillsDir);
271
+ const globalClaude = path.join(phrenPath, "global", "CLAUDE.md");
272
+ if (fs.existsSync(globalClaude)) {
273
+ symlinkFile(globalClaude, homePath(".claude", "CLAUDE.md"), phrenPath);
274
+ if (tools.has("copilot")) {
275
+ try {
276
+ const copilotInstrDir = homePath(".github");
277
+ fs.mkdirSync(copilotInstrDir, { recursive: true });
278
+ symlinkFile(globalClaude, path.join(copilotInstrDir, "copilot-instructions.md"), phrenPath);
279
+ }
280
+ catch (err) {
281
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
282
+ process.stderr.write(`[phren] linkGlobal copilotInstructions: ${errorMessage(err)}\n`);
283
+ }
284
+ }
285
+ }
286
+ }
287
+ function linkProject(phrenPath, project, tools) {
288
+ const config = readProjectConfig(phrenPath, project);
289
+ const ownership = getProjectOwnershipMode(phrenPath, project, config);
290
+ const target = findProjectDir(project);
291
+ if (!target && ownership === "phren-managed") {
292
+ log(` skip ${project} (not found on disk)`);
293
+ if (isRecord(config.mcpServers)) {
294
+ linkProjectMcpServers(project, config.mcpServers);
295
+ }
296
+ return;
297
+ }
298
+ if (ownership !== "phren-managed") {
299
+ if (target) {
300
+ log(` ${project} -> ${target} (${ownership}, repo mirrors disabled)`);
301
+ }
302
+ else {
303
+ log(` ${project} (${ownership}, repo mirrors disabled)`);
304
+ }
305
+ if (isRecord(config.mcpServers)) {
306
+ linkProjectMcpServers(project, config.mcpServers);
307
+ }
308
+ return;
309
+ }
310
+ if (!target)
311
+ return;
312
+ log(` ${project} -> ${target}`);
313
+ const excludeEntries = [];
314
+ for (const f of ["CLAUDE.md", "REFERENCE.md", "FINDINGS.md"]) {
315
+ const src = path.join(phrenPath, project, f);
316
+ if (fs.existsSync(src)) {
317
+ if (symlinkFile(src, path.join(target, f), phrenPath))
318
+ excludeEntries.push(f);
319
+ if (f === "CLAUDE.md") {
320
+ if (tools.has("copilot")) {
321
+ try {
322
+ const copilotDir = path.join(target, ".github");
323
+ fs.mkdirSync(copilotDir, { recursive: true });
324
+ symlinkFile(src, path.join(copilotDir, "copilot-instructions.md"), phrenPath);
325
+ }
326
+ catch (err) {
327
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
328
+ process.stderr.write(`[phren] linkProject copilotInstructions: ${errorMessage(err)}\n`);
329
+ }
330
+ }
331
+ }
332
+ }
333
+ }
334
+ // CLAUDE-*.md split files
335
+ const projectDir = path.join(phrenPath, project);
336
+ if (fs.existsSync(projectDir)) {
337
+ for (const f of fs.readdirSync(projectDir)) {
338
+ if (/^CLAUDE-.+\.md$/.test(f)) {
339
+ if (symlinkFile(path.join(projectDir, f), path.join(target, f), phrenPath))
340
+ excludeEntries.push(f);
341
+ }
342
+ }
343
+ }
344
+ // Token annotation on CLAUDE.md
345
+ const claudeFile = path.join(phrenPath, project, "CLAUDE.md");
346
+ if (fs.existsSync(claudeFile)) {
347
+ try {
348
+ addTokenAnnotation(claudeFile);
349
+ }
350
+ catch (err) {
351
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
352
+ process.stderr.write(`[phren] linkProject tokenAnnotation: ${errorMessage(err)}\n`);
353
+ }
354
+ }
355
+ // Project-level skills
356
+ const targetSkills = path.join(target, ".claude", "skills");
357
+ const skillManifest = config.skills !== false
358
+ ? syncScopeSkillsToDir(phrenPath, project, targetSkills)
359
+ : undefined;
360
+ if (tools.has("codex") && fs.existsSync(claudeFile)) {
361
+ try {
362
+ const manifest = skillManifest || syncScopeSkillsToDir(phrenPath, project, targetSkills);
363
+ const agentsContent = `${fs.readFileSync(claudeFile, "utf8").trimEnd()}\n\n${GENERATED_AGENTS_MARKER}\n${renderSkillInstructionsSection(manifest)}\n`;
364
+ if (writeManagedAgentsFile(claudeFile, path.join(target, "AGENTS.md"), agentsContent, phrenPath))
365
+ excludeEntries.push("AGENTS.md");
366
+ }
367
+ catch (err) {
368
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
369
+ process.stderr.write(`[phren] linkProject agentsMd: ${errorMessage(err)}\n`);
370
+ }
371
+ }
372
+ // Auto-exclude phren-managed files from git status
373
+ if (excludeEntries.length > 0)
374
+ addGitExcludes(target, excludeEntries);
375
+ // Per-project MCP servers
376
+ if (isRecord(config.mcpServers)) {
377
+ linkProjectMcpServers(project, config.mcpServers);
378
+ }
379
+ }
380
+ /**
381
+ * Merge per-project MCP servers into Claude's settings.json.
382
+ * Keys are namespaced as "phren__<project>__<name>" so we can identify
383
+ * and clean them up without touching user-managed servers.
384
+ */
385
+ function linkProjectMcpServers(project, servers) {
386
+ const settingsPath = hookConfigPath("claude");
387
+ if (!fs.existsSync(settingsPath) && Object.keys(servers).length === 0)
388
+ return;
389
+ try {
390
+ patchJsonFile(settingsPath, (data) => {
391
+ const mcpServers = isRecord(data.mcpServers) ? data.mcpServers : (data.mcpServers = {});
392
+ // Remove stale entries for this project (keys we previously wrote)
393
+ for (const key of Object.keys(mcpServers)) {
394
+ if (key.startsWith(`phren__${project}__`))
395
+ delete mcpServers[key];
396
+ }
397
+ // Add current entries
398
+ for (const [name, entry] of Object.entries(servers)) {
399
+ const key = `phren__${project}__${name}`;
400
+ const server = { command: entry.command };
401
+ if (Array.isArray(entry.args))
402
+ server.args = entry.args;
403
+ if (entry.env && typeof entry.env === "object")
404
+ server.env = entry.env;
405
+ mcpServers[key] = server;
406
+ }
407
+ });
408
+ }
409
+ catch (err) {
410
+ debugLog(`linkProjectMcpServers: failed for ${project}: ${errorMessage(err)}`);
411
+ }
412
+ }
413
+ /** Remove any phren__<project>__* MCP entries for projects no longer in the active set. */
414
+ function pruneStaleProjectMcpServers(activeProjects) {
415
+ const settingsPath = hookConfigPath("claude");
416
+ if (!fs.existsSync(settingsPath))
417
+ return;
418
+ try {
419
+ patchJsonFile(settingsPath, (data) => {
420
+ const mcpServers = isRecord(data.mcpServers) ? data.mcpServers : undefined;
421
+ if (!mcpServers)
422
+ return;
423
+ for (const key of Object.keys(mcpServers)) {
424
+ if (!key.startsWith("phren__"))
425
+ continue;
426
+ // Key format: phren__<project>__<name>
427
+ const parts = key.split("__");
428
+ if (parts.length < 3)
429
+ continue;
430
+ const project = parts[1];
431
+ if (!activeProjects.includes(project)) {
432
+ delete mcpServers[key];
433
+ debugLog(`pruneStaleProjectMcpServers: removed stale entry "${key}"`);
434
+ }
435
+ }
436
+ });
437
+ }
438
+ catch (err) {
439
+ debugLog(`pruneStaleProjectMcpServers: failed: ${errorMessage(err)}`);
440
+ }
441
+ }
442
+ // ── Main orchestrator ───────────────────────────────────────────────────────
443
+ export async function runLink(phrenPath, opts = {}) {
444
+ log("phren link\n");
445
+ ensureGovernanceFiles(phrenPath);
446
+ // Step 1: Identify machine + profile
447
+ let machine = opts.machine ?? getMachineName();
448
+ let profile = "";
449
+ if (opts.profile) {
450
+ profile = opts.profile;
451
+ }
452
+ else if (opts.register) {
453
+ const reg = await registerMachine(phrenPath);
454
+ machine = reg.machine;
455
+ profile = reg.profile;
456
+ }
457
+ else {
458
+ profile = lookupProfile(phrenPath, machine);
459
+ if (!profile) {
460
+ const reg = await registerMachine(phrenPath);
461
+ machine = reg.machine;
462
+ profile = reg.profile;
463
+ }
464
+ }
465
+ if (!profile)
466
+ throw new Error(`Could not determine profile for machine '${machine}'.`);
467
+ persistMachineName(machine);
468
+ // Step 2: Find profile file
469
+ const profileFile = findProfileFile(phrenPath, profile);
470
+ if (!profileFile)
471
+ throw new Error(`Profile '${profile}' not found in profiles/.`);
472
+ log(`Machine: ${machine}`);
473
+ log(`Profile: ${profile} (${profileFile})\n`);
474
+ // Step 3: Read projects
475
+ const projects = getProfileProjects(profileFile);
476
+ if (!projects.length)
477
+ throw new Error(`Profile '${profile}' has no projects listed.`);
478
+ // Step 4: Sparse checkout
479
+ log("Setting up sparse checkout...");
480
+ setupSparseCheckout(phrenPath, projects);
481
+ log("");
482
+ // Detect installed tools once
483
+ const detectedTools = opts.allTools
484
+ ? new Set(["copilot", "cursor", "codex"])
485
+ : detectInstalledTools();
486
+ // Step 5: Symlink
487
+ log("Linking...");
488
+ linkGlobal(phrenPath, detectedTools);
489
+ for (const p of projects) {
490
+ if (p !== "global")
491
+ linkProject(phrenPath, p, detectedTools);
492
+ }
493
+ // Remove stale phren__<project>__* MCP entries for removed projects
494
+ pruneStaleProjectMcpServers(projects.filter(p => p !== "global"));
495
+ log("");
496
+ // Step 6: Configure MCP
497
+ log("Configuring MCP...");
498
+ const mcpEnabled = opts.mcp ? opts.mcp === "on" : getMcpEnabledPreference(phrenPath);
499
+ const hooksEnabled = getHooksEnabledPreference(phrenPath);
500
+ setMcpEnabledPreference(phrenPath, mcpEnabled);
501
+ log(` MCP mode: ${mcpEnabled ? "ON (recommended)" : "OFF (hooks-only fallback)"}`);
502
+ log(` Hooks mode: ${hooksEnabled ? "ON (active)" : "OFF (disabled)"}`);
503
+ maybeOfferStarterTemplateUpdate(phrenPath);
504
+ let mcpStatus = "no_settings";
505
+ try {
506
+ mcpStatus = configureClaude(phrenPath, { mcpEnabled, hooksEnabled }) ?? "installed";
507
+ }
508
+ catch (err) {
509
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
510
+ process.stderr.write(`[phren] link configureClaude: ${errorMessage(err)}\n`);
511
+ }
512
+ logMcpTargetStatus("Claude", mcpStatus);
513
+ let vsStatus = "no_vscode";
514
+ try {
515
+ vsStatus = configureVSCode(phrenPath, { mcpEnabled }) ?? "no_vscode";
516
+ }
517
+ catch (err) {
518
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
519
+ process.stderr.write(`[phren] link configureVSCode: ${errorMessage(err)}\n`);
520
+ }
521
+ logMcpTargetStatus("VS Code", vsStatus);
522
+ let cursorStatus = "no_cursor";
523
+ try {
524
+ cursorStatus = configureCursorMcp(phrenPath, { mcpEnabled }) ?? "no_cursor";
525
+ }
526
+ catch (err) {
527
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
528
+ process.stderr.write(`[phren] link configureCursorMcp: ${errorMessage(err)}\n`);
529
+ }
530
+ logMcpTargetStatus("Cursor", cursorStatus);
531
+ let copilotStatus = "no_copilot";
532
+ try {
533
+ copilotStatus = configureCopilotMcp(phrenPath, { mcpEnabled }) ?? "no_copilot";
534
+ }
535
+ catch (err) {
536
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
537
+ process.stderr.write(`[phren] link configureCopilotMcp: ${errorMessage(err)}\n`);
538
+ }
539
+ logMcpTargetStatus("Copilot CLI", copilotStatus);
540
+ let codexStatus = "no_codex";
541
+ try {
542
+ codexStatus = configureCodexMcp(phrenPath, { mcpEnabled }) ?? "no_codex";
543
+ }
544
+ catch (err) {
545
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
546
+ process.stderr.write(`[phren] link configureCodexMcp: ${errorMessage(err)}\n`);
547
+ }
548
+ logMcpTargetStatus("Codex", codexStatus);
549
+ const mcpStatusForContext = [mcpStatus, vsStatus, cursorStatus, copilotStatus, codexStatus].some((s) => s === "installed" || s === "already_configured")
550
+ ? "installed"
551
+ : [mcpStatus, vsStatus, cursorStatus, copilotStatus, codexStatus].some((s) => s === "disabled" || s === "already_disabled")
552
+ ? "disabled"
553
+ : mcpStatus;
554
+ // Register hooks for Copilot CLI, Cursor, Codex
555
+ if (hooksEnabled) {
556
+ const hookedTools = configureAllHooks(phrenPath, { tools: detectedTools });
557
+ if (hookedTools.length)
558
+ log(` Hooks registered: ${hookedTools.join(", ")}`);
559
+ }
560
+ else {
561
+ log(` Hooks registration skipped (hooks-mode is off)`);
562
+ }
563
+ // Write phren.SKILL.md
564
+ try {
565
+ writeSkillMd(phrenPath);
566
+ log(` phren.SKILL.md written (agentskills-compatible tools)`);
567
+ }
568
+ catch (err) {
569
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
570
+ process.stderr.write(`[phren] link writeSkillMd: ${errorMessage(err)}\n`);
571
+ }
572
+ log("");
573
+ // Step 7: Context file
574
+ if (opts.task === "debugging") {
575
+ writeContextDebugging(machine, profile, mcpStatusForContext, projects, phrenPath);
576
+ }
577
+ else if (opts.task === "planning") {
578
+ writeContextPlanning(machine, profile, mcpStatusForContext, projects, phrenPath);
579
+ }
580
+ else if (opts.task === "clean") {
581
+ writeContextClean(machine, profile, mcpStatusForContext, projects);
582
+ }
583
+ else {
584
+ writeContextDefault(machine, profile, mcpStatusForContext, projects, phrenPath);
585
+ }
586
+ // Step 8: Memory (read back native changes, then rebuild)
587
+ readBackNativeMemory(phrenPath, projects);
588
+ rebuildMemory(phrenPath, projects);
589
+ log(`\nDone. Profile '${profile}' is active.`);
590
+ if (opts.task)
591
+ log(`Task mode: ${opts.task}`);
592
+ log(`\nWhat's next:`);
593
+ log(` Start Claude in your project directory — phren injects context automatically.`);
594
+ log(` Run phren-discover after your first week to surface gaps in project knowledge.`);
595
+ log(` Run phren-consolidate after working across projects to find shared patterns.`);
596
+ }
@@ -0,0 +1,15 @@
1
+ import * as fs from "fs";
2
+ import { runtimeFile, findPhrenPath } from "./shared.js";
3
+ export function log(level, tool, message, extra) {
4
+ try {
5
+ const phrenPath = findPhrenPath();
6
+ if (!phrenPath)
7
+ return;
8
+ const logPath = runtimeFile(phrenPath, "debug.log");
9
+ const line = JSON.stringify({ ts: new Date().toISOString(), level, tool, message, ...extra });
10
+ fs.appendFileSync(logPath, line + "\n");
11
+ }
12
+ catch {
13
+ // Logging must never throw
14
+ }
15
+ }
@@ -0,0 +1,38 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import * as crypto from "crypto";
5
+ import { homePath } from "./shared.js";
6
+ function phrenMachineFilePath() {
7
+ return homePath(".phren", ".machine-id");
8
+ }
9
+ function atomicWriteText(filePath, content) {
10
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
11
+ const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
12
+ fs.writeFileSync(tmpPath, content);
13
+ fs.renameSync(tmpPath, filePath);
14
+ }
15
+ export function machineFilePath() {
16
+ return phrenMachineFilePath();
17
+ }
18
+ export function defaultMachineName() {
19
+ if (process.env.WSL_DISTRO_NAME && process.env.COMPUTERNAME) {
20
+ return process.env.COMPUTERNAME.toLowerCase();
21
+ }
22
+ return os.hostname();
23
+ }
24
+ export function getMachineName() {
25
+ const filePath = machineFilePath();
26
+ if (fs.existsSync(filePath)) {
27
+ const persisted = fs.readFileSync(filePath, "utf8").trim();
28
+ if (persisted)
29
+ return persisted;
30
+ }
31
+ return defaultMachineName();
32
+ }
33
+ export function persistMachineName(machine) {
34
+ const normalized = machine.trim();
35
+ if (!normalized)
36
+ return;
37
+ atomicWriteText(machineFilePath(), `${normalized}\n`);
38
+ }