UNPKG

@kya-os/cli

Version:

CLI for MCP-I setup and management

740 lines 31.8 kB
import { execFile } from "child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { promisify } from "util"; import { readAgentMetadata, writeAgentMetadata } from "./agent-metadata.js"; import { buildAttributionBlock, updateClaudeSettings, upsertClaudeMdBlock, } from "./claude-config.js"; import { detectSigningConflicts, getGitConfigValue, installTrailerHook, isGitRepo, setGitConfig, } from "./git-config.js"; import { GhNotInstalledError, GhScopeError, getGhUser, githubNoreplyEmail, isGhAuthenticated, isGhInstalled, listSigningKeys, refreshAuthScope, signingKeyAlreadyUploaded, uploadSigningKey, SCOPE_REFRESH_COMMAND, } from "./github.js"; import { isValidSlug, keysDir, signingKeyPath } from "./paths.js"; import { convertEd25519ToSshKey } from "./ssh-key.js"; const execFileAsync = promisify(execFile); function slugify(value) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } /** * Strip control characters (including CR/LF) and collapse whitespace. * Names and emails end up inside a generated shell hook and git trailers, * where an embedded newline would corrupt the trailer format. */ function sanitizeIdentityValue(value) { // eslint-disable-next-line no-control-regex return value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim(); } /** * Distinguishes a missing identity from a present-but-unparseable one. The * two need different advice: "run mcpi register" after a corrupt read would * overwrite a potentially recoverable key with a brand new identity. */ function readIdentityFile(cwd) { const path = join(cwd, ".mcpi", "identity.json"); if (!existsSync(path)) { return { status: "missing" }; } try { const parsed = JSON.parse(readFileSync(path, "utf-8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return { status: "ok", identity: parsed }; } return { status: "corrupt" }; } catch { return { status: "corrupt" }; } } /** * A tracked identity file is a red flag: identity.json holds a private key * and is documented as never-commit. In a freshly cloned repo it means the * "identity" was shipped by whoever authored the repo, and uploading that * key to the user's GitHub account would let the repo author forge Verified * commits as the user. */ async function isIdentityTrackedByGit(cwd) { try { await execFileAsync("git", ["ls-files", "--error-unmatch", ".mcpi/identity.json"], { cwd, encoding: "utf8" }); return true; } catch { return false; } } /** * The PR byline links to the agent's registry page, so its host must be the * trusted registry. A url from .mcpi/agent.json in an untrusted repo could * otherwise point the byline at an arbitrary off-site (phishing) link while * signing still uses the local key. */ function isTrustedAgentUrl(value) { try { const url = new URL(value); if (url.protocol !== "https:") { return false; } const host = url.hostname.toLowerCase(); return host === "knowthat.ai" || host.endsWith(".knowthat.ai"); } catch { return false; } } async function confirm(message, def) { const inquirer = (await import("inquirer")).default; const { answer } = await inquirer.prompt([ { type: "confirm", name: "answer", message, default: def }, ]); return answer; } async function promptForAgentName() { const inquirer = (await import("inquirer")).default; const { name } = await inquirer.prompt([ { type: "input", name: "name", message: "Agent name (as it should appear in PR attribution):", validate: (input) => input.trim().length > 0 || "Agent name is required", }, ]); const { slug } = await inquirer.prompt([ { type: "input", name: "slug", message: "Agent slug (used in the knowthat.ai URL):", default: slugify(name), validate: (input) => /^[a-z0-9][a-z0-9-]*$/.test(input) || "Slug must be lowercase letters, numbers, and dashes", }, ]); return { name: name.trim(), slug }; } /** * Orchestrates the full DCO/attribution setup. Steps run in order; the three * foundation steps (preflight, identities, key material) abort the run when * they fail, everything after degrades independently so one broken layer * never blocks the others. */ export async function runDcoSetup(options = {}) { const cwd = options.cwd ?? process.cwd(); const interactive = options.interactive ?? (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)); const steps = []; const result = { ok: true, actionRequired: false, steps, }; const abort = (remaining) => { for (const step of remaining) { steps.push({ id: step.id, title: step.title, status: "skipped", detail: "Prerequisite step failed", }); } result.ok = false; result.actionRequired = steps.some((s) => s.status === "action-required"); return result; }; const laterSteps = [ { id: "github-upload", title: "Upload signing key to GitHub" }, { id: "git-config", title: "Configure git commit signing" }, { id: "trailer-hook", title: "Install commit trailer hook" }, { id: "claude-config", title: "Configure Claude Code attribution" }, { id: "verify", title: "Verify signing works" }, ]; // Step 1: preflight if (!(await isGitRepo(cwd))) { steps.push({ id: "preflight", title: "Preflight checks", status: "failed", detail: "Not inside a git repository. Run this from your project root.", }); return abort([ { id: "identities", title: "Resolve agent and human identity" }, { id: "key-material", title: "Write SSH signing key" }, ...laterSteps, ]); } const identityRead = readIdentityFile(cwd); const identity = identityRead.status === "ok" ? identityRead.identity : null; let preflightFailure = null; if (identityRead.status === "corrupt") { preflightFailure = ".mcpi/identity.json exists but could not be parsed. Do NOT re-run `mcpi register` (it would overwrite the identity); inspect or restore the file first."; } else if (!identity?.privateKey || !identity?.did) { preflightFailure = identityRead.status === "ok" ? ".mcpi/identity.json is missing required fields (did, privateKey). Inspect or restore the file before continuing." : "No agent identity found in .mcpi/identity.json. Run `mcpi register` first, then re-run `mcpi dco setup`."; } else if (await isIdentityTrackedByGit(cwd)) { preflightFailure = ".mcpi/identity.json is tracked by git. A committed identity may have been shipped by the repo author; refusing to configure signing with it. Remove it from git history and generate your own identity with `mcpi register`."; } if (preflightFailure || !identity?.privateKey || !identity?.did) { steps.push({ id: "preflight", title: "Preflight checks", status: "failed", detail: preflightFailure ?? "No agent identity found in .mcpi/identity.json. Run `mcpi register` first, then re-run `mcpi dco setup`.", }); return abort([ { id: "identities", title: "Resolve agent and human identity" }, { id: "key-material", title: "Write SSH signing key" }, ...laterSteps, ]); } steps.push({ id: "preflight", title: "Preflight checks", status: "done" }); // Step 2: resolve identities let agent; let human; try { const metadata = readAgentMetadata(cwd); if (metadata?.did && metadata.did !== identity.did) { throw new Error(`.mcpi/agent.json is for a different agent (${metadata.did}) than .mcpi/identity.json (${identity.did}). Fix or remove agent.json before continuing.`); } // Only trust agent.json for attribution when it is cryptographically bound // to this identity (its did matches identity.json). Unbound metadata (no // did, e.g. planted in an untrusted repo) is ignored so it cannot spoof the // agent name/slug/url; the resolved values are then persisted with the real // did, healing the binding for later runs. const boundMetadata = metadata && metadata.did === identity.did ? metadata : null; let name = boundMetadata?.name ?? process.env.AGENT_NAME; let slug = boundMetadata?.slug ?? process.env.AGENT_SLUG; if ((!name || !slug) && interactive && !options.dryRun) { const prompted = await promptForAgentName(); name = name ?? prompted.name; slug = slug ?? prompted.slug; } if (!name || !slug) { throw new Error("Agent name/slug unknown. Run `mcpi register`, or set AGENT_NAME and AGENT_SLUG."); } // The interactive prompt validates its own input; values arriving from // agent.json or the environment must pass the same shape check because // the slug becomes a filename under the keys directory. if (!isValidSlug(slug)) { throw new Error(`Invalid agent slug "${slug}": must be lowercase letters, numbers, and dashes.`); } name = sanitizeIdentityValue(name); const metadataUrl = boundMetadata?.url ? sanitizeIdentityValue(boundMetadata.url) : undefined; agent = { did: identity.did, name, slug, url: metadataUrl && isTrustedAgentUrl(metadataUrl) ? metadataUrl : `https://knowthat.ai/agents/${slug}`, email: `${slug}@agents.knowthat.ai`, }; // Resolve the human name and email INDEPENDENTLY, each preferring the // configured git value and only falling back to gh for the field that is // missing. Resolving them together would let a missing email drop a // configured name (or vice versa) in favor of the GitHub profile. // Reading gh here is unrelated to the key upload, so it stays available // even under --skip-github (which only suppresses the upload). const gitName = await getGitConfigValue("user.name", cwd); const gitEmail = await getGitConfigValue("user.email", cwd); let ghUser = null; if ((!gitName || !gitEmail) && (await isGhInstalled()) && (await isGhAuthenticated())) { ghUser = await getGhUser(); } // Use || (not ??) so an explicitly-empty git value is treated as missing, // consistent with the truthiness gate above that decides to fetch gh. const resolvedName = gitName || (ghUser ? ghUser.name || ghUser.login : undefined); const resolvedEmail = gitEmail || (ghUser ? githubNoreplyEmail(ghUser) : undefined); if (!resolvedName || !resolvedEmail) { throw new Error("Could not resolve your git identity. Set git config user.name and user.email, or authenticate gh."); } human = { name: sanitizeIdentityValue(resolvedName), email: sanitizeIdentityValue(resolvedEmail), }; result.agent = agent; result.human = human; // Persist resolved metadata when the bound record was incomplete (prompted, // from env, or unbound), so a later non-interactive run resolves the agent // without prompting AND the did binding is written for future runs. // Best-effort: a cache-write failure must not fail identity resolution. if (!options.dryRun && (!boundMetadata?.name || !boundMetadata?.slug || !boundMetadata?.did)) { try { writeAgentMetadata(cwd, { name: agent.name, slug: agent.slug, url: agent.url, did: agent.did, }); } catch (error) { result.metadataPersistWarning = error instanceof Error ? error.message : String(error); } } steps.push({ id: "identities", title: "Resolve agent and human identity", status: "done", detail: `agent=${agent.name} (${agent.slug}), human=${human.name} <${human.email}>`, }); } catch (error) { steps.push({ id: "identities", title: "Resolve agent and human identity", status: "failed", detail: error instanceof Error ? error.message : String(error), }); return abort([ { id: "key-material", title: "Write SSH signing key" }, ...laterSteps, ]); } // Step 3: key material let keyMaterial; // Slug was validated in the identities step; signingKeyPath re-checks and // always yields a path inside the keys directory. const privateKeyPath = signingKeyPath(agent.slug); const publicKeyPath = `${privateKeyPath}.pub`; // Only the write branch below sets this; a failure before it (e.g. a bad // identity.json that fails conversion) must NOT delete pre-existing keys. let startedKeyWrite = false; try { keyMaterial = convertEd25519ToSshKey(identity.privateKey, `mcpi:${agent.slug}`, { expectedPublicKeyBase64: identity.publicKey }); result.privateKeyPath = privateKeyPath; result.fingerprint = keyMaterial.fingerprint; // Compare BOTH files against the (now deterministic) expected material, so // a corrupt/truncated private key with an intact .pub is detected and // rewritten rather than left in place to fail signing forever. const privUpToDate = existsSync(privateKeyPath) && readFileSync(privateKeyPath, "utf-8") === keyMaterial.privateKeyPem; const pubUpToDate = existsSync(publicKeyPath) && readFileSync(publicKeyPath, "utf-8").trim() === keyMaterial.publicKeyLine; if (privUpToDate && pubUpToDate) { steps.push({ id: "key-material", title: "Write SSH signing key", status: "done", detail: `Already present at ${privateKeyPath}`, }); } else if (options.dryRun) { steps.push({ id: "key-material", title: "Write SSH signing key", status: "would-change", detail: `Would write ${privateKeyPath} and ${publicKeyPath}`, }); } else { mkdirSync(keysDir(), { recursive: true, mode: 0o700 }); startedKeyWrite = true; // mode on write avoids any window where the key exists with loose // permissions; the chmod covers overwrites of a pre-existing file, // where the mode option is ignored. writeFileSync(privateKeyPath, keyMaterial.privateKeyPem, { mode: 0o600 }); chmodSync(privateKeyPath, 0o600); writeFileSync(publicKeyPath, keyMaterial.publicKeyLine + "\n", { mode: 0o644, }); chmodSync(publicKeyPath, 0o644); steps.push({ id: "key-material", title: "Write SSH signing key", status: "done", detail: `${privateKeyPath} (${keyMaterial.fingerprint})`, }); } } catch (error) { // Clean up only a key pair THIS run started writing (a half-written pair); // a failure before the write (e.g. bad identity.json) must leave any // pre-existing valid keys intact so signing is not silently broken. if (!options.dryRun && startedKeyWrite) { rmSync(privateKeyPath, { force: true }); rmSync(publicKeyPath, { force: true }); } steps.push({ id: "key-material", title: "Write SSH signing key", status: "failed", detail: error instanceof Error ? error.message : String(error), }); return abort(laterSteps); } // Step 4: GitHub upload if (options.skipGithub) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "skipped", detail: "--skip-github", }); } else { try { if (!(await isGhInstalled())) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "action-required", detail: "GitHub CLI not found. Install from https://cli.github.com, run `gh auth login`, then re-run `mcpi dco setup`.", }); } else if (!(await isGhAuthenticated())) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "action-required", detail: "Not authenticated. Run `gh auth login`, then re-run `mcpi dco setup`.", }); } else { let keys; try { keys = await listSigningKeys(); } catch (error) { if (error instanceof GhScopeError && interactive && !options.dryRun && (await confirm(`GitHub token is missing the signing key scope. Run \`${SCOPE_REFRESH_COMMAND}\` now?`, true)) && (await refreshAuthScope())) { keys = await listSigningKeys(); } else { throw error; } } if (signingKeyAlreadyUploaded(keys, keyMaterial.publicKeyLine)) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "done", detail: `Already uploaded (${keyMaterial.fingerprint})`, }); } else if (options.dryRun) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "would-change", detail: `Would upload ${keyMaterial.fingerprint} as mcpi:${agent.slug}`, }); } else if (interactive && !(await confirm(`Upload signing key ${keyMaterial.fingerprint} for agent "${agent.name}" (${agent.did}) to your GitHub account?`, true))) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "action-required", detail: "Upload declined. Re-run `mcpi dco setup` when you are ready to upload the key.", }); } else { await uploadSigningKey(keyMaterial.publicKeyLine, `mcpi:${agent.slug}`); steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "done", detail: `Uploaded as mcpi:${agent.slug} (${keyMaterial.fingerprint})`, }); } } } catch (error) { if (error instanceof GhScopeError) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "action-required", detail: `${error.message} Then re-run \`mcpi dco setup\`.`, }); } else if (error instanceof GhNotInstalledError) { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "action-required", detail: error.message, }); } else { steps.push({ id: "github-upload", title: "Upload signing key to GitHub", status: "failed", detail: error instanceof Error ? error.message : String(error), }); } } } // Step 5: git config const desiredConfig = { "gpg.format": "ssh", "user.signingkey": privateKeyPath, "commit.gpgsign": "true", }; try { const conflicts = options.force ? [] : await detectSigningConflicts(cwd, desiredConfig); if (conflicts.length > 0) { const detail = conflicts .map((c) => `${c.key} is "${c.current}" (wanted "${c.desired}")`) .join("; "); steps.push({ id: "git-config", title: "Configure git commit signing", status: "action-required", detail: `Existing signing config differs: ${detail}. Re-run with --force to overwrite.`, }); } else if (options.dryRun) { // Surface the same repo-local shadow that a real --global write would // hit, so the preview does not look clean while an actual run ends in // action-required. const localShadow = options.global ? await detectSigningConflicts(cwd, desiredConfig, "local") : []; const base = Object.entries(desiredConfig) .map(([k, v]) => `${k}=${v}`) .join(", "); if (localShadow.length > 0) { const detail = localShadow .map((c) => `${c.key}="${c.current}"`) .join("; "); steps.push({ id: "git-config", title: "Configure git commit signing", status: "action-required", detail: `${base}; but repo-local git config would still shadow the global write: ${detail}. Clear these local values or re-run without --global.`, }); } else { steps.push({ id: "git-config", title: "Configure git commit signing", status: "would-change", detail: base, }); } } else { await setGitConfig(desiredConfig, { cwd, global: options.global }); // A repo-local value overrides a global one, so a --global write can be // silently shadowed by repo-local config that --force does not touch. const localShadow = options.global ? await detectSigningConflicts(cwd, desiredConfig, "local") : []; if (localShadow.length > 0) { const detail = localShadow .map((c) => `${c.key}="${c.current}"`) .join("; "); steps.push({ id: "git-config", title: "Configure git commit signing", status: "action-required", detail: `Global signing config written, but repo-local git config still shadows it: ${detail}. Clear these local values or re-run without --global.`, }); } else { steps.push({ id: "git-config", title: "Configure git commit signing", status: "done", detail: `${options.global ? "global" : "repo-local"}: gpg.format=ssh, commit.gpgsign=true`, }); } } } catch (error) { steps.push({ id: "git-config", title: "Configure git commit signing", status: "failed", detail: error instanceof Error ? error.message : String(error), }); } // Step 6: trailer hook let hookFallback = false; // The hook-problem part only; whether the trailers actually reached CLAUDE.md // is not known until the Claude step runs, so that claim is appended below // rather than asserted here (it would be false under --skip-claude or if the // Claude step fails). let hookFallbackDetail = ""; try { const hook = await installTrailerHook(cwd, human, { name: agent.name, email: agent.email }, { dryRun: options.dryRun }); if (hook.result === "foreign-hook") { hookFallback = true; hookFallbackDetail = `A prepare-commit-msg hook already exists at ${hook.path}; merge it manually if you want enforced trailers.`; steps.push({ id: "trailer-hook", title: "Install commit trailer hook", status: "action-required", detail: hookFallbackDetail, }); } else if (hook.result === "unsafe-hooks-path") { hookFallback = true; hookFallbackDetail = `core.hooksPath points outside this repository (${hook.path}); refusing to write there.`; steps.push({ id: "trailer-hook", title: "Install commit trailer hook", status: "action-required", detail: hookFallbackDetail, }); } else { steps.push({ id: "trailer-hook", title: "Install commit trailer hook", status: hook.result === "would-change" ? "would-change" : "done", detail: `${hook.result} at ${hook.path}`, }); } } catch (error) { hookFallback = true; hookFallbackDetail = error instanceof Error ? error.message : String(error); steps.push({ id: "trailer-hook", title: "Install commit trailer hook", status: "failed", detail: hookFallbackDetail, }); } // Step 7: Claude Code config if (options.skipClaude) { steps.push({ id: "claude-config", title: "Configure Claude Code attribution", status: "skipped", detail: "--skip-claude", }); } else { try { const block = buildAttributionBlock({ agentName: agent.name, agentUrl: agent.url, includeTrailerInstructions: hookFallback, humanName: human.name, humanEmail: human.email, agentEmail: agent.email, }); const md = upsertClaudeMdBlock(cwd, block, { dryRun: options.dryRun }); const settings = updateClaudeSettings(cwd, { dryRun: options.dryRun }); if (settings.result === "skipped-invalid") { steps.push({ id: "claude-config", title: "Configure Claude Code attribution", status: "action-required", detail: `CLAUDE.md ${md.result}; ${settings.path} could not be parsed, set "includeCoAuthoredBy": false manually.`, }); } else { const changed = md.result === "would-change" || settings.result === "would-change"; steps.push({ id: "claude-config", title: "Configure Claude Code attribution", status: changed ? "would-change" : "done", detail: `CLAUDE.md ${md.result}, settings.json ${settings.result}`, }); } } catch (error) { steps.push({ id: "claude-config", title: "Configure Claude Code attribution", status: "failed", detail: error instanceof Error ? error.message : String(error), }); } } // Now that the Claude step has run (or been skipped), state truthfully // whether the trailer fallback actually reached CLAUDE.md. The trailers are // only in the block when the Claude step wrote it (includeTrailerInstructions // was hookFallback); under --skip-claude or a Claude-step failure they were // not written anywhere. if (hookFallback) { const trailerStep = steps.find((s) => s.id === "trailer-hook"); const claudeStatus = steps.find((s) => s.id === "claude-config")?.status; let outcome; if (claudeStatus === "done") { outcome = "Trailer instructions were added to the CLAUDE.md attribution block instead."; } else if (claudeStatus === "would-change") { outcome = "Trailer instructions would be added to the CLAUDE.md attribution block instead."; } else { outcome = `Trailers were NOT added to CLAUDE.md (${options.skipClaude ? "--skip-claude" : "the Claude Code step did not complete"}); add Signed-off-by / Co-authored-by manually or re-run without --skip-claude.`; } if (trailerStep) { trailerStep.detail = `${hookFallbackDetail} ${outcome}`; } } // Step 8: verify if (options.dryRun) { steps.push({ id: "verify", title: "Verify signing works", status: "skipped", detail: "dry run", }); } else { // Private temp dir: a predictable path in the shared tmpdir would be // vulnerable to symlink games from other local users. const verifyDir = mkdtempSync(join(tmpdir(), "mcpi-dco-verify-")); const payloadPath = join(verifyDir, "payload"); try { writeFileSync(payloadPath, "mcpi dco verification payload\n"); await execFileAsync("ssh-keygen", [ "-Y", "sign", "-f", privateKeyPath, "-n", "git", payloadPath, ]); steps.push({ id: "verify", title: "Verify signing works", status: "done", detail: `Test signature created with ${keyMaterial.fingerprint}`, }); } catch (error) { const err = error; steps.push({ id: "verify", title: "Verify signing works", status: "action-required", detail: err?.code === "ENOENT" ? "ssh-keygen not found on PATH. Git needs OpenSSH 8.0+ to sign commits." : `Test signature failed: ${err?.stderr?.trim() || err?.message}`, }); } finally { rmSync(verifyDir, { recursive: true, force: true }); } } result.ok = !steps.some((s) => s.status === "failed"); result.actionRequired = steps.some((s) => s.status === "action-required"); return result; } //# sourceMappingURL=setup.js.map