@kya-os/cli 1.5.9 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/commands/dco.d.ts +18 -0
  2. package/dist/commands/dco.d.ts.map +1 -0
  3. package/dist/commands/dco.js +107 -0
  4. package/dist/commands/dco.js.map +1 -0
  5. package/dist/commands/init.d.ts +2 -0
  6. package/dist/commands/init.d.ts.map +1 -1
  7. package/dist/commands/init.js +76 -7
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/register.d.ts +2 -0
  10. package/dist/commands/register.d.ts.map +1 -1
  11. package/dist/commands/register.js +50 -0
  12. package/dist/commands/register.js.map +1 -1
  13. package/dist/index.js +7 -0
  14. package/dist/utils/dco/agent-metadata.d.ts +24 -0
  15. package/dist/utils/dco/agent-metadata.d.ts.map +1 -0
  16. package/dist/utils/dco/agent-metadata.js +46 -0
  17. package/dist/utils/dco/agent-metadata.js.map +1 -0
  18. package/dist/utils/dco/claude-config.d.ts +45 -0
  19. package/dist/utils/dco/claude-config.d.ts.map +1 -0
  20. package/dist/utils/dco/claude-config.js +112 -0
  21. package/dist/utils/dco/claude-config.js.map +1 -0
  22. package/dist/utils/dco/fs-safe.d.ts +14 -0
  23. package/dist/utils/dco/fs-safe.d.ts.map +1 -0
  24. package/dist/utils/dco/fs-safe.js +20 -0
  25. package/dist/utils/dco/fs-safe.js.map +1 -0
  26. package/dist/utils/dco/git-config.d.ts +47 -0
  27. package/dist/utils/dco/git-config.d.ts.map +1 -0
  28. package/dist/utils/dco/git-config.js +159 -0
  29. package/dist/utils/dco/git-config.js.map +1 -0
  30. package/dist/utils/dco/github.d.ts +49 -0
  31. package/dist/utils/dco/github.d.ts.map +1 -0
  32. package/dist/utils/dco/github.js +154 -0
  33. package/dist/utils/dco/github.js.map +1 -0
  34. package/dist/utils/dco/paths.d.ts +16 -0
  35. package/dist/utils/dco/paths.d.ts.map +1 -0
  36. package/dist/utils/dco/paths.js +27 -0
  37. package/dist/utils/dco/paths.js.map +1 -0
  38. package/dist/utils/dco/render.d.ts +4 -0
  39. package/dist/utils/dco/render.d.ts.map +1 -0
  40. package/dist/utils/dco/render.js +52 -0
  41. package/dist/utils/dco/render.js.map +1 -0
  42. package/dist/utils/dco/setup.d.ts +47 -0
  43. package/dist/utils/dco/setup.d.ts.map +1 -0
  44. package/dist/utils/dco/setup.js +740 -0
  45. package/dist/utils/dco/setup.js.map +1 -0
  46. package/dist/utils/dco/ssh-key.d.ts +43 -0
  47. package/dist/utils/dco/ssh-key.d.ts.map +1 -0
  48. package/dist/utils/dco/ssh-key.js +176 -0
  49. package/dist/utils/dco/ssh-key.js.map +1 -0
  50. package/dist/utils/dco/status.d.ts +12 -0
  51. package/dist/utils/dco/status.d.ts.map +1 -0
  52. package/dist/utils/dco/status.js +238 -0
  53. package/dist/utils/dco/status.js.map +1 -0
  54. package/package.json +6 -5
@@ -0,0 +1,740 @@
1
+ import { execFile } from "child_process";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { promisify } from "util";
6
+ import { readAgentMetadata, writeAgentMetadata } from "./agent-metadata.js";
7
+ import { buildAttributionBlock, updateClaudeSettings, upsertClaudeMdBlock, } from "./claude-config.js";
8
+ import { detectSigningConflicts, getGitConfigValue, installTrailerHook, isGitRepo, setGitConfig, } from "./git-config.js";
9
+ import { GhNotInstalledError, GhScopeError, getGhUser, githubNoreplyEmail, isGhAuthenticated, isGhInstalled, listSigningKeys, refreshAuthScope, signingKeyAlreadyUploaded, uploadSigningKey, SCOPE_REFRESH_COMMAND, } from "./github.js";
10
+ import { isValidSlug, keysDir, signingKeyPath } from "./paths.js";
11
+ import { convertEd25519ToSshKey } from "./ssh-key.js";
12
+ const execFileAsync = promisify(execFile);
13
+ function slugify(value) {
14
+ return value
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-+|-+$/g, "");
18
+ }
19
+ /**
20
+ * Strip control characters (including CR/LF) and collapse whitespace.
21
+ * Names and emails end up inside a generated shell hook and git trailers,
22
+ * where an embedded newline would corrupt the trailer format.
23
+ */
24
+ function sanitizeIdentityValue(value) {
25
+ // eslint-disable-next-line no-control-regex
26
+ return value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
27
+ }
28
+ /**
29
+ * Distinguishes a missing identity from a present-but-unparseable one. The
30
+ * two need different advice: "run mcpi register" after a corrupt read would
31
+ * overwrite a potentially recoverable key with a brand new identity.
32
+ */
33
+ function readIdentityFile(cwd) {
34
+ const path = join(cwd, ".mcpi", "identity.json");
35
+ if (!existsSync(path)) {
36
+ return { status: "missing" };
37
+ }
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
40
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
41
+ return { status: "ok", identity: parsed };
42
+ }
43
+ return { status: "corrupt" };
44
+ }
45
+ catch {
46
+ return { status: "corrupt" };
47
+ }
48
+ }
49
+ /**
50
+ * A tracked identity file is a red flag: identity.json holds a private key
51
+ * and is documented as never-commit. In a freshly cloned repo it means the
52
+ * "identity" was shipped by whoever authored the repo, and uploading that
53
+ * key to the user's GitHub account would let the repo author forge Verified
54
+ * commits as the user.
55
+ */
56
+ async function isIdentityTrackedByGit(cwd) {
57
+ try {
58
+ await execFileAsync("git", ["ls-files", "--error-unmatch", ".mcpi/identity.json"], { cwd, encoding: "utf8" });
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ /**
66
+ * The PR byline links to the agent's registry page, so its host must be the
67
+ * trusted registry. A url from .mcpi/agent.json in an untrusted repo could
68
+ * otherwise point the byline at an arbitrary off-site (phishing) link while
69
+ * signing still uses the local key.
70
+ */
71
+ function isTrustedAgentUrl(value) {
72
+ try {
73
+ const url = new URL(value);
74
+ if (url.protocol !== "https:") {
75
+ return false;
76
+ }
77
+ const host = url.hostname.toLowerCase();
78
+ return host === "knowthat.ai" || host.endsWith(".knowthat.ai");
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ async function confirm(message, def) {
85
+ const inquirer = (await import("inquirer")).default;
86
+ const { answer } = await inquirer.prompt([
87
+ { type: "confirm", name: "answer", message, default: def },
88
+ ]);
89
+ return answer;
90
+ }
91
+ async function promptForAgentName() {
92
+ const inquirer = (await import("inquirer")).default;
93
+ const { name } = await inquirer.prompt([
94
+ {
95
+ type: "input",
96
+ name: "name",
97
+ message: "Agent name (as it should appear in PR attribution):",
98
+ validate: (input) => input.trim().length > 0 || "Agent name is required",
99
+ },
100
+ ]);
101
+ const { slug } = await inquirer.prompt([
102
+ {
103
+ type: "input",
104
+ name: "slug",
105
+ message: "Agent slug (used in the knowthat.ai URL):",
106
+ default: slugify(name),
107
+ validate: (input) => /^[a-z0-9][a-z0-9-]*$/.test(input) ||
108
+ "Slug must be lowercase letters, numbers, and dashes",
109
+ },
110
+ ]);
111
+ return { name: name.trim(), slug };
112
+ }
113
+ /**
114
+ * Orchestrates the full DCO/attribution setup. Steps run in order; the three
115
+ * foundation steps (preflight, identities, key material) abort the run when
116
+ * they fail, everything after degrades independently so one broken layer
117
+ * never blocks the others.
118
+ */
119
+ export async function runDcoSetup(options = {}) {
120
+ const cwd = options.cwd ?? process.cwd();
121
+ const interactive = options.interactive ??
122
+ (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY));
123
+ const steps = [];
124
+ const result = {
125
+ ok: true,
126
+ actionRequired: false,
127
+ steps,
128
+ };
129
+ const abort = (remaining) => {
130
+ for (const step of remaining) {
131
+ steps.push({
132
+ id: step.id,
133
+ title: step.title,
134
+ status: "skipped",
135
+ detail: "Prerequisite step failed",
136
+ });
137
+ }
138
+ result.ok = false;
139
+ result.actionRequired = steps.some((s) => s.status === "action-required");
140
+ return result;
141
+ };
142
+ const laterSteps = [
143
+ { id: "github-upload", title: "Upload signing key to GitHub" },
144
+ { id: "git-config", title: "Configure git commit signing" },
145
+ { id: "trailer-hook", title: "Install commit trailer hook" },
146
+ { id: "claude-config", title: "Configure Claude Code attribution" },
147
+ { id: "verify", title: "Verify signing works" },
148
+ ];
149
+ // Step 1: preflight
150
+ if (!(await isGitRepo(cwd))) {
151
+ steps.push({
152
+ id: "preflight",
153
+ title: "Preflight checks",
154
+ status: "failed",
155
+ detail: "Not inside a git repository. Run this from your project root.",
156
+ });
157
+ return abort([
158
+ { id: "identities", title: "Resolve agent and human identity" },
159
+ { id: "key-material", title: "Write SSH signing key" },
160
+ ...laterSteps,
161
+ ]);
162
+ }
163
+ const identityRead = readIdentityFile(cwd);
164
+ const identity = identityRead.status === "ok" ? identityRead.identity : null;
165
+ let preflightFailure = null;
166
+ if (identityRead.status === "corrupt") {
167
+ preflightFailure =
168
+ ".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.";
169
+ }
170
+ else if (!identity?.privateKey || !identity?.did) {
171
+ preflightFailure =
172
+ identityRead.status === "ok"
173
+ ? ".mcpi/identity.json is missing required fields (did, privateKey). Inspect or restore the file before continuing."
174
+ : "No agent identity found in .mcpi/identity.json. Run `mcpi register` first, then re-run `mcpi dco setup`.";
175
+ }
176
+ else if (await isIdentityTrackedByGit(cwd)) {
177
+ preflightFailure =
178
+ ".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`.";
179
+ }
180
+ if (preflightFailure || !identity?.privateKey || !identity?.did) {
181
+ steps.push({
182
+ id: "preflight",
183
+ title: "Preflight checks",
184
+ status: "failed",
185
+ detail: preflightFailure ??
186
+ "No agent identity found in .mcpi/identity.json. Run `mcpi register` first, then re-run `mcpi dco setup`.",
187
+ });
188
+ return abort([
189
+ { id: "identities", title: "Resolve agent and human identity" },
190
+ { id: "key-material", title: "Write SSH signing key" },
191
+ ...laterSteps,
192
+ ]);
193
+ }
194
+ steps.push({ id: "preflight", title: "Preflight checks", status: "done" });
195
+ // Step 2: resolve identities
196
+ let agent;
197
+ let human;
198
+ try {
199
+ const metadata = readAgentMetadata(cwd);
200
+ if (metadata?.did && metadata.did !== identity.did) {
201
+ 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.`);
202
+ }
203
+ // Only trust agent.json for attribution when it is cryptographically bound
204
+ // to this identity (its did matches identity.json). Unbound metadata (no
205
+ // did, e.g. planted in an untrusted repo) is ignored so it cannot spoof the
206
+ // agent name/slug/url; the resolved values are then persisted with the real
207
+ // did, healing the binding for later runs.
208
+ const boundMetadata = metadata && metadata.did === identity.did ? metadata : null;
209
+ let name = boundMetadata?.name ?? process.env.AGENT_NAME;
210
+ let slug = boundMetadata?.slug ?? process.env.AGENT_SLUG;
211
+ if ((!name || !slug) && interactive && !options.dryRun) {
212
+ const prompted = await promptForAgentName();
213
+ name = name ?? prompted.name;
214
+ slug = slug ?? prompted.slug;
215
+ }
216
+ if (!name || !slug) {
217
+ throw new Error("Agent name/slug unknown. Run `mcpi register`, or set AGENT_NAME and AGENT_SLUG.");
218
+ }
219
+ // The interactive prompt validates its own input; values arriving from
220
+ // agent.json or the environment must pass the same shape check because
221
+ // the slug becomes a filename under the keys directory.
222
+ if (!isValidSlug(slug)) {
223
+ throw new Error(`Invalid agent slug "${slug}": must be lowercase letters, numbers, and dashes.`);
224
+ }
225
+ name = sanitizeIdentityValue(name);
226
+ const metadataUrl = boundMetadata?.url
227
+ ? sanitizeIdentityValue(boundMetadata.url)
228
+ : undefined;
229
+ agent = {
230
+ did: identity.did,
231
+ name,
232
+ slug,
233
+ url: metadataUrl && isTrustedAgentUrl(metadataUrl)
234
+ ? metadataUrl
235
+ : `https://knowthat.ai/agents/${slug}`,
236
+ email: `${slug}@agents.knowthat.ai`,
237
+ };
238
+ // Resolve the human name and email INDEPENDENTLY, each preferring the
239
+ // configured git value and only falling back to gh for the field that is
240
+ // missing. Resolving them together would let a missing email drop a
241
+ // configured name (or vice versa) in favor of the GitHub profile.
242
+ // Reading gh here is unrelated to the key upload, so it stays available
243
+ // even under --skip-github (which only suppresses the upload).
244
+ const gitName = await getGitConfigValue("user.name", cwd);
245
+ const gitEmail = await getGitConfigValue("user.email", cwd);
246
+ let ghUser = null;
247
+ if ((!gitName || !gitEmail) &&
248
+ (await isGhInstalled()) &&
249
+ (await isGhAuthenticated())) {
250
+ ghUser = await getGhUser();
251
+ }
252
+ // Use || (not ??) so an explicitly-empty git value is treated as missing,
253
+ // consistent with the truthiness gate above that decides to fetch gh.
254
+ const resolvedName = gitName || (ghUser ? ghUser.name || ghUser.login : undefined);
255
+ const resolvedEmail = gitEmail || (ghUser ? githubNoreplyEmail(ghUser) : undefined);
256
+ if (!resolvedName || !resolvedEmail) {
257
+ throw new Error("Could not resolve your git identity. Set git config user.name and user.email, or authenticate gh.");
258
+ }
259
+ human = {
260
+ name: sanitizeIdentityValue(resolvedName),
261
+ email: sanitizeIdentityValue(resolvedEmail),
262
+ };
263
+ result.agent = agent;
264
+ result.human = human;
265
+ // Persist resolved metadata when the bound record was incomplete (prompted,
266
+ // from env, or unbound), so a later non-interactive run resolves the agent
267
+ // without prompting AND the did binding is written for future runs.
268
+ // Best-effort: a cache-write failure must not fail identity resolution.
269
+ if (!options.dryRun &&
270
+ (!boundMetadata?.name || !boundMetadata?.slug || !boundMetadata?.did)) {
271
+ try {
272
+ writeAgentMetadata(cwd, {
273
+ name: agent.name,
274
+ slug: agent.slug,
275
+ url: agent.url,
276
+ did: agent.did,
277
+ });
278
+ }
279
+ catch (error) {
280
+ result.metadataPersistWarning =
281
+ error instanceof Error ? error.message : String(error);
282
+ }
283
+ }
284
+ steps.push({
285
+ id: "identities",
286
+ title: "Resolve agent and human identity",
287
+ status: "done",
288
+ detail: `agent=${agent.name} (${agent.slug}), human=${human.name} <${human.email}>`,
289
+ });
290
+ }
291
+ catch (error) {
292
+ steps.push({
293
+ id: "identities",
294
+ title: "Resolve agent and human identity",
295
+ status: "failed",
296
+ detail: error instanceof Error ? error.message : String(error),
297
+ });
298
+ return abort([
299
+ { id: "key-material", title: "Write SSH signing key" },
300
+ ...laterSteps,
301
+ ]);
302
+ }
303
+ // Step 3: key material
304
+ let keyMaterial;
305
+ // Slug was validated in the identities step; signingKeyPath re-checks and
306
+ // always yields a path inside the keys directory.
307
+ const privateKeyPath = signingKeyPath(agent.slug);
308
+ const publicKeyPath = `${privateKeyPath}.pub`;
309
+ // Only the write branch below sets this; a failure before it (e.g. a bad
310
+ // identity.json that fails conversion) must NOT delete pre-existing keys.
311
+ let startedKeyWrite = false;
312
+ try {
313
+ keyMaterial = convertEd25519ToSshKey(identity.privateKey, `mcpi:${agent.slug}`, { expectedPublicKeyBase64: identity.publicKey });
314
+ result.privateKeyPath = privateKeyPath;
315
+ result.fingerprint = keyMaterial.fingerprint;
316
+ // Compare BOTH files against the (now deterministic) expected material, so
317
+ // a corrupt/truncated private key with an intact .pub is detected and
318
+ // rewritten rather than left in place to fail signing forever.
319
+ const privUpToDate = existsSync(privateKeyPath) &&
320
+ readFileSync(privateKeyPath, "utf-8") === keyMaterial.privateKeyPem;
321
+ const pubUpToDate = existsSync(publicKeyPath) &&
322
+ readFileSync(publicKeyPath, "utf-8").trim() === keyMaterial.publicKeyLine;
323
+ if (privUpToDate && pubUpToDate) {
324
+ steps.push({
325
+ id: "key-material",
326
+ title: "Write SSH signing key",
327
+ status: "done",
328
+ detail: `Already present at ${privateKeyPath}`,
329
+ });
330
+ }
331
+ else if (options.dryRun) {
332
+ steps.push({
333
+ id: "key-material",
334
+ title: "Write SSH signing key",
335
+ status: "would-change",
336
+ detail: `Would write ${privateKeyPath} and ${publicKeyPath}`,
337
+ });
338
+ }
339
+ else {
340
+ mkdirSync(keysDir(), { recursive: true, mode: 0o700 });
341
+ startedKeyWrite = true;
342
+ // mode on write avoids any window where the key exists with loose
343
+ // permissions; the chmod covers overwrites of a pre-existing file,
344
+ // where the mode option is ignored.
345
+ writeFileSync(privateKeyPath, keyMaterial.privateKeyPem, { mode: 0o600 });
346
+ chmodSync(privateKeyPath, 0o600);
347
+ writeFileSync(publicKeyPath, keyMaterial.publicKeyLine + "\n", {
348
+ mode: 0o644,
349
+ });
350
+ chmodSync(publicKeyPath, 0o644);
351
+ steps.push({
352
+ id: "key-material",
353
+ title: "Write SSH signing key",
354
+ status: "done",
355
+ detail: `${privateKeyPath} (${keyMaterial.fingerprint})`,
356
+ });
357
+ }
358
+ }
359
+ catch (error) {
360
+ // Clean up only a key pair THIS run started writing (a half-written pair);
361
+ // a failure before the write (e.g. bad identity.json) must leave any
362
+ // pre-existing valid keys intact so signing is not silently broken.
363
+ if (!options.dryRun && startedKeyWrite) {
364
+ rmSync(privateKeyPath, { force: true });
365
+ rmSync(publicKeyPath, { force: true });
366
+ }
367
+ steps.push({
368
+ id: "key-material",
369
+ title: "Write SSH signing key",
370
+ status: "failed",
371
+ detail: error instanceof Error ? error.message : String(error),
372
+ });
373
+ return abort(laterSteps);
374
+ }
375
+ // Step 4: GitHub upload
376
+ if (options.skipGithub) {
377
+ steps.push({
378
+ id: "github-upload",
379
+ title: "Upload signing key to GitHub",
380
+ status: "skipped",
381
+ detail: "--skip-github",
382
+ });
383
+ }
384
+ else {
385
+ try {
386
+ if (!(await isGhInstalled())) {
387
+ steps.push({
388
+ id: "github-upload",
389
+ title: "Upload signing key to GitHub",
390
+ status: "action-required",
391
+ detail: "GitHub CLI not found. Install from https://cli.github.com, run `gh auth login`, then re-run `mcpi dco setup`.",
392
+ });
393
+ }
394
+ else if (!(await isGhAuthenticated())) {
395
+ steps.push({
396
+ id: "github-upload",
397
+ title: "Upload signing key to GitHub",
398
+ status: "action-required",
399
+ detail: "Not authenticated. Run `gh auth login`, then re-run `mcpi dco setup`.",
400
+ });
401
+ }
402
+ else {
403
+ let keys;
404
+ try {
405
+ keys = await listSigningKeys();
406
+ }
407
+ catch (error) {
408
+ if (error instanceof GhScopeError &&
409
+ interactive &&
410
+ !options.dryRun &&
411
+ (await confirm(`GitHub token is missing the signing key scope. Run \`${SCOPE_REFRESH_COMMAND}\` now?`, true)) &&
412
+ (await refreshAuthScope())) {
413
+ keys = await listSigningKeys();
414
+ }
415
+ else {
416
+ throw error;
417
+ }
418
+ }
419
+ if (signingKeyAlreadyUploaded(keys, keyMaterial.publicKeyLine)) {
420
+ steps.push({
421
+ id: "github-upload",
422
+ title: "Upload signing key to GitHub",
423
+ status: "done",
424
+ detail: `Already uploaded (${keyMaterial.fingerprint})`,
425
+ });
426
+ }
427
+ else if (options.dryRun) {
428
+ steps.push({
429
+ id: "github-upload",
430
+ title: "Upload signing key to GitHub",
431
+ status: "would-change",
432
+ detail: `Would upload ${keyMaterial.fingerprint} as mcpi:${agent.slug}`,
433
+ });
434
+ }
435
+ else if (interactive &&
436
+ !(await confirm(`Upload signing key ${keyMaterial.fingerprint} for agent "${agent.name}" (${agent.did}) to your GitHub account?`, true))) {
437
+ steps.push({
438
+ id: "github-upload",
439
+ title: "Upload signing key to GitHub",
440
+ status: "action-required",
441
+ detail: "Upload declined. Re-run `mcpi dco setup` when you are ready to upload the key.",
442
+ });
443
+ }
444
+ else {
445
+ await uploadSigningKey(keyMaterial.publicKeyLine, `mcpi:${agent.slug}`);
446
+ steps.push({
447
+ id: "github-upload",
448
+ title: "Upload signing key to GitHub",
449
+ status: "done",
450
+ detail: `Uploaded as mcpi:${agent.slug} (${keyMaterial.fingerprint})`,
451
+ });
452
+ }
453
+ }
454
+ }
455
+ catch (error) {
456
+ if (error instanceof GhScopeError) {
457
+ steps.push({
458
+ id: "github-upload",
459
+ title: "Upload signing key to GitHub",
460
+ status: "action-required",
461
+ detail: `${error.message} Then re-run \`mcpi dco setup\`.`,
462
+ });
463
+ }
464
+ else if (error instanceof GhNotInstalledError) {
465
+ steps.push({
466
+ id: "github-upload",
467
+ title: "Upload signing key to GitHub",
468
+ status: "action-required",
469
+ detail: error.message,
470
+ });
471
+ }
472
+ else {
473
+ steps.push({
474
+ id: "github-upload",
475
+ title: "Upload signing key to GitHub",
476
+ status: "failed",
477
+ detail: error instanceof Error ? error.message : String(error),
478
+ });
479
+ }
480
+ }
481
+ }
482
+ // Step 5: git config
483
+ const desiredConfig = {
484
+ "gpg.format": "ssh",
485
+ "user.signingkey": privateKeyPath,
486
+ "commit.gpgsign": "true",
487
+ };
488
+ try {
489
+ const conflicts = options.force
490
+ ? []
491
+ : await detectSigningConflicts(cwd, desiredConfig);
492
+ if (conflicts.length > 0) {
493
+ const detail = conflicts
494
+ .map((c) => `${c.key} is "${c.current}" (wanted "${c.desired}")`)
495
+ .join("; ");
496
+ steps.push({
497
+ id: "git-config",
498
+ title: "Configure git commit signing",
499
+ status: "action-required",
500
+ detail: `Existing signing config differs: ${detail}. Re-run with --force to overwrite.`,
501
+ });
502
+ }
503
+ else if (options.dryRun) {
504
+ // Surface the same repo-local shadow that a real --global write would
505
+ // hit, so the preview does not look clean while an actual run ends in
506
+ // action-required.
507
+ const localShadow = options.global
508
+ ? await detectSigningConflicts(cwd, desiredConfig, "local")
509
+ : [];
510
+ const base = Object.entries(desiredConfig)
511
+ .map(([k, v]) => `${k}=${v}`)
512
+ .join(", ");
513
+ if (localShadow.length > 0) {
514
+ const detail = localShadow
515
+ .map((c) => `${c.key}="${c.current}"`)
516
+ .join("; ");
517
+ steps.push({
518
+ id: "git-config",
519
+ title: "Configure git commit signing",
520
+ status: "action-required",
521
+ detail: `${base}; but repo-local git config would still shadow the global write: ${detail}. Clear these local values or re-run without --global.`,
522
+ });
523
+ }
524
+ else {
525
+ steps.push({
526
+ id: "git-config",
527
+ title: "Configure git commit signing",
528
+ status: "would-change",
529
+ detail: base,
530
+ });
531
+ }
532
+ }
533
+ else {
534
+ await setGitConfig(desiredConfig, { cwd, global: options.global });
535
+ // A repo-local value overrides a global one, so a --global write can be
536
+ // silently shadowed by repo-local config that --force does not touch.
537
+ const localShadow = options.global
538
+ ? await detectSigningConflicts(cwd, desiredConfig, "local")
539
+ : [];
540
+ if (localShadow.length > 0) {
541
+ const detail = localShadow
542
+ .map((c) => `${c.key}="${c.current}"`)
543
+ .join("; ");
544
+ steps.push({
545
+ id: "git-config",
546
+ title: "Configure git commit signing",
547
+ status: "action-required",
548
+ detail: `Global signing config written, but repo-local git config still shadows it: ${detail}. Clear these local values or re-run without --global.`,
549
+ });
550
+ }
551
+ else {
552
+ steps.push({
553
+ id: "git-config",
554
+ title: "Configure git commit signing",
555
+ status: "done",
556
+ detail: `${options.global ? "global" : "repo-local"}: gpg.format=ssh, commit.gpgsign=true`,
557
+ });
558
+ }
559
+ }
560
+ }
561
+ catch (error) {
562
+ steps.push({
563
+ id: "git-config",
564
+ title: "Configure git commit signing",
565
+ status: "failed",
566
+ detail: error instanceof Error ? error.message : String(error),
567
+ });
568
+ }
569
+ // Step 6: trailer hook
570
+ let hookFallback = false;
571
+ // The hook-problem part only; whether the trailers actually reached CLAUDE.md
572
+ // is not known until the Claude step runs, so that claim is appended below
573
+ // rather than asserted here (it would be false under --skip-claude or if the
574
+ // Claude step fails).
575
+ let hookFallbackDetail = "";
576
+ try {
577
+ const hook = await installTrailerHook(cwd, human, { name: agent.name, email: agent.email }, { dryRun: options.dryRun });
578
+ if (hook.result === "foreign-hook") {
579
+ hookFallback = true;
580
+ hookFallbackDetail = `A prepare-commit-msg hook already exists at ${hook.path}; merge it manually if you want enforced trailers.`;
581
+ steps.push({
582
+ id: "trailer-hook",
583
+ title: "Install commit trailer hook",
584
+ status: "action-required",
585
+ detail: hookFallbackDetail,
586
+ });
587
+ }
588
+ else if (hook.result === "unsafe-hooks-path") {
589
+ hookFallback = true;
590
+ hookFallbackDetail = `core.hooksPath points outside this repository (${hook.path}); refusing to write there.`;
591
+ steps.push({
592
+ id: "trailer-hook",
593
+ title: "Install commit trailer hook",
594
+ status: "action-required",
595
+ detail: hookFallbackDetail,
596
+ });
597
+ }
598
+ else {
599
+ steps.push({
600
+ id: "trailer-hook",
601
+ title: "Install commit trailer hook",
602
+ status: hook.result === "would-change" ? "would-change" : "done",
603
+ detail: `${hook.result} at ${hook.path}`,
604
+ });
605
+ }
606
+ }
607
+ catch (error) {
608
+ hookFallback = true;
609
+ hookFallbackDetail = error instanceof Error ? error.message : String(error);
610
+ steps.push({
611
+ id: "trailer-hook",
612
+ title: "Install commit trailer hook",
613
+ status: "failed",
614
+ detail: hookFallbackDetail,
615
+ });
616
+ }
617
+ // Step 7: Claude Code config
618
+ if (options.skipClaude) {
619
+ steps.push({
620
+ id: "claude-config",
621
+ title: "Configure Claude Code attribution",
622
+ status: "skipped",
623
+ detail: "--skip-claude",
624
+ });
625
+ }
626
+ else {
627
+ try {
628
+ const block = buildAttributionBlock({
629
+ agentName: agent.name,
630
+ agentUrl: agent.url,
631
+ includeTrailerInstructions: hookFallback,
632
+ humanName: human.name,
633
+ humanEmail: human.email,
634
+ agentEmail: agent.email,
635
+ });
636
+ const md = upsertClaudeMdBlock(cwd, block, { dryRun: options.dryRun });
637
+ const settings = updateClaudeSettings(cwd, { dryRun: options.dryRun });
638
+ if (settings.result === "skipped-invalid") {
639
+ steps.push({
640
+ id: "claude-config",
641
+ title: "Configure Claude Code attribution",
642
+ status: "action-required",
643
+ detail: `CLAUDE.md ${md.result}; ${settings.path} could not be parsed, set "includeCoAuthoredBy": false manually.`,
644
+ });
645
+ }
646
+ else {
647
+ const changed = md.result === "would-change" || settings.result === "would-change";
648
+ steps.push({
649
+ id: "claude-config",
650
+ title: "Configure Claude Code attribution",
651
+ status: changed ? "would-change" : "done",
652
+ detail: `CLAUDE.md ${md.result}, settings.json ${settings.result}`,
653
+ });
654
+ }
655
+ }
656
+ catch (error) {
657
+ steps.push({
658
+ id: "claude-config",
659
+ title: "Configure Claude Code attribution",
660
+ status: "failed",
661
+ detail: error instanceof Error ? error.message : String(error),
662
+ });
663
+ }
664
+ }
665
+ // Now that the Claude step has run (or been skipped), state truthfully
666
+ // whether the trailer fallback actually reached CLAUDE.md. The trailers are
667
+ // only in the block when the Claude step wrote it (includeTrailerInstructions
668
+ // was hookFallback); under --skip-claude or a Claude-step failure they were
669
+ // not written anywhere.
670
+ if (hookFallback) {
671
+ const trailerStep = steps.find((s) => s.id === "trailer-hook");
672
+ const claudeStatus = steps.find((s) => s.id === "claude-config")?.status;
673
+ let outcome;
674
+ if (claudeStatus === "done") {
675
+ outcome =
676
+ "Trailer instructions were added to the CLAUDE.md attribution block instead.";
677
+ }
678
+ else if (claudeStatus === "would-change") {
679
+ outcome =
680
+ "Trailer instructions would be added to the CLAUDE.md attribution block instead.";
681
+ }
682
+ else {
683
+ 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.`;
684
+ }
685
+ if (trailerStep) {
686
+ trailerStep.detail = `${hookFallbackDetail} ${outcome}`;
687
+ }
688
+ }
689
+ // Step 8: verify
690
+ if (options.dryRun) {
691
+ steps.push({
692
+ id: "verify",
693
+ title: "Verify signing works",
694
+ status: "skipped",
695
+ detail: "dry run",
696
+ });
697
+ }
698
+ else {
699
+ // Private temp dir: a predictable path in the shared tmpdir would be
700
+ // vulnerable to symlink games from other local users.
701
+ const verifyDir = mkdtempSync(join(tmpdir(), "mcpi-dco-verify-"));
702
+ const payloadPath = join(verifyDir, "payload");
703
+ try {
704
+ writeFileSync(payloadPath, "mcpi dco verification payload\n");
705
+ await execFileAsync("ssh-keygen", [
706
+ "-Y",
707
+ "sign",
708
+ "-f",
709
+ privateKeyPath,
710
+ "-n",
711
+ "git",
712
+ payloadPath,
713
+ ]);
714
+ steps.push({
715
+ id: "verify",
716
+ title: "Verify signing works",
717
+ status: "done",
718
+ detail: `Test signature created with ${keyMaterial.fingerprint}`,
719
+ });
720
+ }
721
+ catch (error) {
722
+ const err = error;
723
+ steps.push({
724
+ id: "verify",
725
+ title: "Verify signing works",
726
+ status: "action-required",
727
+ detail: err?.code === "ENOENT"
728
+ ? "ssh-keygen not found on PATH. Git needs OpenSSH 8.0+ to sign commits."
729
+ : `Test signature failed: ${err?.stderr?.trim() || err?.message}`,
730
+ });
731
+ }
732
+ finally {
733
+ rmSync(verifyDir, { recursive: true, force: true });
734
+ }
735
+ }
736
+ result.ok = !steps.some((s) => s.status === "failed");
737
+ result.actionRequired = steps.some((s) => s.status === "action-required");
738
+ return result;
739
+ }
740
+ //# sourceMappingURL=setup.js.map