@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.
- package/LICENSE +21 -0
- package/README.md +590 -0
- package/mcp/dist/capabilities/cli.js +61 -0
- package/mcp/dist/capabilities/index.js +15 -0
- package/mcp/dist/capabilities/mcp.js +61 -0
- package/mcp/dist/capabilities/types.js +57 -0
- package/mcp/dist/capabilities/vscode.js +61 -0
- package/mcp/dist/capabilities/web-ui.js +61 -0
- package/mcp/dist/cli-actions.js +302 -0
- package/mcp/dist/cli-config.js +580 -0
- package/mcp/dist/cli-extract.js +305 -0
- package/mcp/dist/cli-govern.js +371 -0
- package/mcp/dist/cli-graph.js +169 -0
- package/mcp/dist/cli-hooks-citations.js +44 -0
- package/mcp/dist/cli-hooks-context.js +56 -0
- package/mcp/dist/cli-hooks-globs.js +83 -0
- package/mcp/dist/cli-hooks-output.js +130 -0
- package/mcp/dist/cli-hooks-retrieval.js +2 -0
- package/mcp/dist/cli-hooks-session.js +1402 -0
- package/mcp/dist/cli-hooks.js +350 -0
- package/mcp/dist/cli-namespaces.js +989 -0
- package/mcp/dist/cli-ops.js +253 -0
- package/mcp/dist/cli-search.js +407 -0
- package/mcp/dist/cli.js +108 -0
- package/mcp/dist/content-archive.js +278 -0
- package/mcp/dist/content-citation.js +391 -0
- package/mcp/dist/content-dedup.js +622 -0
- package/mcp/dist/content-learning.js +472 -0
- package/mcp/dist/content-metadata.js +186 -0
- package/mcp/dist/content-validate.js +462 -0
- package/mcp/dist/core-finding.js +54 -0
- package/mcp/dist/core-project.js +36 -0
- package/mcp/dist/core-search.js +50 -0
- package/mcp/dist/data-access.js +400 -0
- package/mcp/dist/data-tasks.js +821 -0
- package/mcp/dist/embedding.js +344 -0
- package/mcp/dist/entrypoint.js +387 -0
- package/mcp/dist/finding-context.js +172 -0
- package/mcp/dist/finding-impact.js +181 -0
- package/mcp/dist/finding-journal.js +122 -0
- package/mcp/dist/finding-lifecycle.js +259 -0
- package/mcp/dist/governance-audit.js +22 -0
- package/mcp/dist/governance-locks.js +96 -0
- package/mcp/dist/governance-policy.js +648 -0
- package/mcp/dist/governance-scores.js +355 -0
- package/mcp/dist/hooks.js +449 -0
- package/mcp/dist/impact-scoring.js +22 -0
- package/mcp/dist/index-query.js +168 -0
- package/mcp/dist/index.js +205 -0
- package/mcp/dist/init-config.js +336 -0
- package/mcp/dist/init-preferences.js +62 -0
- package/mcp/dist/init-setup.js +1305 -0
- package/mcp/dist/init-shared.js +29 -0
- package/mcp/dist/init.js +1730 -0
- package/mcp/dist/link-checksums.js +62 -0
- package/mcp/dist/link-context.js +257 -0
- package/mcp/dist/link-doctor.js +591 -0
- package/mcp/dist/link-skills.js +212 -0
- package/mcp/dist/link.js +596 -0
- package/mcp/dist/logger.js +15 -0
- package/mcp/dist/machine-identity.js +38 -0
- package/mcp/dist/mcp-config.js +254 -0
- package/mcp/dist/mcp-data.js +315 -0
- package/mcp/dist/mcp-extract-facts.js +78 -0
- package/mcp/dist/mcp-extract.js +133 -0
- package/mcp/dist/mcp-finding.js +557 -0
- package/mcp/dist/mcp-graph.js +339 -0
- package/mcp/dist/mcp-hooks.js +256 -0
- package/mcp/dist/mcp-memory.js +58 -0
- package/mcp/dist/mcp-ops.js +328 -0
- package/mcp/dist/mcp-search.js +628 -0
- package/mcp/dist/mcp-session.js +651 -0
- package/mcp/dist/mcp-skills.js +189 -0
- package/mcp/dist/mcp-tasks.js +551 -0
- package/mcp/dist/mcp-types.js +7 -0
- package/mcp/dist/memory-ui-assets.js +6 -0
- package/mcp/dist/memory-ui-data.js +513 -0
- package/mcp/dist/memory-ui-graph.js +1910 -0
- package/mcp/dist/memory-ui-page.js +353 -0
- package/mcp/dist/memory-ui-scripts.js +1387 -0
- package/mcp/dist/memory-ui-server.js +1218 -0
- package/mcp/dist/memory-ui-styles.js +555 -0
- package/mcp/dist/memory-ui.js +9 -0
- package/mcp/dist/package-metadata.js +13 -0
- package/mcp/dist/phren-art.js +52 -0
- package/mcp/dist/phren-core.js +108 -0
- package/mcp/dist/phren-dotenv.js +67 -0
- package/mcp/dist/phren-paths.js +476 -0
- package/mcp/dist/proactivity.js +172 -0
- package/mcp/dist/profile-store.js +228 -0
- package/mcp/dist/project-config.js +85 -0
- package/mcp/dist/project-locator.js +25 -0
- package/mcp/dist/project-topics.js +1134 -0
- package/mcp/dist/provider-adapters.js +176 -0
- package/mcp/dist/runtime-profile.js +18 -0
- package/mcp/dist/session-checkpoints.js +131 -0
- package/mcp/dist/session-utils.js +68 -0
- package/mcp/dist/shared-content.js +8 -0
- package/mcp/dist/shared-embedding-cache.js +143 -0
- package/mcp/dist/shared-fragment-graph.js +456 -0
- package/mcp/dist/shared-governance.js +4 -0
- package/mcp/dist/shared-index.js +1334 -0
- package/mcp/dist/shared-ollama.js +192 -0
- package/mcp/dist/shared-paths.js +1 -0
- package/mcp/dist/shared-retrieval.js +796 -0
- package/mcp/dist/shared-search-fallback.js +375 -0
- package/mcp/dist/shared-sqljs.js +42 -0
- package/mcp/dist/shared-stemmer.js +171 -0
- package/mcp/dist/shared-vector-index.js +199 -0
- package/mcp/dist/shared.js +114 -0
- package/mcp/dist/shell-entry.js +209 -0
- package/mcp/dist/shell-input.js +943 -0
- package/mcp/dist/shell-palette.js +119 -0
- package/mcp/dist/shell-render.js +252 -0
- package/mcp/dist/shell-state-store.js +81 -0
- package/mcp/dist/shell-types.js +13 -0
- package/mcp/dist/shell-view-list.js +14 -0
- package/mcp/dist/shell-view.js +707 -0
- package/mcp/dist/shell.js +352 -0
- package/mcp/dist/skill-files.js +117 -0
- package/mcp/dist/skill-registry.js +279 -0
- package/mcp/dist/skill-state.js +28 -0
- package/mcp/dist/startup-embedding.js +57 -0
- package/mcp/dist/status.js +323 -0
- package/mcp/dist/synonyms.json +670 -0
- package/mcp/dist/task-hygiene.js +251 -0
- package/mcp/dist/task-lifecycle.js +347 -0
- package/mcp/dist/tasks-github.js +76 -0
- package/mcp/dist/telemetry.js +165 -0
- package/mcp/dist/test-global-setup.js +37 -0
- package/mcp/dist/tool-registry.js +104 -0
- package/mcp/dist/update.js +97 -0
- package/mcp/dist/utils.js +543 -0
- package/package.json +67 -0
- package/skills/README.md +7 -0
- package/skills/consolidate/SKILL.md +152 -0
- package/skills/discover/SKILL.md +175 -0
- package/skills/init/SKILL.md +216 -0
- package/skills/profiles/SKILL.md +121 -0
- package/skills/sync/SKILL.md +261 -0
- package/starter/README.md +74 -0
- package/starter/global/CLAUDE.md +89 -0
- package/starter/global/skills/humanize.md +30 -0
- package/starter/global/skills/pipeline.md +35 -0
- package/starter/global/skills/release.md +35 -0
- package/starter/machines.yaml +8 -0
- package/starter/my-api/.claude/skills/README.md +7 -0
- package/starter/my-api/CLAUDE.md +33 -0
- package/starter/my-api/FINDINGS.md +9 -0
- package/starter/my-api/summary.md +7 -0
- package/starter/my-api/tasks.md +7 -0
- package/starter/my-first-project/.claude/skills/README.md +7 -0
- package/starter/my-first-project/CLAUDE.md +49 -0
- package/starter/my-first-project/FINDINGS.md +24 -0
- package/starter/my-first-project/summary.md +11 -0
- package/starter/my-first-project/tasks.md +25 -0
- package/starter/my-frontend/.claude/skills/README.md +7 -0
- package/starter/my-frontend/CLAUDE.md +33 -0
- package/starter/my-frontend/FINDINGS.md +9 -0
- package/starter/my-frontend/summary.md +7 -0
- package/starter/my-frontend/tasks.md +7 -0
- package/starter/profiles/default.yaml +4 -0
- package/starter/profiles/personal.yaml +4 -0
- package/starter/profiles/work.yaml +4 -0
- package/starter/templates/README.md +7 -0
- package/starter/templates/frontend/CLAUDE.md +23 -0
- package/starter/templates/frontend/FINDINGS.md +7 -0
- package/starter/templates/frontend/reference/README.md +4 -0
- package/starter/templates/frontend/summary.md +7 -0
- package/starter/templates/frontend/tasks.md +11 -0
- package/starter/templates/library/CLAUDE.md +22 -0
- package/starter/templates/library/FINDINGS.md +7 -0
- package/starter/templates/library/reference/README.md +4 -0
- package/starter/templates/library/summary.md +7 -0
- package/starter/templates/library/tasks.md +11 -0
- package/starter/templates/monorepo/CLAUDE.md +21 -0
- package/starter/templates/monorepo/FINDINGS.md +7 -0
- package/starter/templates/monorepo/reference/README.md +4 -0
- package/starter/templates/monorepo/summary.md +7 -0
- package/starter/templates/monorepo/tasks.md +11 -0
- package/starter/templates/python-project/CLAUDE.md +21 -0
- package/starter/templates/python-project/FINDINGS.md +7 -0
- package/starter/templates/python-project/reference/README.md +4 -0
- package/starter/templates/python-project/summary.md +7 -0
- package/starter/templates/python-project/tasks.md +10 -0
package/mcp/dist/link.js
ADDED
|
@@ -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
|
+
}
|