From e18ff5c4bdfec079a997e1d4e544719f927286c1 Mon Sep 17 00:00:00 2001 From: Filip131311 Date: Thu, 21 May 2026 10:50:32 +0200 Subject: [PATCH] refactor(installer): split init.ts into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the spaghetti in init/update/uninstall with a topology table + phase modules. New modules: - package-manager.ts PM detection, install/uninstall commands - topology.ts GLOBAL/LOCAL with probe + install/uninstall/spawnCwd - preflight.ts hasPackageJson / isYarnPnp - shell.ts runShellCommand - install-error.ts manifest-error pattern matching + reporting - init-args.ts flag parsing + cross-flag validation - init-mode-prompt.ts Step-0 install-mode selector - install-runner.ts shared install execution for both topologies - init-adapters.ts Step-1 adapter universe + multiselect - init-scope.ts scope prompt (local/global/custom) - init-mcp-write.ts per-adapter MCP entry writes (with fallbacks) - init-allowlist.ts tool auto-approval step - init-skills.ts Step-2 skills install flow Refactors: - init.ts: 822 → 345 lines, reads top-to-bottom like a recipe - update.ts: iterates TOPOLOGIES instead of duplicating per-topology helpers - uninstall.ts: collapses the two duplicate topology branches into a single loop over TOPOLOGIES - mcp-configs.ts: replaces `adapter.name === "Claude Code"` string match with a typed `expandsProjectDirVariable` adapter flag - utils.ts: 646 → 425 lines, PM and topology helpers re-exported from their new homes for backwards compatibility All 314 installer tests + 691 tool-server tests still pass. --- .../argent-installer/src/init-adapters.ts | 87 ++ .../argent-installer/src/init-allowlist.ts | 70 ++ packages/argent-installer/src/init-args.ts | 61 ++ .../argent-installer/src/init-mcp-write.ts | 89 ++ .../argent-installer/src/init-mode-prompt.ts | 72 ++ packages/argent-installer/src/init-scope.ts | 75 ++ packages/argent-installer/src/init-skills.ts | 168 +++ packages/argent-installer/src/init.ts | 979 +++++------------- .../argent-installer/src/install-error.ts | 44 + .../argent-installer/src/install-runner.ts | 86 ++ packages/argent-installer/src/mcp-configs.ts | 18 +- .../argent-installer/src/package-manager.ts | 101 ++ packages/argent-installer/src/preflight.ts | 19 + packages/argent-installer/src/shell.ts | 28 + packages/argent-installer/src/topology.ts | 192 ++++ packages/argent-installer/src/uninstall.ts | 161 ++- packages/argent-installer/src/update.ts | 366 ++++--- packages/argent-installer/src/utils.ts | 267 +---- 18 files changed, 1623 insertions(+), 1260 deletions(-) create mode 100644 packages/argent-installer/src/init-adapters.ts create mode 100644 packages/argent-installer/src/init-allowlist.ts create mode 100644 packages/argent-installer/src/init-args.ts create mode 100644 packages/argent-installer/src/init-mcp-write.ts create mode 100644 packages/argent-installer/src/init-mode-prompt.ts create mode 100644 packages/argent-installer/src/init-scope.ts create mode 100644 packages/argent-installer/src/init-skills.ts create mode 100644 packages/argent-installer/src/install-error.ts create mode 100644 packages/argent-installer/src/install-runner.ts create mode 100644 packages/argent-installer/src/package-manager.ts create mode 100644 packages/argent-installer/src/preflight.ts create mode 100644 packages/argent-installer/src/shell.ts create mode 100644 packages/argent-installer/src/topology.ts diff --git a/packages/argent-installer/src/init-adapters.ts b/packages/argent-installer/src/init-adapters.ts new file mode 100644 index 00000000..63d32ac4 --- /dev/null +++ b/packages/argent-installer/src/init-adapters.ts @@ -0,0 +1,87 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { detectAdapters, ALL_ADAPTERS, type McpConfigAdapter } from "./mcp-configs.js"; +import type { TopologyId } from "./topology.js"; + +// Step 1a — pick which editors get an MCP entry. In local mode the +// adapter universe is filtered to those that have a project-scoped +// config file (Windsurf / Hermes are excluded). + +export interface AdapterSelection { + selected: McpConfigAdapter[]; + universe: McpConfigAdapter[]; + detected: McpConfigAdapter[]; + /** Adapters dropped because of local-mode filtering. */ + droppedForLocal: McpConfigAdapter[]; +} + +interface SelectArgs { + topology: TopologyId; + nonInteractive: boolean; +} + +function buildUniverse(topology: TopologyId): { + universe: McpConfigAdapter[]; + dropped: McpConfigAdapter[]; +} { + if (topology !== "local") return { universe: [...ALL_ADAPTERS], dropped: [] }; + const universe: McpConfigAdapter[] = []; + const dropped: McpConfigAdapter[] = []; + for (const a of ALL_ADAPTERS) { + (a.acceptsLocalInstall === false ? dropped : universe).push(a); + } + return { universe, dropped }; +} + +export async function chooseAdapters({ + topology, + nonInteractive, +}: SelectArgs): Promise { + const { universe, dropped: droppedForLocal } = buildUniverse(topology); + const detected = detectAdapters().filter((a) => universe.includes(a)); + + if (topology === "local" && droppedForLocal.length > 0) { + p.log.info( + pc.dim( + `Skipping ${droppedForLocal.map((a) => a.name).join(", ")} ` + + `(global-only — no project config file to commit).` + ) + ); + } + + if (nonInteractive) { + const selected = detected.length > 0 ? detected : universe; + return { selected, universe, detected, droppedForLocal }; + } + + const detectedNames = new Set(detected.map((a) => a.name)); + const choices = universe.map((a) => { + const parts: string[] = []; + if (detectedNames.has(a.name)) parts.push("detected"); + const hasProject = a.projectPath(process.cwd()) != null; + const hasGlobal = a.globalPath() != null; + if (!hasProject && hasGlobal) { + parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s global config`))); + } else if (hasProject && !hasGlobal) { + parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s project config`))); + } + return { value: a, label: a.name, hint: parts.length > 0 ? parts.join(", ") : undefined }; + }); + + p.log.message(pc.dim(" Use arrow keys to move, space to toggle, enter to confirm.")); + + const result = await p.multiselect({ + message: "Which editors should Argent be configured for?", + options: choices, + initialValues: detected, + required: true, + }); + + if (p.isCancel(result)) { + p.cancel("Initialization cancelled."); + process.exit(0); + } + + const selected = result as McpConfigAdapter[]; + return { selected, universe, detected, droppedForLocal }; +} diff --git a/packages/argent-installer/src/init-allowlist.ts b/packages/argent-installer/src/init-allowlist.ts new file mode 100644 index 00000000..287b44bf --- /dev/null +++ b/packages/argent-installer/src/init-allowlist.ts @@ -0,0 +1,70 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import type { McpConfigAdapter } from "./mcp-configs.js"; + +interface AllowlistArgs { + adapters: McpConfigAdapter[]; + effectiveRoot: string; + scope: "local" | "global"; + nonInteractive: boolean; +} + +export interface AllowlistResult { + enabled: boolean; + lines: string[]; +} + +export async function configureAllowlist({ + adapters, + effectiveRoot, + scope, + nonInteractive, +}: AllowlistArgs): Promise { + const withApi = adapters.filter((a) => a.addAllowlist); + const withoutApi = adapters.filter((a) => !a.addAllowlist); + + if (withApi.length === 0) return { enabled: false, lines: [] }; + + p.log.info( + `By default, editors ask for confirmation before running each MCP tool.\n` + + ` Adding Argent to the auto-approve allowlist lets tools run without\n` + + ` repeated prompts. This is ${pc.cyan("recommended")} for a smooth experience.` + ); + + let enabled = nonInteractive; + if (!nonInteractive) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + const choice = await p.confirm({ + message: "Add Argent tools to editor auto-approve lists? - recommended", + initialValue: true, + }); + if (p.isCancel(choice)) { + p.cancel("Initialization cancelled."); + process.exit(0); + } + enabled = choice as boolean; + } + + if (!enabled) return { enabled: false, lines: [] }; + + const lines: string[] = []; + for (const adapter of withApi) { + const hasPath = scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); + if (!hasPath) { + lines.push(`${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config for this scope)")}`); + continue; + } + try { + adapter.addAllowlist!(effectiveRoot, scope); + lines.push(`${pc.green("+")} ${adapter.name}`); + } catch (err) { + lines.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } + } + for (const adapter of withoutApi) { + lines.push( + `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no auto-approve API - configure manually)")}` + ); + } + return { enabled: true, lines }; +} diff --git a/packages/argent-installer/src/init-args.ts b/packages/argent-installer/src/init-args.ts new file mode 100644 index 00000000..d292075c --- /dev/null +++ b/packages/argent-installer/src/init-args.ts @@ -0,0 +1,61 @@ +import pc from "picocolors"; +import type { TopologyId } from "./topology.js"; + +// Parsed view of `argent init `. Single source of truth for what the +// user typed; downstream code reads named fields instead of grepping the +// raw argv. validateInitArgs enforces cross-flag invariants. + +export interface InitArgs { + /** --yes / -y */ + nonInteractive: boolean; + /** --from reinstall from a local tarball/path */ + fromTar: string | null; + /** --devdep / --local-install forces the local topology */ + forcedTopology: TopologyId | null; + /** --scope local|global, when present */ + explicitScope: "local" | "global" | null; +} + +function extractValueFlag(args: string[], flag: string): string | null { + const idx = args.indexOf(flag); + if (idx === -1 || idx + 1 >= args.length) return null; + return args[idx + 1] ?? null; +} + +export function parseInitArgs(args: string[]): InitArgs { + const nonInteractive = args.includes("--yes") || args.includes("-y"); + const fromTar = extractValueFlag(args, "--from"); + const devdep = args.includes("--devdep") || args.includes("--local-install"); + const scope = extractValueFlag(args, "--scope"); + const explicitScope = scope === "local" || scope === "global" ? scope : null; + + return { + nonInteractive, + fromTar, + forcedTopology: devdep ? "local" : null, + explicitScope, + }; +} + +// Cross-flag validation. Throws to a process.exit(1) at the call site so +// the error string can be tested without a TUI in the loop. +export class InitArgsError extends Error { + constructor(message: string) { + super(message); + this.name = "InitArgsError"; + } +} + +export function validateInitArgs(parsed: InitArgs): void { + if (parsed.forcedTopology === "local" && parsed.explicitScope === "global") { + throw new InitArgsError( + "--devdep is incompatible with --scope global " + + "(local installs must use the project-scoped MCP config)." + ); + } +} + +// Tiny stderr formatter so the dispatcher doesn't have to know about pc. +export function reportInitArgsError(err: InitArgsError): void { + process.stderr.write(`${pc.red("error")}: ${err.message}\n`); +} diff --git a/packages/argent-installer/src/init-mcp-write.ts b/packages/argent-installer/src/init-mcp-write.ts new file mode 100644 index 00000000..5fd2eded --- /dev/null +++ b/packages/argent-installer/src/init-mcp-write.ts @@ -0,0 +1,89 @@ +import pc from "picocolors"; +import { getMcpEntry, type McpConfigAdapter, type McpEntryMode } from "./mcp-configs.js"; +import type { TopologyId } from "./topology.js"; +import type { Scope } from "./init-scope.js"; + +// Step 1c — write the MCP config files for the selected adapters. +// Returns one line per adapter for the summary note. + +interface WriteArgs { + adapters: McpConfigAdapter[]; + topology: TopologyId; + scope: Scope; + /** projectRoot OR customRoot, depending on scope. */ + effectiveRoot: string; + /** Always projectRoot (for "fallback to project" message paths). */ + projectRoot: string; +} + +function entryModeFor(topology: TopologyId, effectiveRoot: string): McpEntryMode { + return topology === "local" ? { kind: "local", projectRoot: effectiveRoot } : { kind: "global" }; +} + +function configPathFor( + adapter: McpConfigAdapter, + scope: Scope, + effectiveRoot: string +): string | null { + return scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); +} + +function safeWrite(adapter: McpConfigAdapter, configPath: string, mode: McpEntryMode): string { + try { + adapter.write(configPath, getMcpEntry(mode, adapter)); + return `${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`; + } catch (err) { + return `${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`; + } +} + +function fallbackLine( + adapter: McpConfigAdapter, + fallback: string, + mode: McpEntryMode, + label: "local" | "global" +): string { + try { + adapter.write(fallback, getMcpEntry(mode, adapter)); + return `${pc.green("+")} ${adapter.name} ${pc.dim(`(${label} fallback: ${fallback})`)}`; + } catch (err) { + return `${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`; + } +} + +export function writeMcpConfigs({ + adapters, + topology, + scope, + effectiveRoot, + projectRoot, +}: WriteArgs): string[] { + const mode = entryModeFor(topology, effectiveRoot); + const results: string[] = []; + + for (const adapter of adapters) { + const configPath = configPathFor(adapter, scope, effectiveRoot); + if (configPath) { + results.push(safeWrite(adapter, configPath, mode)); + continue; + } + + // No path for the requested scope — try the other scope as a fallback. + if (scope === "global") { + const projectFallback = adapter.projectPath(projectRoot); + if (projectFallback) { + results.push(fallbackLine(adapter, projectFallback, mode, "local")); + continue; + } + } else { + const globalFallback = adapter.globalPath(); + if (globalFallback) { + results.push(fallbackLine(adapter, globalFallback, mode, "global")); + continue; + } + } + results.push(`${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config path for this scope)")}`); + } + + return results; +} diff --git a/packages/argent-installer/src/init-mode-prompt.ts b/packages/argent-installer/src/init-mode-prompt.ts new file mode 100644 index 00000000..c4f53405 --- /dev/null +++ b/packages/argent-installer/src/init-mode-prompt.ts @@ -0,0 +1,72 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import type { TopologyId } from "./topology.js"; + +// Step-0 install-mode selection. Encapsulates the prompt loop so init.ts +// just receives a final TopologyId (or exits on cancel). + +interface PromptArgs { + /** True if argent is already a project devDep on disk. */ + locallyInstalled: boolean; +} + +const PROMPT_MESSAGE = (locallyInstalled: boolean): string => + locallyInstalled + ? "How would you like to configure argent?" + : "Argent isn't installed yet. How would you like to set it up?"; + +const LOCAL_CAVEAT = + "The locally-installed argent will only work if your agent runs from the " + + "root directory of your project. If a teammate's editor fails to start " + + "argent, verify they are in the root directory first."; + +function buildOptions(locallyInstalled: boolean) { + return [ + { + value: "global" as const, + label: "Global (recommended)", + hint: "Makes the argent command available everywhere", + }, + { + value: "local" as const, + label: locallyInstalled + ? "Local (devDependency, already installed)" + : "Local (devDependency)", + hint: "Might be used by teams to share configuration", + }, + { value: "cancel" as const, label: "Cancel installation" }, + ]; +} + +// Returns the chosen TopologyId. Exits the process on cancel — the prompt +// is interactive only, callers must guard with a nonInteractive check. +export async function promptInstallMode({ locallyInstalled }: PromptArgs): Promise { + while (true) { + const choice = await p.select({ + message: PROMPT_MESSAGE(locallyInstalled), + initialValue: "global" as const, + options: buildOptions(locallyInstalled), + }); + + if (p.isCancel(choice) || choice === "cancel") { + p.cancel("Installation cancelled."); + process.exit(0); + } + + if (choice === "global") return "global"; + + // Local — surface the caveat as decision context, not noise. + p.log.warn(LOCAL_CAVEAT); + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + const confirmLocal = await p.confirm({ + message: "Proceed with the Local devDependency install?", + initialValue: true, + }); + if (p.isCancel(confirmLocal)) { + p.cancel("Installation cancelled."); + process.exit(0); + } + if (confirmLocal) return "local"; + // Decline → loop back to the mode select. + } +} diff --git a/packages/argent-installer/src/init-scope.ts b/packages/argent-installer/src/init-scope.ts new file mode 100644 index 00000000..0e88660b --- /dev/null +++ b/packages/argent-installer/src/init-scope.ts @@ -0,0 +1,75 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import type { TopologyId } from "./topology.js"; + +// Step 1b — pick the MCP config scope (local / global / custom path). +// Local mode locks scope=local since the committed config must live next +// to package.json. + +export type Scope = "local" | "global" | "custom"; + +export interface ScopeChoice { + scope: Scope; + customRoot?: string; +} + +interface ChooseArgs { + topology: TopologyId; + nonInteractive: boolean; +} + +export async function chooseScope({ topology, nonInteractive }: ChooseArgs): Promise { + if (topology === "local") return { scope: "local" }; + if (nonInteractive) return { scope: "local" }; + + p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); + + const choice = await p.select({ + message: "Install MCP server globally or locally?", + options: [ + { + value: "local" as const, + label: "Local", + hint: "Current project only - .cursor/mcp.json, .mcp.json, ...", + }, + { + value: "global" as const, + label: "Global", + hint: "Available across all projects - ~/.*/mcp.json", + }, + { + value: "custom" as const, + label: "Specify installation directory", + hint: "Specify a directory to use as the project root", + }, + ], + }); + + if (p.isCancel(choice)) { + p.cancel("Initialization cancelled."); + process.exit(0); + } + + const scope = choice as Scope; + if (scope !== "custom") return { scope }; + + const customPathInput = await p.text({ + message: "Enter the path to use as the project root for MCP config:", + placeholder: process.cwd(), + validate(value) { + if (!value?.trim()) return "Path cannot be empty."; + const resolved = resolve(value.trim()); + if (!existsSync(resolved)) + return `Path does not exist: ${resolved}. Please verify and enter a valid path.`; + }, + }); + + if (p.isCancel(customPathInput)) { + p.cancel("Initialization cancelled."); + process.exit(0); + } + + return { scope: "custom", customRoot: resolve((customPathInput as string).trim()) }; +} diff --git a/packages/argent-installer/src/init-skills.ts b/packages/argent-installer/src/init-skills.ts new file mode 100644 index 00000000..2c0ab004 --- /dev/null +++ b/packages/argent-installer/src/init-skills.ts @@ -0,0 +1,168 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { spawn } from "node:child_process"; +import { + buildArgentSkillsSource, + isOnline, + isSkillsCliAvailable, + SKILLS_DIR, +} from "./utils.js"; +import type { Scope } from "./init-scope.js"; + +export type SkillsMethod = "default" | "interactive" | "manual"; + +interface SkillsArgs { + nonInteractive: boolean; + fromTar: string | null; + version: string; + scope: Scope; + customRoot: string | undefined; +} + +export async function runSkillsStep(args: SkillsArgs): Promise { + p.log.step(pc.bold("Step 2: Skills Installation")); + p.log.warn(pc.yellow("Skills installation is required for Argent to function properly.")); + + const online = await isOnline(); + const offlineWithCache = !online && isSkillsCliAvailable(); + const skillsCliReady = online || offlineWithCache; + + if (!skillsCliReady) { + p.log.warn( + pc.yellow("You appear to be offline. ") + + "Automatic skills installation requires a network connection." + ); + } + + const method = await chooseMethod(args.nonInteractive, skillsCliReady); + + const useGitHubSource = online && !args.fromTar && args.version !== "unknown"; + const skillsSource = useGitHubSource ? buildArgentSkillsSource(args.version) : SKILLS_DIR; + + if (method === "manual") { + printManualInstructions(args.scope, args.customRoot, skillsSource); + return method; + } + + await runNpxFlow(method, args.scope, args.customRoot, skillsSource, offlineWithCache); + return method; +} + +async function chooseMethod(nonInteractive: boolean, skillsCliReady: boolean): Promise { + if (!skillsCliReady) return "manual"; + if (nonInteractive) return "default"; + + p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); + + const choice = await p.select({ + message: "How would you like to install skills?", + options: [ + { + value: "default" as const, + label: "Automatic", + hint: "Installs all skills automatically with npx skills", + }, + { + value: "interactive" as const, + label: "Interactive", + hint: "Full npx skills TUI - choose skills, agents, and method", + }, + { + value: "manual" as const, + label: "Manual", + hint: "Print instructions for manual installation", + }, + ], + }); + + if (p.isCancel(choice)) { + p.cancel("Initialization cancelled."); + process.exit(0); + } + return choice as SkillsMethod; +} + +function printManualInstructions(scope: Scope, customRoot: string | undefined, skillsSource: string): void { + const projectPrefix = customRoot ?? "."; + const claudeTarget = scope === "global" ? "~/.claude/skills/" : `${projectPrefix}/.claude/skills/`; + const cursorTarget = scope === "global" ? "~/.cursor/skills/" : `${projectPrefix}/.cursor/skills/`; + p.note( + [ + `Skills are bundled at:`, + ` ${pc.cyan(SKILLS_DIR)}`, + ``, + `To install manually, copy them to your editor's skills directory:`, + ``, + ` ${pc.dim("# Claude Code")}`, + ` cp -r ${SKILLS_DIR}/* ${claudeTarget}`, + ``, + ` ${pc.dim("# Cursor")}`, + ` cp -r ${SKILLS_DIR}/* ${cursorTarget}`, + ``, + ` ${pc.dim("# Or use npx skills directly:")}`, + ` npx skills add ${skillsSource}`, + ].join("\n"), + "Manual Skills Installation" + ); +} + +async function runNpxFlow( + method: Exclude, + scope: Scope, + customRoot: string | undefined, + skillsSource: string, + offlineWithCache: boolean +): Promise { + const skillsArgs = ["skills", "add", skillsSource]; + if (scope === "global") skillsArgs.push("-g"); + if (method === "default") skillsArgs.push("--skill", "*", "-y"); + const npxArgs = offlineWithCache ? ["--no-install", ...skillsArgs] : skillsArgs; + + p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); + + const spinner = p.spinner(); + if (method === "default") spinner.start("Installing skills..."); + + try { + await runNpxSkills(npxArgs, method === "interactive", scope === "custom" ? customRoot : undefined); + if (method === "default") spinner.stop("Skills installed."); + } catch (err) { + if (method === "default") spinner.stop(pc.red("Skills installation failed.")); + p.log.error(`Failed to run npx skills: ${err}`); + p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); + } +} + +function runNpxSkills(args: string[], interactive: boolean, cwd?: string): Promise { + return new Promise((resolve, reject) => { + const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"; + const child = spawn(npxCmd, args, { + stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + ...(cwd ? { cwd } : {}), + }); + + let stdout = ""; + let stderr = ""; + + if (!interactive) { + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + } + + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + const output = [stderr, stdout].filter(Boolean).join("\n").trim(); + reject(new Error(output || `npx skills exited with code ${code}`)); + } + }); + + child.on("error", reject); + }); +} diff --git a/packages/argent-installer/src/init.ts b/packages/argent-installer/src/init.ts index 1a28b88b..5406e838 100644 --- a/packages/argent-installer/src/init.ts +++ b/packages/argent-installer/src/init.ts @@ -1,704 +1,317 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { spawn } from "node:child_process"; import { - detectAdapters, - ALL_ADAPTERS, - getMcpEntry, copyRulesAndAgents, type McpConfigAdapter, - type McpEntryMode, } from "./mcp-configs.js"; import { - SKILLS_DIR, RULES_DIR, AGENTS_DIR, - buildArgentSkillsSource, getInstalledVersion, getLatestVersion, getLocallyInstalledVersion, - isGloballyInstalled, - isLocallyInstalled, - isYarnPnp, - hasPackageJson, isNewerVersion, - isOnline, - isSkillsCliAvailable, - detectPackageManager, - globalInstallCommand, - localDevInstallCommand, - formatShellCommand, resolveProjectRoot, - type ShellCommand, } from "./utils.js"; +import { formatShellCommand } from "./package-manager.js"; +import { runShellCommand } from "./shell.js"; import { refreshArgentSkills, formatSkillRefreshSummary } from "./skills.js"; import { PACKAGE_NAME } from "./constants.js"; - -function runShellCommand(cmd: ShellCommand): Promise { - return new Promise((resolve, reject) => { - const isWin = process.platform === "win32"; - const child = spawn(isWin ? `${cmd.bin}.cmd` : cmd.bin, cmd.args, { - stdio: ["ignore", "pipe", "pipe"], - shell: isWin, - }); - - let stderr = ""; - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - child.on("close", (code) => { - if (code === 0) resolve(); - else reject(new Error(stderr.trim() || `Command exited with code ${code}`)); - }); - - child.on("error", reject); - }); -} - -function extractFlag(args: string[], flag: string): string | null { - const idx = args.indexOf(flag); - if (idx === -1 || idx + 1 >= args.length) return null; - return args[idx + 1]!; -} - -type InstallMode = "global" | "local"; - -export async function init(args: string[]): Promise { - const nonInteractive = args.includes("--yes") || args.includes("-y"); - const fromTar = extractFlag(args, "--from"); - // `--devdep` (alias `--local-install`) is the non-interactive selector - // for the team-share install topology. - const devdepFlagRequested = args.includes("--devdep") || args.includes("--local-install"); - const explicitGlobalScope = (() => { - const idx = args.indexOf("--scope"); - if (idx === -1 || idx + 1 >= args.length) return false; - return args[idx + 1] === "global"; - })(); - if (devdepFlagRequested && explicitGlobalScope) { - process.stderr.write( - `${pc.red("error")}: --devdep is incompatible with --scope global ` + - "(local installs must use the project-scoped MCP config).\n" - ); - process.exit(1); +import { + parseInitArgs, + validateInitArgs, + reportInitArgsError, + InitArgsError, + type InitArgs, +} from "./init-args.js"; +import { promptInstallMode } from "./init-mode-prompt.js"; +import { runInstall } from "./install-runner.js"; +import { + GLOBAL, + LOCAL, + isGloballyInstalled, + isLocallyInstalled, + type Topology, + type TopologyId, +} from "./topology.js"; +import { chooseAdapters } from "./init-adapters.js"; +import { chooseScope, type Scope } from "./init-scope.js"; +import { writeMcpConfigs } from "./init-mcp-write.js"; +import { configureAllowlist } from "./init-allowlist.js"; +import { runSkillsStep, type SkillsMethod } from "./init-skills.js"; + +// `argent init` orchestrator. Each phase below is a thin call into a +// dedicated module — the goal is for this file to read top-to-bottom +// like a recipe, with no inline branching on install topology. + +export async function init(rawArgs: string[]): Promise { + const parsed = parseInitArgs(rawArgs); + try { + validateInitArgs(parsed); + } catch (err) { + if (err instanceof InitArgsError) { + reportInitArgsError(err); + process.exit(1); + } + throw err; } printBanner(); - p.intro(pc.bgCyan(pc.black(" argent init "))); let version = getInstalledVersion() ?? "unknown"; p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); - // ── Step 0: Install / Update Check ────────────────────────────────────────── - - const globallyInstalled = isGloballyInstalled(); + // ── Step 0 — decide topology + install if needed ───────────────────── const projectRoot = resolveProjectRoot(process.cwd()); - const locallyInstalled = isLocallyInstalled(projectRoot); - - // Default to global to match the historical single-mode behavior; never - // auto-pick local on the presence of node_modules/@swmansion/argent — - // a workspace ancestor with argent installed would silently change the - // mode for users running init via `npx`. - let installMode: InstallMode = devdepFlagRequested ? "local" : "global"; - - if (!globallyInstalled && !nonInteractive && !devdepFlagRequested) { - // Loop so a "no" on the Local confirm re-prompts the mode select - // instead of aborting init. Only Esc/Ctrl+C cancels. - while (true) { - const installChoice = await p.select({ - message: locallyInstalled - ? "How would you like to configure argent?" - : "Argent isn't installed yet. How would you like to set it up?", - initialValue: "global" as const, - options: [ - { - value: "global" as const, - label: "Global (recommended)", - hint: "Makes the argent command available everywhere", - }, - { - value: "local" as const, - label: locallyInstalled - ? "Local (devDependency, already installed)" - : "Local (devDependency)", - hint: "Might be used by teams to share configuration", - }, - { - value: "cancel" as const, - label: "Cancel installation", - }, - ], - }); - - if (p.isCancel(installChoice) || installChoice === "cancel") { - p.cancel("Installation cancelled."); - process.exit(0); - } - - // Surface the cross-editor relative-path caveat only on the Local - // path so it lands as decision context, not noise. - if (installChoice === "local") { - p.log.warn( - `The localy set up argent will only work if your agent runs from the root directory of your project. If a teammate's editor fails to start argent, verify if he is in the root directory first.` - ); - - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const confirmLocal = await p.confirm({ - message: "Proceed with the Local devDependency install?", - initialValue: true, - }); - if (p.isCancel(confirmLocal)) { - p.cancel("Installation cancelled."); - process.exit(0); - } - if (!confirmLocal) { - continue; - } - } - - installMode = installChoice; - break; - } - } - - // ── Step 0a: actually install (or skip if already in the right state) ── - - if (installMode === "local") { - if (locallyInstalled) { - p.log.info( - `Argent is already installed as a devDependency at ` + - `${pc.dim(`${projectRoot}/node_modules/@swmansion/argent`)}. Skipping install step.` - ); - version = getLocallyInstalledVersion(projectRoot) ?? version; - } else { - // Refuse early when the workspace can't host a devDep. - if (!hasPackageJson(projectRoot)) { - p.log.error( - `No package.json found at ${pc.dim(projectRoot)}.\n` + - ` Run ${pc.cyan("npm init -y")} first, then re-run ${pc.cyan("argent init --devdep")}.` - ); - process.exit(1); - } - if (isYarnPnp(projectRoot)) { - p.log.error( - `Yarn PnP detected (.pnp.cjs at ${pc.dim(projectRoot)}).\n` + - ` The devDep flow needs a real node_modules/.bin directory.\n` + - ` Switch to ${pc.cyan('nodeLinker: "node-modules"')} in .yarnrc.yml or ` + - `re-run with ${pc.cyan("argent init")} for a global install.` - ); - process.exit(1); - } - - // Prefer the project's lockfile over the runtime user-agent — - // under `npx` the agent is always npm regardless of the project's - // actual PM, which breaks yarn-only protocols like `link:`. - const pm = detectPackageManager(projectRoot); - const installTarget = fromTar ?? PACKAGE_NAME; - const cmd = localDevInstallCommand(pm, installTarget); - const cmdStr = formatShellCommand(cmd); - const spinner = p.spinner(); - spinner.start(`Installing ${PACKAGE_NAME} as a devDependency with ${pm}...`); - try { - await runShellCommand(cmd); - spinner.stop(pc.green(`Installed as devDependency (via ${pm}).`)); - // Read the just-installed copy, not the running module: under - // `npx`, getInstalledVersion() returns the npx cache version. - version = getLocallyInstalledVersion(projectRoot) ?? version; - } catch (err) { - spinner.stop(pc.red("Installation failed.")); - reportLocalInstallFailure(err, cmdStr, projectRoot); - process.exit(1); - } - } - } else if (installMode === "global" && !globallyInstalled) { - const pm = detectPackageManager(); - const installTarget = fromTar ?? PACKAGE_NAME; - const cmd = globalInstallCommand(pm, installTarget); - const cmdStr = formatShellCommand(cmd); - const spinner = p.spinner(); - spinner.start(`Installing ${PACKAGE_NAME} globally...`); - try { - await runShellCommand(cmd); - spinner.stop(pc.green("Installed globally.")); - version = getInstalledVersion() ?? version; - } catch (err) { - spinner.stop(pc.red("Installation failed.")); - p.log.error(`${err}`); - p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); - process.exit(1); - } - } else if (installMode === "global" && fromTar) { - // --from flag: reinstall from the specified tarball/path - const pm = detectPackageManager(); - const cmd = globalInstallCommand(pm, fromTar); - const cmdStr = formatShellCommand(cmd); - const spinner = p.spinner(); - spinner.start(`Installing from ${fromTar}...`); - try { - await runShellCommand(cmd); - spinner.stop(pc.green("Installed from tarball.")); - version = getInstalledVersion() ?? version; - } catch (err) { - spinner.stop(pc.red("Installation failed.")); - p.log.error(`${err}`); - p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); - process.exit(1); - } - } else if (installMode === "global") { - let latest: string | null = null; - const spinner = p.spinner(); - spinner.start("Checking for updates..."); - try { - latest = getLatestVersion(); - } catch { - // Registry unreachable - silently skip - } - spinner.stop(pc.dim("Version check complete.")); - - if (latest && isNewerVersion(latest, version)) { - if (!nonInteractive) { - const updateChoice = await p.select({ - message: `Update available: ${pc.yellow(`v${version}`)} → ${pc.green(`v${latest}`)}`, - options: [ - { - value: "update" as const, - label: `Update to v${latest} (recommended)`, - }, - { - value: "skip" as const, - label: "Skip", - hint: "Continue with current version", - }, - ], - }); - - if (!p.isCancel(updateChoice) && updateChoice === "update") { - const pm = detectPackageManager(); - const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${latest}`); - const cmdStr = formatShellCommand(cmd); - const updateSpinner = p.spinner(); - updateSpinner.start(`Updating to v${latest}...`); - try { - await runShellCommand(cmd); - updateSpinner.stop(pc.green(`Updated to v${latest}.`)); - version = getInstalledVersion() ?? version; - - // After a version bump, refresh every scope that already - // tracks argent skills so orphans (skills removed by the - // newer argent) surface before Step 2's single-scope add. - const skillSummary = formatSkillRefreshSummary( - refreshArgentSkills(resolveProjectRoot(process.cwd())) - ); - if (skillSummary) { - p.note(skillSummary, "Skills Updated"); - } - } catch (err) { - updateSpinner.stop(pc.red("Update failed.")); - p.log.error(`${err}`); - p.log.info(`You can update manually later: ${pc.cyan(cmdStr)}`); - } - } - } - } - } - - // ── Step 1: MCP Server Configuration ──────────────────────────────────────── + const topology = await decideTopology(parsed, projectRoot); + version = await ensureInstalled({ topology, parsed, projectRoot, version }); + // ── Step 1 — MCP configuration ──────────────────────────────────────── p.log.step(pc.bold("Step 1: MCP Server Configuration")); + announceLocalMode(topology.id); - if (installMode === "local") { - p.log.info( - `${pc.dim("Mode:")} Local devDependency — argent is pinned in ${pc.cyan("package.json")}, ` + - `MCP configs point at ${pc.cyan("./node_modules/.bin/argent")}.\n` + - ` Commit the changed files (package.json, lockfile, MCP configs) so the team shares this setup.` - ); - } - - // Local mode: keep only adapters that have a project-scoped config - // file. Global-only adapters (Windsurf, Hermes) can't participate. - const adapterUniverse = - installMode === "local" - ? ALL_ADAPTERS.filter((a) => a.acceptsLocalInstall !== false) - : ALL_ADAPTERS; - const detected = detectAdapters().filter((a) => adapterUniverse.includes(a)); - const detectedNames = detected.map((a) => a.name); - - if (installMode === "local") { - const dropped = ALL_ADAPTERS.filter((a) => a.acceptsLocalInstall === false); - if (dropped.length > 0) { - p.log.info( - pc.dim( - `Skipping ${dropped.map((a) => a.name).join(", ")} ` + - `(global-only — no project config file to commit).` - ) - ); - } - } - - let selectedAdapters: McpConfigAdapter[]; - - if (nonInteractive) { - selectedAdapters = detected.length > 0 ? detected : adapterUniverse; - } else { - const choices = adapterUniverse.map((a) => { - const parts: string[] = []; - if (detectedNames.includes(a.name)) parts.push("detected"); - const hasProject = a.projectPath(process.cwd()) != null; - const hasGlobal = a.globalPath() != null; - if (!hasProject && hasGlobal) { - parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s global config`))); - } else if (hasProject && !hasGlobal) { - parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s project config`))); - } - return { - value: a, - label: a.name, - hint: parts.length > 0 ? parts.join(", ") : undefined, - }; - }); - - p.log.message(pc.dim(" Use arrow keys to move, space to toggle, enter to confirm.")); - - const selected = await p.multiselect({ - message: "Which editors should Argent be configured for?", - options: choices, - initialValues: detected, - required: true, - }); - - if (p.isCancel(selected)) { - p.cancel("Initialization cancelled."); - process.exit(0); - } - - selectedAdapters = selected as McpConfigAdapter[]; - } - + const { selected: selectedAdapters } = await chooseAdapters({ + topology: topology.id, + nonInteractive: parsed.nonInteractive, + }); p.log.info(`Editors: ${selectedAdapters.map((a) => pc.cyan(a.name)).join(", ")}`); - // Ask scope: global, local, or custom path - let scope: "local" | "global" | "custom"; - let customRoot: string | undefined; - - if (installMode === "local") { - // Committed config must live next to package.json — scope prompt has - // only one legitimate answer in this mode. - scope = "local"; - } else if (nonInteractive) { - scope = "local"; - } else { - p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); - - const scopeChoice = await p.select({ - message: "Install MCP server globally or locally?", - options: [ - { - value: "local" as const, - label: "Local", - hint: "Current project only - .cursor/mcp.json, .mcp.json, ...", - }, - { - value: "global" as const, - label: "Global", - hint: "Available across all projects - ~/.*/mcp.json", - }, - { - value: "custom" as const, - label: "Specify installation directory", - hint: "Specify a directory to use as the project root", - }, - ], - }); - - if (p.isCancel(scopeChoice)) { - p.cancel("Initialization cancelled."); - process.exit(0); - } + const scopeChoice = await chooseScope({ + topology: topology.id, + nonInteractive: parsed.nonInteractive, + }); + const effectiveRoot = scopeChoice.scope === "custom" ? scopeChoice.customRoot! : projectRoot; + const normalizedScope: "local" | "global" = + scopeChoice.scope === "global" ? "global" : "local"; + + const mcpLines = writeMcpConfigs({ + adapters: selectedAdapters, + topology: topology.id, + scope: scopeChoice.scope, + effectiveRoot, + projectRoot, + }); + p.note(mcpLines.join("\n"), "MCP Configuration"); - scope = scopeChoice as "local" | "global" | "custom"; - - if (scope === "custom") { - const customPathInput = await p.text({ - message: "Enter the path to use as the project root for MCP config:", - placeholder: process.cwd(), - validate(value) { - if (!value?.trim()) return "Path cannot be empty."; - const resolved = resolve(value.trim()); - if (!existsSync(resolved)) - return `Path does not exist: ${resolved}. Please verify and enter a valid path.`; - }, - }); - - if (p.isCancel(customPathInput)) { - p.cancel("Initialization cancelled."); - process.exit(0); - } - - customRoot = resolve((customPathInput as string).trim()); - } + // ── Tool auto-approval ─────────────────────────────────────────────── + const allowlist = await configureAllowlist({ + adapters: selectedAdapters, + effectiveRoot, + scope: normalizedScope, + nonInteractive: parsed.nonInteractive, + }); + if (allowlist.enabled && allowlist.lines.length > 0) { + p.note(allowlist.lines.join("\n"), "Tool Auto-Approval"); } - const effectiveRoot = scope === "custom" ? customRoot! : projectRoot; - const normalizedScope: "local" | "global" = scope === "global" ? "global" : "local"; - // Entry shape depends on the adapter (Claude Code expands - // `${CLAUDE_PROJECT_DIR}`), so construct it per-adapter in the loop. - const entryMode: McpEntryMode = - installMode === "local" ? { kind: "local", projectRoot: effectiveRoot } : { kind: "global" }; - const mcpResults: string[] = []; - - for (const adapter of selectedAdapters) { - const mcpEntry = getMcpEntry(entryMode, adapter); - const configPath = - scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); - - if (!configPath) { - if (scope === "global" && adapter.projectPath(projectRoot)) { - const fallback = adapter.projectPath(projectRoot)!; - try { - adapter.write(fallback, mcpEntry); - mcpResults.push( - `${pc.green("+")} ${adapter.name} ${pc.dim(`(local fallback: ${fallback})`)}` - ); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } - } else if (scope !== "global" && adapter.globalPath()) { - const fallback = adapter.globalPath()!; - try { - adapter.write(fallback, mcpEntry); - mcpResults.push( - `${pc.green("+")} ${adapter.name} ${pc.dim(`(global fallback: ${fallback})`)}` - ); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } - } else { - mcpResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config path for this scope)")}` - ); - } - continue; - } + // ── Step 2 — Skills ───────────────────────────────────────────────── + const skillsMethod = await runSkillsStep({ + nonInteractive: parsed.nonInteractive, + fromTar: parsed.fromTar, + version, + scope: scopeChoice.scope, + customRoot: scopeChoice.customRoot, + }); - try { - adapter.write(configPath, mcpEntry); - mcpResults.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } + // ── Step 3 — Rules & Agents ────────────────────────────────────────── + p.log.step(pc.bold("Step 3: Rules & Agents")); + const copyResults = copyRulesAndAgents( + selectedAdapters, + effectiveRoot, + normalizedScope, + RULES_DIR, + AGENTS_DIR + ); + if (copyResults.length > 0) { + p.note(copyResults.join("\n"), "Rules & Agents"); + } else { + p.log.info(pc.dim("No rules or agents to copy for selected editors.")); } - p.note(mcpResults.join("\n"), "MCP Configuration"); - - // ── Tool Auto-Approval ──────────────────────────────────────────────────── - - const adaptersWithAllowlist = selectedAdapters.filter((a) => a.addAllowlist); - const adaptersWithoutAllowlist = selectedAdapters.filter((a) => !a.addAllowlist); - - let allowlistEnabled = false; - - if (adaptersWithAllowlist.length > 0) { - p.log.info( - `By default, editors ask for confirmation before running each MCP tool.\n` + - ` Adding Argent to the auto-approve allowlist lets tools run without\n` + - ` repeated prompts. This is ${pc.cyan("recommended")} for a smooth experience.` - ); + // ── Summary ────────────────────────────────────────────────────────── + printSummary({ + topology: topology.id, + selectedAdapters, + scope: scopeChoice.scope, + allowlistEnabled: allowlist.enabled, + skillsMethod, + copiedRules: copyResults.length > 0, + }); - if (nonInteractive) { - allowlistEnabled = true; - } else { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + p.note( + [ + pc.bold(pc.green("Argent is ready!")), + "", + `${pc.bold("Get started")} by asking your assistant:`, + "", + ` ${pc.bold(pc.cyan(`"What can Argent do?"`))}`, + "", + pc.dim("It will walk you through all capabilities available."), + ].join("\n"), + pc.bgGreen(pc.black(" Get Started ")) + ); + p.outro("Done."); +} - const allowlistChoice = await p.confirm({ - message: "Add Argent tools to editor auto-approve lists? - recommended", - initialValue: true, - }); +// ── Step 0 helpers ────────────────────────────────────────────────────── - if (p.isCancel(allowlistChoice)) { - p.cancel("Initialization cancelled."); - process.exit(0); - } +// Pick the topology by following the same priority the CLI documents: +// 1. --devdep / --local-install (parsed.forcedTopology) +// 2. Already globally installed → stay global +// 3. Non-interactive → global default +// 4. Interactive prompt +async function decideTopology(parsed: InitArgs, projectRoot: string): Promise { + if (parsed.forcedTopology === "local") return LOCAL; + if (isGloballyInstalled()) return GLOBAL; + if (parsed.nonInteractive) return GLOBAL; - allowlistEnabled = allowlistChoice as boolean; - } - } + const choice = await promptInstallMode({ + locallyInstalled: isLocallyInstalled(projectRoot), + }); + return choice === "local" ? LOCAL : GLOBAL; +} - if (allowlistEnabled) { - const allowlistResults: string[] = []; - - for (const adapter of adaptersWithAllowlist) { - const hasPath = - normalizedScope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); - if (!hasPath) { - allowlistResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config for this scope)")}` - ); - continue; - } - try { - adapter.addAllowlist!(effectiveRoot, normalizedScope); - allowlistResults.push(`${pc.green("+")} ${adapter.name}`); - } catch (err) { - allowlistResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } - } +interface EnsureInstalledArgs { + topology: Topology; + parsed: InitArgs; + projectRoot: string; + version: string; +} - for (const adapter of adaptersWithoutAllowlist) { - allowlistResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no auto-approve API - configure manually)")}` +// Branches by topology, but each branch is short. Local: skip when already +// installed, otherwise run via install-runner. Global: install if missing, +// reinstall if --from, otherwise offer an interactive update. +async function ensureInstalled({ + topology, + parsed, + projectRoot, + version, +}: EnsureInstalledArgs): Promise { + if (topology === LOCAL) { + if (isLocallyInstalled(projectRoot)) { + p.log.info( + `Argent is already installed as a devDependency at ` + + `${pc.dim(`${projectRoot}/node_modules/@swmansion/argent`)}. Skipping install step.` ); + return getLocallyInstalledVersion(projectRoot) ?? version; } - - p.note(allowlistResults.join("\n"), "Tool Auto-Approval"); - } - - // ── Step 2: Skills Installation ───────────────────────────────────────────── - - p.log.step(pc.bold("Step 2: Skills Installation")); - p.log.warn(pc.yellow("Skills installation is required for Argent to function properly.")); - - type SkillsMethod = "default" | "interactive" | "manual"; - let skillsMethod: SkillsMethod; - - const online = await isOnline(); - const offlineWithCache = !online && isSkillsCliAvailable(); - const skillsCliReady = online || offlineWithCache; - - if (!skillsCliReady) { - p.log.warn( - pc.yellow("You appear to be offline. ") + - "Automatic skills installation requires a network connection." - ); + return runInstall({ + topology, + projectRoot, + fromTar: parsed.fromTar, + fallbackVersion: version, + }); } - if (!skillsCliReady) { - skillsMethod = "manual"; - } else if (nonInteractive) { - skillsMethod = "default"; - } else { - p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); - - const choice = await p.select({ - message: "How would you like to install skills?", - options: [ - { - value: "default" as const, - label: "Automatic", - hint: "Installs all skills automatically with npx skills", - }, - { - value: "interactive" as const, - label: "Interactive", - hint: "Full npx skills TUI - choose skills, agents, and method", - }, - { - value: "manual" as const, - label: "Manual", - hint: "Print instructions for manual installation", - }, - ], + // Global topology. + if (!isGloballyInstalled() || parsed.fromTar) { + return runInstall({ + topology, + projectRoot, + fromTar: parsed.fromTar, + fallbackVersion: version, }); - - if (p.isCancel(choice)) { - p.cancel("Initialization cancelled."); - process.exit(0); - } - - skillsMethod = choice as SkillsMethod; } + return await offerInteractiveUpdate({ version, nonInteractive: parsed.nonInteractive, projectRoot }); +} - // Prefer the GitHub-pinned source. SKILLS_DIR as a fallback. - const useGitHubSource = online && !fromTar && version !== "unknown"; - const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; - - if (skillsMethod === "manual") { - p.note( - [ - `Skills are bundled at:`, - ` ${pc.cyan(SKILLS_DIR)}`, - ``, - `To install manually, copy them to your editor's skills directory:`, - ``, - ` ${pc.dim("# Claude Code")}`, - ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.claude/skills/" : `${scope === "custom" ? customRoot! : "."}/.claude/skills/`}`, - ``, - ` ${pc.dim("# Cursor")}`, - ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.cursor/skills/" : `${scope === "custom" ? customRoot! : "."}/.cursor/skills/`}`, - ``, - ` ${pc.dim("# Or use npx skills directly:")}`, - ` npx skills add ${skillsSource}`, - ].join("\n"), - "Manual Skills Installation" - ); - } else { - const skillsArgs = ["skills", "add", skillsSource]; - - if (scope === "global") { - skillsArgs.push("-g"); - } - - if (skillsMethod === "default") { - skillsArgs.push("--skill", "*", "-y"); - } - - const npxArgs = offlineWithCache ? ["--no-install", ...skillsArgs] : skillsArgs; +interface OfferUpdateArgs { + version: string; + nonInteractive: boolean; + projectRoot: string; +} - p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); +async function offerInteractiveUpdate({ + version, + nonInteractive, + projectRoot, +}: OfferUpdateArgs): Promise { + let latest: string | null = null; + const spinner = p.spinner(); + spinner.start("Checking for updates..."); + try { + latest = getLatestVersion(); + } catch { + // Registry unreachable - silently skip. + } + spinner.stop(pc.dim("Version check complete.")); - const spinner = p.spinner(); - if (skillsMethod === "default") { - spinner.start("Installing skills..."); - } + if (!latest || !isNewerVersion(latest, version)) return version; + if (nonInteractive) return version; - try { - const skillsCwd = scope === "custom" ? customRoot : undefined; - await runNpxSkills(npxArgs, skillsMethod === "interactive", skillsCwd); - if (skillsMethod === "default") { - spinner.stop("Skills installed."); - } - } catch (err) { - if (skillsMethod === "default") { - spinner.stop(pc.red("Skills installation failed.")); - } - p.log.error(`Failed to run npx skills: ${err}`); - p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); - } + const choice = await p.select({ + message: `Update available: ${pc.yellow(`v${version}`)} → ${pc.green(`v${latest}`)}`, + options: [ + { value: "update" as const, label: `Update to v${latest} (recommended)` }, + { value: "skip" as const, label: "Skip", hint: "Continue with current version" }, + ], + }); + if (p.isCancel(choice) || choice !== "update") return version; + + const cmd = GLOBAL.installCommand(projectRoot, `${PACKAGE_NAME}@${latest}`); + const cmdStr = formatShellCommand(cmd); + const updateSpinner = p.spinner(); + updateSpinner.start(`Updating to v${latest}...`); + try { + await runShellCommand(cmd); + updateSpinner.stop(pc.green(`Updated to v${latest}.`)); + const installedVersion = getInstalledVersion() ?? latest; + + // After a version bump, refresh every scope that already tracks + // argent skills so orphans (skills removed by the newer argent) + // surface before Step 2's single-scope add. + const summary = formatSkillRefreshSummary(refreshArgentSkills(projectRoot)); + if (summary) p.note(summary, "Skills Updated"); + return installedVersion; + } catch (err) { + updateSpinner.stop(pc.red("Update failed.")); + p.log.error(`${err}`); + p.log.info(`You can update manually later: ${pc.cyan(cmdStr)}`); + return version; } +} - // ── Step 3: Rules and Agents ──────────────────────────────────────────────── - - p.log.step(pc.bold("Step 3: Rules & Agents")); +// ── Step-1 helpers ───────────────────────────────────────────────────── - const copyResults = copyRulesAndAgents( - selectedAdapters, - effectiveRoot, - normalizedScope, - RULES_DIR, - AGENTS_DIR +function announceLocalMode(topology: TopologyId): void { + if (topology !== "local") return; + p.log.info( + `${pc.dim("Mode:")} Local devDependency — argent is pinned in ${pc.cyan("package.json")}, ` + + `MCP configs point at ${pc.cyan("./node_modules/.bin/argent")}.\n` + + ` Commit the changed files (package.json, lockfile, MCP configs) so the team shares this setup.` ); +} - if (copyResults.length > 0) { - p.note(copyResults.join("\n"), "Rules & Agents"); - } else { - p.log.info(pc.dim("No rules or agents to copy for selected editors.")); - } +// ── Summary ──────────────────────────────────────────────────────────── - // ── Summary ───────────────────────────────────────────────────────────────── +interface SummaryArgs { + topology: TopologyId; + selectedAdapters: McpConfigAdapter[]; + scope: Scope; + allowlistEnabled: boolean; + skillsMethod: SkillsMethod; + copiedRules: boolean; +} - const scopeLabel = installMode === "local" ? "local devDependency" : scope; - const summaryLines = [ +function printSummary({ + topology, + selectedAdapters, + scope, + allowlistEnabled, + skillsMethod, + copiedRules, +}: SummaryArgs): void { + const scopeLabel = topology === "local" ? "local devDependency" : scope; + const lines = [ `${pc.green("MCP server")} configured for ${selectedAdapters.map((a) => a.name).join(", ")} (${scopeLabel})`, `${pc.green("Auto-approve")} ${allowlistEnabled ? "enabled" : "skipped"}`, `${pc.green("Skills")} ${skillsMethod === "manual" ? "instructions printed" : "installed"}`, - `${pc.green("Rules & agents")} ${copyResults.length > 0 ? "copied" : "n/a"}`, + `${pc.green("Rules & agents")} ${copiedRules ? "copied" : "n/a"}`, ]; + p.note(lines.join("\n"), "Summary"); - p.note(summaryLines.join("\n"), "Summary"); - - if (installMode === "local") { + if (topology === "local") { p.note( [ pc.bold("Commit these so the team shares the setup:"), @@ -710,60 +323,9 @@ export async function init(args: string[]): Promise { "Team Share" ); } - - p.note( - [ - pc.bold(pc.green("Argent is ready!")), - "", - `${pc.bold("Get started")} by asking your assistant:`, - "", - ` ${pc.bold(pc.cyan(`"What can Argent do?"`))}`, - "", - pc.dim("It will walk you through all capabilities available."), - ].join("\n"), - pc.bgGreen(pc.black(" Get Started ")) - ); - p.outro("Done."); -} - -// Signals that an install failure came from the user's existing manifest -// (broken protocol / file: dep / peer-dep), not from argent itself — -// used to redirect blame in the error hint. -const EXISTING_MANIFEST_ERROR_PATTERNS = [ - /EUNSUPPORTEDPROTOCOL/i, - /Unsupported URL Type/i, - /\blink:/i, // `link:./foo` and friends - /ERESOLVE/i, - /peer dep/i, - /could not resolve dependency/i, - /ENOENT.*package\.json/i, -]; - -function looksLikeExistingManifestError(message: string): boolean { - return EXISTING_MANIFEST_ERROR_PATTERNS.some((pattern) => pattern.test(message)); } -// Print the local-install failure. `npm install --save-dev` re-resolves -// every existing dep, so EUNSUPPORTEDPROTOCOL/ERESOLVE etc. usually -// point at the user's manifest, not argent — surface that hint so the -// bug doesn't get filed against the wrong project. -function reportLocalInstallFailure(err: unknown, cmdStr: string, projectRoot: string): void { - const message = err instanceof Error ? err.message : String(err); - p.log.error(message); - - if (looksLikeExistingManifestError(message)) { - p.log.info( - `${pc.yellow("Note:")} this looks like a problem with an existing dependency in ` + - `${pc.dim(`${projectRoot}/package.json`)}, not with argent itself. ` + - `Argent was added to package.json but the wider install ran a re-resolve ` + - `of every dep and one of them failed. Fix the offending entry (the error ` + - `above names it) and re-run ${pc.cyan("argent init")}, or install argent ` + - `globally instead with ${pc.cyan("argent init")} → Global.` - ); - } - - p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); -} +// ── Banner ───────────────────────────────────────────────────────────── export function printBanner(): void { const lines = [ @@ -774,49 +336,10 @@ export function printBanner(): void { "██║ ██║██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║", "╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝", ]; - const width = Math.max(...lines.map((l) => l.length)); - console.log(); - for (const line of lines) { - console.log(line); - } - + for (const line of lines) console.log(line); const attribution = "by Software Mansion"; console.log(" ".repeat(width - attribution.length) + pc.dim(attribution)); console.log(); } - -function runNpxSkills(args: string[], interactive: boolean, cwd?: string): Promise { - return new Promise((resolve, reject) => { - const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"; - const child = spawn(npxCmd, args, { - stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", - ...(cwd ? { cwd } : {}), - }); - - let stdout = ""; - let stderr = ""; - - if (!interactive) { - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - } - - child.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - const output = [stderr, stdout].filter(Boolean).join("\n").trim(); - reject(new Error(output || `npx skills exited with code ${code}`)); - } - }); - - child.on("error", reject); - }); -} diff --git a/packages/argent-installer/src/install-error.ts b/packages/argent-installer/src/install-error.ts new file mode 100644 index 00000000..c9b0fdae --- /dev/null +++ b/packages/argent-installer/src/install-error.ts @@ -0,0 +1,44 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; + +// `npm install --save-dev ` re-resolves every existing dep in the +// user's manifest. When that fails, the surfaced error is usually about +// one of their own entries (a broken `link:` path, an unreachable file: +// dep, a peer conflict) — not about argent itself. We pattern-match the +// common error strings so the hint can redirect blame appropriately. + +const EXISTING_MANIFEST_ERROR_PATTERNS: ReadonlyArray = [ + /EUNSUPPORTEDPROTOCOL/i, + /Unsupported URL Type/i, + /\blink:/i, + /ERESOLVE/i, + /peer dep/i, + /could not resolve dependency/i, + /ENOENT.*package\.json/i, +]; + +export function looksLikeExistingManifestError(message: string): boolean { + return EXISTING_MANIFEST_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function reportLocalInstallFailure( + err: unknown, + cmdStr: string, + projectRoot: string +): void { + const message = err instanceof Error ? err.message : String(err); + p.log.error(message); + + if (looksLikeExistingManifestError(message)) { + p.log.info( + `${pc.yellow("Note:")} this looks like a problem with an existing dependency in ` + + `${pc.dim(`${projectRoot}/package.json`)}, not with argent itself. ` + + `Argent was added to package.json but the wider install ran a re-resolve ` + + `of every dep and one of them failed. Fix the offending entry (the error ` + + `above names it) and re-run ${pc.cyan("argent init")}, or install argent ` + + `globally instead with ${pc.cyan("argent init")} → Global.` + ); + } + + p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); +} diff --git a/packages/argent-installer/src/install-runner.ts b/packages/argent-installer/src/install-runner.ts new file mode 100644 index 00000000..e00e162e --- /dev/null +++ b/packages/argent-installer/src/install-runner.ts @@ -0,0 +1,86 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { PACKAGE_NAME } from "./constants.js"; +import { formatShellCommand } from "./package-manager.js"; +import { runShellCommand } from "./shell.js"; +import { hasPackageJson, isYarnPnp } from "./preflight.js"; +import { GLOBAL, LOCAL, type Topology } from "./topology.js"; +import { reportLocalInstallFailure } from "./install-error.js"; +import { getInstalledVersion, getLocallyInstalledVersion } from "./utils.js"; + +// Run the install for a chosen topology, returning the post-install +// version. On failure: prints contextual guidance and process.exit(1). + +interface RunArgs { + topology: Topology; + projectRoot: string; + /** --from or null. */ + fromTar: string | null; + /** Version reported before install — fallback if post-install read fails. */ + fallbackVersion: string; +} + +export async function runInstall(args: RunArgs): Promise { + return args.topology === LOCAL ? runLocal(args) : runGlobal(args); +} + +async function runGlobal({ projectRoot, fromTar, fallbackVersion }: RunArgs): Promise { + const target = fromTar ?? PACKAGE_NAME; + const cmd = GLOBAL.installCommand(projectRoot, target); + const cmdStr = formatShellCommand(cmd); + const spinner = p.spinner(); + spinner.start( + fromTar ? `Installing from ${fromTar}...` : `Installing ${PACKAGE_NAME} globally...` + ); + try { + await runShellCommand(cmd, GLOBAL.spawnCwd(projectRoot)); + spinner.stop(pc.green(fromTar ? "Installed from tarball." : "Installed globally.")); + return getInstalledVersion() ?? fallbackVersion; + } catch (err) { + spinner.stop(pc.red("Installation failed.")); + p.log.error(`${err}`); + p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); + process.exit(1); + } +} + +async function runLocal({ projectRoot, fromTar, fallbackVersion }: RunArgs): Promise { + refusePreflightFailures(projectRoot); + + const target = fromTar ?? PACKAGE_NAME; + const cmd = LOCAL.installCommand(projectRoot, target); + const cmdStr = formatShellCommand(cmd); + const pmName = cmd.bin; + const spinner = p.spinner(); + spinner.start(`Installing ${PACKAGE_NAME} as a devDependency with ${pmName}...`); + try { + await runShellCommand(cmd, LOCAL.spawnCwd(projectRoot)); + spinner.stop(pc.green(`Installed as devDependency (via ${pmName}).`)); + // Read the just-installed copy, not the running module — under `npx`, + // getInstalledVersion() returns the npx cache version. + return getLocallyInstalledVersion(projectRoot) ?? fallbackVersion; + } catch (err) { + spinner.stop(pc.red("Installation failed.")); + reportLocalInstallFailure(err, cmdStr, projectRoot); + process.exit(1); + } +} + +function refusePreflightFailures(projectRoot: string): void { + if (!hasPackageJson(projectRoot)) { + p.log.error( + `No package.json found at ${pc.dim(projectRoot)}.\n` + + ` Run ${pc.cyan("npm init -y")} first, then re-run ${pc.cyan("argent init --devdep")}.` + ); + process.exit(1); + } + if (isYarnPnp(projectRoot)) { + p.log.error( + `Yarn PnP detected (.pnp.cjs at ${pc.dim(projectRoot)}).\n` + + ` The devDep flow needs a real node_modules/.bin directory.\n` + + ` Switch to ${pc.cyan('nodeLinker: "node-modules"')} in .yarnrc.yml or ` + + `re-run with ${pc.cyan("argent init")} for a global install.` + ); + process.exit(1); + } +} diff --git a/packages/argent-installer/src/mcp-configs.ts b/packages/argent-installer/src/mcp-configs.ts index 4202fe5d..a3c18eb9 100644 --- a/packages/argent-installer/src/mcp-configs.ts +++ b/packages/argent-installer/src/mcp-configs.ts @@ -55,6 +55,11 @@ export interface McpConfigAdapter { // False for adapters with no project-scoped config file (Windsurf, // Hermes) — excluded from the `--devdep` flow. Defaults to true. acceptsLocalInstall?: boolean; + // When true, the adapter natively expands ${CLAUDE_PROJECT_DIR} in + // `command`/`args`. Currently only Claude Code documents this. + // getMcpEntry uses it to pick the local-mode command path. Defaults + // to false (plain "./node_modules/.bin/argent"). + expandsProjectDirVariable?: boolean; } type CodexConfig = { @@ -96,11 +101,11 @@ export function getMcpEntry( }; } - // Claude Code documents `${CLAUDE_PROJECT_DIR}` substitution (see - // code.claude.com/docs/en/mcp); `:-.` keeps the path usable when the - // variable isn't populated (e.g. manual debug runs). - const useClaudeProjectDir = adapter?.name === "Claude Code"; - const command = useClaudeProjectDir + // Adapters that natively expand ${CLAUDE_PROJECT_DIR} (Claude Code) use + // the documented substitution; `:-.` keeps it usable when the variable + // isn't populated (e.g. manual debug runs). See `expandsProjectDirVariable` + // on each adapter for the contract. + const command = adapter?.expandsProjectDirVariable ? "${CLAUDE_PROJECT_DIR:-.}/node_modules/.bin/argent" : LOCAL_BIN_REL_PATH; @@ -241,6 +246,9 @@ const cursorAdapter: McpConfigAdapter = { const claudeAdapter: McpConfigAdapter = { name: "Claude Code", + // Claude Code documents `${CLAUDE_PROJECT_DIR}` substitution in `command` + // / `args` (see code.claude.com/docs/en/mcp). + expandsProjectDirVariable: true, detect(): boolean { return ( diff --git a/packages/argent-installer/src/package-manager.ts b/packages/argent-installer/src/package-manager.ts new file mode 100644 index 00000000..7492aae3 --- /dev/null +++ b/packages/argent-installer/src/package-manager.ts @@ -0,0 +1,101 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// One place to learn about package managers: the type, how to detect which +// one a project uses, and how to build install/uninstall commands for +// both topologies (global on PATH, local devDependency under +// node_modules/@swmansion/argent). + +export type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; + +export interface ShellCommand { + bin: string; + args: string[]; +} + +export function formatShellCommand(cmd: ShellCommand): string { + const parts = [cmd.bin, ...cmd.args.map((a) => (a.includes(" ") ? `"${a}"` : a))]; + return parts.join(" "); +} + +// Ordered by specificity — pnpm/bun lockfiles are unique to their PM; +// yarn is unambiguous; package-lock.json / shrinkwrap fall through last. +const LOCKFILE_TO_PM: ReadonlyArray = [ + ["pnpm-lock.yaml", "pnpm"], + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], + ["npm-shrinkwrap.json", "npm"], +]; + +function detectFromLockfile(projectRoot: string): PackageManager | null { + for (const [lockfile, pm] of LOCKFILE_TO_PM) { + if (fs.existsSync(path.join(projectRoot, lockfile))) return pm; + } + return null; +} + +function detectFromUserAgent(): PackageManager { + const agent = process.env.npm_config_user_agent ?? ""; + if (agent.startsWith("yarn")) return "yarn"; + if (agent.startsWith("pnpm")) return "pnpm"; + if (agent.startsWith("bun")) return "bun"; + return "npm"; +} + +// Resolution: 1) projectRoot's lockfile (load-bearing for `--devdep` — +// `npx` sets npm_config_user_agent=npm/... even inside a yarn workspace, +// which would issue `npm install` and fail on yarn-only `link:` deps); +// 2) npm_config_user_agent; 3) npm. +export function detectPackageManager(projectRoot?: string): PackageManager { + if (projectRoot) { + const fromLockfile = detectFromLockfile(projectRoot); + if (fromLockfile) return fromLockfile; + } + return detectFromUserAgent(); +} + +// ── Command builders ────────────────────────────────────────────────────── +// Each PM's flag for "install/remove a package globally / as devDep". +// All four PMs accept a registry name or a tarball/file path as the +// positional, so the same recipes handle the --from flag. + +interface CommandRecipe { + install: ShellCommand["args"]; // before the package name + uninstall: ShellCommand["args"]; +} + +const GLOBAL_RECIPES: Record = { + npm: { install: ["install", "-g"], uninstall: ["uninstall", "-g"] }, + yarn: { install: ["global", "add"], uninstall: ["global", "remove"] }, + pnpm: { install: ["add", "-g"], uninstall: ["remove", "-g"] }, + bun: { install: ["add", "-g"], uninstall: ["remove", "-g"] }, +}; + +const LOCAL_DEV_RECIPES: Record = { + npm: { install: ["install", "--save-dev"], uninstall: ["uninstall"] }, + yarn: { install: ["add", "--dev"], uninstall: ["remove"] }, + pnpm: { install: ["add", "-D"], uninstall: ["remove"] }, + bun: { install: ["add", "-d"], uninstall: ["remove"] }, +}; + +function build(pm: PackageManager, args: string[], pkg: string): ShellCommand { + return { bin: pm, args: [...args, pkg] }; +} + +export function globalInstallCommand(pm: PackageManager, pkg: string): ShellCommand { + return build(pm, GLOBAL_RECIPES[pm].install, pkg); +} + +export function globalUninstallCommand(pm: PackageManager, pkg: string): ShellCommand { + return build(pm, GLOBAL_RECIPES[pm].uninstall, pkg); +} + +export function localDevInstallCommand(pm: PackageManager, pkg: string): ShellCommand { + return build(pm, LOCAL_DEV_RECIPES[pm].install, pkg); +} + +export function localDevUninstallCommand(pm: PackageManager, pkg: string): ShellCommand { + return build(pm, LOCAL_DEV_RECIPES[pm].uninstall, pkg); +} diff --git a/packages/argent-installer/src/preflight.ts b/packages/argent-installer/src/preflight.ts new file mode 100644 index 00000000..39948141 --- /dev/null +++ b/packages/argent-installer/src/preflight.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Filesystem probes that gate the `--devdep` flow. Kept separate from +// topology.ts because these answer "can we run a local install?" rather +// than "do we already have a local install?". + +export function hasPackageJson(projectRoot: string): boolean { + return fs.existsSync(path.join(projectRoot, "package.json")); +} + +// Yarn 2+ PnP — no literal node_modules/.bin/argent, so the devDep +// flow's MCP command would resolve to nothing. Surface upfront. +export function isYarnPnp(projectRoot: string): boolean { + return ( + fs.existsSync(path.join(projectRoot, ".pnp.cjs")) || + fs.existsSync(path.join(projectRoot, ".pnp.loader.mjs")) + ); +} diff --git a/packages/argent-installer/src/shell.ts b/packages/argent-installer/src/shell.ts new file mode 100644 index 00000000..e9937b4e --- /dev/null +++ b/packages/argent-installer/src/shell.ts @@ -0,0 +1,28 @@ +import { spawn } from "node:child_process"; +import type { ShellCommand } from "./package-manager.js"; + +// Run a ShellCommand to completion and reject with its stderr on +// non-zero exit. Used by the install/update spinners — stdout/stderr are +// piped (not inherited) so they don't fight with @clack/prompts. +export function runShellCommand(cmd: ShellCommand, cwd?: string): Promise { + return new Promise((resolve, reject) => { + const isWin = process.platform === "win32"; + const child = spawn(isWin ? `${cmd.bin}.cmd` : cmd.bin, cmd.args, { + stdio: ["ignore", "pipe", "pipe"], + shell: isWin, + ...(cwd ? { cwd } : {}), + }); + + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(stderr.trim() || `Command exited with code ${code}`)); + }); + + child.on("error", reject); + }); +} diff --git a/packages/argent-installer/src/topology.ts b/packages/argent-installer/src/topology.ts new file mode 100644 index 00000000..5681ec61 --- /dev/null +++ b/packages/argent-installer/src/topology.ts @@ -0,0 +1,192 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execSync } from "node:child_process"; +import { + detectPackageManager, + globalInstallCommand, + globalUninstallCommand, + localDevInstallCommand, + localDevUninstallCommand, + type PackageManager, + type ShellCommand, +} from "./package-manager.js"; +import { MCP_BINARY_NAME, PACKAGE_NAME } from "./constants.js"; +import { resolvePackageRoot } from "./utils.js"; + +// Argent supports two independent install topologies: +// - global: argent is on the user's PATH (historical default). +// - local : argent is a project devDependency under +// /node_modules/@swmansion/argent. This is the +// "team-share" flow — the MCP config can be committed. +// +// They can coexist: a developer can have a global install AND a project +// devDep. update/uninstall iterate TOPOLOGIES so each is probed and +// handled independently. + +export type TopologyId = "global" | "local"; + +export interface TopologyState { + readonly installed: boolean; + /** Version reported by the install on disk, NOT the running module. */ + readonly version: string | null; +} + +export interface Topology { + readonly id: TopologyId; + /** Short label for UI ("global package", "local devDependency"). */ + readonly label: string; + /** Probe presence + version. */ + probe(projectRoot: string): TopologyState; + installCommand(projectRoot: string, pkg: string): ShellCommand; + uninstallCommand(projectRoot: string, pkg: string): ShellCommand; + /** cwd to spawn the install/uninstall child with (undefined = inherit). */ + spawnCwd(projectRoot: string): string | undefined; +} + +// ── Path segments used by temp package runners ────────────────────────── +// When argent is invoked via npx / pnpm dlx / bunx / yarn dlx, the runner +// prepends its cache .bin/ dir to PATH, so `which argent` succeeds even +// though argent is not permanently installed globally. Filter these out +// so a concurrent npx invocation doesn't mask a real (or missing) global. +const TEMP_RUNNER_MARKERS = [ + "_npx", + "/dlx-", + "\\dlx-", + "bun/install/cache", + ".bun\\install\\cache", +]; + +export function isTempRunnerPath(binaryPath: string): boolean { + return TEMP_RUNNER_MARKERS.some((marker) => binaryPath.includes(marker)); +} + +function getGlobalBinaryPath(): string | null { + try { + const cmd = process.platform === "win32" ? "where" : "which -a"; + const output = execSync(`${cmd} ${MCP_BINARY_NAME}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return ( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .find((line) => !isTempRunnerPath(line)) ?? null + ); + } catch { + return null; + } +} + +function readJsonSafely(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch { + return null; + } +} + +// ── Global topology ───────────────────────────────────────────────────── + +function probeGlobal(): TopologyState { + const binaryPath = getGlobalBinaryPath(); + if (!binaryPath) return { installed: false, version: null }; + + let version: string | null = null; + try { + const realPath = fs.realpathSync(binaryPath); + const pkgRoot = resolvePackageRoot(path.dirname(realPath)); + const pkg = readJsonSafely<{ version?: string }>(path.join(pkgRoot, "package.json")); + version = pkg?.version ?? null; + } catch { + // Couldn't follow the symlink (Windows .cmd wrappers) — leave version null. + } + return { installed: true, version }; +} + +export const GLOBAL: Topology = { + id: "global", + label: "global package", + probe: () => probeGlobal(), + installCommand: (_root, pkg) => globalInstallCommand(detectPackageManager(), pkg), + uninstallCommand: (_root, pkg) => globalUninstallCommand(detectPackageManager(), pkg), + // Global install doesn't depend on the project tree — inherit the cwd. + spawnCwd: () => undefined, +}; + +// ── Local topology ────────────────────────────────────────────────────── + +const DEPENDENCY_FIELDS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +function localPackageJsonPath(projectRoot: string): string { + return path.join(projectRoot, "node_modules", "@swmansion", "argent", "package.json"); +} + +// A real consumer install requires BOTH a dep declaration in the project's +// package.json AND the files on disk. The declaration check disambiguates +// from npm/yarn workspaces where node_modules/@swmansion/argent is a +// symlink to the workspace source (workspace members don't list themselves +// in the root manifest). +function isDeclaredAsDependency(projectRoot: string): boolean { + const pkg = readJsonSafely<{ [field: string]: Record | undefined }>( + path.join(projectRoot, "package.json") + ); + if (!pkg) return false; + return DEPENDENCY_FIELDS.some((field) => Boolean(pkg[field]?.[PACKAGE_NAME])); +} + +function readLocalVersion(projectRoot: string): string | null { + const pkg = readJsonSafely<{ version?: string }>(localPackageJsonPath(projectRoot)); + return pkg?.version ?? null; +} + +function probeLocal(projectRoot: string): TopologyState { + if (!isDeclaredAsDependency(projectRoot)) return { installed: false, version: null }; + if (!fs.existsSync(localPackageJsonPath(projectRoot))) return { installed: false, version: null }; + return { installed: true, version: readLocalVersion(projectRoot) }; +} + +export const LOCAL: Topology = { + id: "local", + label: "local devDependency", + probe: (root) => probeLocal(root), + installCommand: (root, pkg) => localDevInstallCommand(detectPackageManager(root), pkg), + uninstallCommand: (root, pkg) => localDevUninstallCommand(detectPackageManager(root), pkg), + spawnCwd: (root) => root, +}; + +// ── Registry ──────────────────────────────────────────────────────────── + +export const TOPOLOGIES: ReadonlyArray = [GLOBAL, LOCAL]; + +export function topologyById(id: TopologyId): Topology { + return id === "global" ? GLOBAL : LOCAL; +} + +// Convenience predicates kept for older call sites that don't want the +// full TopologyState object. New code should call topology.probe(). +export function isGloballyInstalled(): boolean { + return GLOBAL.probe("").installed; +} + +export function isLocallyInstalled(projectRoot: string): boolean { + return LOCAL.probe(projectRoot).installed; +} + +export function getGloballyInstalledVersion(): string | null { + return GLOBAL.probe("").version; +} + +// Pure file read — independent of dep declaration. Returns whatever is on +// disk under node_modules/@swmansion/argent so post-install reporting +// reflects the real version even when the manifest hasn't been refreshed +// yet (e.g. mid-install state). +export function getLocallyInstalledVersion(projectRoot: string): string | null { + return readLocalVersion(projectRoot); +} diff --git a/packages/argent-installer/src/uninstall.ts b/packages/argent-installer/src/uninstall.ts index 111b8a3a..ffa53b67 100644 --- a/packages/argent-installer/src/uninstall.ts +++ b/packages/argent-installer/src/uninstall.ts @@ -9,18 +9,9 @@ import { removeCodexRules, type ManagedContentTarget, } from "./mcp-configs.js"; -import { - AGENTS_DIR, - detectPackageManager, - formatShellCommand, - globalUninstallCommand, - isGloballyInstalled, - isLocallyInstalled, - localDevUninstallCommand, - resolveProjectRoot, - RULES_DIR, - SKILLS_DIR, -} from "./utils.js"; +import { AGENTS_DIR, resolveProjectRoot, RULES_DIR, SKILLS_DIR } from "./utils.js"; +import { formatShellCommand } from "./package-manager.js"; +import { GLOBAL, LOCAL, TOPOLOGIES, type Topology, type TopologyState } from "./topology.js"; import { PACKAGE_NAME } from "./constants.js"; import { killToolServer } from "@argent/tools-client"; @@ -443,93 +434,83 @@ export async function uninstall(args: string[]): Promise { } // ── Uninstall the package itself ──────────────────────────────────────────── - // Argent can be present globally and/or as a project devDep - // independently — probe both and prompt separately. - - const globallyInstalled = isGloballyInstalled(); - const locallyInstalled = isLocallyInstalled(projectRoot); - - // Kill the tool-server at most once across both branches. - let toolServerKilled = false; - async function ensureToolServerKilled(): Promise { - if (toolServerKilled) return; - await killToolServer(); - toolServerKilled = true; - } - - if (globallyInstalled) { - let shouldUninstallGlobal = nonInteractive; - - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - - const uninstallPkg = await p.confirm({ - message: `Uninstall the global ${PACKAGE_NAME} package?`, - initialValue: false, - }); - - if (!p.isCancel(uninstallPkg)) { - shouldUninstallGlobal = uninstallPkg as boolean; - } - } - - if (shouldUninstallGlobal) { - // Global uninstall doesn't depend on the project's lockfile; no-arg - // detectPackageManager() preserves the user-agent fallback. - const pm = detectPackageManager(); - const cmd = globalUninstallCommand(pm, PACKAGE_NAME); - p.log.info(`Running: ${pc.dim(formatShellCommand(cmd))}`); + // Iterate TOPOLOGIES — each detected install gets its own confirm + // prompt so the user only sees questions that are actually actionable. - await ensureToolServerKilled(); + const probed = TOPOLOGIES.map((topology) => ({ topology, state: topology.probe(projectRoot) })); + const installed = probed.filter((p) => p.state.installed); - try { - execFileSync(cmd.bin, cmd.args, { stdio: "inherit" }); - p.log.success("Global package uninstalled."); - } catch (err) { - p.log.error(`Global uninstall failed: ${err}`); - } + if (installed.length === 0) { + p.log.info(pc.dim("No argent install detected on disk — only configuration was removed.")); + } else { + const killOnce = makeOnce(killToolServer); + for (const { topology, state } of installed) { + await uninstallTopology({ topology, state, projectRoot, nonInteractive, killOnce }); } } - if (locallyInstalled) { - let shouldUninstallLocal = nonInteractive; - - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - - const uninstallPkg = await p.confirm({ - message: - `Argent is also installed as a devDependency at ${pc.dim(projectRoot)}. ` + - `Remove it from package.json?`, - initialValue: false, - }); - - if (!p.isCancel(uninstallPkg)) { - shouldUninstallLocal = uninstallPkg as boolean; - } - } + p.outro(pc.green("argent has been removed.")); +} - if (shouldUninstallLocal) { - // MUST use the project's lockfile here — under `npx` the user-agent - // is always npm regardless of what manages the project. - const pm = detectPackageManager(projectRoot); - const cmd = localDevUninstallCommand(pm, PACKAGE_NAME); - p.log.info(`Running: ${pc.dim(formatShellCommand(cmd))} (in ${pc.dim(projectRoot)})`); +// ── Per-topology uninstall ───────────────────────────────────────────── - await ensureToolServerKilled(); +interface UninstallTopologyArgs { + topology: Topology; + state: TopologyState; + projectRoot: string; + nonInteractive: boolean; + killOnce: () => Promise; +} - try { - execFileSync(cmd.bin, cmd.args, { stdio: "inherit", cwd: projectRoot }); - p.log.success(`Local devDependency uninstalled via ${pm}.`); - } catch (err) { - p.log.error(`Local uninstall failed: ${err}`); - } - } +async function uninstallTopology({ + topology, + projectRoot, + nonInteractive, + killOnce, +}: UninstallTopologyArgs): Promise { + const shouldRun = await confirmUninstall(topology, projectRoot, nonInteractive); + if (!shouldRun) return; + + const cmd = topology.uninstallCommand(projectRoot, PACKAGE_NAME); + const cwd = topology.spawnCwd(projectRoot); + const suffix = cwd ? ` (in ${pc.dim(cwd)})` : ""; + p.log.info(`Running: ${pc.dim(formatShellCommand(cmd))}${suffix}`); + + await killOnce(); + try { + execFileSync(cmd.bin, cmd.args, { stdio: "inherit", ...(cwd ? { cwd } : {}) }); + p.log.success( + topology === LOCAL ? `Local devDependency uninstalled via ${cmd.bin}.` : "Global package uninstalled." + ); + } catch (err) { + p.log.error(`${topology.label} uninstall failed: ${err}`); } +} - if (!globallyInstalled && !locallyInstalled) { - p.log.info(pc.dim("No argent install detected on disk — only configuration was removed.")); - } +async function confirmUninstall( + topology: Topology, + projectRoot: string, + nonInteractive: boolean +): Promise { + if (nonInteractive) return true; + const message = + topology === GLOBAL + ? `Uninstall the global ${PACKAGE_NAME} package?` + : `Argent is also installed as a devDependency at ${pc.dim(projectRoot)}. Remove it from package.json?`; + + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + const choice = await p.confirm({ message, initialValue: false }); + if (p.isCancel(choice)) return false; + return choice as boolean; +} - p.outro(pc.green("argent has been removed.")); +// Single-fire wrapper so killToolServer() runs at most once across both +// topology branches. +function makeOnce(fn: () => Promise): () => Promise { + let done = false; + return async () => { + if (done) return; + await fn(); + done = true; + }; } diff --git a/packages/argent-installer/src/update.ts b/packages/argent-installer/src/update.ts index f10bea4f..318827c8 100644 --- a/packages/argent-installer/src/update.ts +++ b/packages/argent-installer/src/update.ts @@ -9,184 +9,132 @@ import { type McpConfigAdapter, type McpEntryMode, } from "./mcp-configs.js"; +import { formatShellCommand } from "./package-manager.js"; import { - detectPackageManager, - formatShellCommand, - getGloballyInstalledVersion, getLatestVersion, - getLocallyInstalledVersion, - globalInstallCommand, - isGloballyInstalled, - isLocallyInstalled, isNewerVersion, - localDevInstallCommand, resolveProjectRoot, - AGENTS_DIR, RULES_DIR, - type ShellCommand, + AGENTS_DIR, } from "./utils.js"; +import { GLOBAL, LOCAL, TOPOLOGIES, type Topology, type TopologyState } from "./topology.js"; import { refreshArgentSkills, formatSkillRefreshSummary } from "./skills.js"; import { PACKAGE_NAME } from "./constants.js"; import { killToolServer } from "@argent/tools-client"; -type InstallTopology = "global" | "local"; - -interface TopologyState { - globallyInstalled: boolean; - locallyInstalled: boolean; - globalVersion: string | null; - localVersion: string | null; - projectRoot: string; +// `argent update` orchestrator. +// +// Argent can be installed under two independent topologies (global on +// PATH and/or local devDep). update() probes each, asks whether to bump +// it to npm's latest, and then refreshes MCP entries / skills regardless +// of whether an install fired — that's the path a teammate uses to +// repair stale config after a `git pull` bumped package.json. + +interface ProbedTopology { + topology: Topology; + state: TopologyState; } export async function update(args: string[]): Promise { const nonInteractive = args.includes("--yes") || args.includes("-y"); - p.intro(pc.bgCyan(pc.black(" argent update "))); - // Read versions from each install on disk, not from the running module. - // Under `npx`, PACKAGE_ROOT is always "latest" and would mask an - // outdated install. const projectRoot = resolveProjectRoot(process.cwd()); - const state: TopologyState = { - globallyInstalled: isGloballyInstalled(), - locallyInstalled: isLocallyInstalled(projectRoot), - globalVersion: null, - localVersion: null, - projectRoot, - }; - if (state.globallyInstalled) state.globalVersion = getGloballyInstalledVersion(); - if (state.locallyInstalled) state.localVersion = getLocallyInstalledVersion(projectRoot); - - if (state.globallyInstalled && !state.globalVersion) { - p.log.error("Could not determine globally-installed version."); - process.exit(1); - } - if (state.locallyInstalled && !state.localVersion) { - p.log.error("Could not determine locally-installed devDependency version."); - process.exit(1); - } - - const spinner = p.spinner(); - spinner.start("Checking for updates..."); - - let latest: string; - try { - latest = getLatestVersion(); - } catch (err) { - spinner.stop(pc.red("Could not reach registry.")); - p.log.error(`Failed to check registry: ${err}`); - process.exit(1); - } + const probed = probeTopologies(projectRoot); + if (!assertVersionsResolved(probed)) process.exit(1); - spinner.stop("Version check complete."); + const latest = await fetchLatestOrExit(); - reportInstalledStatus(state); + reportInstalledStatus(probed, projectRoot); p.log.info(`Latest: ${pc.cyan(`v${latest}`)}`); - // Only update topologies already present — never proactively introduce - // one during an update (would surprise team-share users). - const needsGlobal = state.globallyInstalled && isNewerVersion(latest, state.globalVersion!); - const needsLocal = state.locallyInstalled && isNewerVersion(latest, state.localVersion!); - - // Neither installed → preserve the historical bootstrap behavior - // (`argent update` from scratch installs globally). - if (!state.globallyInstalled && !state.locallyInstalled) { - await runFirstTimeGlobalInstall(latest, nonInteractive); - } else if (!needsGlobal && !needsLocal) { - p.log.success("Already on the latest version."); - } else { - if (needsGlobal) { - await runTopologyUpdate("global", state, latest, nonInteractive); - } - if (needsLocal) { - await runTopologyUpdate("local", state, latest, nonInteractive); - } - } - - // ── Refresh configuration ───────────────────────────────────────────── - // Runs even when no install fired, so a teammate can repair stale MCP - // entries / skills after a `git pull` bumped package.json. - - spinner.start("Refreshing workspace configuration..."); - - const detected = detectAdapters(); - const mcpResults = refreshMcpConfigs(detected, state); - refreshAllowlists(detected, projectRoot); + await applyUpdates(probed, latest, projectRoot, nonInteractive); + await refreshConfiguration(probed, projectRoot); - // Ship rules/agents from the same install the MCP server runs from. - // In local mode that's node_modules/@swmansion/argent; module-relative - // paths would, under `npx`, leak the npx cache's "latest" into the - // project instead of the version pinned in package.json. - const localArgentRoot = state.locallyInstalled - ? join(projectRoot, "node_modules", "@swmansion", "argent") - : null; - const effectiveRulesDir = localArgentRoot ? join(localArgentRoot, "rules") : RULES_DIR; - const effectiveAgentsDir = localArgentRoot ? join(localArgentRoot, "agents") : AGENTS_DIR; + p.outro(pc.green("Update complete.")); +} - const ruleResults = [ - ...copyRulesAndAgents(detected, projectRoot, "global", effectiveRulesDir, effectiveAgentsDir), - ...copyRulesAndAgents(detected, projectRoot, "local", effectiveRulesDir, effectiveAgentsDir), - ]; +// ── Topology probing ──────────────────────────────────────────────────── - spinner.stop("Configuration refreshed."); +function probeTopologies(projectRoot: string): ProbedTopology[] { + return TOPOLOGIES.map((topology) => ({ topology, state: topology.probe(projectRoot) })); +} - if (mcpResults.length > 0) { - p.note(mcpResults.join("\n"), "MCP Configs Updated"); +function assertVersionsResolved(probed: ProbedTopology[]): boolean { + for (const { topology, state } of probed) { + if (state.installed && !state.version) { + p.log.error(`Could not determine ${topology.label} version.`); + return false; + } } + return true; +} - if (ruleResults.length > 0) { - p.note(ruleResults.join("\n"), "Rules & Agents Updated"); +async function fetchLatestOrExit(): Promise { + const spinner = p.spinner(); + spinner.start("Checking for updates..."); + try { + const latest = getLatestVersion(); + spinner.stop("Version check complete."); + return latest; + } catch (err) { + spinner.stop(pc.red("Could not reach registry.")); + p.log.error(`Failed to check registry: ${err}`); + process.exit(1); } +} - const skillSummary = formatSkillRefreshSummary(refreshArgentSkills(projectRoot)); - if (skillSummary) { - p.note(skillSummary, "Skills Updated"); +function reportInstalledStatus(probed: ProbedTopology[], projectRoot: string): void { + if (!probed.some((t) => t.state.installed)) { + p.log.warn(`${PACKAGE_NAME} is not installed.`); + return; + } + for (const { topology, state } of probed) { + if (!state.installed) continue; + const suffix = topology === LOCAL ? ` ${pc.dim(`(${projectRoot})`)}` : ""; + p.log.info(`Installed (${topology.label}): ${pc.cyan(`v${state.version}`)}${suffix}`); } - - p.outro(pc.green("Update complete.")); } -// ── Reporting helpers ──────────────────────────────────────────────────── +// ── Update application ────────────────────────────────────────────────── -function reportInstalledStatus(state: TopologyState): void { - if (!state.globallyInstalled && !state.locallyInstalled) { - p.log.warn(`${PACKAGE_NAME} is not installed.`); +async function applyUpdates( + probed: ProbedTopology[], + latest: string, + projectRoot: string, + nonInteractive: boolean +): Promise { + // Neither topology installed → preserve historical bootstrap behavior + // (install globally). + if (!probed.some((t) => t.state.installed)) { + await runFirstTimeGlobalInstall(latest, nonInteractive); return; } - if (state.globallyInstalled) { - p.log.info(`Installed (global): ${pc.cyan(`v${state.globalVersion}`)}`); + + const needsUpdate = probed.filter( + ({ state }) => state.installed && state.version && isNewerVersion(latest, state.version) + ); + if (needsUpdate.length === 0) { + p.log.success("Already on the latest version."); + return; } - if (state.locallyInstalled) { - p.log.info( - `Installed (local devDep): ${pc.cyan(`v${state.localVersion}`)} ` + - `${pc.dim(`(${state.projectRoot})`)}` - ); + + for (const { topology, state } of needsUpdate) { + await runTopologyUpdate(topology, state.version!, latest, projectRoot, nonInteractive); } } -// ── Install/update flows ──────────────────────────────────────────────── - async function runFirstTimeGlobalInstall(latest: string, nonInteractive: boolean): Promise { - const pm = detectPackageManager(); - const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${latest}`); + const cmd = GLOBAL.installCommand("", `${PACKAGE_NAME}@${latest}`); const cmdStr = formatShellCommand(cmd); - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const proceed = await p.confirm({ - message: `Install ${PACKAGE_NAME}@${latest} globally?`, - initialValue: true, - }); - if (p.isCancel(proceed) || !proceed) { - p.cancel("Install cancelled."); - process.exit(0); - } + if (!nonInteractive && !(await confirmYesNo(`Install ${PACKAGE_NAME}@${latest} globally?`))) { + p.cancel("Install cancelled."); + process.exit(0); } p.log.info(`Running: ${pc.dim(cmdStr)}`); await killToolServer(); - try { execFileSync(cmd.bin, cmd.args, { stdio: "inherit", @@ -199,112 +147,144 @@ async function runFirstTimeGlobalInstall(latest: string, nonInteractive: boolean } async function runTopologyUpdate( - topology: InstallTopology, - state: TopologyState, + topology: Topology, + fromVersion: string, latest: string, + projectRoot: string, nonInteractive: boolean ): Promise { - const fromVersion = topology === "global" ? state.globalVersion! : state.localVersion!; - const cmd = buildUpdateCommand(topology, state.projectRoot, latest); + const cmd = topology.installCommand(projectRoot, `${PACKAGE_NAME}@${latest}`); + const cwd = topology.spawnCwd(projectRoot); const cmdStr = formatShellCommand(cmd); - const label = topology === "global" ? "global package" : "local devDependency (package.json)"; + const label = topology === LOCAL ? "local devDependency (package.json)" : "global package"; p.log.warn( `Update available (${label}): ${pc.yellow(`v${fromVersion}`)} -> ${pc.green(`v${latest}`)}` ); - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const proceed = await p.confirm({ - message: `Update the ${label} to v${latest}?`, - initialValue: true, - }); - if (p.isCancel(proceed) || !proceed) { - p.log.info(pc.dim(`Skipped ${label} update.`)); - return; - } + if (!nonInteractive && !(await confirmYesNo(`Update the ${label} to v${latest}?`))) { + p.log.info(pc.dim(`Skipped ${label} update.`)); + return; } - p.log.info( - `Running: ${pc.dim(cmdStr)}` + - (topology === "local" ? ` ${pc.dim(`(in ${state.projectRoot})`)}` : "") - ); - + p.log.info(`Running: ${pc.dim(cmdStr)}${cwd ? ` ${pc.dim(`(in ${cwd})`)}` : ""}`); await killToolServer(); - try { execFileSync(cmd.bin, cmd.args, { stdio: "inherit", env: { ...process.env, ARGENT_SKIP_POSTINSTALL: "1" }, - ...(topology === "local" ? { cwd: state.projectRoot } : {}), + ...(cwd ? { cwd } : {}), }); p.log.success(`${label} updated to v${latest}.`); } catch (err) { - p.log.error(`${label} update failed: ${err}`); // Don't process.exit — let the other topology and the config refresh // still run; a partial update beats halting the whole flow. + p.log.error(`${label} update failed: ${err}`); } } -function buildUpdateCommand( - topology: InstallTopology, - projectRoot: string, - latest: string -): ShellCommand { - const versioned = `${PACKAGE_NAME}@${latest}`; - if (topology === "global") { - // No lockfile dependency — no-arg keeps the user-agent fallback. - return globalInstallCommand(detectPackageManager(), versioned); - } - // Local: must use the lockfile — `npx` always reports npm in the agent. - return localDevInstallCommand(detectPackageManager(projectRoot), versioned); +async function confirmYesNo(message: string): Promise { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + const choice = await p.confirm({ message, initialValue: true }); + if (p.isCancel(choice)) return false; + return choice as boolean; } -// ── MCP / allowlist refresh ───────────────────────────────────────────── +// ── Config refresh ────────────────────────────────────────────────────── -function refreshMcpConfigs(adapters: McpConfigAdapter[], state: TopologyState): string[] { - const results: string[] = []; +async function refreshConfiguration( + probed: ProbedTopology[], + projectRoot: string +): Promise { + const spinner = p.spinner(); + spinner.start("Refreshing workspace configuration..."); - for (const adapter of adapters) { - // Project-scoped configs use local mode iff the project has argent - // as a devDep (keeps team-share wiring); global-scoped configs are - // always global mode. - const targets: Array<{ configPath: string; entryMode: McpEntryMode }> = []; - const projectPath = adapter.projectPath(state.projectRoot); - const globalPath = adapter.globalPath(); - if (projectPath) { - const entryMode: McpEntryMode = state.locallyInstalled - ? { kind: "local", projectRoot: state.projectRoot } - : { kind: "global" }; - targets.push({ configPath: projectPath, entryMode }); - } - if (globalPath) { - targets.push({ configPath: globalPath, entryMode: { kind: "global" } }); - } + const locallyInstalled = probed.find((t) => t.topology === LOCAL)?.state.installed ?? false; + const detected = detectAdapters(); + const mcpResults = refreshMcpConfigs(detected, projectRoot, locallyInstalled); + refreshAllowlists(detected, projectRoot); + const ruleResults = refreshRulesAndAgents(detected, projectRoot, locallyInstalled); + + spinner.stop("Configuration refreshed."); + + if (mcpResults.length > 0) p.note(mcpResults.join("\n"), "MCP Configs Updated"); + if (ruleResults.length > 0) p.note(ruleResults.join("\n"), "Rules & Agents Updated"); - for (const { configPath, entryMode } of targets) { + const skillSummary = formatSkillRefreshSummary(refreshArgentSkills(projectRoot)); + if (skillSummary) p.note(skillSummary, "Skills Updated"); +} + +function refreshMcpConfigs( + adapters: McpConfigAdapter[], + projectRoot: string, + locallyInstalled: boolean +): string[] { + const results: string[] = []; + for (const adapter of adapters) { + // Project-scoped configs follow the project's current topology (keeps + // team-share wiring intact); global-scoped configs always use global. + for (const { configPath, mode } of configRefreshTargets(adapter, projectRoot, locallyInstalled)) { try { - const entry = getMcpEntry(entryMode, adapter); - adapter.write(configPath, entry); + adapter.write(configPath, getMcpEntry(mode, adapter)); results.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); } catch { - // Skip paths that don't exist or can't be written + // skip paths that don't exist or can't be written } } } - return results; } +function configRefreshTargets( + adapter: McpConfigAdapter, + projectRoot: string, + locallyInstalled: boolean +): Array<{ configPath: string; mode: McpEntryMode }> { + const out: Array<{ configPath: string; mode: McpEntryMode }> = []; + const projectPath = adapter.projectPath(projectRoot); + const globalPath = adapter.globalPath(); + if (projectPath) { + out.push({ + configPath: projectPath, + mode: locallyInstalled ? { kind: "local", projectRoot } : { kind: "global" }, + }); + } + if (globalPath) { + out.push({ configPath: globalPath, mode: { kind: "global" } }); + } + return out; +} + function refreshAllowlists(adapters: McpConfigAdapter[], projectRoot: string): void { for (const adapter of adapters) { if (!adapter.addAllowlist) continue; - for (const s of ["global", "local"] as const) { + for (const scope of ["global", "local"] as const) { try { - adapter.addAllowlist(projectRoot, s); + adapter.addAllowlist(projectRoot, scope); } catch { // non-fatal } } } } + +// Ship rules/agents from the same install the MCP server runs from. +// In local mode that's node_modules/@swmansion/argent; module-relative +// paths would, under `npx`, leak the npx cache's "latest" into the +// project instead of the version pinned in package.json. +function refreshRulesAndAgents( + adapters: McpConfigAdapter[], + projectRoot: string, + locallyInstalled: boolean +): string[] { + const localRoot = locallyInstalled + ? join(projectRoot, "node_modules", "@swmansion", "argent") + : null; + const rulesDir = localRoot ? join(localRoot, "rules") : RULES_DIR; + const agentsDir = localRoot ? join(localRoot, "agents") : AGENTS_DIR; + + return [ + ...copyRulesAndAgents(adapters, projectRoot, "global", rulesDir, agentsDir), + ...copyRulesAndAgents(adapters, projectRoot, "local", rulesDir, agentsDir), + ]; +} diff --git a/packages/argent-installer/src/utils.ts b/packages/argent-installer/src/utils.ts index 63db07e7..cc7a57df 100644 --- a/packages/argent-installer/src/utils.ts +++ b/packages/argent-installer/src/utils.ts @@ -350,37 +350,6 @@ export function getInstalledVersion(): string | null { } } -/** - * Read the version of the globally-installed argent package — distinct from - * {@link getInstalledVersion}, which reads the package.json this code is - * currently executing from. When invoked via `npx @swmansion/argent`, the - * npx cache is always at the latest published version, so reading - * PACKAGE_ROOT/package.json masks an outdated global install and lets the - * update check report "already on the latest" incorrectly. This helper - * resolves the global binary via `which -a` / `where`, follows symlinks to - * the actual entrypoint, and walks up to the owning package.json instead. - * - * Returns null when argent is not permanently installed on PATH, or when - * the global package layout cannot be resolved (e.g., Windows wrapper - * scripts that aren't symlinks). Callers should treat null as "could not - * determine" — preferable to silently using the running package's version, - * which is the bug this guards against. - */ -export function getGloballyInstalledVersion(): string | null { - const binaryPath = getGlobalBinaryPath(); - if (!binaryPath) return null; - try { - const realPath = fs.realpathSync(binaryPath); - const pkgRoot = resolvePackageRoot(path.dirname(realPath)); - const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8")) as { - version?: string; - }; - return pkg.version ?? null; - } catch { - return null; - } -} - const PROBE_TIMEOUT_MS = 3_000; export function getLatestVersion(): string { @@ -399,54 +368,15 @@ export function isNewerVersion(candidate: string, current: string): boolean { return semver.gt(candidate, current); } -// Path segments used by temp package runners (npx, pnpm dlx, bunx, yarn dlx). -// When invoked via one of these, the runner prepends its cache .bin/ dir to PATH, -// so `which argent` succeeds even though argent is not permanently installed globally. -const TEMP_RUNNER_MARKERS = [ - "_npx", - "/dlx-", - "\\dlx-", - "bun/install/cache", - ".bun\\install\\cache", -]; - -export function isTempRunnerPath(binaryPath: string): boolean { - return TEMP_RUNNER_MARKERS.some((marker) => binaryPath.includes(marker)); -} - -/** - * Resolve the path of the globally-installed argent binary, ignoring - * temp-runner caches (npx / pnpm dlx / bunx / yarn dlx). On Windows `where` - * returns every match, on Unix `which -a` does — we inspect each line so a - * concurrent npx invocation does not mask a real global install. Returns - * null when argent is not permanently installed on PATH. - */ -function getGlobalBinaryPath(): string | null { - try { - const cmd = process.platform === "win32" ? "where" : "which -a"; - const output = execSync(`${cmd} ${MCP_BINARY_NAME}`, { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - return ( - output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .find((line) => !isTempRunnerPath(line)) ?? null - ); - } catch { - return null; - } -} - -/** - * True iff argent is permanently installed on the user's PATH (not just being - * executed transiently from an npx / dlx / bunx cache). - */ -export function isGloballyInstalled(): boolean { - return getGlobalBinaryPath() !== null; -} +// Re-exported from ./topology so existing imports keep working. New code +// should import directly from ./topology.js. +export { + getGloballyInstalledVersion, + getLocallyInstalledVersion, + isGloballyInstalled, + isLocallyInstalled, + isTempRunnerPath, +} from "./topology.js"; export function isSkillsCliAvailable(): boolean { try { @@ -479,168 +409,17 @@ export async function isOnline(timeoutMs = PROBE_TIMEOUT_MS): Promise { } // ── Package manager detection ───────────────────────────────────────────────── - -export type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; - -export interface ShellCommand { - bin: string; - args: string[]; -} - -export function formatShellCommand(cmd: ShellCommand): string { - const parts = [cmd.bin, ...cmd.args.map((a) => (a.includes(" ") ? `"${a}"` : a))]; - return parts.join(" "); -} - -// Resolution: 1) projectRoot's lockfile (load-bearing for `--devdep` — -// `npx` sets npm_config_user_agent=npm/... even inside a yarn workspace, -// which would issue `npm install` and fail on yarn-only `link:` deps); -// 2) npm_config_user_agent; 3) npm. -// projectRoot is optional so user-agent-only call sites still work. -export function detectPackageManager(projectRoot?: string): PackageManager { - if (projectRoot) { - const fromLockfile = detectFromLockfile(projectRoot); - if (fromLockfile) return fromLockfile; - } - const agent = process.env.npm_config_user_agent ?? ""; - if (agent.startsWith("yarn")) return "yarn"; - if (agent.startsWith("pnpm")) return "pnpm"; - if (agent.startsWith("bun")) return "bun"; - return "npm"; -} - -// Ordered by specificity — pnpm/bun lockfiles are unique to their PM; -// yarn is unambiguous; package-lock.json / shrinkwrap fall through last. -const LOCKFILE_TO_PM: ReadonlyArray = [ - ["pnpm-lock.yaml", "pnpm"], - ["bun.lock", "bun"], - ["bun.lockb", "bun"], - ["yarn.lock", "yarn"], - ["package-lock.json", "npm"], - ["npm-shrinkwrap.json", "npm"], -]; - -function detectFromLockfile(projectRoot: string): PackageManager | null { - for (const [lockfile, pm] of LOCKFILE_TO_PM) { - if (fs.existsSync(path.join(projectRoot, lockfile))) return pm; - } - return null; -} - -export function globalInstallCommand(pm: PackageManager, pkg: string): ShellCommand { - switch (pm) { - case "yarn": - return { bin: "yarn", args: ["global", "add", pkg] }; - case "pnpm": - return { bin: "pnpm", args: ["add", "-g", pkg] }; - case "bun": - return { bin: "bun", args: ["add", "-g", pkg] }; - default: - return { bin: "npm", args: ["install", "-g", pkg] }; - } -} - -export function globalUninstallCommand(pm: PackageManager, pkg: string): ShellCommand { - switch (pm) { - case "yarn": - return { bin: "yarn", args: ["global", "remove", pkg] }; - case "pnpm": - return { bin: "pnpm", args: ["remove", "-g", pkg] }; - case "bun": - return { bin: "bun", args: ["remove", "-g", pkg] }; - default: - return { bin: "npm", args: ["uninstall", "-g", pkg] }; - } -} - -// ── Local devDependency helpers ────────────────────────────────────────────── -// Counterparts to globalInstall/UninstallCommand for the `--devdep` flow. -// All four PMs accept a registry name or a tarball/file path as the -// positional, so the same recipes work for the --from flag. - -export function localDevUninstallCommand(pm: PackageManager, pkg: string): ShellCommand { - switch (pm) { - case "yarn": - return { bin: "yarn", args: ["remove", pkg] }; - case "pnpm": - return { bin: "pnpm", args: ["remove", pkg] }; - case "bun": - return { bin: "bun", args: ["remove", pkg] }; - default: - return { bin: "npm", args: ["uninstall", pkg] }; - } -} - -export function localDevInstallCommand(pm: PackageManager, pkg: string): ShellCommand { - switch (pm) { - case "yarn": - return { bin: "yarn", args: ["add", "--dev", pkg] }; - case "pnpm": - return { bin: "pnpm", args: ["add", "-D", pkg] }; - case "bun": - return { bin: "bun", args: ["add", "-d", pkg] }; - default: - return { bin: "npm", args: ["install", "--save-dev", pkg] }; - } -} - -// Requires BOTH a dep declaration in the project's package.json AND the -// files on disk. The declaration check disambiguates a real consumer -// install from an npm/yarn workspace where node_modules/@swmansion/argent -// is just a symlink to the workspace source (workspace members don't -// list themselves in the root manifest). -export function isLocallyInstalled(projectRoot: string): boolean { - if (!isDeclaredAsDependency(projectRoot)) return false; - return fs.existsSync(localPackageJsonPath(projectRoot)); -} - -const DEPENDENCY_FIELDS = [ - "dependencies", - "devDependencies", - "peerDependencies", - "optionalDependencies", -] as const; - -// Missing or unparseable package.json → false (safer default than -// claiming the project depends on argent). -function isDeclaredAsDependency(projectRoot: string): boolean { - try { - const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")) as { - [field: string]: Record | undefined; - }; - return DEPENDENCY_FIELDS.some((field) => Boolean(pkg[field]?.[PACKAGE_NAME])); - } catch { - return false; - } -} - -function localPackageJsonPath(projectRoot: string): string { - return path.join(projectRoot, "node_modules", "@swmansion", "argent", "package.json"); -} - -// Reads /node_modules/@swmansion/argent/package.json so -// the version reported after install is the one that landed on disk, -// not the npx cache that's still running init. Null on miss/parse fail. -export function getLocallyInstalledVersion(projectRoot: string): string | null { - try { - const pkg = JSON.parse(fs.readFileSync(localPackageJsonPath(projectRoot), "utf8")) as { - version?: string; - }; - return pkg.version ?? null; - } catch { - return null; - } -} - -// Yarn 2+ PnP — no literal node_modules/.bin/argent, so the devDep -// flow's MCP command would resolve to nothing. Surface upfront. -export function isYarnPnp(projectRoot: string): boolean { - return ( - fs.existsSync(path.join(projectRoot, ".pnp.cjs")) || - fs.existsSync(path.join(projectRoot, ".pnp.loader.mjs")) - ); -} - -export function hasPackageJson(projectRoot: string): boolean { - return fs.existsSync(path.join(projectRoot, "package.json")); -} +// Re-exported from ./package-manager so existing callers keep working. +// New code should import directly from ./package-manager.js. +export { + detectPackageManager, + formatShellCommand, + globalInstallCommand, + globalUninstallCommand, + localDevInstallCommand, + localDevUninstallCommand, +} from "./package-manager.js"; +export type { PackageManager, ShellCommand } from "./package-manager.js"; + +// Re-exported from ./preflight for compatibility. +export { hasPackageJson, isYarnPnp } from "./preflight.js";