@kya-os/cli
Version:
CLI for MCP-I setup and management
740 lines • 31.8 kB
JavaScript
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