create-claude-cabinet 0.29.13 → 0.30.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.
@@ -0,0 +1,92 @@
1
+ # Handoff Checklist Schema
2
+
3
+ The checklist YAML (`handoff.yaml`) defines what to collect from a client.
4
+ It ships with the client's Claude Code plugin — the `/handoff` skill reads
5
+ it and walks the client through each section conversationally.
6
+
7
+ ## Structure
8
+
9
+ ```yaml
10
+ meta:
11
+ title: "Project Go-Live Credentials" # shown to client at start
12
+ consultant: "Your Name" # who receives the credentials
13
+ public_key: "./keys/consultant.pub.jwk" # RSA public key for encryption
14
+
15
+ transport:
16
+ type: email # email | mcp | file
17
+ consultant: "you@example.com" # client sends here
18
+ client: "client@example.com" # consultant sends here
19
+
20
+ sections:
21
+ - key: section_id
22
+ title: "Section Title"
23
+ items:
24
+ - key: item_id
25
+ prompt: "What the client sees"
26
+ kind: decide # decide | provide | confirm | credential
27
+ options: [A, B, C] # only for decide kind
28
+ help: "Optional help text"
29
+ visibility:
30
+ depends_on: other_item_key
31
+ value_in: [A, B] # show this item only when parent matches
32
+ ```
33
+
34
+ ## Item Kinds
35
+
36
+ | Kind | Captured via | Stored as | Use for |
37
+ |------|-------------|-----------|---------|
38
+ | `decide` | Conversation (choice) | Plaintext value | Decisions that drive visibility |
39
+ | `provide` | Conversation (free text) | Plaintext value | Non-sensitive information |
40
+ | `confirm` | Conversation (yes/no) | Boolean | Acknowledgments |
41
+ | `credential` | OS dialog (hidden input) | Encrypted envelope ID | API keys, tokens, passwords |
42
+
43
+ Credential values are captured through a secure OS dialog, encrypted
44
+ immediately with the consultant's public key, and transported as opaque
45
+ envelopes. The plaintext never enters Claude's context or Anthropic's API.
46
+
47
+ ## Visibility Rules
48
+
49
+ Items with a `visibility` block are hidden until their dependency is met.
50
+ Visibility is transitive: if B depends on A and C depends on B, hiding A
51
+ hides both B and C.
52
+
53
+ Cycles are rejected at load time (before any conversation starts). The
54
+ checklist parser runs topological sort on the dependency graph and fails
55
+ with a clear error if a cycle exists.
56
+
57
+ ## Transport Types
58
+
59
+ **`email`** (default) — Auto-detects the connected email MCP server
60
+ (Gmail, Outlook, etc.) and dispatches through it. Falls back to `file`
61
+ if no email MCP is available.
62
+
63
+ **`mcp`** — Calls a specific MCP tool to POST the encrypted payload.
64
+ Requires additional config: `tool_name`, `payload_param`, `extra_params`.
65
+
66
+ **`file`** — Writes encrypted envelopes to `./handoff-out/`. Also serves
67
+ as automatic fallback when email transport has no MCP connected.
68
+
69
+ ## State File
70
+
71
+ Progress is tracked in `handoff-state.json` (gitignored). Non-credential
72
+ answers store the value directly. Credential items store only the
73
+ envelope ID and delivery status — never the plaintext.
74
+
75
+ ```json
76
+ {
77
+ "checklist": "handoff.yaml",
78
+ "started_at": "2026-05-30T12:00:00Z",
79
+ "updated_at": "2026-05-30T12:30:00Z",
80
+ "answers": {
81
+ "hosting_provider": {
82
+ "value": "Railway",
83
+ "answered_at": "2026-05-30T12:05:00Z"
84
+ },
85
+ "railway_token": {
86
+ "status": "sent",
87
+ "envelope_id": "env_abc123",
88
+ "sent_at": "2026-05-30T12:10:00Z"
89
+ }
90
+ }
91
+ }
92
+ ```
@@ -0,0 +1,99 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export class DialogCancelledError extends Error {
7
+ constructor() { super('User cancelled dialog'); this.code = 'CANCELLED'; }
8
+ }
9
+
10
+ export class DialogUnavailableError extends Error {
11
+ constructor(platform) {
12
+ super(`No secure input dialog available on ${platform}`);
13
+ this.code = 'NO_DIALOG';
14
+ this.platform = platform;
15
+ }
16
+ }
17
+
18
+ export function detectPlatform() {
19
+ switch (process.platform) {
20
+ case 'darwin': return 'macos';
21
+ case 'win32': return 'windows';
22
+ default: return 'linux';
23
+ }
24
+ }
25
+
26
+ function escapeAppleScript(str) {
27
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
28
+ }
29
+
30
+ async function captureViaMacOS(prompt) {
31
+ const escaped = escapeAppleScript(prompt);
32
+ const script = `display dialog "${escaped}" with hidden answer default answer ""`;
33
+ try {
34
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
35
+ const match = stdout.match(/text returned:(.*)/);
36
+ if (!match) throw new Error('Dialog returned no value');
37
+ return match[1].trim();
38
+ } catch (err) {
39
+ if (err.stderr?.includes('User canceled') || err.stderr?.includes('(-128)')) {
40
+ throw new DialogCancelledError();
41
+ }
42
+ throw err;
43
+ }
44
+ }
45
+
46
+ async function captureViaZenity(prompt) {
47
+ try {
48
+ const { stdout } = await execFileAsync('zenity', ['--password', '--title', prompt]);
49
+ return stdout.trim();
50
+ } catch (err) {
51
+ if (err.code === 'ENOENT') throw new DialogUnavailableError('linux');
52
+ if (err.status === 1) throw new DialogCancelledError();
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ async function captureViaTerminal(prompt) {
58
+ process.stderr.write('Warning: No GUI dialog available — falling back to terminal input.\n');
59
+ process.stderr.write('Input will NOT be masked. Ensure no one is looking at your screen.\n');
60
+ const { createInterface } = await import('node:readline');
61
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
62
+ process.stderr.write(`${prompt}: `);
63
+ return new Promise((resolve) => {
64
+ rl.question('', (answer) => {
65
+ rl.close();
66
+ process.stderr.write('\n');
67
+ resolve(answer);
68
+ });
69
+ });
70
+ }
71
+
72
+ async function captureViaPowerShell(prompt) {
73
+ const escaped = prompt.replace(/'/g, "''");
74
+ const script = `$s = Read-Host -Prompt '${escaped}' -AsSecureString; [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))`;
75
+ try {
76
+ const { stdout } = await execFileAsync('powershell', ['-Command', script]);
77
+ return stdout.trim();
78
+ } catch (err) {
79
+ if (err.code === 'ENOENT') throw new DialogUnavailableError('windows');
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ export async function captureSecureInput(prompt) {
85
+ const platform = detectPlatform();
86
+ switch (platform) {
87
+ case 'macos': return captureViaMacOS(prompt);
88
+ case 'windows': return captureViaPowerShell(prompt);
89
+ case 'linux': {
90
+ try {
91
+ return await captureViaZenity(prompt);
92
+ } catch (err) {
93
+ if (err instanceof DialogUnavailableError) return captureViaTerminal(prompt);
94
+ throw err;
95
+ }
96
+ }
97
+ default: throw new DialogUnavailableError(process.platform);
98
+ }
99
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-cabinet/site-audit",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Comprehensive deployed-site quality audit engine for Claude Cabinet. Runs checks across performance, accessibility, security, SEO, content, DNS, and privacy against a deployed URL; single-site and comparison modes; standalone HTML report.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,12 +33,19 @@ export function normalize(raw, durationMs) {
33
33
  }
34
34
 
35
35
  const violations = Array.isArray(data) ? data.flatMap(p => p.violations || []) : (data.violations || []);
36
- const findings = violations.map(v => ({
37
- severity: IMPACT_TO_SEVERITY[v.impact] || 'info',
38
- message: v.description || v.id,
39
- url: v.helpUrl || undefined,
40
- context: v.nodes?.[0]?.html?.slice(0, 200) || undefined,
41
- }));
36
+ const findings = violations.map(v => {
37
+ const targets = (v.nodes || []).slice(0, 3).map(n =>
38
+ (n.target || []).flat().join(' > ')
39
+ ).filter(Boolean);
40
+ return {
41
+ severity: IMPACT_TO_SEVERITY[v.impact] || 'info',
42
+ message: v.description || v.id,
43
+ url: v.helpUrl || undefined,
44
+ context: targets.length
45
+ ? `Elements: ${targets.join(', ')}${v.nodes?.length > 3 ? ` (+${v.nodes.length - 3} more)` : ''}`
46
+ : (v.nodes?.[0]?.html?.slice(0, 200) || undefined),
47
+ };
48
+ });
42
49
 
43
50
  const worstSev = findings.length ? findings.reduce((w, f) => {
44
51
  const o = { critical: 0, serious: 1, moderate: 2, info: 3 };
@@ -59,7 +59,21 @@ export function normalize(raw, durationMs) {
59
59
  return (order[f.severity] ?? 3) < (order[w] ?? 3) ? f.severity : w;
60
60
  }, 'info') : null;
61
61
 
62
- const details = { categories: scores };
62
+ const cwvKeys = {
63
+ 'largest-contentful-paint': 'LCP',
64
+ 'cumulative-layout-shift': 'CLS',
65
+ 'first-contentful-paint': 'FCP',
66
+ 'total-blocking-time': 'TBT',
67
+ 'speed-index': 'Speed Index',
68
+ 'interactive': 'TTI',
69
+ };
70
+ const metrics = {};
71
+ for (const [auditId, label] of Object.entries(cwvKeys)) {
72
+ const a = audits[auditId];
73
+ if (a?.displayValue) metrics[label] = a.displayValue;
74
+ }
75
+
76
+ const details = { categories: scores, ...(Object.keys(metrics).length && { metrics }) };
63
77
  const passSummary = Object.entries(scores)
64
78
  .map(([k, v]) => `${k.replace(/-/g, ' ')}: ${v}`)
65
79
  .join(', ');
@@ -28,8 +28,8 @@ export function normalize(raw, durationMs) {
28
28
  const findings = issues.map(i => ({
29
29
  severity: TYPE_TO_SEVERITY[i.type] || 'info',
30
30
  message: i.message || i.code || 'unknown',
31
- context: i.context?.slice(0, 200) || undefined,
32
- url: i.selector || undefined,
31
+ context: [i.code, i.selector ? `Element: ${i.selector}` : null].filter(Boolean).join(' — ') || undefined,
32
+ url: i.context?.slice(0, 150) || undefined,
33
33
  }));
34
34
 
35
35
  const errors = findings.filter(f => f.severity === 'serious' || f.severity === 'critical').length;
@@ -132,6 +132,13 @@ function renderDetails(result) {
132
132
  });
133
133
  html += `<div style="margin-bottom:.75rem;font-size:.9rem">${items.join('')}</div>`;
134
134
  }
135
+ const metrics = result.details.metrics;
136
+ if (metrics && typeof metrics === 'object') {
137
+ const items = Object.entries(metrics).map(([label, value]) =>
138
+ `<span style="display:inline-block;margin-right:1.2rem"><strong>${esc(label)}</strong> ${esc(String(value))}</span>`
139
+ );
140
+ html += `<div style="margin-bottom:.75rem;font-size:.85rem;color:#555">Core Web Vitals: ${items.join('')}</div>`;
141
+ }
135
142
  const types = result.details.types;
136
143
  if (Array.isArray(types) && types.length) {
137
144
  html += `<div style="margin-bottom:.75rem;font-size:.85rem;color:#555">Schema types: ${types.map(t => `<strong>${esc(String(t))}</strong>`).join(', ')}</div>`;
@@ -348,3 +348,24 @@ field-feedback. The CC maintainer adds it to this section. Project-specific
348
348
  patterns that don't generalize stay in `patterns-project.md`.
349
349
 
350
350
  <!-- Universal patterns below this line -->
351
+
352
+ ### Collapse with `in={true}` on mount renders invisible children
353
+
354
+ **Pattern:** `<Collapse in={expr}>` where `expr` is truthy on initial
355
+ render. Children exist in the DOM but have height 0 — completely
356
+ invisible. Mantine's Collapse animates from closed→open, so mounting
357
+ already-open skips the animation and the height never resolves.
358
+
359
+ **Detection:** Flag any `<Collapse in={...}>` where the `in` prop can
360
+ be truthy on first render — e.g., `in={data.length > 0}`, `in={true}`,
361
+ `in={!!initialValue}`. Static `in={false}` or state that starts false
362
+ (`useState(false)`) is safe.
363
+
364
+ **Fix:** Replace `<Collapse in={opened}>...</Collapse>` with conditional
365
+ rendering: `{opened && <>...</>}`. Use Collapse only when you need the
366
+ open/close animation from a user interaction, not for initial-render
367
+ conditional display.
368
+
369
+ **Source:** claudeconsult-maginnis, 2026-05-30. Legal theory edit form's
370
+ "Default Content" section auto-expanded when data existed but all fields
371
+ were invisible. Required Playwright DOM inspection to diagnose.
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: handoff
3
+ description: |
4
+ Walk through a consultant's handoff checklist conversationally. Collects
5
+ decisions, information, and credentials from the client with progressive
6
+ disclosure. Credentials are captured via secure OS dialog and encrypted
7
+ before Claude ever sees them. Use when: "handoff", "/handoff", or the
8
+ client needs to provide go-live credentials and configuration.
9
+ manual: true
10
+ ---
11
+
12
+ # /handoff — Complete Your Checklist
13
+
14
+ ## Purpose
15
+
16
+ Walk the client through a structured checklist their consultant prepared.
17
+ Each item is asked conversationally — decisions drive which follow-up
18
+ items appear, credentials are captured securely, and progress is saved
19
+ so the client can leave and come back.
20
+
21
+ ## Workflow
22
+
23
+ ### 1. Load and Validate
24
+
25
+ 1. Locate `handoff.yaml` — check in order:
26
+ a. Path provided as argument (the plugin's `/handoff` skill passes
27
+ the absolute path to the config — this is the primary path for
28
+ plugin-based handoffs)
29
+ b. Project root: `./handoff.yaml`
30
+ Use the first match found.
31
+ - **Missing:** "No handoff.yaml found — has the plugin been installed?"
32
+ - **Malformed:** Run validation via `handoff-checklist.mjs` and surface
33
+ specific errors before conversation starts.
34
+ - **Cycle detected:** "This checklist has a circular dependency between
35
+ [keys] — contact your consultant to fix it."
36
+ - **Public key missing:** Resolve `meta.public_key` relative to the
37
+ directory containing `handoff.yaml`. Check that it exists. A missing
38
+ key would only surface on the first credential item — catch it early.
39
+
40
+ 2. **State file co-location:** The state file (`handoff-state.json`)
41
+ lives in the same directory as `handoff.yaml`. Derive the state path
42
+ from the checklist path: replace the filename. This ensures the
43
+ plugin's config and state stay together regardless of install location.
44
+
45
+ 3. Read `handoff-state.json` if it exists (resume mode).
46
+ - **Corrupt state:** Offer "start fresh" vs "show me what's broken."
47
+ Never silently skip items.
48
+
49
+ 4. **Terminal state check:** If all items are complete/sent, warn:
50
+ "This checklist was completed on [date]. Re-running will send
51
+ duplicate credentials. Continue?" Do not silently re-prompt.
52
+
53
+ 5. Show progress summary: "You've completed N of M items. Let's pick up
54
+ with [next section]."
55
+
56
+ ### 2. Check for Incoming Messages
57
+
58
+ Check the connected email MCP for incoming `[Handoff]` emails from the
59
+ consultant (notes, checklist updates, questions). Process and display
60
+ any new messages before starting the walk.
61
+
62
+ If no email MCP is connected, skip with no warning — the client may
63
+ be using file transport.
64
+
65
+ ### 3. Walk Through Sections
66
+
67
+ Walk through sections in order. For each visible item:
68
+
69
+ **`decide`** — Present options conversationally. Record answer. After
70
+ answering, re-evaluate visibility — new items may appear. Tell the
71
+ client: "Great, since you chose [X], I'll need a few things from you
72
+ for that..."
73
+
74
+ **`provide`** — Ask for the value. Record in state.
75
+
76
+ **`confirm`** — Ask yes/no. Record in state.
77
+
78
+ **`credential`** — Explain what's needed. Show `help` text if present.
79
+ Invoke capture-and-encrypt:
80
+
81
+ ```bash
82
+ node .claude/handoff/capture-and-encrypt.mjs \
83
+ --prompt "<item prompt>" \
84
+ --public-key <absolute path to meta.public_key, resolved relative to handoff.yaml's directory>
85
+ ```
86
+
87
+ This returns ONLY the encrypted envelope (base64) on stdout. The
88
+ plaintext credential never enters this conversation or Anthropic's API.
89
+
90
+ **Important:** The stdout is a base64-serialized envelope. To send it
91
+ with the correct `envelope_id` in the email subject, base64-decode and
92
+ JSON-parse it to recover the envelope object before passing to
93
+ transport. The `envelope_id` field (e.g., `env_abc123`) is needed for
94
+ both the email subject and the state file entry.
95
+
96
+ Then send the envelope object via transport (using `handoff-transport.mjs`).
97
+ Pass `context.side = 'client'` so the transport addresses the email to
98
+ the consultant.
99
+
100
+ - **Transport failure:** Write `status: "send_failed"` + serialized
101
+ envelope to state so retry is possible without re-prompting.
102
+ - **Dialog cancel (exit code 2):** Skip item, note in state, continue.
103
+ - **Success:** Record `status: sent` + `envelope_id` in state. Tell
104
+ the client: "Got it — encrypted and sent. I never saw the value."
105
+
106
+ Save state after every answer (atomic write via `handoff-checklist.mjs`).
107
+
108
+ ### 4. Completion
109
+
110
+ Show final summary: "All done! N of N items complete. [Consultant] will
111
+ be notified."
112
+
113
+ Send a `session_summary` with all non-credential answers via transport.
114
+
115
+ ## Rules
116
+
117
+ - **Never** echo or reference a credential value in conversation
118
+ - **Never** store credential values in the state file
119
+ - If the client asks where to find something, teach them — this is
120
+ Claude's advantage over a form
121
+ - If the client needs to leave, reassure them progress is saved
122
+ - Show a progress table at the start and end of each section
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: handoff-add
3
+ description: |
4
+ Add items to an existing handoff checklist mid-engagement. Preserves
5
+ existing client progress. Use when: "handoff add", "/handoff-add",
6
+ "add items to the checklist", "I need something else from the client".
7
+ manual: true
8
+ ---
9
+
10
+ # /handoff-add — Add Items to an Existing Checklist
11
+
12
+ ## Purpose
13
+
14
+ Amend a handoff checklist after the client has already started working
15
+ on it. New items appear as "not started" without affecting existing
16
+ progress.
17
+
18
+ ## Workflow
19
+
20
+ 1. Read existing `handoff.yaml`. If missing: "No checklist found. Run
21
+ `/handoff-create` to build one first."
22
+
23
+ 2. Show current structure: sections and item counts.
24
+
25
+ 3. Ask: "What do you need to add?"
26
+ - "Which section does this belong in?" (existing or new section)
27
+ - Same item-creation flow as `/handoff-create`: kind, prompt, help,
28
+ options, visibility rules.
29
+ - "Any more items to add?"
30
+
31
+ 4. Re-validate the updated checklist:
32
+ - Cycle detection (new visibility rules may create cycles)
33
+ - Orphan dependency check
34
+
35
+ 5. Write updated `handoff.yaml`.
36
+
37
+ 6. Send a `checklist_update` notification to the client via transport:
38
+ - **Email subject:** `[Handoff] N new items added`
39
+ - **Email body:** Structured JSON with the new items and their
40
+ sections, so the client's `/handoff` or `/handoff-progress` can
41
+ surface them.
42
+
43
+ 7. Confirm: "Added N items. [Client name] will see them next time they
44
+ run `/handoff` or `/handoff-progress`."
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: handoff-ask
3
+ description: |
4
+ Send a free-text message to the other side of a handoff engagement.
5
+ Works for both consultant and client. Use when: "handoff ask",
6
+ "/handoff-ask", "message the consultant", "message the client",
7
+ "ask Ed", "send a note".
8
+ ---
9
+
10
+ # /handoff-ask — Send a Message
11
+
12
+ ## Purpose
13
+
14
+ Send a free-text message to the other party in a handoff engagement.
15
+ Works for both sides — the client can ask the consultant a question,
16
+ and the consultant can send a note to the client.
17
+
18
+ ## Workflow
19
+
20
+ 1. Read `handoff.yaml` for transport config and party identifiers.
21
+ - Determine which side we are: if `keys/consultant.priv.jwk.enc`
22
+ exists locally, we're the consultant. Otherwise we're the client.
23
+ (The public key exists on both sides — only the private key
24
+ distinguishes them.)
25
+ - Recipient is the other side's email from `transport` config.
26
+
27
+ 2. Ask: "What do you want to say to [other party name]?"
28
+
29
+ 3. Send as a `question` payload via transport:
30
+ - **Email subject:** `[Handoff] Question from [sender name]`
31
+ - **Email body:** The free-text message as structured JSON:
32
+ ```json
33
+ {
34
+ "type": "question",
35
+ "from": "sender name",
36
+ "message": "the message text",
37
+ "sent_at": "ISO timestamp"
38
+ }
39
+ ```
40
+
41
+ 4. Confirm: "Message sent to [recipient name]."
42
+
43
+ 5. If email transport falls back to file: "Message written to
44
+ `handoff-out/`. Transfer it manually or connect an email MCP
45
+ server."
@@ -0,0 +1,161 @@
1
+ ---
2
+ name: handoff-create
3
+ description: |
4
+ Build a handoff checklist conversationally. Interviews the consultant
5
+ about what the client needs to provide, generates the YAML and keypair.
6
+ Use when: "handoff create", "/handoff-create", "create a handoff",
7
+ "build a checklist for the client", "set up handoff".
8
+ manual: true
9
+ ---
10
+
11
+ # /handoff-create — Build a Handoff Checklist
12
+
13
+ ## Purpose
14
+
15
+ Interview the consultant to build the `handoff.yaml` checklist that
16
+ drives the client's `/handoff` experience. Generates the keypair for
17
+ credential encryption. This is where engagement-specific configuration
18
+ gets created.
19
+
20
+ ## Workflow
21
+
22
+ ### 1. Gather Basics
23
+
24
+ Ask one question at a time:
25
+
26
+ 1. "Who's the client? What's their name and email?"
27
+ 2. "What's this handoff for?" (becomes `meta.title`)
28
+ 3. "What transport should we use? Email is the default — it works
29
+ with Gmail, Outlook, or any email MCP. Or file-based if you'll
30
+ transfer manually." (becomes `transport.type`)
31
+ 4. "What's your email for receiving handoff items?" (consultant email)
32
+
33
+ ### 2. Build Sections
34
+
35
+ "What categories of items does the client need to provide? For example:
36
+ Hosting, Email Service, Domain, Analytics..."
37
+
38
+ For each section:
39
+ 1. "What does the client need to provide for [section]?"
40
+ 2. For each item, determine:
41
+ - **Kind:** Is this a decision (choose from options), a credential
42
+ (API key, token, password), free-text information, or a
43
+ confirmation (yes/no)?
44
+ - **Prompt:** What should Claude ask the client?
45
+ - **Help text:** Where can the client find this? (optional)
46
+ - **Options:** If it's a decision, what are the choices?
47
+ - **Visibility:** "Does this item depend on a prior answer?"
48
+ If yes, which item and which value(s) make it visible?
49
+ 3. "Any more items in [section]? Or ready for the next section?"
50
+
51
+ ### 3. Validate
52
+
53
+ Run validation on the constructed checklist:
54
+ - Cycle detection on visibility graph
55
+ - Orphan dependency check (depends_on references a non-existent key)
56
+ - Duplicate key detection
57
+
58
+ If issues found, surface them and help the consultant fix them
59
+ interactively.
60
+
61
+ ### 4. Generate
62
+
63
+ 1. Write `handoff.yaml` to the project root
64
+ 2. Check if `keys/consultant.pub.jwk` exists. If not, generate a
65
+ keypair:
66
+
67
+ ```bash
68
+ node .claude/handoff/generate-keys.mjs
69
+ ```
70
+
71
+ A secure OS dialog will prompt the consultant for a passphrase
72
+ (never enters conversation). Explain: "A dialog will appear — create
73
+ a passphrase to protect your private key. You'll need it when
74
+ retrieving credentials via `/handoff-status`."
75
+
76
+ 3. Show summary:
77
+ ```
78
+ Checklist: "Maginnis Go-Live Credentials"
79
+ Sections: 4 | Items: 12 (3 credentials, 5 decisions, 4 info)
80
+ Transport: email
81
+ Public key: keys/consultant.pub.jwk
82
+ ```
83
+
84
+ ### 5. Deploy to Client Plugin
85
+
86
+ Auto-detect the client plugin by scanning for `*/.claude-plugin/plugin.json`
87
+ from the project root (one level deep). If multiple plugins found, ask
88
+ which one. Always run this scan from the project root directory.
89
+
90
+ If a plugin is found:
91
+
92
+ 1. Create a `handoff/` directory inside the plugin root (sibling to
93
+ `.claude-plugin/`, `skills/`, etc.):
94
+ ```
95
+ <plugin-root>/handoff/handoff.yaml
96
+ <plugin-root>/handoff/consultant.pub.jwk
97
+ ```
98
+
99
+ 2. **Rewrite `meta.public_key`** in the deployed `handoff.yaml` copy.
100
+ The original says `./keys/consultant.pub.jwk` (relative to the
101
+ consultant's project). The deployed copy must say
102
+ `./consultant.pub.jwk` (relative to the deployed `handoff/`
103
+ directory where the key file now lives). Read the deployed YAML,
104
+ update the path, write it back.
105
+
106
+ 3. Create a `/handoff` skill in the plugin's `skills/` directory.
107
+ This skill tells the client's Claude exactly where the config is
108
+ using a path relative to the skill file itself — this works
109
+ regardless of where Claude Code installs the plugin on the client's
110
+ machine:
111
+
112
+ **Write to `<plugin-root>/skills/handoff/SKILL.md`:**
113
+ ```markdown
114
+ ---
115
+ name: handoff
116
+ description: |
117
+ Provide go-live credentials and configuration for your consultant.
118
+ Credentials are captured via a secure dialog — they never enter
119
+ this conversation or reach Anthropic's servers.
120
+ Use when: "handoff", "/handoff", "provide credentials".
121
+ manual: true
122
+ ---
123
+
124
+ # /handoff — Provide Your Credentials
125
+
126
+ This plugin bundles a handoff checklist from your consultant.
127
+
128
+ ## Locating the config
129
+
130
+ This skill file lives at `skills/handoff/SKILL.md` within the
131
+ plugin. The handoff config is two levels up in the `handoff/`
132
+ directory:
133
+
134
+ - Checklist: `../../handoff/handoff.yaml` (relative to this file)
135
+ - Public key: `../../handoff/consultant.pub.jwk` (relative to this file)
136
+
137
+ To get the absolute paths: take this skill file's absolute path,
138
+ go up two directories, then into `handoff/`.
139
+
140
+ ## What to do
141
+
142
+ 1. Resolve the absolute path to `handoff.yaml` as described above.
143
+ 2. Follow the `/handoff` skill workflow (from the project's CC
144
+ installation) using that path as the config location.
145
+ 3. Read and write `handoff-state.json` in the same directory as
146
+ `handoff.yaml` (the plugin's `handoff/` directory).
147
+ ```
148
+
149
+ 4. Confirm: "Deployed to [plugin-name]. When the client installs the
150
+ plugin and runs `/handoff`, everything is ready."
151
+
152
+ If no plugin found:
153
+
154
+ Surface the manual steps:
155
+ ```
156
+ No client plugin found in this project. To deploy manually:
157
+ 1. Create a handoff/ directory in the client's plugin
158
+ 2. Copy handoff.yaml into it (update meta.public_key to ./consultant.pub.jwk)
159
+ 3. Copy keys/consultant.pub.jwk as handoff/consultant.pub.jwk
160
+ 4. Add a /handoff skill to the plugin's skills/ directory (see schema.md)
161
+ ```