create-claude-cabinet 0.29.14 → 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.
package/lib/cli.js CHANGED
@@ -592,6 +592,22 @@ const MODULES = {
592
592
  ],
593
593
  postInstall: 'site-audit-setup',
594
594
  },
595
+ handoff: {
596
+ name: 'Handoff (secure credential collection)',
597
+ description: 'Skill-based credential handoff for consulting engagements. Encrypted credential capture via OS dialog, pluggable transport (email/MCP/file), bidirectional messaging. Six skills across consultant and client sides.',
598
+ mandatory: false,
599
+ default: false,
600
+ lean: false,
601
+ templates: [
602
+ 'skills/handoff',
603
+ 'skills/handoff-progress',
604
+ 'skills/handoff-ask',
605
+ 'skills/handoff-create',
606
+ 'skills/handoff-add',
607
+ 'skills/handoff-status',
608
+ 'handoff',
609
+ ],
610
+ },
595
611
  };
596
612
 
597
613
  /** Recursively collect all relative file paths under a directory. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.29.14",
3
+ "version": "0.30.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Single-subprocess credential capture + encryption.
4
+ // Plaintext never exits this process, never enters Claude's context or Anthropic's API.
5
+ // stdout: serialized encrypted envelope (base64) only.
6
+ //
7
+ import { readFile } from 'node:fs/promises';
8
+ import { captureSecureInput, detectPlatform, DialogCancelledError, DialogUnavailableError } from './secure-input.mjs';
9
+ import { encryptCredential, serializeEnvelope } from './handoff-crypto.mjs';
10
+
11
+ const args = process.argv.slice(2);
12
+
13
+ function getArg(name) {
14
+ const i = args.indexOf(name);
15
+ return i >= 0 ? args[i + 1] : null;
16
+ }
17
+
18
+ const prompt = getArg('--prompt');
19
+ const publicKeyPath = getArg('--public-key');
20
+ const testMode = args.includes('--test');
21
+
22
+ if (!testMode && (!prompt || !publicKeyPath)) {
23
+ console.error('Usage: node capture-and-encrypt.mjs --prompt <text> --public-key <path>');
24
+ console.error(' node capture-and-encrypt.mjs --test');
25
+ process.exit(1);
26
+ }
27
+
28
+ try {
29
+ if (testMode) {
30
+ const { generateKeypair, decryptCredential, deserializeEnvelope } = await import('./handoff-crypto.mjs');
31
+ const plaintext = 'test-credential-value';
32
+ const { publicKey, privateKey } = await generateKeypair();
33
+ const envelope = await encryptCredential(plaintext, publicKey);
34
+ const serialized = serializeEnvelope(envelope);
35
+ const recovered = deserializeEnvelope(serialized);
36
+ const decrypted = await decryptCredential(recovered, privateKey);
37
+
38
+ if (decrypted !== plaintext) {
39
+ console.error(JSON.stringify({ error: 'test_failed', detail: 'Roundtrip mismatch' }));
40
+ process.exit(4);
41
+ }
42
+ console.log(JSON.stringify({
43
+ ok: true,
44
+ platform: detectPlatform(),
45
+ envelope_id: envelope.envelope_id,
46
+ }));
47
+ process.exit(0);
48
+ }
49
+
50
+ const publicKeyJWK = JSON.parse(await readFile(publicKeyPath, 'utf-8'));
51
+ const plaintext = await captureSecureInput(prompt);
52
+ const envelope = await encryptCredential(plaintext, publicKeyJWK);
53
+ // Plaintext is now only in local `plaintext` var — GC will collect it.
54
+ // Only the encrypted envelope reaches stdout.
55
+ process.stdout.write(serializeEnvelope(envelope));
56
+ } catch (err) {
57
+ if (err instanceof DialogCancelledError) {
58
+ console.error(JSON.stringify({ error: 'cancelled' }));
59
+ process.exit(2);
60
+ }
61
+ if (err instanceof DialogUnavailableError) {
62
+ console.error(JSON.stringify({ error: 'no_dialog', platform: err.platform }));
63
+ process.exit(3);
64
+ }
65
+ console.error(JSON.stringify({ error: 'encrypt_failed', detail: err.message }));
66
+ process.exit(4);
67
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Decrypt a credential envelope using the consultant's passphrase-protected private key.
4
+ // Passphrase captured via secure OS dialog — never enters Claude's context.
5
+ // stdout: decrypted plaintext (displayed once by the skill, not stored).
6
+ //
7
+ import { readFile } from 'node:fs/promises';
8
+ import { captureSecureInput, DialogCancelledError, DialogUnavailableError } from './secure-input.mjs';
9
+ import { decryptCredential, decryptPrivateKey, deserializeEnvelope } from './handoff-crypto.mjs';
10
+
11
+ const args = process.argv.slice(2);
12
+
13
+ function getArg(name) {
14
+ const i = args.indexOf(name);
15
+ return i >= 0 ? args[i + 1] : null;
16
+ }
17
+
18
+ const envelopeInput = getArg('--envelope');
19
+ const privateKeyPath = getArg('--private-key') || './keys/consultant.priv.jwk.enc';
20
+ const testMode = args.includes('--test');
21
+
22
+ if (!testMode && !envelopeInput) {
23
+ console.error('Usage: node decrypt-credential.mjs --envelope <base64> [--private-key <path>]');
24
+ console.error(' node decrypt-credential.mjs --test');
25
+ process.exit(1);
26
+ }
27
+
28
+ try {
29
+ if (testMode) {
30
+ const { generateKeypair, encryptCredential, encryptPrivateKey, serializeEnvelope } = await import('./handoff-crypto.mjs');
31
+ const testPassphrase = 'test-pass-123';
32
+ const testPlaintext = 'test-secret-value';
33
+
34
+ const { publicKey, privateKey } = await generateKeypair();
35
+ const encrypted = await encryptPrivateKey(privateKey, testPassphrase);
36
+ const envelope = await encryptCredential(testPlaintext, publicKey);
37
+ const serialized = serializeEnvelope(envelope);
38
+
39
+ const recovered = decryptPrivateKey(encrypted, testPassphrase);
40
+ const decrypted = await decryptCredential(deserializeEnvelope(serialized), await recovered);
41
+
42
+ console.log(JSON.stringify({
43
+ ok: decrypted === testPlaintext,
44
+ detail: decrypted === testPlaintext ? 'Roundtrip passed' : 'Mismatch',
45
+ }));
46
+ process.exit(decrypted === testPlaintext ? 0 : 4);
47
+ }
48
+
49
+ const encryptedKey = JSON.parse(await readFile(privateKeyPath, 'utf-8'));
50
+ const envelope = deserializeEnvelope(envelopeInput);
51
+
52
+ const passphrase = await captureSecureInput('Enter your private key passphrase');
53
+ const privateKeyJWK = await decryptPrivateKey(encryptedKey, passphrase);
54
+ const plaintext = await decryptCredential(envelope, privateKeyJWK);
55
+
56
+ process.stdout.write(plaintext);
57
+ } catch (err) {
58
+ if (err instanceof DialogCancelledError) {
59
+ console.error(JSON.stringify({ error: 'cancelled' }));
60
+ process.exit(2);
61
+ }
62
+ if (err instanceof DialogUnavailableError) {
63
+ console.error(JSON.stringify({ error: 'no_dialog', platform: err.platform }));
64
+ process.exit(3);
65
+ }
66
+ if (err.message?.includes('decrypt') || err.message?.includes('OperationError')) {
67
+ console.error(JSON.stringify({ error: 'wrong_passphrase' }));
68
+ process.exit(5);
69
+ }
70
+ console.error(JSON.stringify({ error: 'decrypt_failed', detail: err.message }));
71
+ process.exit(4);
72
+ }
@@ -0,0 +1,81 @@
1
+ # Example handoff checklist — customize for your engagement.
2
+ # See schema.md for the full format reference.
3
+
4
+ meta:
5
+ title: "Project Go-Live Credentials"
6
+ consultant: "Your Name"
7
+ public_key: "./keys/consultant.pub.jwk"
8
+
9
+ transport:
10
+ type: email
11
+ consultant: "consultant@example.com"
12
+ client: "client@example.com"
13
+
14
+ sections:
15
+ - key: hosting
16
+ title: "Hosting Setup"
17
+ items:
18
+ - key: hosting_provider
19
+ prompt: "Which hosting provider are you using?"
20
+ kind: decide
21
+ options: [Railway, Vercel, Fly.io, Other]
22
+
23
+ - key: railway_token
24
+ prompt: "Your Railway API token"
25
+ kind: credential
26
+ help: "Find this at railway.app → Account Settings → Tokens"
27
+ visibility:
28
+ depends_on: hosting_provider
29
+ value_in: [Railway]
30
+
31
+ - key: vercel_token
32
+ prompt: "Your Vercel access token"
33
+ kind: credential
34
+ help: "Find this at vercel.com → Settings → Tokens"
35
+ visibility:
36
+ depends_on: hosting_provider
37
+ value_in: [Vercel]
38
+
39
+ - key: email_service
40
+ title: "Email Service"
41
+ items:
42
+ - key: email_provider
43
+ prompt: "Which email service are you using?"
44
+ kind: decide
45
+ options: [Postmark, SendGrid, Resend, None]
46
+
47
+ - key: postmark_token
48
+ prompt: "Your Postmark server API token"
49
+ kind: credential
50
+ help: "Find this in your Postmark server → API Tokens tab"
51
+ visibility:
52
+ depends_on: email_provider
53
+ value_in: [Postmark]
54
+
55
+ - key: sendgrid_key
56
+ prompt: "Your SendGrid API key"
57
+ kind: credential
58
+ visibility:
59
+ depends_on: email_provider
60
+ value_in: [SendGrid]
61
+
62
+ - key: resend_key
63
+ prompt: "Your Resend API key"
64
+ kind: credential
65
+ help: "Find this at resend.com → API Keys"
66
+ visibility:
67
+ depends_on: email_provider
68
+ value_in: [Resend]
69
+
70
+ - key: domain
71
+ title: "Domain & DNS"
72
+ items:
73
+ - key: domain_name
74
+ prompt: "What domain will this project use?"
75
+ kind: provide
76
+ help: "e.g., example.com or app.example.com"
77
+
78
+ - key: dns_access
79
+ prompt: "Do you have access to your domain's DNS settings?"
80
+ kind: confirm
81
+ help: "We'll need to add records for the hosting provider and email service."
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { generateKeypair, encryptPrivateKey } from './handoff-crypto.mjs';
3
+ import { captureSecureInput } from './secure-input.mjs';
4
+ import { mkdir, writeFile } from 'node:fs/promises';
5
+ import { resolve } from 'node:path';
6
+
7
+ const args = process.argv.slice(2);
8
+
9
+ function getArg(name) {
10
+ const i = args.indexOf(name);
11
+ return i >= 0 ? args[i + 1] : null;
12
+ }
13
+
14
+ const outdir = getArg('--outdir') || './keys';
15
+ const testPassphrase = getArg('--test-passphrase');
16
+
17
+ let passphrase;
18
+ if (testPassphrase) {
19
+ passphrase = testPassphrase;
20
+ } else {
21
+ console.error('A passphrase dialog will appear — enter a passphrase to protect your private key.');
22
+ console.error('You will need this passphrase to decrypt credentials via /handoff-status.');
23
+ passphrase = await captureSecureInput('Create a passphrase for your private key');
24
+ }
25
+
26
+ const { publicKey, privateKey } = await generateKeypair();
27
+ const encrypted = await encryptPrivateKey(privateKey, passphrase);
28
+
29
+ await mkdir(resolve(outdir), { recursive: true });
30
+ await writeFile(resolve(outdir, 'consultant.pub.jwk'), JSON.stringify(publicKey, null, 2));
31
+ await writeFile(resolve(outdir, 'consultant.priv.jwk.enc'), JSON.stringify(encrypted, null, 2));
32
+
33
+ console.log(`Keys written to ${outdir}/`);
34
+ console.log(' consultant.pub.jwk — share with client (ships with plugin)');
35
+ console.log(' consultant.priv.jwk.enc — keep private (encrypted with your passphrase)');
@@ -0,0 +1,172 @@
1
+ import { readFile, writeFile, rename } from 'node:fs/promises';
2
+
3
+ const VALID_KINDS = ['decide', 'provide', 'confirm', 'credential'];
4
+ const VALID_TRANSPORT_TYPES = ['email', 'mcp', 'file'];
5
+
6
+ export function validateChecklist(checklist) {
7
+ const errors = [];
8
+
9
+ if (!checklist.meta) errors.push('Missing "meta" section');
10
+ else {
11
+ if (!checklist.meta.title) errors.push('meta.title is required');
12
+ if (!checklist.meta.public_key) errors.push('meta.public_key is required');
13
+ }
14
+
15
+ if (!checklist.transport) {
16
+ errors.push('Missing "transport" section');
17
+ } else if (!VALID_TRANSPORT_TYPES.includes(checklist.transport.type)) {
18
+ errors.push(`transport.type must be one of: ${VALID_TRANSPORT_TYPES.join(', ')}`);
19
+ }
20
+
21
+ if (!Array.isArray(checklist.sections)) {
22
+ errors.push('Missing or invalid "sections" array');
23
+ return { valid: false, errors };
24
+ }
25
+
26
+ // First pass: collect all keys for cross-section dependency validation
27
+ const allKeys = new Set();
28
+ for (const section of checklist.sections) {
29
+ for (const item of (section.items || [])) {
30
+ if (item.key) allKeys.add(item.key);
31
+ }
32
+ }
33
+
34
+ const seenKeys = new Set();
35
+ for (const section of checklist.sections) {
36
+ if (!section.key) errors.push('Section missing "key"');
37
+ if (!section.title) errors.push(`Section ${section.key || '?'} missing "title"`);
38
+ if (!Array.isArray(section.items)) {
39
+ errors.push(`Section ${section.key || '?'} missing "items" array`);
40
+ continue;
41
+ }
42
+ for (const item of section.items) {
43
+ if (!item.key) { errors.push('Item missing "key"'); continue; }
44
+ if (!item.prompt) errors.push(`Item ${item.key} missing "prompt"`);
45
+ if (!VALID_KINDS.includes(item.kind)) {
46
+ errors.push(`Item ${item.key}: kind must be one of: ${VALID_KINDS.join(', ')}`);
47
+ }
48
+ if (seenKeys.has(item.key)) errors.push(`Duplicate item key: ${item.key}`);
49
+ seenKeys.add(item.key);
50
+
51
+ if (item.visibility) {
52
+ if (!item.visibility.depends_on) errors.push(`Item ${item.key}: visibility.depends_on is required`);
53
+ if (!Array.isArray(item.visibility.value_in)) errors.push(`Item ${item.key}: visibility.value_in must be an array`);
54
+ if (item.visibility.depends_on && !allKeys.has(item.visibility.depends_on)) {
55
+ errors.push(`Item ${item.key}: depends_on "${item.visibility.depends_on}" references non-existent key`);
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ return { valid: errors.length === 0, errors };
62
+ }
63
+
64
+ function getAllItems(checklist) {
65
+ return (checklist.sections || []).flatMap(s => s.items || []);
66
+ }
67
+
68
+ export function detectCycles(checklist) {
69
+ const items = getAllItems(checklist);
70
+ const keySet = new Set(items.map(i => i.key));
71
+ const graph = new Map();
72
+ const inDegree = new Map();
73
+
74
+ for (const item of items) {
75
+ graph.set(item.key, []);
76
+ inDegree.set(item.key, 0);
77
+ }
78
+
79
+ for (const item of items) {
80
+ const parent = item.visibility?.depends_on;
81
+ if (parent && keySet.has(parent)) {
82
+ graph.get(parent).push(item.key);
83
+ inDegree.set(item.key, inDegree.get(item.key) + 1);
84
+ }
85
+ }
86
+
87
+ const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([k]) => k);
88
+ let processed = 0;
89
+
90
+ while (queue.length > 0) {
91
+ const key = queue.shift();
92
+ processed++;
93
+ for (const child of graph.get(key) || []) {
94
+ const nd = inDegree.get(child) - 1;
95
+ inDegree.set(child, nd);
96
+ if (nd === 0) queue.push(child);
97
+ }
98
+ }
99
+
100
+ const hasCycle = processed < items.length;
101
+ const cycleKeys = hasCycle
102
+ ? [...inDegree.entries()].filter(([, d]) => d > 0).map(([k]) => k)
103
+ : [];
104
+
105
+ return { hasCycle, cycleKeys };
106
+ }
107
+
108
+ export function computeVisibility(checklist, answers) {
109
+ const items = getAllItems(checklist);
110
+ const visible = new Set();
111
+
112
+ for (const item of items) {
113
+ if (!item.visibility) visible.add(item.key);
114
+ }
115
+
116
+ let changed = true;
117
+ while (changed) {
118
+ changed = false;
119
+ for (const item of items) {
120
+ if (visible.has(item.key) || !item.visibility) continue;
121
+ const parentKey = item.visibility.depends_on;
122
+ if (!visible.has(parentKey)) continue;
123
+ const parentAnswer = answers[parentKey]?.value;
124
+ if (parentAnswer && item.visibility.value_in.includes(parentAnswer)) {
125
+ visible.add(item.key);
126
+ changed = true;
127
+ }
128
+ }
129
+ }
130
+
131
+ return visible;
132
+ }
133
+
134
+ export async function loadState(path) {
135
+ try {
136
+ return JSON.parse(await readFile(path, 'utf-8'));
137
+ } catch (err) {
138
+ if (err.code === 'ENOENT') return null;
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ export function createState(checklistPath) {
144
+ return {
145
+ checklist: checklistPath,
146
+ started_at: new Date().toISOString(),
147
+ updated_at: new Date().toISOString(),
148
+ answers: {},
149
+ };
150
+ }
151
+
152
+ export async function saveState(path, state) {
153
+ state.updated_at = new Date().toISOString();
154
+ const tmp = path + '.tmp';
155
+ await writeFile(tmp, JSON.stringify(state, null, 2));
156
+ await rename(tmp, path);
157
+ }
158
+
159
+ export function recordAnswer(state, key, value) {
160
+ state.answers[key] = { value, answered_at: new Date().toISOString() };
161
+ }
162
+
163
+ export function recordCredentialSent(state, key, envelopeId) {
164
+ state.answers[key] = { status: 'sent', envelope_id: envelopeId, sent_at: new Date().toISOString() };
165
+ }
166
+
167
+ export function getProgress(checklist, state) {
168
+ const answers = state?.answers || {};
169
+ const visible = computeVisibility(checklist, answers);
170
+ const completed = [...visible].filter(key => answers[key]).length;
171
+ return { total: visible.size, completed, remaining: visible.size - completed };
172
+ }
@@ -0,0 +1,102 @@
1
+ import { webcrypto } from 'node:crypto';
2
+
3
+ const { subtle } = webcrypto;
4
+
5
+ const RSA_ALGO = {
6
+ name: 'RSA-OAEP',
7
+ modulusLength: 2048,
8
+ publicExponent: new Uint8Array([1, 0, 1]),
9
+ hash: 'SHA-256',
10
+ };
11
+
12
+ const AES_ALGO = { name: 'AES-GCM', length: 256 };
13
+ const PBKDF2_ITERATIONS = 600_000;
14
+
15
+ export async function generateKeypair() {
16
+ const pair = await subtle.generateKey(RSA_ALGO, true, ['wrapKey', 'unwrapKey']);
17
+ return {
18
+ publicKey: await subtle.exportKey('jwk', pair.publicKey),
19
+ privateKey: await subtle.exportKey('jwk', pair.privateKey),
20
+ };
21
+ }
22
+
23
+ export async function encryptCredential(plaintext, publicKeyJWK) {
24
+ const publicKey = await subtle.importKey('jwk', publicKeyJWK, RSA_ALGO, false, ['wrapKey']);
25
+ const aesKey = await subtle.generateKey(AES_ALGO, true, ['encrypt']);
26
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
27
+ const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, new TextEncoder().encode(plaintext));
28
+ const wrappedKey = await subtle.wrapKey('raw', aesKey, publicKey, RSA_ALGO);
29
+ const idBytes = webcrypto.getRandomValues(new Uint8Array(6));
30
+ const envelope_id = 'env_' + [...idBytes].map(b => b.toString(16).padStart(2, '0')).join('');
31
+
32
+ return {
33
+ envelope_id,
34
+ ciphertext: Buffer.from(ciphertext).toString('base64'),
35
+ iv: Buffer.from(iv).toString('base64'),
36
+ wrappedKey: Buffer.from(wrappedKey).toString('base64'),
37
+ };
38
+ }
39
+
40
+ export async function decryptCredential(envelope, privateKeyJWK) {
41
+ const privateKey = await subtle.importKey('jwk', privateKeyJWK, RSA_ALGO, false, ['unwrapKey']);
42
+ const aesKey = await subtle.unwrapKey(
43
+ 'raw',
44
+ Buffer.from(envelope.wrappedKey, 'base64'),
45
+ privateKey,
46
+ RSA_ALGO,
47
+ AES_ALGO,
48
+ false,
49
+ ['decrypt'],
50
+ );
51
+ const decrypted = await subtle.decrypt(
52
+ { name: 'AES-GCM', iv: Buffer.from(envelope.iv, 'base64') },
53
+ aesKey,
54
+ Buffer.from(envelope.ciphertext, 'base64'),
55
+ );
56
+ return new TextDecoder().decode(decrypted);
57
+ }
58
+
59
+ export function serializeEnvelope(envelope) {
60
+ return Buffer.from(JSON.stringify(envelope)).toString('base64');
61
+ }
62
+
63
+ export function deserializeEnvelope(base64) {
64
+ return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
65
+ }
66
+
67
+ export async function encryptPrivateKey(privateKeyJWK, passphrase) {
68
+ const salt = webcrypto.getRandomValues(new Uint8Array(16));
69
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
70
+ const keyMaterial = await subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
71
+ const derived = await subtle.deriveKey(
72
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
73
+ keyMaterial,
74
+ AES_ALGO,
75
+ false,
76
+ ['encrypt'],
77
+ );
78
+ const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, derived, new TextEncoder().encode(JSON.stringify(privateKeyJWK)));
79
+
80
+ return {
81
+ salt: Buffer.from(salt).toString('base64'),
82
+ iv: Buffer.from(iv).toString('base64'),
83
+ ciphertext: Buffer.from(ciphertext).toString('base64'),
84
+ };
85
+ }
86
+
87
+ export async function decryptPrivateKey(encrypted, passphrase) {
88
+ const keyMaterial = await subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
89
+ const derived = await subtle.deriveKey(
90
+ { name: 'PBKDF2', salt: Buffer.from(encrypted.salt, 'base64'), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
91
+ keyMaterial,
92
+ AES_ALGO,
93
+ false,
94
+ ['decrypt'],
95
+ );
96
+ const decrypted = await subtle.decrypt(
97
+ { name: 'AES-GCM', iv: Buffer.from(encrypted.iv, 'base64') },
98
+ derived,
99
+ Buffer.from(encrypted.ciphertext, 'base64'),
100
+ );
101
+ return JSON.parse(new TextDecoder().decode(decrypted));
102
+ }
@@ -0,0 +1,105 @@
1
+ import { writeFile, readFile, readdir, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ const EMAIL_MCP_SIGNATURES = [
5
+ { provider: 'gmail', toolPattern: /gmail/i },
6
+ { provider: 'outlook', toolPattern: /outlook|microsoft/i },
7
+ ];
8
+
9
+ export function detectEmailProvider(availableTools) {
10
+ if (!availableTools?.length) return null;
11
+ for (const sig of EMAIL_MCP_SIGNATURES) {
12
+ if (availableTools.some(t => sig.toolPattern.test(t))) return sig.provider;
13
+ }
14
+ return null;
15
+ }
16
+
17
+ export function buildEmailInstruction(envelope, transportConfig, provider, recipient) {
18
+ const envelopeId = typeof envelope === 'object' ? envelope.envelope_id : transportConfig.envelope_id;
19
+ const serialized = typeof envelope === 'string'
20
+ ? envelope
21
+ : Buffer.from(JSON.stringify(envelope)).toString('base64');
22
+
23
+ return {
24
+ type: 'email',
25
+ provider,
26
+ to: recipient,
27
+ subject: `[Handoff] ${envelopeId || 'delivery'}`,
28
+ body: serialized,
29
+ };
30
+ }
31
+
32
+ export function buildMcpInstruction(envelope, transportConfig) {
33
+ const serialized = typeof envelope === 'string'
34
+ ? envelope
35
+ : Buffer.from(JSON.stringify(envelope)).toString('base64');
36
+
37
+ return {
38
+ type: 'mcp',
39
+ tool: transportConfig.tool_name,
40
+ params: {
41
+ [transportConfig.payload_param || 'payload']: serialized,
42
+ ...(transportConfig.extra_params || {}),
43
+ },
44
+ };
45
+ }
46
+
47
+ export async function sendViaFile(envelope, config) {
48
+ const outdir = config?.outdir || './handoff-out';
49
+ await mkdir(outdir, { recursive: true });
50
+
51
+ const id = envelope.envelope_id || `env_${Date.now().toString(36)}`;
52
+ const filepath = join(outdir, `${id}.enc`);
53
+ const serialized = typeof envelope === 'string'
54
+ ? envelope
55
+ : Buffer.from(JSON.stringify(envelope)).toString('base64');
56
+
57
+ await writeFile(filepath, serialized);
58
+ return { type: 'file', delivered: true, ref: id, path: filepath };
59
+ }
60
+
61
+ export async function readFromFile(envelopeId, config) {
62
+ const outdir = config?.outdir || './handoff-out';
63
+ return readFile(join(outdir, `${envelopeId}.enc`), 'utf-8');
64
+ }
65
+
66
+ export async function listFiles(config) {
67
+ const outdir = config?.outdir || './handoff-out';
68
+ try {
69
+ const files = await readdir(outdir);
70
+ return files.filter(f => f.endsWith('.enc')).map(f => f.replace('.enc', ''));
71
+ } catch (err) {
72
+ if (err.code === 'ENOENT') return [];
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ export async function send(envelope, transportConfig, context) {
78
+ const type = transportConfig.type || 'file';
79
+
80
+ switch (type) {
81
+ case 'email': {
82
+ const provider = context?.emailProvider || detectEmailProvider(context?.availableTools);
83
+ if (!provider) {
84
+ process.stderr.write(
85
+ 'Warning: No email MCP connected — falling back to file transport.\n' +
86
+ 'Transfer files manually or connect an email MCP server.\n',
87
+ );
88
+ return { ...(await sendViaFile(envelope, transportConfig)), fallback: true };
89
+ }
90
+ const recipient = context?.side === 'consultant'
91
+ ? transportConfig.client
92
+ : transportConfig.consultant;
93
+ return buildEmailInstruction(envelope, transportConfig, provider, recipient);
94
+ }
95
+
96
+ case 'mcp':
97
+ return buildMcpInstruction(envelope, transportConfig);
98
+
99
+ case 'file':
100
+ return sendViaFile(envelope, transportConfig);
101
+
102
+ default:
103
+ throw new Error(`Unknown transport type: ${type}`);
104
+ }
105
+ }