UNPKG

@phren/cli

Version:

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

338 lines (336 loc) • 17 kB
import { getPhrenPath, readRootManifest } from "../shared.js"; import { listMachines as listMachinesStore, listProfiles as listProfilesStore } from "../data/access.js"; import { setTelemetryEnabled, getTelemetrySummary, resetTelemetry } from "../telemetry.js"; import { readInstallPreferences, updateInstallPreferences } from "../init/preferences.js"; import { resolveMcpProfile } from "../mcp/profile.js"; import { resolveClanker } from "../clanker.js"; import { resolveAccountFailover } from "../bridge/account-choice.js"; import { parsePullInterval, resolvePullInterval, periodicPullEnabled } from "../sync/pull.js"; import * as path from "path"; import { buildConfigView } from "../config/resolve.js"; import { CONFIG_DOMAINS } from "../config/schema.js"; import { isValidProjectName } from "../utils.js"; import { parseProjectArg, checkProjectInProfile } from "./config-shared.js"; import { handleConfigProactivity, handleConfigProjectOwnership } from "./config-proactivity.js"; import { handleIndexPolicy, handleRetentionPolicy, handleWorkflowPolicy, handleConfigTaskMode, handleConfigFindingSensitivity, } from "./config-policy.js"; import { handleConfigLlm } from "./config-llm.js"; import { handleConfigAccess } from "./config-access.js"; import { handleConfigSynonyms } from "./config-synonyms.js"; // Re-export the public surface so external importers (cli-registry.ts, tools/config.ts, // tests) keep resolving the same names from "./cli/config.js" after the split. export { checkProjectInProfile, handleIndexPolicy, handleRetentionPolicy, handleWorkflowPolicy, handleConfigAccess }; export { buildProactivitySnapshot } from "./config-proactivity.js"; export { FINDING_SENSITIVITY_CONFIG } from "./config-policy.js"; // ── Config show ─────────────────────────────────────────────────────────────── function formatConfigValue(value) { if (value === undefined || value === null) return "(unset)"; if (Array.isArray(value)) return value.length ? value.join(", ") : "(none)"; if (typeof value === "boolean") return value ? "true" : "false"; return String(value); } /** * Render a resolved {@link ConfigView} grouped by domain, with a source column * that names where each value came from (default / global / project) and the * file that set it. */ function renderConfigView(view, opts) { const heading = view.scope === "project" ? `phren config — project: ${view.project}` : "phren config — global (applies to all projects unless overridden)"; console.log(`\n${heading}`); if (opts.diff) console.log("(showing only values customised away from defaults)"); let shownAny = false; for (const domain of CONFIG_DOMAINS) { const rows = []; for (const field of domain.fields) { const resolved = view.fields[field.key]; if (!resolved) continue; // topic fields are resolved separately if (opts.diff && resolved.source === "default") continue; const file = resolved.sourcePath ? path.basename(resolved.sourcePath) : ""; rows.push({ label: field.label, value: formatConfigValue(resolved.value), source: resolved.source === "default" ? "default" : `${resolved.source}${file ? ` (${file})` : ""}`, }); } if (!rows.length) continue; shownAny = true; console.log(`\n${domain.label} — ${domain.summary}`); const labelW = Math.max(...rows.map((r) => r.label.length)); const valueW = Math.max(...rows.map((r) => r.value.length), 6); for (const r of rows) { console.log(` ${r.label.padEnd(labelW)} ${r.value.padEnd(valueW)} ${r.source}`); } } if (!shownAny) { console.log(opts.diff ? "\nEverything is at its default value." : "\n(no config)"); } console.log(""); } function handleConfigShow(args) { const phrenPath = getPhrenPath(); const { project: projectArg, rest } = parseProjectArg(args); const asJson = rest.includes("--json"); const diff = rest.includes("--diff"); if (projectArg && !isValidProjectName(projectArg)) { console.error(`Invalid project name: "${projectArg}"`); process.exit(1); } const view = buildConfigView(phrenPath, projectArg); if (asJson) { console.log(JSON.stringify(view, null, 2)); return; } renderConfigView(view, { diff }); if (projectArg) { const notRegistered = checkProjectInProfile(phrenPath, projectArg); if (notRegistered) { console.error(`Run 'phren add /path/to/${projectArg}' to register this project.\n`); } } else { console.log("Tips: '--project <name>' for a project's merged config, '--diff' for only customised values, '--json' for machine-readable output.\n"); } } // ── Config router ──────────────────────────────────────────────────────────── export async function handleConfig(args) { const sub = args[0]; const rest = args.slice(1); switch (sub) { case "show": return handleConfigShow(rest); case "policy": return handleRetentionPolicy(rest); case "workflow": return handleWorkflowPolicy(rest); case "index": return handleIndexPolicy(rest); case "machines": return handleConfigMachines(); case "profiles": return handleConfigProfiles(); case "telemetry": return handleConfigTelemetry(rest); case "mcp-profile": return handleConfigMcpProfile(rest); case "clanker": return handleConfigClanker(rest); case "account-failover": return handleConfigAccountFailover(rest); case "set": // `phren config set clanker on` reads naturally. if (rest[0] === "clanker") return handleConfigClanker(rest.slice(1)); if (rest[0] === "account-failover") return handleConfigAccountFailover(rest.slice(1)); console.error("phren config set takes: clanker on|off, account-failover on|off. Other settings have their own subcommand (phren config)."); process.exitCode = 1; return; case "pull-interval": return handleConfigPullInterval(rest); case "proactivity": case "proactivity.findings": case "proactivity.tasks": return handleConfigProactivity(sub, rest); case "project-ownership": return handleConfigProjectOwnership(rest); case "task-mode": return handleConfigTaskMode(rest); case "finding-sensitivity": return handleConfigFindingSensitivity(rest); case "access": return handleConfigAccess(rest); case "llm": return handleConfigLlm(rest); case "synonyms": return handleConfigSynonyms(rest); default: console.log(`phren config - manage settings and policies Subcommands: phren config show [--project <name>] [--diff] [--json] Full merged config grouped by domain, with the source (default/global/project) of every value phren config policy [get|set ...] Memory retention, TTL, confidence, decay phren config workflow [get|set ...] Risky-memory thresholds, task automation mode phren config index [get|set ...] Indexer include/exclude globs phren config proactivity [level] [--scope base|findings|tasks] Auto-capture level (high|medium|low); --scope sets the findings-only or task-only override instead phren config proactivity.findings [level] Findings-specific auto-capture level override phren config proactivity.tasks [level] Task-specific auto-capture level override (all take --project <name> for one project) phren config task-mode [get|set <mode>] Task automation mode (off|manual|suggest|auto) phren config finding-sensitivity [get|set <level>] Finding capture level (minimal|conservative|balanced|aggressive) phren config access [--project <name>] [get|set --admins=...] Role-based access lists (admins|contributors|readers) phren config project-ownership [mode] Default ownership for future project enrollments phren config llm [get|set model|endpoint|key] LLM config for semantic dedup/conflict features phren config synonyms [list|add|remove] ... Manage project learned synonyms phren config machines Registered machines and profiles phren config profiles All profiles and their projects phren config clanker [on|off] Compact keyword-first retrieval: hook, search and lists return id/title/keyword rows; full text by id phren config account-failover [on|off] Continue on another signed-in account when a dispatched Claude worker stops at its usage limit (default: off) phren config pull-interval [seconds|off] Periodic MCP remote checks (default: off) phren config telemetry [on|off|reset] Local usage stats (opt-in, no external reporting)`); if (sub) { console.error(`\nUnknown config subcommand: "${sub}"`); process.exit(1); } } } // ── Machines and profiles ──────────────────────────────────────────────────── function handleConfigPullInterval(args) { const phrenPath = getPhrenPath(); if (args.length > 0) { const seconds = parsePullInterval(args[0]); if (seconds === undefined || args.length !== 1) { console.error("Use phren config pull-interval <seconds|off>: 0 disables polling; otherwise use a whole number from 30 to 86400."); process.exitCode = 1; return; } updateInstallPreferences(phrenPath, () => ({ pullIntervalSeconds: seconds })); console.log(seconds === 0 ? "Periodic pulls disabled on this machine." : `Periodic remote checks set to every ${seconds} seconds on this machine.`); } const seconds = resolvePullInterval(phrenPath); const source = process.env.PHREN_PULL_INTERVAL_SECONDS !== undefined && parsePullInterval(process.env.PHREN_PULL_INTERVAL_SECONDS) !== undefined ? "PHREN_PULL_INTERVAL_SECONDS" : readInstallPreferences(phrenPath).pullIntervalSeconds !== undefined ? "install preferences" : "default"; console.log(`Pull interval: ${seconds === 0 ? "off" : `${seconds} seconds`} (${source}).`); if (!periodicPullEnabled(phrenPath)) console.log("Periodic pulls are inactive for this install mode or management preset."); else console.log("Running MCP servers pick up changes automatically. Only changed remotes are fetched; updates are fast-forward only."); } function handleConfigMachines() { const manifest = readRootManifest(getPhrenPath()); if (manifest?.installMode === "project-local") { console.log("config machines is shared-mode only"); return; } const result = listMachinesStore(getPhrenPath()); if (!result.ok) { console.log(result.error); return; } const lines = Object.entries(result.data).map(([machine, prof]) => ` ${machine}: ${prof}`); console.log(`Registered Machines\n${lines.join("\n")}`); } function handleConfigProfiles() { const manifest = readRootManifest(getPhrenPath()); if (manifest?.installMode === "project-local") { console.log("config profiles is shared-mode only"); return; } const result = listProfilesStore(getPhrenPath()); if (!result.ok) { console.log(result.error); return; } for (const p of result.data) { console.log(`\n${p.name}`); for (const proj of p.projects) console.log(` - ${proj}`); if (!p.projects.length) console.log(" (no projects)"); } } function handleConfigMcpProfile(args) { const phrenPath = getPhrenPath(); const want = args[0]?.trim().toLowerCase(); if (want === "core" || want === "full") { updateInstallPreferences(phrenPath, () => ({ mcpProfile: want })); console.log(`MCP profile set to ${want}. Restart your MCP clients (Claude Code, Cursor, …) to pick it up.`); if (want === "core") console.log("Agents see 10 tools; everything else is reachable through phren_admin."); else console.log("Agents see every tool by name, plus the revise_finding / manage_task / session composites."); return; } if (want) { console.error(`Unknown profile "${args[0]}". Use core or full.`); process.exitCode = 1; return; } const current = resolveMcpProfile(phrenPath); const source = process.env.PHREN_MCP_PROFILE ? "PHREN_MCP_PROFILE" : (readInstallPreferences(phrenPath).mcpProfile ? "install preferences" : "default"); console.log(`MCP profile: ${current} (${source})`); console.log(current === "core" ? "10 tools: search_knowledge, get_memory_detail, get_project_summary, add_finding, revise_finding, get_tasks, add_task, manage_task, session, phren_admin." : "Every tool by name. `phren config mcp-profile core` for the compact surface."); } function handleConfigClanker(args) { const phrenPath = getPhrenPath(); const want = args[0]?.trim().toLowerCase(); if (want === "on" || want === "off") { updateInstallPreferences(phrenPath, () => ({ clanker: want === "on" })); console.log(`Clanker mode ${want}. It applies from the next prompt and tool call.`); } else if (want) { console.error(`Unknown value "${want}". Use on or off.`); process.exitCode = 1; return; } const { on, source } = resolveClanker(phrenPath); console.log(`Clanker mode: ${on ? "on" : "off"} (${source})`); console.log(on ? "The prompt hook, search_knowledge, get_tasks and get_findings return id, title, keyword and score rows; get_memory_detail fetches an entry by id." : "The prompt hook and tools return text snippets. `phren config clanker on` for compact rows."); } function handleConfigAccountFailover(args) { const phrenPath = getPhrenPath(); const want = args[0]?.trim().toLowerCase(); if (want === "on" || want === "off") { updateInstallPreferences(phrenPath, () => ({ accountFailover: want === "on" })); console.log(`Account failover ${want}. The Hook reads it on its next returns poll.`); } else if (want) { console.error(`Unknown value "${want}". Use on or off.`); process.exitCode = 1; return; } const { on, source } = resolveAccountFailover(process.env, phrenPath); console.log(`Account failover: ${on ? "on" : "off"} (${source})`); console.log(on ? "A dispatched Claude worker that stops at its usage limit continues on another of your signed-in accounts with room, on its computer or another; never on the same login before its window resets." : "A dispatched Claude worker that stops at its usage limit returns failed and stays stopped. `phren config account-failover on` continues it on another signed-in account."); } function handleConfigTelemetry(args) { const action = args[0]; switch (action) { case "on": setTelemetryEnabled(getPhrenPath(), true); console.log("Telemetry enabled. Local usage stats will be collected."); console.log("No data is sent externally. Stats are stored in .runtime/telemetry.json."); return; case "off": setTelemetryEnabled(getPhrenPath(), false); console.log("Telemetry disabled."); return; case "reset": resetTelemetry(getPhrenPath()); console.log("Telemetry stats reset."); return; default: console.log(getTelemetrySummary(getPhrenPath())); } }