diff --git a/apps/cli/src/commands/system/install-core.ts b/apps/cli/src/commands/system/install-core.ts new file mode 100644 index 000000000..4cb331a54 --- /dev/null +++ b/apps/cli/src/commands/system/install-core.ts @@ -0,0 +1,1594 @@ +import { execa } from "execa"; +import * as JSONC from "jsonc-parser"; +import { createHash, randomUUID } from "node:crypto"; +import { constants, existsSync } from "node:fs"; +import { + access, + copyFile, + lstat, + mkdir, + readFile, + readdir, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const INSTALL_AGENT_IDS = [ + "claude", + "codex", + "gemini", + "opencode", + "cursor", + "windsurf", + "zed", +] as const; + +export const INSTALL_AGENT_INPUT_IDS = [...INSTALL_AGENT_IDS, "claude-code", "gemini-cli"] as const; + +export type InstallAgentId = (typeof INSTALL_AGENT_IDS)[number]; +export type InstallAgentInputId = (typeof INSTALL_AGENT_INPUT_IDS)[number]; +export type InstallOnly = "mcp" | "skills"; + +export interface InstallCommandInput { + only?: InstallOnly; + agents?: InstallAgentInputId[]; + dryRun?: boolean; + uninstall?: boolean; +} + +export type InstallTargetStatus = + | "installed" + | "uninstalled" + | "unchanged" + | "planned" + | "failed" + | "cancelled"; + +export interface InstallOperation { + type: "backup" | "command" | "write" | "remove"; + value: string; +} + +export interface InstallTargetReceipt { + id: string; + name: string; + capability: "mcp" | "skill" | "state" | "selection"; + status: InstallTargetStatus; + changed: boolean; + detected?: boolean; + path?: string; + backupPath?: string; + command?: string[]; + message: string; + operations: InstallOperation[]; +} + +export interface InstallReceipt { + schemaVersion: 1; + command: "install"; + action: "install" | "uninstall"; + dryRun: boolean; + success: boolean; + selection: { + only: InstallOnly | "all"; + agents: InstallAgentId[]; + }; + targets: InstallTargetReceipt[]; + summary: { + requested: number; + changed: number; + unchanged: number; + failed: number; + }; + tryPrompt: string; +} + +interface DetectedAgent { + id: InstallAgentId; + name: string; + detected: boolean; + binaryPath?: string; +} + +interface OwnedCommandTarget { + kind: "command"; + agent: InstallAgentId; + signature: string[]; +} + +interface OwnedJsonTarget { + kind: "json"; + agent: InstallAgentId; + path: string; + parentKey: string; + createdFile: boolean; + createdParent: boolean; + valueHash: string; +} + +interface OwnedSkillTarget { + kind: "skill"; + path: string; + contentHash: string; +} + +type OwnedTarget = OwnedCommandTarget | OwnedJsonTarget | OwnedSkillTarget; + +interface InstallState { + schemaVersion: 1; + targets: Record; +} + +interface CommandDefinition { + id: "claude" | "codex" | "gemini"; + name: string; + configPath: (homeDir: string) => string; + configFormat: "json" | "toml"; + addArgs: string[]; + removeArgs: string[]; +} + +interface JsonDefinition { + id: "opencode" | "cursor" | "windsurf" | "zed"; + name: string; + path: (environment: ResolvedInstallEnvironment) => string; + parentKey: string; + value: Record; +} + +interface SkillDefinition { + id: "scaffold-project" | "add-to-project"; + installedName: string; +} + +export interface InstallEnvironmentOverrides { + homeDir?: string; + path?: string; + platform?: NodeJS.Platform; + moduleDir?: string; + now?: () => Date; + stdinIsTTY?: boolean; + skillSourceDir?: string; + runCommand?: (command: string, args: string[]) => Promise; + confirm?: (summary: string) => Promise; +} + +interface ResolvedInstallEnvironment { + homeDir: string; + path: string; + platform: NodeJS.Platform; + now: () => Date; + stdinIsTTY: boolean; + skillSourceDir: string; + runCommand: (command: string, args: string[]) => Promise; + confirm?: (summary: string) => Promise; +} + +const MCP_COMMAND = ["npx", "-y", "create-better-fullstack@latest", "mcp"] as const; +const STATE_RELATIVE_PATH = ".config/better-fullstack/install-state.json"; +const TRY_PROMPT = "Create a Better Fullstack app with Next.js, Hono, and PostgreSQL."; + +const COMMAND_DEFINITIONS: readonly CommandDefinition[] = [ + { + id: "claude", + name: "Claude Code", + configPath: (homeDir) => join(homeDir, ".claude.json"), + configFormat: "json", + addArgs: ["mcp", "add", "--scope", "user", "better-fullstack", "--", ...MCP_COMMAND], + removeArgs: ["mcp", "remove", "--scope", "user", "better-fullstack"], + }, + { + id: "codex", + name: "Codex CLI", + configPath: (homeDir) => join(homeDir, ".codex", "config.toml"), + configFormat: "toml", + addArgs: ["mcp", "add", "better-fullstack", "--", ...MCP_COMMAND], + removeArgs: ["mcp", "remove", "better-fullstack"], + }, + { + id: "gemini", + name: "Gemini CLI", + configPath: (homeDir) => join(homeDir, ".gemini", "settings.json"), + configFormat: "json", + addArgs: ["mcp", "add", "--scope", "user", "better-fullstack", ...MCP_COMMAND], + removeArgs: ["mcp", "remove", "--scope", "user", "better-fullstack"], + }, +] as const; + +const JSON_DEFINITIONS: readonly JsonDefinition[] = [ + { + id: "opencode", + name: "OpenCode", + path: ({ homeDir }) => join(homeDir, ".config", "opencode", "opencode.json"), + parentKey: "mcp", + value: { + type: "local", + command: [...MCP_COMMAND], + enabled: true, + }, + }, + { + id: "cursor", + name: "Cursor", + path: ({ homeDir }) => join(homeDir, ".cursor", "mcp.json"), + parentKey: "mcpServers", + value: { + command: "npx", + args: MCP_COMMAND.slice(1), + }, + }, + { + id: "windsurf", + name: "Windsurf", + path: ({ homeDir }) => join(homeDir, ".codeium", "windsurf", "mcp_config.json"), + parentKey: "mcpServers", + value: { + command: "npx", + args: MCP_COMMAND.slice(1), + }, + }, + { + id: "zed", + name: "Zed", + path: zedSettingsPath, + parentKey: "context_servers", + value: { + command: "npx", + args: MCP_COMMAND.slice(1), + }, + }, +] as const; + +const SKILL_DEFINITIONS: readonly SkillDefinition[] = [ + { id: "scaffold-project", installedName: "better-fullstack-scaffold-project" }, + { id: "add-to-project", installedName: "better-fullstack-add-to-project" }, +] as const; + +const SKILL_AGENT_IDS = new Set(["claude", "codex", "opencode", "cursor"]); + +function normalizeAgentId(id: InstallAgentInputId): InstallAgentId { + if (id === "claude-code") return "claude"; + if (id === "gemini-cli") return "gemini"; + return id; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hashesEqual(left: unknown, right: unknown): boolean { + return hashValue(left) === hashValue(right); +} + +function hashValue(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +function isWithin(parent: string, child: string): boolean { + const pathFromParent = relative(parent, child); + return pathFromParent === "" || (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent)); +} + +async function assertWritePathInHome(homeDir: string, targetPath: string): Promise { + const lexicalHome = resolve(homeDir); + const lexicalTarget = resolve(targetPath); + if (!isWithin(lexicalHome, lexicalTarget)) { + throw new Error("Refusing to write outside the configured home directory."); + } + + const realHome = await realpath(lexicalHome); + let existingAncestor = lexicalTarget; + const missingParts: string[] = []; + while (!(await pathExists(existingAncestor))) { + missingParts.unshift(existingAncestor.slice(dirname(existingAncestor).length + 1)); + existingAncestor = dirname(existingAncestor); + } + const resolvedAncestor = await realpath(existingAncestor); + const projectedTarget = resolve(resolvedAncestor, ...missingParts); + if (!isWithin(realHome, projectedTarget)) { + throw new Error("Refusing to follow a config path outside the configured home directory."); + } +} + +async function findExecutable(name: string, searchPath: string, platform: NodeJS.Platform) { + const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""]; + for (const directory of searchPath.split(delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = join(directory, `${name}${extension}`); + try { + await access(candidate, platform === "win32" ? constants.F_OK : constants.X_OK); + if ((await stat(candidate)).isFile()) return candidate; + } catch { + // Continue searching PATH. + } + } + } + return undefined; +} + +function zedSettingsPath(environment: ResolvedInstallEnvironment): string { + const legacyOrLinuxPath = join(environment.homeDir, ".config", "zed", "settings.json"); + const macPath = join(environment.homeDir, ".zed", "settings.json"); + if (environment.platform === "darwin") { + if (existsSync(macPath) || !existsSync(legacyOrLinuxPath)) return macPath; + } + return legacyOrLinuxPath; +} + +async function anyPathExists(paths: string[]) { + for (const path of paths) { + if (await pathExists(path)) return true; + } + return false; +} + +export async function detectInstallAgents( + overrides: Pick = {}, +): Promise { + const homeDir = resolve(overrides.homeDir ?? homedir()); + const searchPath = overrides.path ?? process.env.PATH ?? ""; + const platform = overrides.platform ?? process.platform; + const binaries = await Promise.all( + ["claude", "codex", "gemini", "opencode", "cursor", "windsurf", "zed"].map((name) => + findExecutable(name, searchPath, platform), + ), + ); + const [claude, codex, gemini, opencode, cursor, windsurf, zed] = binaries; + const applications = + platform === "darwin" ? ["/Applications", join(homeDir, "Applications")] : []; + + return [ + { id: "claude", name: "Claude Code", detected: Boolean(claude), binaryPath: claude }, + { id: "codex", name: "Codex CLI", detected: Boolean(codex), binaryPath: codex }, + { id: "gemini", name: "Gemini CLI", detected: Boolean(gemini), binaryPath: gemini }, + { + id: "opencode", + name: "OpenCode", + detected: + Boolean(opencode) || + (await pathExists(join(homeDir, ".config", "opencode", "opencode.json"))), + binaryPath: opencode, + }, + { + id: "cursor", + name: "Cursor", + detected: + Boolean(cursor) || + (await anyPathExists([ + join(homeDir, ".cursor"), + ...applications.map((directory) => join(directory, "Cursor.app")), + ])), + binaryPath: cursor, + }, + { + id: "windsurf", + name: "Windsurf", + detected: + Boolean(windsurf) || + (await anyPathExists([ + join(homeDir, ".codeium", "windsurf"), + ...applications.map((directory) => join(directory, "Windsurf.app")), + ])), + binaryPath: windsurf, + }, + { + id: "zed", + name: "Zed", + detected: + Boolean(zed) || + (await anyPathExists([ + join(homeDir, ".zed"), + join(homeDir, ".config", "zed"), + ...applications.map((directory) => join(directory, "Zed.app")), + ])), + binaryPath: zed, + }, + ]; +} + +function defaultSkillSourceDir( + moduleDirectory = dirname(fileURLToPath(import.meta.url)), +) { + const bundled = join(moduleDirectory, "skills"); + const repository = resolve(moduleDirectory, "../../../../../plugin/skills"); + return { bundled, repository }; +} + +async function resolveEnvironment( + overrides: InstallEnvironmentOverrides, +): Promise { + const homeDir = resolve(overrides.homeDir ?? homedir()); + const sourceCandidates = defaultSkillSourceDir(overrides.moduleDir); + const skillSourceDir = overrides.skillSourceDir + ? resolve(overrides.skillSourceDir) + : (await pathExists(sourceCandidates.bundled)) + ? sourceCandidates.bundled + : sourceCandidates.repository; + + return { + homeDir, + path: overrides.path ?? process.env.PATH ?? "", + platform: overrides.platform ?? process.platform, + now: overrides.now ?? (() => new Date()), + stdinIsTTY: overrides.stdinIsTTY ?? Boolean(process.stdin.isTTY), + skillSourceDir, + runCommand: + overrides.runCommand ?? + (async (command, args) => { + await execa(command, args, { stdin: "ignore" }); + }), + confirm: overrides.confirm, + }; +} + +function statePath(environment: ResolvedInstallEnvironment) { + return join(environment.homeDir, STATE_RELATIVE_PATH); +} + +function relativeToHome(environment: ResolvedInstallEnvironment, path: string) { + return relative(environment.homeDir, path); +} + +async function readState(environment: ResolvedInstallEnvironment): Promise { + const path = statePath(environment); + if (!(await pathExists(path))) return { schemaVersion: 1, targets: {} }; + await assertWritePathInHome(environment.homeDir, path); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error(`The Better Fullstack install receipt is not valid JSON: ${path}`); + } + if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !isRecord(parsed.targets)) { + throw new Error(`The Better Fullstack install receipt has an unsupported shape: ${path}`); + } + return parsed as unknown as InstallState; +} + +function stateIsEmpty(state: InstallState) { + return Object.keys(state.targets).length === 0; +} + +async function writeState(environment: ResolvedInstallEnvironment, state: InstallState) { + const path = statePath(environment); + await assertWritePathInHome(environment.homeDir, path); + if (stateIsEmpty(state)) { + if (await pathExists(path)) await rm(path); + return; + } + await mkdir(dirname(path), { recursive: true }); + const temporaryPath = `${path}.tmp`; + await assertWritePathInHome(environment.homeDir, temporaryPath); + await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + await rename(temporaryPath, path); +} + +async function preflightStateWrite(environment: ResolvedInstallEnvironment) { + const path = statePath(environment); + await assertWritePathInHome(environment.homeDir, path); + const directory = dirname(path); + await mkdir(directory, { recursive: true }); + const temporaryPath = join(directory, `.install-state-preflight-${randomUUID()}.tmp`); + await assertWritePathInHome(environment.homeDir, temporaryPath); + let created = false; + try { + await writeFile(temporaryPath, "", { flag: "wx", mode: 0o600 }); + created = true; + } finally { + if (created) await rm(temporaryPath); + } +} + +function timestampSuffix(date: Date) { + return date.toISOString().replaceAll(":", "-"); +} + +async function backupPath(path: string, environment: ResolvedInstallEnvironment) { + const base = `${path}.better-fullstack-backup-${timestampSuffix(environment.now())}`; + let candidate = base; + let suffix = 2; + while (await pathExists(candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + return candidate; +} + +async function validateJsonFile(path: string) { + if (!(await pathExists(path))) return; + try { + parseConfigObject(await readFile(path, "utf8"), path); + } catch { + throw new Error(`Config is not valid JSON; left it unchanged: ${path}`); + } +} + +type CommandConfigEntryState = "matching" | "missing" | "modified"; + +function managedCommandMatches(value: unknown) { + if (!isRecord(value) || value.command !== MCP_COMMAND[0] || !Array.isArray(value.args)) { + return false; + } + return hashesEqual(value.args, MCP_COMMAND.slice(1)); +} + +function parseTomlString(value: string) { + if (value.startsWith("'")) return value.slice(1, -1); + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "string" ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseTomlStringArray(value: string) { + const inner = value.slice(1, -1); + const tokens = [...inner.matchAll(/"(?:\\.|[^"\\])*"|'[^']*'/g)]; + const remainder = inner + .replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, "") + .replace(/#[^\r\n]*/g, ""); + if (!/^[\s,]*$/.test(remainder)) return undefined; + const parsed = tokens.map((token) => parseTomlString(token[0])); + return parsed.every((item): item is string => item !== undefined) ? parsed : undefined; +} + +function codexConfigEntryState(content: string): CommandConfigEntryState { + const header = /^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*(?:better-fullstack|"better-fullstack"|'better-fullstack')[ \t]*\][ \t]*(?:#.*)?\r?$/gm.exec( + content, + ); + if (!header) return "missing"; + const following = content.slice(header.index + header[0].length); + const nextHeader = /^[ \t]*\[{1,2}[^\r\n]+/m.exec(following); + const body = following.slice(0, nextHeader?.index); + const commandMatches = [ + ...body.matchAll( + /^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?\r?$/gm, + ), + ]; + const argsMatches = [ + ...body.matchAll( + /^[ \t]*args[ \t]*=[ \t]*(\[(?:[^\]"']|"(?:\\.|[^"\\])*"|'[^']*')*\])[ \t]*(?:#.*)?\r?$/gm, + ), + ]; + if (commandMatches.length !== 1 || argsMatches.length !== 1) return "modified"; + const command = parseTomlString(commandMatches[0]?.[1] ?? ""); + const args = parseTomlStringArray(argsMatches[0]?.[1] ?? ""); + return command === MCP_COMMAND[0] && hashesEqual(args, MCP_COMMAND.slice(1)) + ? "matching" + : "modified"; +} + +async function commandConfigEntryState( + definition: CommandDefinition, + path: string, +): Promise { + let content: string; + try { + content = await readFile(path, "utf8"); + } catch { + return "missing"; + } + if (definition.configFormat === "toml") return codexConfigEntryState(content); + try { + const parsed = parseConfigObject(content, path); + const servers = parsed.mcpServers; + if (!isRecord(servers) || !Object.hasOwn(servers, "better-fullstack")) return "missing"; + return managedCommandMatches(servers["better-fullstack"]) ? "matching" : "modified"; + } catch { + return "modified"; + } +} + +async function createBackup( + path: string, + environment: ResolvedInstallEnvironment, + dryRun: boolean, +) { + if (!(await pathExists(path))) return undefined; + await assertWritePathInHome(environment.homeDir, path); + const target = await backupPath(path, environment); + await assertWritePathInHome(environment.homeDir, target); + if (!dryRun) await copyFile(path, target, constants.COPYFILE_EXCL); + return target; +} + +function formattingOptions(content: string): JSONC.FormattingOptions { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const indentMatch = content.match(/\n([ \t]+)\S/); + const indent = indentMatch?.[1] ?? " "; + return { + insertSpaces: !indent.includes("\t"), + tabSize: indent.includes("\t") ? 1 : indent.length, + eol, + }; +} + +function indentationUnit(content: string) { + const options = formattingOptions(content); + return options.insertSpaces ? " ".repeat(options.tabSize ?? 2) : "\t"; +} + +function lineIndentAt(content: string, offset: number) { + const lineStart = content.lastIndexOf("\n", Math.max(0, offset - 1)) + 1; + return content.slice(lineStart, offset).match(/^[ \t]*/)?.[0] ?? ""; +} + +function formatProperty(key: string, value: unknown, indent: string) { + const valueLines = JSON.stringify(value, null, indent).split("\n"); + return valueLines + .map((line, index) => (index === 0 ? `${JSON.stringify(key)}: ${line}` : line)) + .join("\n"); +} + +function insertObjectProperty( + content: string, + objectNode: JSONC.Node, + key: string, + value: unknown, +) { + const eol = formattingOptions(content).eol; + const unit = indentationUnit(content); + const closeOffset = objectNode.offset + objectNode.length - 1; + const closingIndent = lineIndentAt(content, closeOffset); + const properties = objectNode.children ?? []; + const propertyIndent = properties[0] + ? lineIndentAt(content, properties[0].offset) + : `${closingIndent}${unit}`; + const property = formatProperty(key, value, unit) + .split("\n") + .map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`)) + .join(eol); + + if (properties.length === 0) { + return `${content.slice(0, objectNode.offset + 1)}${eol}${propertyIndent}${property}${eol}${closingIndent}${content.slice(closeOffset)}`; + } + + const lastProperty = properties.at(-1); + if (!lastProperty) throw new Error("Could not locate the last JSON property."); + const insertionOffset = lastProperty.offset + lastProperty.length; + const closingGap = content.slice(insertionOffset, closeOffset); + const suffix = closingGap.includes("\n") ? closingGap : `${eol}${closingIndent}`; + return `${content.slice(0, insertionOffset)},${eol}${propertyIndent}${property}${suffix}${content.slice(closeOffset)}`; +} + +function removeObjectProperty(content: string, objectNode: JSONC.Node, key: string) { + const properties = objectNode.children ?? []; + const propertyIndex = properties.findIndex((property) => property.children?.[0]?.value === key); + if (propertyIndex === -1) return content; + const property = properties[propertyIndex]; + if (!property) return content; + const closeOffset = objectNode.offset + objectNode.length - 1; + + if (properties.length === 1) { + return `${content.slice(0, objectNode.offset + 1)}${content.slice(closeOffset)}`; + } + if (propertyIndex > 0) { + const previous = properties[propertyIndex - 1]; + if (!previous) return content; + const removalStart = previous.offset + previous.length; + return `${content.slice(0, removalStart)}${content.slice(property.offset + property.length)}`; + } + + const next = properties[1]; + if (!next) return content; + return `${content.slice(0, property.offset)}${content.slice(next.offset)}`; +} + +const jsoncParseOptions: JSONC.ParseOptions = { + allowTrailingComma: true, + disallowComments: false, +}; + +function invalidJsonConfig(path: string) { + return new Error(`Config is not valid JSON; left it unchanged: ${path}`); +} + +function parseConfigObject(content: string, path: string): Record { + const errors: JSONC.ParseError[] = []; + const parsed: unknown = JSONC.parse(content, errors, jsoncParseOptions); + if (errors.length > 0 || !isRecord(parsed)) throw invalidJsonConfig(path); + return parsed; +} + +function parseConfigTree(content: string, path: string) { + const errors: JSONC.ParseError[] = []; + const root = JSONC.parseTree(content, errors, jsoncParseOptions); + if (errors.length > 0 || !root || root.type !== "object") { + throw invalidJsonConfig(path); + } + return root; +} + +function addJsonEntry( + content: string, + path: string, + parentKey: string, + value: Record, +) { + const parsed = parseConfigObject(content, path); + const parent = parsed[parentKey]; + if (parent !== undefined && !isRecord(parent)) { + throw new Error(`Config key "${parentKey}" is not an object; left ${path} unchanged.`); + } + const existing = isRecord(parent) ? parent["better-fullstack"] : undefined; + if (existing !== undefined) { + if (hashesEqual(existing, value)) return { content, changed: false, createdParent: false }; + throw new Error(`A different better-fullstack entry already exists in ${path}.`); + } + const createdParent = parent === undefined; + const rootNode = parseConfigTree(content, path); + const parentNode = createdParent ? rootNode : JSONC.findNodeAtLocation(rootNode, [parentKey]); + if (!parentNode || parentNode.type !== "object") { + throw new Error(`Config key "${parentKey}" is not an object; left ${path} unchanged.`); + } + const next = insertObjectProperty( + content, + parentNode, + createdParent ? parentKey : "better-fullstack", + createdParent ? { "better-fullstack": value } : value, + ); + return { content: next, changed: true, createdParent }; +} + +function removeJsonEntry( + content: string, + path: string, + ownership: OwnedJsonTarget, + expectedValue: Record, +) { + const parsed = parseConfigObject(content, path); + const parent = parsed[ownership.parentKey]; + if (parent === undefined) return { content, changed: false, removeFile: false }; + if (!isRecord(parent)) { + throw new Error( + `Config key "${ownership.parentKey}" is not an object; left ${path} unchanged.`, + ); + } + const existing = parent["better-fullstack"]; + if (existing === undefined) return { content, changed: false, removeFile: false }; + if (!hashesEqual(existing, expectedValue) || hashValue(existing) !== ownership.valueHash) { + throw new Error( + `The better-fullstack entry in ${path} changed after install; left it unchanged.`, + ); + } + + const rootNode = parseConfigTree(content, path); + const parentNode = JSONC.findNodeAtLocation(rootNode, [ownership.parentKey]); + if (!parentNode || parentNode.type !== "object") { + throw new Error(`Config is not valid JSON; left it unchanged: ${path}`); + } + let next = removeObjectProperty(content, parentNode, "better-fullstack"); + let nextParsed = parseConfigObject(next, path); + const nextParent = nextParsed[ownership.parentKey]; + if (ownership.createdParent && isRecord(nextParent) && Object.keys(nextParent).length === 0) { + const nextRoot = parseConfigTree(next, path); + next = removeObjectProperty(next, nextRoot, ownership.parentKey); + nextParsed = parseConfigObject(next, path); + } + return { + content: next, + changed: true, + removeFile: ownership.createdFile && Object.keys(nextParsed).length === 0, + }; +} + +async function writeConfig(path: string, content: string, environment: ResolvedInstallEnvironment) { + await assertWritePathInHome(environment.homeDir, path); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); +} + +function operationMessage(operations: InstallOperation[]) { + return operations.map((operation) => operation.value).join("; "); +} + +function failureReceipt( + id: string, + name: string, + capability: InstallTargetReceipt["capability"], + error: unknown, + detected?: boolean, + details: Partial< + Pick + > = {}, +): InstallTargetReceipt { + const message = error instanceof Error ? error.message : String(error); + const operations = details.operations ?? []; + return { + id, + name, + capability, + status: "failed", + changed: false, + detected, + ...details, + message: operations.length > 0 ? `${message}; ${operationMessage(operations)}` : message, + operations, + }; +} + +async function commandTarget( + definition: CommandDefinition, + detected: DetectedAgent, + state: InstallState, + environment: ResolvedInstallEnvironment, + input: InstallCommandInput, +): Promise { + const stateKey = `mcp:${definition.id}`; + const ownership = state.targets[stateKey]; + const uninstall = input.uninstall ?? false; + const configPath = definition.configPath(environment.homeDir); + const args = uninstall ? definition.removeArgs : definition.addArgs; + let failureDetails: Partial< + Pick + > = { path: configPath }; + let readding = false; + + if (uninstall && ownership === undefined) { + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + message: "not installed by bfs install", + operations: [], + }; + } + if (ownership !== undefined) { + if (ownership.kind !== "command") { + return failureReceipt( + stateKey, + `${definition.name} MCP`, + "mcp", + `Install ownership for ${definition.name} is inconsistent.`, + detected.detected, + ); + } + const entryState = await commandConfigEntryState(definition, configPath); + if (entryState === "modified") { + return failureReceipt( + stateKey, + `${definition.name} MCP`, + "mcp", + `The better-fullstack entry in ${configPath} was modified by the user; left it unchanged.`, + detected.detected, + { path: configPath }, + ); + } + if (entryState === "missing" && uninstall) { + delete state.targets[stateKey]; + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + path: configPath, + message: "entry was already absent; removed stale ownership", + operations: [], + }; + } + if (entryState === "matching" && !uninstall) { + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + path: configPath, + message: "already installed", + operations: [], + }; + } + readding = entryState === "missing"; + } + if (!uninstall && ownership === undefined) { + const entryState = await commandConfigEntryState(definition, configPath); + if (entryState === "matching") { + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + path: configPath, + message: "matching entry already existed", + operations: [], + }; + } + if (entryState === "modified") { + return failureReceipt( + stateKey, + `${definition.name} MCP`, + "mcp", + `A different better-fullstack entry already exists in ${configPath}; left the user-owned entry unchanged.`, + detected.detected, + { path: configPath }, + ); + } + } + if (!detected.binaryPath) { + return failureReceipt( + stateKey, + `${definition.name} MCP`, + "mcp", + `${definition.name} CLI was not found on PATH.`, + detected.detected, + ); + } + + try { + await assertWritePathInHome(environment.homeDir, configPath); + if (definition.configFormat === "json") await validateJsonFile(configPath); + const backup = await createBackup(configPath, environment, input.dryRun ?? false); + const command = [detected.binaryPath, ...args]; + const operations: InstallOperation[] = []; + if (backup) operations.push({ type: "backup", value: `backup ${backup}` }); + operations.push({ type: "command", value: command.join(" ") }); + failureDetails = { path: configPath, backupPath: backup, command, operations }; + if (!(input.dryRun ?? false)) { + await environment.runCommand(detected.binaryPath, args); + } + if (uninstall) { + delete state.targets[stateKey]; + } else { + state.targets[stateKey] = { + kind: "command", + agent: definition.id, + signature: definition.addArgs, + }; + } + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: input.dryRun ? "planned" : uninstall ? "uninstalled" : "installed", + changed: true, + detected: detected.detected, + path: configPath, + backupPath: backup, + command, + message: readding ? `re-added; ${operationMessage(operations)}` : operationMessage(operations), + operations, + }; + } catch (error) { + return failureReceipt( + stateKey, + `${definition.name} MCP`, + "mcp", + error, + detected.detected, + failureDetails, + ); + } +} + +function emptyConfigContent(parentKey: string, value: Record) { + return `${JSON.stringify({ [parentKey]: { "better-fullstack": value } }, null, 2)}\n`; +} + +async function jsonTarget( + definition: JsonDefinition, + detected: DetectedAgent, + state: InstallState, + environment: ResolvedInstallEnvironment, + input: InstallCommandInput, +): Promise { + const stateKey = `mcp:${definition.id}`; + const ownership = state.targets[stateKey]; + const uninstall = input.uninstall ?? false; + const path = + ownership?.kind === "json" + ? resolve(environment.homeDir, ownership.path) + : definition.path(environment); + let failureDetails: Partial> = { + path, + }; + + if (uninstall && ownership === undefined) { + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + path, + message: "not installed by bfs install", + operations: [], + }; + } + + try { + await assertWritePathInHome(environment.homeDir, path); + const exists = await pathExists(path); + if (uninstall) { + if (!ownership || ownership.kind !== "json") { + throw new Error(`Install ownership for ${definition.name} is inconsistent.`); + } + if (!exists) { + delete state.targets[stateKey]; + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + path, + message: "entry was already absent", + operations: [], + }; + } + const current = await readFile(path, "utf8"); + const result = removeJsonEntry(current, path, ownership, definition.value); + if (!result.changed) { + delete state.targets[stateKey]; + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + path, + message: "entry was already absent", + operations: [], + }; + } + const backup = await createBackup(path, environment, input.dryRun ?? false); + const operations: InstallOperation[] = []; + if (backup) operations.push({ type: "backup", value: `backup ${backup}` }); + operations.push({ + type: "remove", + value: result.removeFile ? `remove ${path}` : `remove better-fullstack from ${path}`, + }); + failureDetails = { path, backupPath: backup, operations }; + if (!input.dryRun) { + if (result.removeFile) await rm(path); + else await writeConfig(path, result.content, environment); + } + delete state.targets[stateKey]; + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: input.dryRun ? "planned" : "uninstalled", + changed: true, + detected: detected.detected, + path, + backupPath: backup, + message: operationMessage(operations), + operations, + }; + } + + const createdFile = !exists; + const current = exists ? await readFile(path, "utf8") : undefined; + const result = current !== undefined + ? addJsonEntry(current, path, definition.parentKey, definition.value) + : { + content: emptyConfigContent(definition.parentKey, definition.value), + changed: true, + createdParent: true, + }; + if (!result.changed) { + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: "unchanged", + changed: false, + detected: detected.detected, + path, + message: ownership ? "already installed" : "matching entry already existed", + operations: [], + }; + } + const backup = await createBackup(path, environment, input.dryRun ?? false); + const operations: InstallOperation[] = []; + if (backup) operations.push({ type: "backup", value: `backup ${backup}` }); + operations.push({ type: "write", value: `write ${path}` }); + failureDetails = { path, backupPath: backup, operations }; + if (!input.dryRun) { + await writeConfig(path, result.content, environment); + } + state.targets[stateKey] = { + kind: "json", + agent: definition.id, + path: relativeToHome(environment, path), + parentKey: definition.parentKey, + createdFile, + createdParent: result.createdParent, + valueHash: hashValue(definition.value), + }; + return { + id: stateKey, + name: `${definition.name} MCP`, + capability: "mcp", + status: input.dryRun ? "planned" : "installed", + changed: true, + detected: detected.detected, + path, + backupPath: backup, + message: operationMessage(operations), + operations, + }; + } catch (error) { + return failureReceipt( + stateKey, + `${definition.name} MCP`, + "mcp", + error, + detected.detected, + failureDetails, + ); + } +} + +interface SkillFile { + relativePath: string; + content: Buffer; + mode: number; +} + +async function readSkillFiles( + sourceDirectory: string, + installedName?: string, + currentDirectory = sourceDirectory, +): Promise { + const entries = await readdir(currentDirectory, { withFileTypes: true }); + const files: SkillFile[] = []; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const path = join(currentDirectory, entry.name); + if (entry.isSymbolicLink()) throw new Error(`Skill source contains a symbolic link: ${path}`); + if (entry.isDirectory()) { + files.push(...(await readSkillFiles(sourceDirectory, installedName, path))); + continue; + } + if (!entry.isFile()) continue; + const relativePath = relative(sourceDirectory, path); + let content = await readFile(path); + if (relativePath === "SKILL.md" && installedName) { + const text = content.toString("utf8"); + content = Buffer.from(text.replace(/^name:\s*[^\n]+/m, `name: ${installedName}`)); + } + files.push({ relativePath, content, mode: (await stat(path)).mode }); + } + return files; +} + +function hashSkillFiles(files: SkillFile[]) { + const hash = createHash("sha256"); + for (const file of files) { + hash.update(file.relativePath); + hash.update("\0"); + hash.update(file.content); + hash.update("\0"); + } + return hash.digest("hex"); +} + +async function installedSkillHash(path: string): Promise { + const files = await readSkillFiles(path); + return hashSkillFiles(files); +} + +async function writeSkillFiles( + path: string, + files: SkillFile[], + environment: ResolvedInstallEnvironment, +) { + await assertWritePathInHome(environment.homeDir, path); + await mkdir(path, { recursive: true }); + for (const file of files) { + const target = join(path, file.relativePath); + await assertWritePathInHome(environment.homeDir, target); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.content, { mode: file.mode }); + } +} + +async function installSkillFiles( + path: string, + files: SkillFile[], + environment: ResolvedInstallEnvironment, + replacing: boolean, +) { + const temporaryPath = `${path}.better-fullstack-tmp-${randomUUID()}`; + const previousPath = `${path}.better-fullstack-previous-${randomUUID()}`; + await assertWritePathInHome(environment.homeDir, temporaryPath); + await assertWritePathInHome(environment.homeDir, previousPath); + try { + await writeSkillFiles(temporaryPath, files, environment); + if (!replacing) { + await rename(temporaryPath, path); + return; + } + await rename(path, previousPath); + try { + await rename(temporaryPath, path); + } catch (error) { + await rename(previousPath, path).catch(() => {}); + throw error; + } + await rm(previousPath, { recursive: true }).catch(() => {}); + } finally { + if (await pathExists(temporaryPath)) await rm(temporaryPath, { recursive: true }); + } +} + +async function skillTarget( + definition: SkillDefinition, + rootName: ".agents" | ".claude", + state: InstallState, + environment: ResolvedInstallEnvironment, + input: InstallCommandInput, +): Promise { + const stateKey = `skill:${rootName.slice(1)}:${definition.id}`; + const ownership = state.targets[stateKey]; + const path = join(environment.homeDir, rootName, "skills", definition.installedName); + const source = join(environment.skillSourceDir, definition.id); + const uninstall = input.uninstall ?? false; + const name = `${definition.installedName} (${rootName}/skills)`; + let failureDetails: Partial> = { path }; + + if (uninstall && ownership === undefined) { + return { + id: stateKey, + name, + capability: "skill", + status: "unchanged", + changed: false, + path, + message: "not installed by bfs install", + operations: [], + }; + } + + try { + await assertWritePathInHome(environment.homeDir, path); + const targetExists = await pathExists(path); + if (targetExists && (await lstat(path)).isSymbolicLink()) { + throw new Error(`Skill destination is a symbolic link; left it unchanged: ${path}`); + } + + if (uninstall) { + if (!ownership || ownership.kind !== "skill") { + throw new Error(`Install ownership for ${name} is inconsistent.`); + } + if (!targetExists) { + delete state.targets[stateKey]; + return { + id: stateKey, + name, + capability: "skill", + status: "unchanged", + changed: false, + path, + message: "skill folder was already absent", + operations: [], + }; + } + const currentHash = await installedSkillHash(path); + if (currentHash !== ownership.contentHash) { + throw new Error(`Skill files changed after install; left them unchanged: ${path}`); + } + const operations: InstallOperation[] = [{ type: "remove", value: `remove ${path}` }]; + failureDetails = { path, operations }; + if (!input.dryRun) { + await rm(path, { recursive: true }); + } + delete state.targets[stateKey]; + return { + id: stateKey, + name, + capability: "skill", + status: input.dryRun ? "planned" : "uninstalled", + changed: true, + path, + message: operationMessage(operations), + operations, + }; + } + + if (!(await pathExists(source))) throw new Error(`Bundled skill source is missing: ${source}`); + const files = await readSkillFiles(source, definition.installedName); + const expectedHash = hashSkillFiles(files); + let updating = false; + if (targetExists) { + const currentHash = await installedSkillHash(path); + if (currentHash === expectedHash) { + return { + id: stateKey, + name, + capability: "skill", + status: "unchanged", + changed: false, + path, + message: ownership ? "already installed" : "matching skill folder already existed", + operations: [], + }; + } + if (!ownership || ownership.kind !== "skill") { + throw new Error(`A different skill folder already exists; left it unchanged: ${path}`); + } + if (currentHash !== ownership.contentHash) { + throw new Error(`Skill files changed after install; left them unchanged: ${path}`); + } + updating = true; + } + const operations: InstallOperation[] = [ + { type: "write", value: `${updating ? "update" : "write"} ${path}` }, + ]; + failureDetails = { path, operations }; + if (!input.dryRun) { + await installSkillFiles(path, files, environment, updating); + } + state.targets[stateKey] = { + kind: "skill", + path: relativeToHome(environment, path), + contentHash: expectedHash, + }; + return { + id: stateKey, + name, + capability: "skill", + status: input.dryRun ? "planned" : "installed", + changed: true, + path, + message: updating ? `updated; ${operationMessage(operations)}` : operationMessage(operations), + operations, + }; + } catch (error) { + return failureReceipt(stateKey, name, "skill", error, undefined, failureDetails); + } +} + +function ownedAgentIds(state: InstallState, only?: InstallOnly) { + const ids = new Set(); + if (only === "skills") return ids; + for (const [key, target] of Object.entries(state.targets)) { + if (!key.startsWith("mcp:")) continue; + if (target.kind === "command" || target.kind === "json") ids.add(target.agent); + } + return ids; +} + +function shouldInstallSkills( + selectedAgents: InstallAgentId[], + state: InstallState, + input: InstallCommandInput, +) { + if (input.only === "mcp") return false; + if (input.uninstall) { + const ownsSkills = Object.keys(state.targets).some((key) => key.startsWith("skill:")); + const hasAgentFilter = (input.agents?.length ?? 0) > 0; + return ( + ownsSkills && (!hasAgentFilter || selectedAgents.some((agent) => SKILL_AGENT_IDS.has(agent))) + ); + } + return selectedAgents.some((agent) => SKILL_AGENT_IDS.has(agent)); +} + +function stateTargetReceipt( + environment: ResolvedInstallEnvironment, + input: InstallCommandInput, + before: InstallState, + after: InstallState, +): InstallTargetReceipt | undefined { + if (JSON.stringify(before) === JSON.stringify(after)) return undefined; + const path = statePath(environment); + const removing = input.uninstall && stateIsEmpty(after); + const operations: InstallOperation[] = [ + { type: removing ? "remove" : "write", value: `${removing ? "remove" : "write"} ${path}` }, + ]; + return { + id: "state", + name: "Install ownership receipt", + capability: "state", + status: input.dryRun ? "planned" : input.uninstall ? "uninstalled" : "installed", + changed: true, + path, + message: operationMessage(operations), + operations, + }; +} + +function cloneState(state: InstallState): InstallState { + return JSON.parse(JSON.stringify(state)) as InstallState; +} + +function selectionSummary(agents: DetectedAgent[], input: InstallCommandInput) { + const action = input.uninstall ? "Remove" : "Install"; + const surfaces = input.only ?? "MCP and skills"; + const names = agents.map((agent) => agent.name).join(", "); + return `${action} ${surfaces} for ${names || "the detected agents"}?`; +} + +export async function runInstall( + input: InstallCommandInput, + overrides: InstallEnvironmentOverrides = {}, +): Promise { + const environment = await resolveEnvironment(overrides); + let state: InstallState; + try { + state = await readState(environment); + } catch (error) { + const target = failureReceipt("state", "Install ownership receipt", "state", error); + return buildReceipt(input, [], [target]); + } + const initialState = cloneState(state); + const detected = await detectInstallAgents(environment); + const requestedAgentIds = [...new Set((input.agents ?? []).map(normalizeAgentId))]; + const ownedIds = ownedAgentIds(state, input.only); + const selected = detected.filter((agent) => { + if (requestedAgentIds.length > 0) return requestedAgentIds.includes(agent.id); + if (input.uninstall && ownedIds.has(agent.id)) return true; + return agent.detected; + }); + const selectedAgentIds = selected.map((agent) => agent.id); + const installsSkills = shouldInstallSkills(selectedAgentIds, state, input); + + if (!input.dryRun && environment.stdinIsTTY && environment.confirm) { + const confirmed = await environment.confirm(selectionSummary(selected, input)); + if (!confirmed) { + const target: InstallTargetReceipt = { + id: "selection", + name: "Installation", + capability: "selection", + status: "cancelled", + changed: false, + message: "cancelled", + operations: [], + }; + return buildReceipt( + input, + selectedAgentIds, + [target], + ); + } + } + + const hasTargetWork = (input.only !== "skills" && selected.length > 0) || installsSkills; + if (!input.dryRun && hasTargetWork) { + try { + await preflightStateWrite(environment); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + const target = failureReceipt( + "state-preflight", + "Install ownership receipt", + "state", + `Cannot prepare the install ownership receipt. No targets were changed. ${reason}`, + undefined, + { path: statePath(environment) }, + ); + return buildReceipt(input, selectedAgentIds, [target]); + } + } + + const targets: InstallTargetReceipt[] = []; + if (input.only !== "skills") { + for (const definition of COMMAND_DEFINITIONS) { + const agent = selected.find((candidate) => candidate.id === definition.id); + if (!agent) continue; + targets.push(await commandTarget(definition, agent, state, environment, input)); + } + for (const definition of JSON_DEFINITIONS) { + const agent = selected.find((candidate) => candidate.id === definition.id); + if (!agent) continue; + targets.push(await jsonTarget(definition, agent, state, environment, input)); + } + } + + if (installsSkills) { + for (const definition of SKILL_DEFINITIONS) { + targets.push(await skillTarget(definition, ".agents", state, environment, input)); + targets.push(await skillTarget(definition, ".claude", state, environment, input)); + } + } + + if (targets.length === 0) { + targets.push( + failureReceipt( + "selection", + "Agent detection", + "selection", + input.only === "skills" + ? "No selected or detected agent supports these skill locations." + : "No requested agent was detected. Use --agent to target one explicitly.", + ), + ); + } + + const stateChanged = JSON.stringify(initialState) !== JSON.stringify(state); + if (stateChanged) { + const stateReceipt = stateTargetReceipt(environment, input, initialState, state); + if (stateReceipt) targets.push(stateReceipt); + if (!input.dryRun) { + try { + await writeState(environment, state); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + const backupPaths = [ + ...new Set( + targets.flatMap((target) => (target.backupPath ? [target.backupPath] : [])), + ), + ]; + targets.push( + failureReceipt( + "state-write", + "Install ownership receipt", + "state", + `Could not save install ownership after changing targets. Backups created in this run: ${backupPaths.length > 0 ? backupPaths.join(", ") : "none"}. ${reason}`, + ), + ); + } + } + } + + return buildReceipt( + input, + selectedAgentIds, + targets, + ); +} + +function buildReceipt( + input: InstallCommandInput, + agents: InstallAgentId[], + targets: InstallTargetReceipt[], +): InstallReceipt { + const actionable = targets.filter( + (target) => + target.capability === "mcp" || + target.capability === "skill" || + target.capability === "selection" || + target.status === "failed", + ); + const failed = actionable.filter((target) => target.status === "failed").length; + const cancelled = actionable.some((target) => target.status === "cancelled"); + const requested = actionable.filter((target) => target.status !== "cancelled").length; + const changed = actionable.filter((target) => target.changed).length; + const unchanged = actionable.filter((target) => target.status === "unchanged").length; + const stateWriteFailed = targets.some( + (target) => target.id === "state-write" && target.status === "failed", + ); + return { + schemaVersion: 1, + command: "install", + action: input.uninstall ? "uninstall" : "install", + dryRun: input.dryRun ?? false, + success: !stateWriteFailed && (cancelled || requested === 0 || failed < requested), + selection: { + only: input.only ?? "all", + agents, + }, + targets, + summary: { requested, changed, unchanged, failed }, + tryPrompt: TRY_PROMPT, + }; +} diff --git a/apps/cli/src/commands/system/install.ts b/apps/cli/src/commands/system/install.ts new file mode 100644 index 000000000..cb4054523 --- /dev/null +++ b/apps/cli/src/commands/system/install.ts @@ -0,0 +1,69 @@ +import { confirm, intro, isCancel, log, outro } from "@clack/prompts"; +import pc from "picocolors"; + +import { CLIError } from "@/presentation/errors"; +import { renderTitle } from "@/presentation/render-title"; + +import type { InstallAgentInputId, InstallOnly, InstallReceipt } from "./install-core"; + +import { runInstall } from "./install-core"; + +export interface InstallCommandOptions { + only?: InstallOnly; + agent?: InstallAgentInputId[]; + dryRun?: boolean; + json?: boolean; + uninstall?: boolean; + yes?: boolean; +} + +function statusMark(target: InstallReceipt["targets"][number]) { + if (target.status === "failed") return pc.red("✗"); + if (target.status === "unchanged") return pc.dim("-"); + if (target.status === "cancelled") return pc.yellow("-"); + if (target.status === "planned") return pc.cyan("◇"); + return pc.green("✓"); +} + +function printHumanReceipt(receipt: InstallReceipt) { + renderTitle(); + intro( + pc.magenta( + receipt.action === "uninstall" ? "Better Fullstack uninstall" : "Better Fullstack install", + ), + ); + for (const target of receipt.targets) { + const detail = target.message ? `: ${target.message}` : ""; + log.message(`${statusMark(target)} ${target.name}${detail}`); + } + outro(`Try: ${receipt.tryPrompt}`); +} + +export async function installCommand(options: InstallCommandOptions): Promise { + const interactive = Boolean(process.stdin.isTTY) && !options.yes && !options.json; + const receipt = await runInstall( + { + only: options.only, + agents: options.agent, + dryRun: options.dryRun, + uninstall: options.uninstall, + }, + { + stdinIsTTY: interactive, + confirm: interactive + ? async (message) => { + const answer = await confirm({ message, initialValue: true }); + return !isCancel(answer) && answer; + } + : undefined, + }, + ); + + if (options.json) process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); + else printHumanReceipt(receipt); + + if (!receipt.success) { + throw new CLIError("Every requested Better Fullstack install target failed."); + } + return receipt; +} diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index f7246b4ac..714d1664b 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -26,6 +26,10 @@ import { getStarterTracksResult, } from "@/commands/stack/starter-tracks"; import { historyHandler } from "@/commands/system/history"; +import { + INSTALL_AGENT_INPUT_IDS, + type InstallReceipt, +} from "@/commands/system/install-core"; import { telemetryHandler } from "@/commands/system/telemetry"; import { BUILDER_URL } from "@/constants"; import { CreateCommandInputSchema, CreateCommandOptionsSchema } from "@/create-command-input"; @@ -203,6 +207,11 @@ const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce( 0, ); +function statusFromInstallResult(result: InstallReceipt | undefined) { + if (result?.targets.some((target) => target.status === "cancelled")) return "cancelled"; + return statusFromCommandResult(result); +} + const AddCommandInputSchema = CreateCommandOptionsSchema.omit({ template: true, shape: true, @@ -355,6 +364,48 @@ export const router = os.router({ { source: "cli-flags" }, ); }), + install: os + .meta({ + description: + "Install or uninstall Better Fullstack MCP and skills for detected coding agents and editors", + }) + .input( + z.object({ + only: z + .enum(["mcp", "skills"]) + .optional() + .describe("Install only the MCP connection or only the skills"), + agent: z + .array(z.enum(INSTALL_AGENT_INPUT_IDS)) + .optional() + .default([]) + .describe("Restrict installation to an agent; repeat for more than one"), + dryRun: z + .boolean() + .optional() + .default(false) + .describe("Print every planned file and command without changing anything"), + json: z.boolean().optional().default(false).describe("Output a machine-readable receipt"), + uninstall: z + .boolean() + .optional() + .default(false) + .describe("Remove only entries and skill folders created by this command"), + yes: z.boolean().optional().default(false).describe("Skip the confirmation prompt"), + }), + ) + .handler(async ({ input }) => { + const { installCommand } = await import("@/commands/system/install.js"); + await withCommandTelemetry("install", () => installCommand(input), { + source: "cli-flags", + mode: input.dryRun ? "dry-run" : input.uninstall ? "uninstall" : "install", + resultStatus: statusFromInstallResult, + resultDetails: (result) => ({ + capabilityCount: result.summary.requested, + issueCount: result.summary.failed, + }), + }); + }), add: os .meta({ description: diff --git a/apps/cli/test/generation/docs-scaffold-commands.test.ts b/apps/cli/test/generation/docs-scaffold-commands.test.ts index 69c15b590..fa4902a8c 100644 --- a/apps/cli/test/generation/docs-scaffold-commands.test.ts +++ b/apps/cli/test/generation/docs-scaffold-commands.test.ts @@ -24,6 +24,7 @@ const NON_SCAFFOLD_COMMANDS = new Set([ "evidence", "gen", "history", + "install", "mcp", "recommend", "recovery", diff --git a/apps/cli/test/support/install-command.test.ts b/apps/cli/test/support/install-command.test.ts new file mode 100644 index 000000000..79118278f --- /dev/null +++ b/apps/cli/test/support/install-command.test.ts @@ -0,0 +1,841 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + detectInstallAgents, + runInstall, + type InstallEnvironmentOverrides, +} from "@/commands/system/install-core"; + +let testDirectory: string; +let homeDirectory: string; +let binaryDirectory: string; +let skillSourceDirectory: string; + +async function createExecutable(name: string) { + const path = join(binaryDirectory, name); + await writeFile(path, "#!/bin/sh\nexit 0\n"); + await chmod(path, 0o755); + return path; +} + +async function createSkillSources(root = skillSourceDirectory, marker = "") { + for (const name of ["scaffold-project", "add-to-project"]) { + const directory = join(root, name); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "SKILL.md"), + `---\nname: ${name}\ndescription: Test skill\n---\n\n# ${name}\n${marker}`, + ); + } +} + +function environment( + overrides: Partial = {}, +): InstallEnvironmentOverrides { + return { + homeDir: homeDirectory, + path: binaryDirectory, + platform: "linux", + skillSourceDir: skillSourceDirectory, + stdinIsTTY: false, + now: () => new Date("2026-08-31T12:34:56.789Z"), + runCommand: async () => {}, + ...overrides, + }; +} + +async function snapshotDirectory(directory: string, root = directory): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const snapshot: string[] = []; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const path = join(directory, entry.name); + const relativePath = path.slice(root.length + 1); + if (entry.isDirectory()) { + snapshot.push(`directory:${relativePath}`); + snapshot.push(...(await snapshotDirectory(path, root))); + } else { + snapshot.push(`file:${relativePath}:${await readFile(path, "utf8")}`); + } + } + return snapshot; +} + +beforeEach(async () => { + testDirectory = await mkdtemp(join(tmpdir(), "better-fullstack-install-")); + homeDirectory = join(testDirectory, "home"); + binaryDirectory = join(testDirectory, "bin"); + skillSourceDirectory = join(testDirectory, "skills"); + await mkdir(homeDirectory, { recursive: true }); + await mkdir(binaryDirectory, { recursive: true }); + await createSkillSources(); +}); + +afterEach(async () => { + await rm(testDirectory, { recursive: true, force: true }); +}); + +describe("bfs install", () => { + it("detects agent binaries from an injected PATH and editor state from an injected home", async () => { + const claudePath = await createExecutable("claude"); + await mkdir(join(homeDirectory, ".cursor")); + + const agents = await detectInstallAgents({ + homeDir: homeDirectory, + path: binaryDirectory, + platform: "linux", + }); + + expect(agents.find((agent) => agent.id === "claude")).toEqual({ + id: "claude", + name: "Claude Code", + detected: true, + binaryPath: claudePath, + }); + expect(agents.find((agent) => agent.id === "cursor")?.detected).toBe(true); + expect(agents.find((agent) => agent.id === "codex")?.detected).toBe(false); + }); + + it("resolves bundled skills from the published dist layout without a repository fallback", async () => { + const moduleDirectory = join(testDirectory, "published-package", "dist"); + await createSkillSources(join(moduleDirectory, "skills"), "Published bundle marker\n"); + + const receipt = await runInstall( + { only: "skills", agents: ["codex"] }, + environment({ moduleDir: moduleDirectory, skillSourceDir: undefined }), + ); + + expect(receipt.success).toBe(true); + expect( + await readFile( + join( + homeDirectory, + ".agents", + "skills", + "better-fullstack-scaffold-project", + "SKILL.md", + ), + "utf8", + ), + ).toContain("Published bundle marker"); + }); + + it("merges JSON without reordering or rewriting unrelated keys", async () => { + const configPath = join(homeDirectory, ".cursor", "mcp.json"); + const original = + '{\n\t"alpha": { "token": "do-not-print" },\n\t"mcpServers": {\n\t\t"existing": { "command": "keep" }\n\t},\n\t"omega": [3, 2, 1]\n}\n'; + await mkdir(join(homeDirectory, ".cursor"), { recursive: true }); + await writeFile(configPath, original); + + const receipt = await runInstall({ only: "mcp", agents: ["cursor"] }, environment()); + const updated = await readFile(configPath, "utf8"); + + expect(receipt.success).toBe(true); + expect(updated.slice(0, updated.indexOf('"mcpServers"'))).toBe( + original.slice(0, original.indexOf('"mcpServers"')), + ); + expect(updated.slice(updated.indexOf('\t"omega"'))).toBe( + original.slice(original.indexOf('\t"omega"')), + ); + expect(updated).toContain('\t\t"existing": { "command": "keep" }'); + expect(JSON.parse(updated)).toMatchObject({ + alpha: { token: "do-not-print" }, + mcpServers: { + existing: { command: "keep" }, + "better-fullstack": { + command: "npx", + args: ["-y", "create-better-fullstack@latest", "mcp"], + }, + }, + omega: [3, 2, 1], + }); + }); + + it("preserves a commented Zed JSONC config across install and uninstall", async () => { + const configPath = join(homeDirectory, ".config", "zed", "settings.json"); + const original = `{ + // Keep the user's theme. + "theme": "One Dark", + "context_servers": { + /* Keep this existing server. */ + "existing": { + "command": "keep", + "args": [], + }, + }, +} +`; + await mkdir(join(homeDirectory, ".config", "zed"), { recursive: true }); + await writeFile(configPath, original); + + const installReceipt = await runInstall({ only: "mcp", agents: ["zed"] }, environment()); + const installed = await readFile(configPath, "utf8"); + + expect(installReceipt.targets.find((item) => item.id === "mcp:zed")?.status).toBe( + "installed", + ); + expect(installed).toContain("// Keep the user's theme."); + expect(installed).toContain("/* Keep this existing server. */"); + expect(installed).toContain('"better-fullstack"'); + + const secondReceipt = await runInstall({ only: "mcp", agents: ["zed"] }, environment()); + expect(secondReceipt.targets.find((item) => item.id === "mcp:zed")?.status).toBe( + "unchanged", + ); + expect(await readFile(configPath, "utf8")).toBe(installed); + + const uninstallReceipt = await runInstall( + { only: "mcp", agents: ["zed"], uninstall: true }, + environment(), + ); + expect(uninstallReceipt.targets.find((item) => item.id === "mcp:zed")?.status).toBe( + "uninstalled", + ); + expect(await readFile(configPath, "utf8")).toBe(original); + }); + + it("keeps using the owned Zed settings path when the preferred macOS path changes", async () => { + const legacyPath = join(homeDirectory, ".config", "zed", "settings.json"); + const macPath = join(homeDirectory, ".zed", "settings.json"); + const legacyOriginal = '{\n "theme": "legacy",\n "context_servers": {}\n}\n'; + const macOriginal = '{\n "theme": "new-location",\n "context_servers": {}\n}\n'; + await mkdir(join(homeDirectory, ".config", "zed"), { recursive: true }); + await writeFile(legacyPath, legacyOriginal); + const macEnvironment = environment({ platform: "darwin" }); + + await runInstall({ only: "mcp", agents: ["zed"] }, macEnvironment); + const installedLegacy = await readFile(legacyPath, "utf8"); + await mkdir(join(homeDirectory, ".zed"), { recursive: true }); + await writeFile(macPath, macOriginal); + + const second = await runInstall({ only: "mcp", agents: ["zed"] }, macEnvironment); + expect(second.targets.find((target) => target.id === "mcp:zed")).toMatchObject({ + status: "unchanged", + path: legacyPath, + }); + expect(await readFile(legacyPath, "utf8")).toBe(installedLegacy); + expect(await readFile(macPath, "utf8")).toBe(macOriginal); + + const uninstall = await runInstall( + { only: "mcp", agents: ["zed"], uninstall: true }, + macEnvironment, + ); + expect(uninstall.targets.find((target) => target.id === "mcp:zed")?.path).toBe(legacyPath); + expect(await readFile(legacyPath, "utf8")).toBe(legacyOriginal); + expect(await readFile(macPath, "utf8")).toBe(macOriginal); + }); + + it("creates a timestamped backup before changing an existing config", async () => { + const configPath = join(homeDirectory, ".config", "opencode", "opencode.json"); + const original = '{\n "theme": "system"\n}\n'; + await mkdir(join(homeDirectory, ".config", "opencode"), { recursive: true }); + await writeFile(configPath, original); + + const receipt = await runInstall({ only: "mcp", agents: ["opencode"] }, environment()); + const target = receipt.targets.find((item) => item.id === "mcp:opencode"); + + expect(target?.backupPath).toBe( + `${configPath}.better-fullstack-backup-2026-08-31T12-34-56.789Z`, + ); + expect(await readFile(target?.backupPath ?? "", "utf8")).toBe(original); + }); + + it("keeps a created backup visible when CLI registration fails", async () => { + await createExecutable("claude"); + const configPath = join(homeDirectory, ".claude.json"); + const original = '{\n "projects": {}\n}\n'; + await writeFile(configPath, original); + + const receipt = await runInstall( + { only: "mcp", agents: ["claude"] }, + environment({ + runCommand: async () => { + throw new Error("registration failed"); + }, + }), + ); + const target = receipt.targets.find((item) => item.id === "mcp:claude"); + + expect(target).toMatchObject({ + status: "failed", + backupPath: `${configPath}.better-fullstack-backup-2026-08-31T12-34-56.789Z`, + }); + expect(target?.message).toContain("backup"); + expect(await readFile(target?.backupPath ?? "", "utf8")).toBe(original); + }); + + it("performs no writes or command execution in dry-run mode", async () => { + const configPath = join(homeDirectory, ".cursor", "mcp.json"); + await mkdir(join(homeDirectory, ".cursor"), { recursive: true }); + await writeFile(configPath, '{\n "mcpServers": {}\n}\n'); + const before = await snapshotDirectory(homeDirectory); + let commandRuns = 0; + + const receipt = await runInstall( + { agents: ["cursor"], dryRun: true }, + environment({ + runCommand: async () => { + commandRuns += 1; + }, + }), + ); + + expect(receipt.targets.some((target) => target.status === "planned")).toBe(true); + expect(commandRuns).toBe(0); + expect(await snapshotDirectory(homeDirectory)).toEqual(before); + }); + + it("aborts before changing targets when the ownership receipt path is not writable", async () => { + await createExecutable("claude"); + const configPath = join(homeDirectory, ".claude.json"); + const original = '{\n "mcpServers": {}\n}\n'; + await writeFile(configPath, original); + const outsideStateDirectory = join(testDirectory, "outside-state"); + await mkdir(outsideStateDirectory); + await mkdir(join(homeDirectory, ".config")); + await symlink( + outsideStateDirectory, + join(homeDirectory, ".config", "better-fullstack"), + "dir", + ); + let commandRuns = 0; + + const receipt = await runInstall( + { only: "mcp", agents: ["claude"] }, + environment({ + runCommand: async () => { + commandRuns += 1; + }, + }), + ); + + expect(receipt.success).toBe(false); + expect(receipt.targets).toHaveLength(1); + expect(receipt.targets[0]).toMatchObject({ + id: "state-preflight", + status: "failed", + changed: false, + }); + expect(receipt.targets[0]?.message).toContain("No targets were changed"); + expect(commandRuns).toBe(0); + expect(await readFile(configPath, "utf8")).toBe(original); + expect(await readdir(outsideStateDirectory)).toEqual([]); + }); + + it("fails the overall receipt when ownership cannot be saved after a target changes", async () => { + await createExecutable("claude"); + const configPath = join(homeDirectory, ".claude.json"); + const original = '{\n "mcpServers": {}\n}\n'; + await writeFile(configPath, original); + const stateDirectory = join(homeDirectory, ".config", "better-fullstack"); + const backupPath = `${configPath}.better-fullstack-backup-2026-08-31T12-34-56.789Z`; + + const receipt = await runInstall( + { only: "mcp", agents: ["claude"] }, + environment({ + runCommand: async () => { + await rm(stateDirectory, { recursive: true }); + await writeFile(stateDirectory, "block state writes"); + }, + }), + ); + const stateFailure = receipt.targets.find((target) => target.id === "state-write"); + + expect(receipt.success).toBe(false); + expect(stateFailure).toMatchObject({ status: "failed", changed: false }); + expect(stateFailure?.message).toContain(backupPath); + expect(await readFile(backupPath, "utf8")).toBe(original); + }); + + it("uses the documented user-scoped CLI commands", async () => { + const binaries = await Promise.all([ + createExecutable("claude"), + createExecutable("codex"), + createExecutable("gemini"), + ]); + const commands: Array<{ command: string; args: string[] }> = []; + + const receipt = await runInstall( + { only: "mcp", agents: ["claude", "codex", "gemini"] }, + environment({ + runCommand: async (command, args) => { + commands.push({ command, args }); + if (args[1] !== "add") return; + if (command === binaries[1]) { + await mkdir(join(homeDirectory, ".codex"), { recursive: true }); + await writeFile( + join(homeDirectory, ".codex", "config.toml"), + '[mcp_servers.better-fullstack]\ncommand = "npx"\nargs = ["-y", "create-better-fullstack@latest", "mcp"]\n', + ); + return; + } + const path = + command === binaries[0] + ? join(homeDirectory, ".claude.json") + : join(homeDirectory, ".gemini", "settings.json"); + await mkdir(command === binaries[0] ? homeDirectory : join(homeDirectory, ".gemini"), { + recursive: true, + }); + await writeFile( + path, + `${JSON.stringify( + { + mcpServers: { + "better-fullstack": { + command: "npx", + args: ["-y", "create-better-fullstack@latest", "mcp"], + }, + }, + }, + null, + 2, + )}\n`, + ); + }, + }), + ); + + expect(receipt.success).toBe(true); + expect(commands).toEqual([ + { + command: binaries[0], + args: [ + "mcp", + "add", + "--scope", + "user", + "better-fullstack", + "--", + "npx", + "-y", + "create-better-fullstack@latest", + "mcp", + ], + }, + { + command: binaries[1], + args: [ + "mcp", + "add", + "better-fullstack", + "--", + "npx", + "-y", + "create-better-fullstack@latest", + "mcp", + ], + }, + { + command: binaries[2], + args: [ + "mcp", + "add", + "--scope", + "user", + "better-fullstack", + "npx", + "-y", + "create-better-fullstack@latest", + "mcp", + ], + }, + ]); + + const uninstallReceipt = await runInstall( + { only: "mcp", agents: ["claude", "codex", "gemini"], uninstall: true }, + environment({ + runCommand: async (command, args) => { + commands.push({ command, args }); + }, + }), + ); + + expect(uninstallReceipt.success).toBe(true); + expect(commands.slice(3)).toEqual([ + { + command: binaries[0], + args: ["mcp", "remove", "--scope", "user", "better-fullstack"], + }, + { + command: binaries[1], + args: ["mcp", "remove", "better-fullstack"], + }, + { + command: binaries[2], + args: ["mcp", "remove", "--scope", "user", "better-fullstack"], + }, + ]); + }); + + it("does not claim or alter pre-existing command-backed entries", async () => { + await createExecutable("claude"); + const configPath = join(homeDirectory, ".claude.json"); + const statePath = join(homeDirectory, ".config", "better-fullstack", "install-state.json"); + const matching = `${JSON.stringify( + { + mcpServers: { + "better-fullstack": { + command: "npx", + args: ["-y", "create-better-fullstack@latest", "mcp"], + }, + }, + }, + null, + 2, + )}\n`; + await writeFile(configPath, matching); + let commandRuns = 0; + const commandEnvironment = environment({ + runCommand: async () => { + commandRuns += 1; + }, + }); + + const install = await runInstall( + { only: "mcp", agents: ["claude"] }, + commandEnvironment, + ); + expect(install.targets.find((target) => target.id === "mcp:claude")).toMatchObject({ + status: "unchanged", + changed: false, + message: "matching entry already existed", + }); + expect(commandRuns).toBe(0); + await expect(readFile(statePath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + + await runInstall( + { only: "mcp", agents: ["claude"], uninstall: true }, + commandEnvironment, + ); + expect(await readFile(configPath, "utf8")).toBe(matching); + expect(commandRuns).toBe(0); + + const modified = matching.replace('"npx"', '"user-mcp"'); + await writeFile(configPath, modified); + const conflict = await runInstall( + { only: "mcp", agents: ["claude"] }, + commandEnvironment, + ); + expect(conflict.success).toBe(false); + expect(conflict.targets[0]?.message).toContain("user-owned entry unchanged"); + expect(await readFile(configPath, "utf8")).toBe(modified); + expect(commandRuns).toBe(0); + }); + + it("re-adds command-backed MCP entries removed after installation", async () => { + const [claudePath, codexPath, geminiPath] = await Promise.all([ + createExecutable("claude"), + createExecutable("codex"), + createExecutable("gemini"), + ]); + const configPaths = { + claude: join(homeDirectory, ".claude.json"), + codex: join(homeDirectory, ".codex", "config.toml"), + gemini: join(homeDirectory, ".gemini", "settings.json"), + }; + const commandRuns: string[] = []; + const commandEnvironment = environment({ + runCommand: async (command, args) => { + if (args[1] !== "add") return; + commandRuns.push(command); + if (command === codexPath) { + await mkdir(join(homeDirectory, ".codex"), { recursive: true }); + await writeFile( + configPaths.codex, + '[mcp_servers.better-fullstack]\ncommand = "npx"\nargs = ["-y", "create-better-fullstack@latest", "mcp"]\n', + ); + return; + } + const path = command === claudePath ? configPaths.claude : configPaths.gemini; + await mkdir(command === claudePath ? homeDirectory : join(homeDirectory, ".gemini"), { + recursive: true, + }); + await writeFile( + path, + `${JSON.stringify( + { + mcpServers: { + "better-fullstack": { + command: "npx", + args: ["-y", "create-better-fullstack@latest", "mcp"], + }, + }, + }, + null, + 2, + )}\n`, + ); + }, + }); + + await runInstall( + { only: "mcp", agents: ["claude", "codex", "gemini"] }, + commandEnvironment, + ); + await writeFile(configPaths.claude, '{\n "mcpServers": {}\n}\n'); + await writeFile(configPaths.codex, 'model = "gpt-5"\n'); + await writeFile(configPaths.gemini, '{\n "mcpServers": {}\n}\n'); + + const second = await runInstall( + { only: "mcp", agents: ["claude", "codex", "gemini"] }, + commandEnvironment, + ); + + expect(commandRuns).toEqual([ + claudePath, + codexPath, + geminiPath, + claudePath, + codexPath, + geminiPath, + ]); + for (const id of ["claude", "codex", "gemini"]) { + expect(second.targets.find((target) => target.id === `mcp:${id}`)).toMatchObject({ + status: "installed", + changed: true, + }); + expect(second.targets.find((target) => target.id === `mcp:${id}`)?.message).toContain( + "re-added", + ); + } + + const third = await runInstall( + { only: "mcp", agents: ["claude", "codex", "gemini"] }, + commandEnvironment, + ); + expect(commandRuns).toHaveLength(6); + expect(third.targets.every((target) => target.status === "unchanged")).toBe(true); + }); + + it("leaves user-modified command-backed entries untouched", async () => { + await createExecutable("claude"); + const configPath = join(homeDirectory, ".claude.json"); + const statePath = join(homeDirectory, ".config", "better-fullstack", "install-state.json"); + const commands: string[][] = []; + const commandEnvironment = environment({ + runCommand: async (_command, args) => { + commands.push(args); + if (args[1] !== "add") return; + await writeFile( + configPath, + `${JSON.stringify( + { + mcpServers: { + "better-fullstack": { + command: "npx", + args: ["-y", "create-better-fullstack@latest", "mcp"], + }, + }, + }, + null, + 2, + )}\n`, + ); + }, + }); + + await runInstall({ only: "mcp", agents: ["claude"] }, commandEnvironment); + const modified = `${JSON.stringify( + { + mcpServers: { + "better-fullstack": { + command: "custom-mcp", + args: ["--user-owned"], + }, + }, + }, + null, + 2, + )}\n`; + await writeFile(configPath, modified); + + const reinstall = await runInstall( + { only: "mcp", agents: ["claude"] }, + commandEnvironment, + ); + const uninstall = await runInstall( + { only: "mcp", agents: ["claude"], uninstall: true }, + commandEnvironment, + ); + + expect(reinstall.success).toBe(false); + expect(uninstall.success).toBe(false); + expect(reinstall.targets[0]?.message).toContain("modified by the user"); + expect(uninstall.targets[0]?.message).toContain("modified by the user"); + expect(commands).toHaveLength(1); + expect(await readFile(configPath, "utf8")).toBe(modified); + expect(await readFile(statePath, "utf8")).toContain('"mcp:claude"'); + + await writeFile(configPath, '{\n "mcpServers": {}\n}\n'); + const missingUninstall = await runInstall( + { only: "mcp", agents: ["claude"], uninstall: true }, + commandEnvironment, + ); + expect(missingUninstall.targets.find((target) => target.id === "mcp:claude")).toMatchObject({ + status: "unchanged", + changed: false, + message: "entry was already absent; removed stale ownership", + }); + expect(commands).toHaveLength(1); + await expect(readFile(statePath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("restores config bytes and removes only owned skill folders on uninstall", async () => { + const configPath = join(homeDirectory, ".cursor", "mcp.json"); + const original = + '{\n "mcpServers": {\n "existing": { "command": "keep" }\n },\n "other": true\n}\n'; + await mkdir(join(homeDirectory, ".cursor"), { recursive: true }); + await writeFile(configPath, original); + + const installReceipt = await runInstall({ agents: ["cursor"] }, environment()); + const ownedSkill = join( + homeDirectory, + ".agents", + "skills", + "better-fullstack-scaffold-project", + "SKILL.md", + ); + expect(installReceipt.success).toBe(true); + expect(await readFile(ownedSkill, "utf8")).toContain("name: better-fullstack-scaffold-project"); + + const uninstallReceipt = await runInstall( + { agents: ["cursor"], uninstall: true }, + environment(), + ); + + expect(uninstallReceipt.success).toBe(true); + expect(await readFile(configPath, "utf8")).toBe(original); + await expect(readFile(ownedSkill, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + readFile(join(homeDirectory, ".config", "better-fullstack", "install-state.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("upgrades unmodified managed skills and leaves user-edited skills alone", async () => { + await runInstall({ only: "skills", agents: ["codex"] }, environment()); + const managedPath = join( + homeDirectory, + ".agents", + "skills", + "better-fullstack-scaffold-project", + "SKILL.md", + ); + const editedPath = join( + homeDirectory, + ".claude", + "skills", + "better-fullstack-scaffold-project", + "SKILL.md", + ); + const editedContent = `${await readFile(editedPath, "utf8")}User edit\n`; + await writeFile(editedPath, editedContent); + await createSkillSources(skillSourceDirectory, "New managed version\n"); + + const receipt = await runInstall( + { only: "skills", agents: ["codex"] }, + environment(), + ); + const updated = receipt.targets.find( + (target) => target.id === "skill:agents:scaffold-project", + ); + const userEdited = receipt.targets.find( + (target) => target.id === "skill:claude:scaffold-project", + ); + + expect(updated).toMatchObject({ status: "installed", changed: true }); + expect(updated?.message).toContain("updated"); + expect(await readFile(managedPath, "utf8")).toContain("New managed version"); + expect(userEdited).toMatchObject({ status: "failed", changed: false }); + expect(userEdited?.message).toContain("Skill files changed after install"); + expect(await readFile(editedPath, "utf8")).toBe(editedContent); + }); + + it("is idempotent after a successful install", async () => { + await mkdir(join(homeDirectory, ".cursor"), { recursive: true }); + await writeFile(join(homeDirectory, ".cursor", "mcp.json"), '{\n "mcpServers": {}\n}\n'); + await runInstall({ agents: ["cursor"] }, environment()); + const before = await snapshotDirectory(homeDirectory); + + const second = await runInstall({ agents: ["cursor"] }, environment()); + + expect(second.summary.changed).toBe(0); + expect(second.summary.failed).toBe(0); + expect(second.targets.every((target) => target.status === "unchanged")).toBe(true); + expect(await snapshotDirectory(homeDirectory)).toEqual(before); + }); + + it("returns the stable machine-readable receipt shape used by --json", async () => { + const receipt = await runInstall( + { only: "mcp", agents: ["cursor"], dryRun: true }, + environment(), + ); + const encoded = JSON.parse(JSON.stringify(receipt)) as Record; + + expect(Object.keys(encoded)).toEqual([ + "schemaVersion", + "command", + "action", + "dryRun", + "success", + "selection", + "targets", + "summary", + "tryPrompt", + ]); + expect(encoded).toMatchObject({ + schemaVersion: 1, + command: "install", + action: "install", + dryRun: true, + success: true, + selection: { only: "mcp", agents: ["cursor"] }, + summary: { requested: 1, changed: 1, unchanged: 0, failed: 0 }, + }); + expect(receipt.targets.find((target) => target.id === "mcp:cursor")).toMatchObject({ + capability: "mcp", + status: "planned", + changed: true, + detected: false, + }); + }); + + it("leaves invalid JSON untouched and reports the target failure", async () => { + const configPath = join(homeDirectory, ".cursor", "mcp.json"); + const invalid = '{ "mcpServers": '; + await mkdir(join(homeDirectory, ".cursor"), { recursive: true }); + await writeFile(configPath, invalid); + + const receipt = await runInstall({ only: "mcp", agents: ["cursor"] }, environment()); + + expect(receipt.success).toBe(false); + expect(receipt.summary.failed).toBe(1); + expect(await readFile(configPath, "utf8")).toBe(invalid); + expect((await readdir(join(homeDirectory, ".cursor"))).sort()).toEqual(["mcp.json"]); + }); + + it("refuses to overwrite an existing zero-byte JSON config", async () => { + const configPath = join(homeDirectory, ".cursor", "mcp.json"); + await mkdir(join(homeDirectory, ".cursor"), { recursive: true }); + await writeFile(configPath, ""); + + const receipt = await runInstall({ only: "mcp", agents: ["cursor"] }, environment()); + + expect(receipt.success).toBe(false); + expect(receipt.targets[0]?.message).toBe( + `Config is not valid JSON; left it unchanged: ${configPath}`, + ); + expect(await readFile(configPath, "utf8")).toBe(""); + expect((await readdir(join(homeDirectory, ".cursor"))).sort()).toEqual(["mcp.json"]); + }); +}); diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index 725ceeec4..25e26b9bb 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ outDir: "dist", dts: true, noExternal: ["@better-fullstack/project-lifecycle"], + copy: [{ from: "../../plugin/skills", to: "dist/skills", flatten: false }], outputOptions: { banner: "#!/usr/bin/env node", }, diff --git a/apps/web/content/docs/ai/mcp.mdx b/apps/web/content/docs/ai/mcp.mdx index 33cc0b27d..ac7242ac8 100644 --- a/apps/web/content/docs/ai/mcp.mdx +++ b/apps/web/content/docs/ai/mcp.mdx @@ -2,11 +2,21 @@ title: MCP Server description: Connect Better Fullstack to Claude Code, Cursor, Codex, VS Code, and other MCP clients, and call its tools safely. translationStatus: pending -updated: 2026-08-22 +updated: 2026-08-31 --- The `mcp` command starts the Better Fullstack MCP server over stdio. Agents use it to discover stacks, inspect schemas, validate compatibility, preview generated files, create projects, and evolve existing ones through structured tool calls instead of guessing CLI flags. +Install the server and both Better Fullstack skills for every supported agent detected on your +machine: + +```bash +npx create-better-fullstack@latest install +``` + +See [`install`](/docs/cli/install/) for target detection, dry runs, JSON receipts, and uninstall. +The launcher commands below start the server directly and remain useful for manual setup. + -## Setup +## Manual setup One-line commands for agents reading this as Markdown: ```bash -claude mcp add --transport stdio better-fullstack -- npx -y create-better-fullstack@latest mcp +claude mcp add --scope user better-fullstack -- npx -y create-better-fullstack@latest mcp codex mcp add better-fullstack -- npx -y create-better-fullstack@latest mcp -gemini mcp add better-fullstack npx -y create-better-fullstack@latest mcp +gemini mcp add --scope user better-fullstack npx -y create-better-fullstack@latest mcp kimi mcp add better-fullstack -- npx -y create-better-fullstack@latest mcp code --add-mcp '{"name":"better-fullstack","command":"npx","args":["-y","create-better-fullstack@latest","mcp"]}' ``` -Clients that read a config file (Cursor, Claude Desktop, Zed, Windsurf, Goose) take the same stdio server: +Cursor, Windsurf, Claude Desktop, and other clients using `mcpServers` take the same stdio server: ```json { @@ -45,7 +55,38 @@ Clients that read a config file (Cursor, Claude Desktop, Zed, Windsurf, Goose) t } ``` -Cursor reads `.cursor/mcp.json`, VS Code uses `servers`, Zed uses `context_servers`, and opencode plus Kilo Code use `mcp` with `"type": "local"`. Swap the launcher per package manager: +Cursor's global file is `~/.cursor/mcp.json`; Windsurf uses +`~/.codeium/windsurf/mcp_config.json`. OpenCode's global +`~/.config/opencode/opencode.json` uses a different shape: + +```json +{ + "mcp": { + "better-fullstack": { + "type": "local", + "command": ["npx", "-y", "create-better-fullstack@latest", "mcp"], + "enabled": true + } + } +} +``` + +Zed uses `context_servers` in `~/.zed/settings.json` on macOS and +`~/.config/zed/settings.json` on Linux: + +```json +{ + "context_servers": { + "better-fullstack": { + "command": "npx", + "args": ["-y", "create-better-fullstack@latest", "mcp"] + } + } +} +``` + +VS Code uses `servers`; Kilo Code follows OpenCode's `mcp` shape. Swap the launcher per package +manager: | Package manager | `command` | `args` | | --------------- | --------- | -------------------------------------------------- | @@ -62,7 +103,9 @@ command = "npx" args = ["-y", "create-better-fullstack@latest", "mcp"] ``` -If your agent supports plugins, the [agent plugin](/docs/ai/overview/#agent-plugin) installs this server and two skills in one step. If it has shell access but no MCP connection, the [agent skill](/docs/ai/overview/#agent-skill) is lighter. +The automatic installer copies two skills alongside the MCP connection. The +[agent plugin](/docs/ai/overview/#agent-plugin) remains available for plugin marketplaces, and the +[agent skill](/docs/ai/overview/#agent-skill) documents the CLI-only path. ## Resources @@ -99,35 +142,35 @@ Call the live guidance and schema tools instead of trusting prose; the schema is ### Existing-project lifecycle -| Tool | Purpose | -| ------------------------------------ | ------------------------------------------------------------------------------------ | -| `bfs_get_project_status` | Read-only recognition, dependency/env diagnostics, provenance, upgrade summary. | -| `bfs_check_project` | Executes every generated target; build tools may write locks, caches, or artifacts. | -| `bfs_plan_doctor_fix` | Plans canonical graph/config drift repair and returns a current-state review token. | -| `bfs_apply_doctor_fix` | Applies the unchanged config repair in one recoverable transaction. | -| `bfs_plan_project_adoption` | Infers likely Stack Parts and uncertainty without writing a baseline. | -| `bfs_confirm_project_adoption` | Creates an unverified baseline only with the exact current-state token. | -| `bfs_plan_project_update` | Categorizes current-template drift and returns a bounded review token when eligible. | -| `bfs_apply_project_update` | Applies token-bound files transactionally with provenance-aware safeguards. | -| `bfs_list_project_recovery_points` | Lists recovery IDs, lifecycle status, integrity, and current restore safety. | -| `bfs_get_project_recovery_point` | Shows and validates one recovery point without writing. | -| `bfs_verify_project_recovery_point` | Rechecks one recovery point against its backups and current project state. | +| Tool | Purpose | +| ------------------------------------ | ------------------------------------------------------------------------------------------------- | +| `bfs_get_project_status` | Read-only recognition, dependency/env diagnostics, provenance, upgrade summary. | +| `bfs_check_project` | Executes every generated target; build tools may write locks, caches, or artifacts. | +| `bfs_plan_doctor_fix` | Plans canonical graph/config drift repair and returns a current-state review token. | +| `bfs_apply_doctor_fix` | Applies the unchanged config repair in one recoverable transaction. | +| `bfs_plan_project_adoption` | Infers likely Stack Parts and uncertainty without writing a baseline. | +| `bfs_confirm_project_adoption` | Creates an unverified baseline only with the exact current-state token. | +| `bfs_plan_project_update` | Categorizes current-template drift and returns a bounded review token when eligible. | +| `bfs_apply_project_update` | Applies token-bound files transactionally with provenance-aware safeguards. | +| `bfs_list_project_recovery_points` | Lists recovery IDs, lifecycle status, integrity, and current restore safety. | +| `bfs_get_project_recovery_point` | Shows and validates one recovery point without writing. | +| `bfs_verify_project_recovery_point` | Rechecks one recovery point against its backups and current project state. | | `bfs_prune_project_recovery_points` | Previews retention and returns a token; apply deletes only the reviewed eligible terminal points. | -| `bfs_recover_project_transaction` | Restores every bound path of a transaction exactly once. | -| `bfs_plan_part_removal` | Plans exact non-primary capability removal and returns a review token. | -| `bfs_apply_part_removal` | Applies a reviewed removal in a recoverable transaction. | -| `bfs_plan_primary_role_replacement` | Plans exact Primary Role replacement, owner rewiring, and migration boundaries. | -| `bfs_apply_primary_role_replacement` | Applies the reviewed replacement with its token and architecture acknowledgement. | -| `bfs_plan_stack_update` | Plans broad stack changes against `bts.jsonc`. | -| `bfs_apply_stack_update` | Applies a reviewed stack update, preserving user-edited generated files. | -| `bfs_plan_addition` | Plans focused Stack Part or deploy additions. | -| `bfs_add_feature` | Applies focused Stack Part or deploy additions after review. | -| `bfs_plan_gen` | Plans an in-project resource or route with exact file bodies and preimage hashes. | -| `bfs_apply_gen` | Applies the unchanged generation token in one recovery transaction. | -| `bfs_check_recipes` | Validates recipe-owned files and managed entries without executing code. | -| `bfs_get_recipe_history` | Correlates local recipe records with recovery transactions. | -| `bfs_plan_registry_add` | Plans a local capability pack, including files, dependencies, and metadata merges. | -| `bfs_apply_registry_add` | Applies the unchanged local-pack token without running a package manager. | +| `bfs_recover_project_transaction` | Restores every bound path of a transaction exactly once. | +| `bfs_plan_part_removal` | Plans exact non-primary capability removal and returns a review token. | +| `bfs_apply_part_removal` | Applies a reviewed removal in a recoverable transaction. | +| `bfs_plan_primary_role_replacement` | Plans exact Primary Role replacement, owner rewiring, and migration boundaries. | +| `bfs_apply_primary_role_replacement` | Applies the reviewed replacement with its token and architecture acknowledgement. | +| `bfs_plan_stack_update` | Plans broad stack changes against `bts.jsonc`. | +| `bfs_apply_stack_update` | Applies a reviewed stack update, preserving user-edited generated files. | +| `bfs_plan_addition` | Plans focused Stack Part or deploy additions. | +| `bfs_add_feature` | Applies focused Stack Part or deploy additions after review. | +| `bfs_plan_gen` | Plans an in-project resource or route with exact file bodies and preimage hashes. | +| `bfs_apply_gen` | Applies the unchanged generation token in one recovery transaction. | +| `bfs_check_recipes` | Validates recipe-owned files and managed entries without executing code. | +| `bfs_get_recipe_history` | Correlates local recipe records with recovery transactions. | +| `bfs_plan_registry_add` | Plans a local capability pack, including files, dependencies, and metadata merges. | +| `bfs_apply_registry_add` | Applies the unchanged local-pack token without running a package manager. | Planning, creation, and addition responses include graph metadata (`graphSummary`, `effectiveStack`, `stackPartSpecs`) alongside the generated file or mutation summary. diff --git a/apps/web/content/docs/ai/overview.mdx b/apps/web/content/docs/ai/overview.mdx index a5e29cb6b..f9f536abc 100644 --- a/apps/web/content/docs/ai/overview.mdx +++ b/apps/web/content/docs/ai/overview.mdx @@ -2,19 +2,28 @@ title: AI Agents description: How coding agents drive Better Fullstack through the MCP server, the agent plugin, the agent skill, or explicit CLI flags. translationStatus: pending -updated: 2026-08-22 +updated: 2026-08-31 --- -Better Fullstack gives coding agents four ways to reach the generator. All of them run the same code, so pick the narrowest one your agent supports. +Start with the automatic installer. It detects supported coding-agent CLIs and editors, registers +the MCP server, and copies both Better Fullstack skills: + +```bash +npx create-better-fullstack@latest install +``` + +All integration paths run the same generator code. Use the manual choices below when automatic +installation is unavailable or you want a narrower setup. ## Choose an integration -| Integration | Includes | Best for | -| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- | -| [MCP server](/docs/ai/mcp/) | Structured tools and resources over stdio | Clients that need schema lookup, validation, planning, and guarded writes | -| Agent plugin | The MCP server and two skills in one portable bundle | Agents with plugin or marketplace support | -| [Agent skill](#agent-skill) | A CLI-driven scaffold workflow | Agents with shell access and no MCP connection | -| Explicit CLI flags | Nothing to install | Any agent that can run a command | +| Integration | Includes | Best for | +| ----------------------------------- | -------------------------------------------------- | ----------------------------------- | +| [`bfs install`](/docs/cli/install/) | MCP and two skills for every detected target | The default machine setup | +| [MCP server](/docs/ai/mcp/) | Structured tools and resources over stdio | Manual client setup or MCP-only use | +| Agent plugin | The MCP server and two skills in a portable bundle | Agents with marketplace support | +| [Agent skill](#agent-skill) | A CLI-driven scaffold and update workflow | Agents with shell access and no MCP | +| Explicit CLI flags | Nothing to install | Any agent that can run a command | ## Preferred workflow @@ -70,7 +79,16 @@ npm create better-fullstack@latest my-app -- \ The skill teaches an agent to use the CLI instead of hand-writing starter files. It maps a requested stack to graph parts, dry-runs, scaffolds without install or Git side effects, then reports follow-up commands. -The skill lives in this repository at `.agents/skills/better-fullstack`. The repository holds internal skills too, so install only this one: +`bfs install` copies the `scaffold-project` and `add-to-project` skills to `~/.agents/skills/` and +`~/.claude/skills/`. Install only those folders with: + +```bash +npx create-better-fullstack@latest install --only skills +``` + +As a manual fallback, the repository also has a broader CLI workflow skill at +`.agents/skills/better-fullstack`. The repository holds internal skills too, so select only this +one when using the standalone skills installer: ` | Restrict targets. Repeat for several agents. | +| `--dry-run` | Print every command and file operation without writing or running commands. | +| `--json` | Print the versioned machine-readable receipt. | +| `--uninstall` | Remove only MCP entries and skill folders recorded as installer-owned. | +| `--yes` | Skip the interactive confirmation. | + +Agent names are `claude`, `codex`, `gemini`, `opencode`, `cursor`, `windsurf`, and `zed`. +`claude-code` and `gemini-cli` are accepted aliases. + +```bash +# Preview only Cursor and Codex +npx create-better-fullstack@latest install --agent cursor --agent codex --dry-run + +# Install skills without touching MCP configuration +npx create-better-fullstack@latest install --only skills --agent codex --yes + +# Remove only setup previously owned by this command +npx create-better-fullstack@latest install --uninstall +``` + +## Safety and receipts + +Existing JSON is parsed strictly before any change. Invalid JSON, a conflicting +`better-fullstack` entry, or a modified installer-owned skill folder is reported and left +untouched. Before an existing config changes, the command writes a timestamped backup beside it. +Localized JSON edits keep unrelated keys in their existing order and preserve their source bytes +where possible. + +The ownership receipt lives at `~/.config/better-fullstack/install-state.json`. It records target +ownership and content hashes, not config contents or secrets. It is what lets `--uninstall` leave +matching setup that existed before `bfs install` alone. Paths and config contents are never sent in +telemetry. + +Human output contains one line per target followed by a `Try:` prompt. `--json` returns the same +result as a versioned object with selection, target operations, and summary fields. A partial +success exits zero; the command exits non-zero only when every requested target fails. + +## Manual fallback + +If a target cannot run this installer, use the per-client commands and config examples in +[MCP Server](/docs/ai/mcp/#manual-setup). The two installed skills also include a CLI-only workflow +for agents that cannot reach the MCP server. diff --git a/apps/web/content/docs/cli/meta.json b/apps/web/content/docs/cli/meta.json index 4a90aa3b1..f3673450b 100644 --- a/apps/web/content/docs/cli/meta.json +++ b/apps/web/content/docs/cli/meta.json @@ -1,5 +1,5 @@ { "title": "CLI", "defaultOpen": false, - "pages": ["index", "create", "add", "update", "gen", "experimental", "telemetry"] + "pages": ["index", "install", "create", "add", "update", "gen", "experimental", "telemetry"] } diff --git a/apps/web/src/components/mcp/agent-command-tabs.tsx b/apps/web/src/components/mcp/agent-command-tabs.tsx index f51e3b90c..36a8b8093 100644 --- a/apps/web/src/components/mcp/agent-command-tabs.tsx +++ b/apps/web/src/components/mcp/agent-command-tabs.tsx @@ -37,7 +37,7 @@ export const AGENT_TABS: readonly AgentTab[] = [ label: "Claude Code", iconSlug: "claudecode", command: - "claude mcp add --transport stdio better-fullstack -- npx -y create-better-fullstack@latest mcp", + "claude mcp add --scope user better-fullstack -- npx -y create-better-fullstack@latest mcp", shell: true, }, { @@ -51,7 +51,8 @@ export const AGENT_TABS: readonly AgentTab[] = [ id: "gemini-cli", label: "Gemini CLI", iconSlug: "googlegemini", - command: "gemini mcp add better-fullstack npx -y create-better-fullstack@latest mcp", + command: + "gemini mcp add --scope user better-fullstack npx -y create-better-fullstack@latest mcp", shell: true, }, { @@ -86,7 +87,7 @@ export const AGENT_TABS: readonly AgentTab[] = [ iconSlug: "opencode", mono: true, command: LOCAL_SNIPPET, - target: "opencode.json (mcp)", + target: "~/.config/opencode/opencode.json (mcp)", shell: false, }, { @@ -135,8 +136,8 @@ export const AGENT_TABS: readonly AgentTab[] = [ iconSlug: "zedindustries", mono: true, command: - '"better-fullstack": { "command": { "path": "npx", "args": ["-y", "create-better-fullstack@latest", "mcp"] } }', - target: "settings.json (context_servers)", + '"better-fullstack": { "command": "npx", "args": ["-y", "create-better-fullstack@latest", "mcp"] }', + target: "~/.zed/settings.json (context_servers)", shell: false, }, ] as const; diff --git a/apps/web/src/lib/stack/constant.ts b/apps/web/src/lib/stack/constant.ts index 28f545699..cb2e0b799 100644 --- a/apps/web/src/lib/stack/constant.ts +++ b/apps/web/src/lib/stack/constant.ts @@ -1086,7 +1086,7 @@ export const TECH_OPTIONS: Record< id: "xendit", name: "Xendit", description: "Payment Sessions for Southeast Asian payment methods and currencies", - icon: "https://www.xendit.co/favicon.ico", + icon: "https://github.com/xendit.png", color: "from-blue-500 to-indigo-700", default: false, }, diff --git a/apps/web/src/lib/stack/tech-icons.ts b/apps/web/src/lib/stack/tech-icons.ts index fe73eec15..c0fe7845d 100644 --- a/apps/web/src/lib/stack/tech-icons.ts +++ b/apps/web/src/lib/stack/tech-icons.ts @@ -75,7 +75,7 @@ export const ICON_REGISTRY: Record = { ga4: { type: "si", slug: "googleanalytics", hex: "E37400" }, "vercel-analytics": { type: "si", slug: "vercel", hex: "000000" }, paypal: { type: "si", slug: "paypal", hex: "003087" }, - xendit: { type: "local", src: "https://www.xendit.co/favicon.ico" }, + xendit: { type: "local", src: "https://github.com/xendit.png" }, medusa: { type: "si", slug: "medusa", hex: "000000" }, electron: { type: "si", slug: "electron", hex: "47848F" }, capacitor: { type: "si", slug: "capacitor", hex: "119EFF" }, diff --git a/apps/web/src/routes/mcp.tsx b/apps/web/src/routes/mcp.tsx index 969a29876..4cb6f79b2 100644 --- a/apps/web/src/routes/mcp.tsx +++ b/apps/web/src/routes/mcp.tsx @@ -62,6 +62,7 @@ export const Route = createFileRoute("/mcp")({ }); const ACCENT_TEXT = "text-black dark:text-[#C6E853]"; +const AUTO_INSTALL_COMMAND = "npx create-better-fullstack@latest install"; interface Agent { id: string; @@ -85,7 +86,7 @@ const AGENTS: readonly Agent[] = [ shell: true, iconSlug: "claudecode", config: - "claude mcp add --transport stdio better-fullstack -- npx -y create-better-fullstack@latest mcp", + "claude mcp add --scope user better-fullstack -- npx -y create-better-fullstack@latest mcp", }, { id: "codex", @@ -118,7 +119,8 @@ const AGENTS: readonly Agent[] = [ file: "terminal", shell: true, iconSlug: "googlegemini", - config: "gemini mcp add better-fullstack npx -y create-better-fullstack@latest mcp", + config: + "gemini mcp add --scope user better-fullstack npx -y create-better-fullstack@latest mcp", }, { id: "cursor", @@ -202,7 +204,7 @@ const AGENTS: readonly Agent[] = [ { id: "opencode", name: "OpenCode", - file: "opencode.json", + file: "~/.config/opencode/opencode.json", shell: false, iconSlug: "opencode", mono: true, @@ -235,17 +237,15 @@ const AGENTS: readonly Agent[] = [ { id: "zed", name: "Zed", - file: "settings.json", + file: "~/.zed/settings.json", shell: false, iconSlug: "zedindustries", mono: true, config: `{ "context_servers": { "better-fullstack": { - "command": { - "path": "npx", - "args": ["-y", "create-better-fullstack@latest", "mcp"] - } + "command": "npx", + "args": ["-y", "create-better-fullstack@latest", "mcp"] } } }`, @@ -559,8 +559,20 @@ function StatCell({ function AgentInstallCard() { const [agentId, setAgentId] = useState(AGENTS[0].id); const [copied, setCopied] = useState(false); + const [autoCopied, setAutoCopied] = useState(false); const agent = AGENTS.find((a) => a.id === agentId) ?? AGENTS[0]; + const copyAutoInstall = useCallback(() => { + navigator.clipboard.writeText(AUTO_INSTALL_COMMAND).then( + () => { + setAutoCopied(true); + window.setTimeout(() => setAutoCopied(false), 1600); + return; + }, + () => {}, + ); + }, []); + const copyConfig = useCallback(() => { const config = AGENTS.find((a) => a.id === agentId)?.config ?? ""; navigator.clipboard.writeText(config).then( @@ -581,6 +593,37 @@ function AgentInstallCard() { return (
+
+ + terminal + + +
+
+          
+            $ 
+            {AUTO_INSTALL_COMMAND}
+          
+        
+
+ +

+ {m.mcpRunTerminal()} +

+ +
{AGENTS.map((a) => ( diff --git a/apps/web/test/docs-content-contract.test.ts b/apps/web/test/docs-content-contract.test.ts index 58b2aadeb..a0f11c4ae 100644 --- a/apps/web/test/docs-content-contract.test.ts +++ b/apps/web/test/docs-content-contract.test.ts @@ -48,6 +48,7 @@ const PENDING_TRANSLATION_PATHS = [ "content/docs/cli/experimental.mdx", "content/docs/cli/gen.mdx", "content/docs/cli/index.mdx", + "content/docs/cli/install.mdx", "content/docs/cli/telemetry.mdx", "content/docs/cli/update.mdx", "content/docs/ecosystems/index.mdx", diff --git a/apps/web/test/docs-navigation.test.ts b/apps/web/test/docs-navigation.test.ts index 387d9087c..218c8a7c8 100644 --- a/apps/web/test/docs-navigation.test.ts +++ b/apps/web/test/docs-navigation.test.ts @@ -28,6 +28,7 @@ describe("docs navigation", () => { const ecosystemsMeta = await readJson<{ pages: string[] }>("ecosystems/meta.json"); expect(cliMeta.pages).toEqual([ "index", + "install", "create", "add", "update", @@ -43,6 +44,7 @@ describe("docs navigation", () => { it("keeps linked milestone docs backed by MDX files", async () => { await expectDocPage("choosing-a-stack.mdx"); await expectDocPage("cli/index.mdx"); + await expectDocPage("cli/install.mdx"); await expectDocPage("cli/update.mdx"); await expectDocPage("cli/gen.mdx"); await expectDocPage("cli/experimental.mdx"); diff --git a/plugin/README.md b/plugin/README.md index 59c24b541..5a60a0cfb 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -18,6 +18,17 @@ The portable manifest uses the open Agent Plugins 1.0 format. Compatible clients from `skills/` and the MCP server from `mcp.json`. The client-specific Codex and Claude manifests remain in place for clients that have not adopted the portable format. +## Install + +The CLI detects supported agents and editors, connects the MCP server, and copies both skills: + +```bash +npx create-better-fullstack@latest install +``` + +Use `--dry-run` to inspect every command and file first. The client-specific plugin and MCP setup +below remains available as a manual fallback. + ## How Agents Should Use It 1. Resolve the user's intent and pick sensible defaults only when the request is underspecified. @@ -27,7 +38,7 @@ remain in place for clients that have not adopted the portable format. 5. Call `bfs_create_project` or `bfs_apply_stack_update` only after the plan matches the request. 6. Keep installs disabled during agent scaffolding and report the exact install/test/dev commands. -## Claude Code +## Manual Claude Code plugin install Add this repository as a Claude Code plugin marketplace, then install the plugin: @@ -41,12 +52,12 @@ You can also install it from Claude Code's interactive `/plugin` flow. Claude Code namespaces the bundled skills as `better-fullstack:scaffold-project` and `better-fullstack:add-to-project`. -## Codex +## Manual Codex plugin install Use the repo marketplace catalog at `.agents/plugins/marketplace.json`. It points at this -shared plugin bundle through the repo-root relative `./plugin` source. +shared plugin bundle through the repo-root-relative `./plugin` source. -## MCP Server Only +## Manual MCP server setup Any MCP client can run: diff --git a/plugin/skills/add-to-project/SKILL.md b/plugin/skills/add-to-project/SKILL.md index 46ae125b3..7f6364985 100644 --- a/plugin/skills/add-to-project/SKILL.md +++ b/plugin/skills/add-to-project/SKILL.md @@ -33,3 +33,10 @@ metadata or guess which template files belong to a capability. explicit. - Set installs disabled unless the user asks for dependency installation. - Do not start a dev server. + +## CLI-only fallback + +If the Better Fullstack MCP tools are unavailable, inspect the current flags with +`npx -y create-better-fullstack@latest add --help`, then run the explicit add command with +`--project-dir --dry-run --no-install`. Review that preview before rerunning without +`--dry-run`. Do not guess option names or hand-edit generated stack metadata. diff --git a/plugin/skills/scaffold-project/SKILL.md b/plugin/skills/scaffold-project/SKILL.md index 49ac43d0f..a2ad0190d 100644 --- a/plugin/skills/scaffold-project/SKILL.md +++ b/plugin/skills/scaffold-project/SKILL.md @@ -46,6 +46,13 @@ framework folders, auth wiring, database wiring, or generated project structure. - Python API: `ecosystem: "python"`, then choose `pythonWebFramework` and related Python fields - Rust API: `ecosystem: "rust"`, then choose `rustWebFramework` and related Rust fields +## CLI-only fallback + +If the Better Fullstack MCP tools are unavailable, inspect the current flags with +`npx -y create-better-fullstack@latest create --help`, then run the explicit scaffold command with +`--dry-run --no-install --no-git`. Review that preview before rerunning without `--dry-run`. Do not +guess option names or hand-write the scaffold. + ## Final Response Say what command/tool path was used, what compatibility adjustments were made, where the project was