UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

390 lines (389 loc) • 18.4 kB
/** * MCP target configuration, hooks setup, and onboarding preference helpers. * Extracted from init.ts to keep the orchestrator focused on flow control. */ import * as fs from "fs"; import * as path from "path"; import * as crypto from "crypto"; import { getProjectOwnershipDefault } from "../project-config.js"; import { atomicWriteText, debugLog, ensurePrivateDir, projectSlugFromPath, readRootManifest, writeRootManifest, } from "../shared.js"; import { STORE_SECRET_GITIGNORE_LINES } from "./store-gitignore.js"; import { errorMessage } from "../utils.js"; import { configureAllHooks, installPhrenCliWrapper, ensureLocalBinOnWindowsPath } from "../hooks.js"; import { updateWorkflowPolicy } from "../shared/governance.js"; import { configureClaude, configureVSCode, configureCursorMcp, configureCopilotMcp, configureCodexMcp, logMcpTargetStatus, } from "./config.js"; import { VERSION } from "./shared.js"; import { writeInstallPreferences, writeGovernanceInstallPreferences, } from "./preferences.js"; import { repairPreexistingInstall, ensureGovernanceFiles, ensureGitignoreEntry, upsertProjectEnvVar, detectProjectDir, bootstrapFromExisting, runPostInitVerify, } from "./setup.js"; import { log } from "./shared.js"; import { resolveManagementCapabilities, getManagementPreset } from "./management-preset.js"; /** * Configure MCP for all detected AI coding tools (Claude, VS Code, Cursor, Copilot, Codex). * @param verb - label used in log messages, e.g. "Updated" or "Configured" */ export function configureMcpTargets(phrenPath, opts, verb = "Configured") { const caps = opts.caps ?? resolveManagementCapabilities(phrenPath); // Install the stable ~/.local/bin/phren wrapper BEFORE writing any hook // commands. buildLifecycleCommands prefers that wrapper (it carries an npx // fallback, so it survives nvm switches and npx cache clears); if it doesn't // yet exist, hook commands fall through to the ephemeral npx-cache entry path // and break the next time npx prunes its cache. configureHooksIfEnabled runs // later and re-installs the wrapper idempotently, emitting the user-facing // "CLI wrapper" line — this early install is intentionally silent. Presets // that opt out of wrappers (assisted/manual) skip it; hooks then use the // node/npx invocation form. if (caps.installWrappers) { try { installPhrenCliWrapper(phrenPath); } catch (err) { debugLog(`configureMcpTargets: early wrapper install failed: ${errorMessage(err)}`); } } let claudeStatus = "no_settings"; try { const status = configureClaude(phrenPath, { mcpEnabled: opts.mcpEnabled, hooksEnabled: opts.hooksEnabled }); claudeStatus = status ?? "installed"; if (status === "disabled" || status === "already_disabled") { log(` ${verb} Claude Code hooks (MCP disabled)`); } else { log(` ${verb} Claude Code MCP + hooks`); } } catch (e) { log(` Could not configure Claude Code settings (${e}), add manually`); } let vsStatus = "no_vscode"; try { vsStatus = configureVSCode(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_vscode"; logMcpTargetStatus("VS Code", vsStatus, verb); } catch (err) { debugLog(`configureVSCode failed: ${errorMessage(err)}`); } let cursorStatus = "no_cursor"; try { cursorStatus = configureCursorMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_cursor"; logMcpTargetStatus("Cursor", cursorStatus, verb); } catch (err) { debugLog(`configureCursorMcp failed: ${errorMessage(err)}`); } let copilotStatus = "no_copilot"; try { copilotStatus = configureCopilotMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_copilot"; logMcpTargetStatus("Copilot CLI", copilotStatus, verb); } catch (err) { debugLog(`configureCopilotMcp failed: ${errorMessage(err)}`); } let codexStatus = "no_codex"; try { codexStatus = configureCodexMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_codex"; logMcpTargetStatus("Codex", codexStatus, verb); } catch (err) { debugLog(`configureCodexMcp failed: ${errorMessage(err)}`); } const allStatuses = [claudeStatus, vsStatus, cursorStatus, copilotStatus, codexStatus]; if (allStatuses.some((s) => s === "installed" || s === "already_configured")) return "installed"; if (allStatuses.some((s) => s === "disabled" || s === "already_disabled")) return "disabled"; return claudeStatus; } /** * Configure hooks if enabled, or log a disabled message. * @param verb - label used in log messages, e.g. "Updated" or "Configured" */ export function configureHooksIfEnabled(phrenPath, hooksEnabled, verb, caps) { const resolvedCaps = caps ?? resolveManagementCapabilities(phrenPath); if (hooksEnabled) { try { const hooked = configureAllHooks(phrenPath, { allTools: true, installWrappers: resolvedCaps.installWrappers }); if (hooked.length) log(` ${verb} hooks: ${hooked.join(", ")}`); } catch (err) { debugLog(`configureAllHooks failed: ${errorMessage(err)}`); } } else { log(` Hooks are disabled by preference (run: phren hooks-mode on)`); } // Presets that opt out of wrappers (assisted/manual) leave the user's PATH and // ~/.local/bin untouched; hooks fall back to the node/npx invocation form. if (!resolvedCaps.installWrappers) { log(` CLI wrapper: skipped (${getManagementPreset(phrenPath)} preset) — use 'npx @phren/cli <command>' or a global install`); return; } // Install phren CLI wrapper at ~/.local/bin/phren so the bare command works const wrapperInstalled = installPhrenCliWrapper(phrenPath); if (wrapperInstalled) { log(` ${verb} CLI wrapper: ~/.local/bin/phren`); } else { log(` Note: phren CLI wrapper not installed (existing non-managed binary, or no entry script found)`); } // Windows: ensure %USERPROFILE%\.local\bin is on the user PATH so the // wrapper is actually discoverable from new terminals. if (wrapperInstalled && process.platform === "win32") { const pathResult = ensureLocalBinOnWindowsPath(); if (pathResult === "added") { log(` Added %USERPROFILE%\\.local\\bin to your user PATH — open a new terminal to use 'phren' directly.`); } else if (pathResult === "failed") { log(` Note: could not update PATH automatically. Add '%USERPROFILE%\\.local\\bin' to your user PATH manually,`); log(` or use 'npx @phren/cli <command>' until then.`); } } } export function applyOnboardingPreferences(phrenPath, opts) { if (opts.projectOwnershipDefault) { writeInstallPreferences(phrenPath, { projectOwnershipDefault: opts.projectOwnershipDefault }); } const runtimePatch = {}; if (opts.findingsProactivity) runtimePatch.proactivityFindings = opts.findingsProactivity; if (opts.taskProactivity) runtimePatch.proactivityTask = opts.taskProactivity; if (Object.keys(runtimePatch).length > 0) { writeInstallPreferences(phrenPath, runtimePatch); } const governancePatch = {}; if (opts.findingsProactivity) governancePatch.proactivityFindings = opts.findingsProactivity; if (opts.taskProactivity) governancePatch.proactivityTask = opts.taskProactivity; if (Object.keys(governancePatch).length > 0) { writeGovernanceInstallPreferences(phrenPath, governancePatch); } const workflowPatch = {}; if (typeof opts.lowConfidenceThreshold === "number") workflowPatch.lowConfidenceThreshold = opts.lowConfidenceThreshold; if (Array.isArray(opts.riskySections)) workflowPatch.riskySections = opts.riskySections; if (opts.taskMode) workflowPatch.taskMode = opts.taskMode; if (opts.findingSensitivity) workflowPatch.findingSensitivity = opts.findingSensitivity; if (Object.keys(workflowPatch).length > 0) { updateWorkflowPolicy(phrenPath, workflowPatch); } } export function writeWalkthroughEnvDefaults(phrenPath, opts, presetContext) { const envFile = path.join(phrenPath, ".env"); let envContent = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf8") : "# phren feature flags — generated by init\n"; const envFlags = []; const manualPreset = presetContext?.preset === "manual"; const presetExplicit = presetContext?.explicit === true; const autoCaptureChoice = opts._walkthroughAutoCapture; const hasAutoCaptureFlag = /^\s*PHREN_FEATURE_AUTO_CAPTURE=.*$/m.test(envContent); if (manualPreset) { // Manual preset turns off the prompt-time/background automations. Force the // flags to 0 only when the preset was explicitly chosen this run; on a plain // re-init we respect any lines the user already set. for (const key of ["PHREN_FEATURE_AUTO_CAPTURE", "PHREN_FEATURE_AUTO_EXTRACT", "PHREN_FEATURE_DAILY_MAINTENANCE"]) { const present = new RegExp(`^\\s*${key}=.*$`, "m").test(envContent); if (presetExplicit || !present) { envFlags.push({ flag: `${key}=0`, label: `${key.replace("PHREN_FEATURE_", "").toLowerCase().replace(/_/g, " ")} disabled (manual preset)` }); } } } else if (typeof autoCaptureChoice === "boolean") { envFlags.push({ flag: `PHREN_FEATURE_AUTO_CAPTURE=${autoCaptureChoice ? "1" : "0"}`, label: `Auto-capture ${autoCaptureChoice ? "enabled" : "disabled"}`, }); } else if (autoCaptureChoice !== false && !hasAutoCaptureFlag) { // Default to enabled on fresh installs and non-walkthrough init. envFlags.push({ flag: "PHREN_FEATURE_AUTO_CAPTURE=1", label: "Auto-capture enabled" }); } if (opts._walkthroughSemanticDedup) envFlags.push({ flag: "PHREN_FEATURE_SEMANTIC_DEDUP=1", label: "Semantic dedup" }); if (opts._walkthroughSemanticConflict) envFlags.push({ flag: "PHREN_FEATURE_SEMANTIC_CONFLICT=1", label: "Conflict detection" }); if (envFlags.length === 0) return []; let changed = false; const enabledLabels = []; for (const { flag, label } of envFlags) { const key = flag.split("=")[0]; const lineRe = new RegExp(`^\\s*${key}=.*$`, "m"); if (lineRe.test(envContent)) { const before = envContent; envContent = envContent.replace(lineRe, flag); if (envContent !== before) { changed = true; enabledLabels.push(label); } } else { if (!envContent.endsWith("\n")) envContent += "\n"; envContent += `${flag}\n`; changed = true; enabledLabels.push(label); } } if (changed) { const tmpPath = `${envFile}.tmp-${crypto.randomUUID()}`; fs.writeFileSync(tmpPath, envContent); fs.renameSync(tmpPath, envFile); } return enabledLabels.map((label) => `${label} (${envFile})`); } export function collectRepairedAssetLabels(repaired) { const repairedAssets = []; if (repaired.createdContextFile) repairedAssets.push("~/.phren-context.md"); if (repaired.createdRootMemory) repairedAssets.push("generated MEMORY.md"); repairedAssets.push(...repaired.createdGlobalAssets); repairedAssets.push(...repaired.createdRuntimeAssets); repairedAssets.push(...repaired.createdFeatureDefaults); repairedAssets.push(...repaired.createdSkillArtifacts); return repairedAssets; } export function applyProjectStorageBindings(repoRoot, phrenPath) { const updates = []; if (ensureGitignoreEntry(repoRoot, ".phren/")) { updates.push(`${path.join(repoRoot, ".gitignore")} (.phren/)`); } if (upsertProjectEnvVar(repoRoot, "PHREN_PATH", phrenPath)) { updates.push(`${path.join(repoRoot, ".env")} (PHREN_PATH=${phrenPath})`); } return updates; } export async function warmSemanticSearch(phrenPath, profile) { const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl, getEmbeddingModel } = await import("../shared/ollama.js"); const ollamaUrl = getOllamaUrl(); if (!ollamaUrl) return "Semantic search: disabled."; const model = getEmbeddingModel(); if (!await checkOllamaAvailable()) { return `Semantic search not warmed: Ollama offline at ${ollamaUrl}.`; } if (!await checkModelAvailable()) { return `Semantic search not warmed: model ${model} is not pulled yet.`; } const { buildIndex, listIndexedDocumentPaths } = await import("../shared/index.js"); const { getEmbeddingCache, formatEmbeddingCoverage } = await import("../shared/embedding-cache.js"); const { backgroundEmbedMissingDocs } = await import("../startup-embedding.js"); const { getPersistentVectorIndex } = await import("../shared/vector-index.js"); const db = await buildIndex(phrenPath, profile); try { const cache = getEmbeddingCache(phrenPath); await cache.load().catch(() => { }); const allPaths = listIndexedDocumentPaths(phrenPath, profile); const before = cache.coverage(allPaths); if (before.missing > 0) { await backgroundEmbedMissingDocs(db, cache); } await cache.load().catch(() => { }); const after = cache.coverage(allPaths); if (cache.size() > 0) { getPersistentVectorIndex(phrenPath).ensure(cache.getAllEntries()); } if (after.total === 0) { return `Semantic search ready (${model}), but there are no indexed docs yet.`; } const embeddedNow = Math.max(0, after.embedded - before.embedded); const prefix = after.state === "warm" ? "Semantic search warmed" : "Semantic search warming"; const delta = embeddedNow > 0 ? `; embedded ${embeddedNow} new docs during init` : ""; return `${prefix}: ${model}, ${formatEmbeddingCoverage(after)}${delta}.`; } finally { try { db.close(); } catch { /* ignore close errors in init */ } } } export async function runProjectLocalInit(opts = {}) { const detectedRoot = detectProjectDir(process.cwd(), path.join(process.cwd(), ".phren")) || process.cwd(); const hasWorkspaceMarker = fs.existsSync(path.join(detectedRoot, ".git")) || fs.existsSync(path.join(detectedRoot, "AGENTS.md")) || fs.existsSync(path.join(detectedRoot, "AGENTS.md")) || fs.existsSync(path.join(detectedRoot, ".claude", "CLAUDE.md")); if (!hasWorkspaceMarker) { throw new Error("project-local mode must be run inside a repo or project root"); } const workspaceRoot = path.resolve(detectedRoot); const phrenPath = path.join(workspaceRoot, ".phren"); const existingManifest = readRootManifest(phrenPath); if (existingManifest && existingManifest.installMode !== "project-local") { throw new Error(`Refusing to reuse non-local phren root at ${phrenPath}`); } const ownershipDefault = opts.projectOwnershipDefault ?? (existingManifest ? getProjectOwnershipDefault(phrenPath) : "detached"); if (!existingManifest && !opts.projectOwnershipDefault) { opts.projectOwnershipDefault = ownershipDefault; } const mcpEnabled = opts.mcp ? opts.mcp === "on" : true; const projectName = projectSlugFromPath(workspaceRoot); if (opts.dryRun) { log("\nInit dry run. No files will be written.\n"); log(`Mode: project-local`); log(`Workspace root: ${workspaceRoot}`); log(`Phren root: ${phrenPath}`); log(`Project: ${projectName}`); log(`VS Code workspace MCP: ${mcpEnabled ? "on" : "off"}`); log(`Hooks: unsupported in project-local mode`); log(""); return; } fs.mkdirSync(phrenPath, { recursive: true }); writeRootManifest(phrenPath, { version: 1, installMode: "project-local", syncMode: "workspace-git", workspaceRoot, primaryProject: projectName, }); ensureGovernanceFiles(phrenPath); repairPreexistingInstall(phrenPath); fs.mkdirSync(path.join(phrenPath, "global", "skills"), { recursive: true }); ensurePrivateDir(path.join(phrenPath, ".runtime")); ensurePrivateDir(path.join(phrenPath, ".sessions")); if (!fs.existsSync(path.join(phrenPath, ".gitignore"))) { atomicWriteText(path.join(phrenPath, ".gitignore"), [ ...STORE_SECRET_GITIGNORE_LINES, "*.lock", "*.tmp-*", "", ].join("\n")); } if (!fs.existsSync(path.join(phrenPath, "global", "AGENTS.md"))) { atomicWriteText(path.join(phrenPath, "global", "AGENTS.md"), "# Global Context\n\nRepo-local Phren instructions shared across this workspace.\n"); } const created = bootstrapFromExisting(phrenPath, workspaceRoot, { ownership: ownershipDefault }); applyOnboardingPreferences(phrenPath, opts); writeInstallPreferences(phrenPath, { mcpEnabled, hooksEnabled: false, skillsScope: opts.skillsScope ?? "global", installedVersion: VERSION, }); try { const vscodeResult = configureVSCode(phrenPath, { mcpEnabled, scope: "workspace" }); logMcpTargetStatus("VS Code", vscodeResult, existingManifest ? "Updated" : "Configured"); } catch (err) { debugLog(`configureVSCode(workspace) failed: ${errorMessage(err)}`); } log(`\n${existingManifest ? "Updated" : "Created"} project-local phren at ${phrenPath}`); log(` Workspace root: ${workspaceRoot}`); log(` Project: ${created.project}`); log(` Ownership: ${created.ownership}`); log(` Sync mode: workspace-git`); log(` Hooks: off (unsupported in project-local mode)`); log(` VS Code MCP: ${mcpEnabled ? "workspace on" : "workspace off"}`); const verify = runPostInitVerify(phrenPath); log(`\nVerifying setup...`); for (const check of verify.checks) { log(` ${check.ok ? "pass" : "FAIL"} ${check.name}: ${check.detail}`); } }