primcli 1.34.0 → 1.36.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,48 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ const receiver = process.argv.length === 4 && process.argv[2] === '--receiver'
5
+ ? process.argv[3]
6
+ : null;
7
+ if (receiver !== 'native' && receiver !== 'external') {
8
+ process.stderr.write('Usage: node scripts/check-cli-capabilities.mjs --receiver native|external\n');
9
+ process.exitCode = 2;
10
+ } else {
11
+ const checks = [
12
+ { args: ['agent', 'connect', '--help'], label: 'agent connect', patterns: [/--session\b/, /--receiver\b/] },
13
+ { args: ['agent', 'enroll', '--help'], label: 'agent enroll', patterns: [/--session\b/, /--receiver\b/, /--name\b/] },
14
+ { args: ['listen', '--help'], label: 'listen', patterns: [/external mail events at tool-output authority/i, /never synthetic user messages/i] },
15
+ { args: ['network', 'peers', '--help'], label: 'network peers', patterns: [/discover listed peers/i, /--owner\b/] },
16
+ ];
17
+ if (receiver === 'external') {
18
+ checks[0].patterns.push(/installs the exact Claude session's fail-open Stop hook/i, /resume SessionStart hook/i);
19
+ checks[1].patterns.push(/exact Claude session, install a fail-open Stop hook/i, /resume SessionStart hook/i);
20
+ checks[2].patterns.push(/--wake\b/, /--hook-session\b/);
21
+ } else {
22
+ checks[2].patterns.push(/--notify-session\b/, /--background\b/);
23
+ }
24
+
25
+ const missing = [];
26
+ for (const { args, label, patterns } of checks) {
27
+ const result = spawnSync('primitive', args, {
28
+ encoding: 'utf8',
29
+ timeout: 10_000,
30
+ maxBuffer: 128 * 1024,
31
+ });
32
+ if (result.error || result.status !== 0) {
33
+ missing.push(`${label} --help is unavailable`);
34
+ continue;
35
+ }
36
+ const help = result.stdout.replace(/\s+/g, ' ');
37
+ if (patterns.some((pattern) => !pattern.test(help))) {
38
+ missing.push(`${label} --help lacks required setup, ${receiver} receiving, or peer-discovery support`);
39
+ }
40
+ }
41
+
42
+ if (missing.length) {
43
+ process.stderr.write(`Primitive CLI preflight failed: ${missing.join('; ')}. Do not claim the invitation or create an address with this CLI. Install a CLI build with these capabilities and run this check again, or connect with the HTTP API instead.\n`);
44
+ process.exitCode = 1;
45
+ } else {
46
+ process.stdout.write(`Primitive CLI preflight passed for ${receiver} receiving and peer discovery.\n`);
47
+ }
48
+ }
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ const ORIGIN = 'https://api.primitive.dev';
9
+ const API = `${ORIGIN}/v1`;
10
+
11
+ export class PrimitiveApiError extends Error {
12
+ constructor(status, code) {
13
+ status = Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
14
+ const safeCode = typeof code === 'string' && /^[a-z_]{1,80}$/.test(code) ? code : undefined;
15
+ super(`Primitive returned HTTP ${status ?? 'unknown'}${safeCode ? ` (${safeCode})` : ''}.${status === 401 ? ' Stop and ask the owner for a fresh invitation.' : ''}`);
16
+ this.status = status;
17
+ this.code = safeCode;
18
+ }
19
+ }
20
+
21
+ function fail(message) { throw new Error(message); }
22
+ function json(text) {
23
+ try { return JSON.parse(text); }
24
+ catch { fail('Invalid JSON. Inspect private state without printing credentials.'); }
25
+ }
26
+ async function optionalFile(path) {
27
+ try { return await readFile(path, 'utf8'); }
28
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
29
+ }
30
+ async function writePrivate(path, value) {
31
+ const temporary = `${path}.${randomUUID()}.tmp`;
32
+ const file = await open(temporary, 'wx', 0o600);
33
+ try {
34
+ await file.writeFile(`${JSON.stringify(value, null, 2)}\n`);
35
+ await file.sync();
36
+ await file.close();
37
+ await rename(temporary, path);
38
+ } finally {
39
+ await file.close();
40
+ await rm(temporary, { force: true });
41
+ }
42
+ }
43
+ function invitationToken(instruction) {
44
+ const matches = instruction.match(/https:\/\/api\.primitive\.dev\/v1\/agent-connections\/setup[^\s<>"']*/g);
45
+ if (matches?.length !== 1) fail('Supply one owner-provided setup URL or copied instruction on stdin.');
46
+ const url = new URL(matches[0]);
47
+ const fragment = new URLSearchParams(url.hash.slice(1));
48
+ const token = fragment.get('token');
49
+ if (url.origin !== ORIGIN || url.pathname !== '/v1/agent-connections/setup' || url.search ||
50
+ url.username || url.password || fragment.size !== 1 || !token || /\s/.test(token)) {
51
+ fail('Invalid setup URL. Get a fresh instruction from the owner app.');
52
+ }
53
+ return token;
54
+ }
55
+ function validateState(value) {
56
+ if (!value || typeof value.api_key !== 'string' || !value.api_key ||
57
+ value.api_base_url !== API || typeof value.org_id !== 'string' || !value.org_id ||
58
+ typeof value.owner_address !== 'string' || !value.owner_address.includes('@') ||
59
+ typeof value.connection?.address !== 'string' || !value.connection.address.includes('@')) {
60
+ fail('Invalid connection credential response. Get a fresh owner invitation if claiming failed.');
61
+ }
62
+ return value;
63
+ }
64
+ function summary(state) {
65
+ return {
66
+ address: state.connection.address,
67
+ owner_address: state.owner_address,
68
+ org_id: state.org_id,
69
+ credential_saved: true,
70
+ verification: 'Confirm with the owner app after replying using this credential.',
71
+ };
72
+ }
73
+ async function call(fetcher, url, options) {
74
+ let response;
75
+ try { response = await fetcher(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(35000) }); }
76
+ catch { fail('Request outcome is unknown. Do not blindly retry a claim or send; reconcile or obtain a fresh invitation.'); }
77
+ let envelope;
78
+ try { envelope = await response.json(); }
79
+ catch { fail('Unreadable API response. A mutation may have succeeded; do not blindly retry it.'); }
80
+ if (!response.ok || envelope.success === false) {
81
+ throw new PrimitiveApiError(response.status, envelope?.error?.code);
82
+ }
83
+ return envelope;
84
+ }
85
+
86
+ export async function run(args, { input = '', directory, fetcher = fetch } = {}) {
87
+ const stateDir = directory ?? process.env.PRIMITIVE_AGENT_STATE_DIR ?? join(homedir(), '.local', 'state', 'primitive-connect');
88
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
89
+ await chmod(stateDir, 0o700);
90
+ const statePath = join(stateDir, 'connection.json');
91
+ const [command, method, path, idempotencyKey] = args;
92
+ if (command === 'claim') {
93
+ if (args.length !== 1) fail('Pass the setup instruction on stdin, never as a command argument.');
94
+ const token = invitationToken(input);
95
+ const digest = createHash('sha256').update(token).digest('hex');
96
+ const lockPath = join(stateDir, 'claim.lock');
97
+ try { await mkdir(lockPath, { mode: 0o700 }); }
98
+ catch (error) {
99
+ if (error.code === 'EEXIST') fail('A claim is running or was interrupted. Check that process before removing the private claim.lock directory.');
100
+ throw error;
101
+ }
102
+ try {
103
+ const previousText = await optionalFile(statePath);
104
+ const previous = previousText ? validateState(json(previousText)) : null;
105
+ if (previous?.invitation_sha256 === digest) return summary(previous);
106
+ const pendingPath = join(stateDir, 'pending-claim.json');
107
+ const pending = await optionalFile(pendingPath);
108
+ if (pending && json(pending).invitation_sha256 === digest) {
109
+ fail('This invitation was already attempted. Its one-time response may be lost; ask the owner for a fresh invitation.');
110
+ }
111
+ await writePrivate(pendingPath, { invitation_sha256: digest, attempted_at: new Date().toISOString() });
112
+ const envelope = await call(fetcher, `${API}/agent-connections/claim`, {
113
+ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
114
+ body: JSON.stringify({ token }),
115
+ });
116
+ const state = validateState(envelope.data);
117
+ if (previous && (previous.org_id !== state.org_id || previous.connection.address !== state.connection.address ||
118
+ previous.owner_address !== state.owner_address)) {
119
+ fail('The invitation changes the saved identity or owner. Existing state was preserved. Confirm the intended identity with the owner.');
120
+ }
121
+ await writePrivate(statePath, { ...state, invitation_sha256: digest });
122
+ await rm(pendingPath, { force: true });
123
+ return summary(state);
124
+ } finally { await rm(lockPath, { recursive: true, force: true }); }
125
+ }
126
+ const saved = await optionalFile(statePath);
127
+ if (!saved) fail('No saved connection. Claim the owner-provided setup instruction first.');
128
+ const state = validateState(json(saved));
129
+ if (command === 'status' && args.length === 1) return summary(state);
130
+ if (command !== 'request' || !method || !path || args.length > 4) {
131
+ fail('Usage: connection.mjs claim | status | request METHOD /path [idempotency-key]. JSON bodies come from stdin.');
132
+ }
133
+ const url = new URL(`${API}${path}`);
134
+ if (!url.pathname.startsWith('/v1/')) fail('This helper supports only the documented address-scoped email and notes paths.');
135
+ const pathname = url.pathname.slice('/v1'.length);
136
+ let ownNote = false;
137
+ if (method === 'PUT') {
138
+ const parts = pathname.split('/');
139
+ if (parts.length === 4 && parts[1] === 'address-notes') {
140
+ let address, name;
141
+ try { address = decodeURIComponent(parts[2]); name = decodeURIComponent(parts[3]); }
142
+ catch { fail('Invalid address-note path encoding.'); }
143
+ ownNote = address === state.connection.address && Boolean(name) && !name.includes('/');
144
+ if (ownNote) url.pathname = `/v1/address-notes/${encodeURIComponent(address)}/${encodeURIComponent(name)}`;
145
+ }
146
+ }
147
+ const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
148
+ const conversationRead = method === 'GET' && !url.search && (
149
+ new RegExp(`^/threads/${uuid}$`, 'i').test(pathname) ||
150
+ new RegExp(`^/emails/${uuid}/conversation$`, 'i').test(pathname)
151
+ );
152
+ const allowed = url.pathname.startsWith('/v1/') && url.origin === ORIGIN && !url.hash && !url.username && !url.password && path.startsWith('/') && (
153
+ (method === 'GET' && (/^\/(emails|sent-emails)(\/[^/]+)?$/.test(pathname) || pathname === '/address-notes')) ||
154
+ conversationRead ||
155
+ (method === 'POST' && pathname === '/send-mail') ||
156
+ (method === 'PUT' && ownNote)
157
+ );
158
+ if (!allowed) fail('This helper supports only the documented address-scoped email, thread and notes paths.');
159
+ const headers = { Authorization: `Bearer ${state.api_key}`, Accept: 'application/json' };
160
+ let body;
161
+ if (method !== 'GET') {
162
+ body = json(input);
163
+ if (!body || typeof body !== 'object' || Array.isArray(body)) fail('Expected a JSON object on stdin.');
164
+ if (method === 'POST') {
165
+ if (!idempotencyKey || /[\r\n]/.test(idempotencyKey)) fail('Provide a stable idempotency key for sending.');
166
+ if (body.from && body.from !== state.connection.address) fail('The sender must match the saved connection address.');
167
+ body.from = state.connection.address;
168
+ headers['Idempotency-Key'] = idempotencyKey;
169
+ }
170
+ headers['Content-Type'] = 'application/json';
171
+ }
172
+ return call(fetcher, url.href, { method, headers, ...(body ? { body: JSON.stringify(body) } : {}) });
173
+ }
174
+
175
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
176
+ try {
177
+ const args = process.argv.slice(2);
178
+ const needsInput = args[0] === 'claim' || (args[0] === 'request' && args[1] !== 'GET');
179
+ let input = '';
180
+ if (needsInput) {
181
+ for await (const chunk of process.stdin) {
182
+ input += chunk;
183
+ if (input.length > 1024 * 1024) fail('Input is too large.');
184
+ }
185
+ }
186
+ console.log(JSON.stringify(await run(args, { input }), null, 2));
187
+ } catch (error) {
188
+ // Never print response bodies, request bodies, URLs, or raw runtime exceptions.
189
+ const known = error instanceof PrimitiveApiError || (error instanceof Error && error.constructor === Error);
190
+ console.error(known ? error.message : 'Local connection operation failed. Check private storage and permissions.');
191
+ process.exitCode = 1;
192
+ }
193
+ }
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { prepareSignalEmail, sendPreparedSignal } from '@primitivedotdev/sdk/interactions';
8
+ import { PrimitiveApiError, run as connection } from './connection.mjs';
9
+
10
+ export const commands = Object.freeze({
11
+ send: 'send OPERATION_ID < message.json {"to":"...","subject":"...","text":"..."}',
12
+ reply: 'reply EMAIL_ID OPERATION_ID < reply.txt',
13
+ signal: 'signal EMAIL_ID OPERATION_ID < signal.json {"kind":"typing"}, {"kind":"working"}, {"kind":"read"}, or {"kind":"ack","status":"received"}',
14
+ });
15
+ export const help = `Usage: mail.mjs ${Object.values(commands).join('\n mail.mjs ')}\n\nEMAIL_ID is a received email record ID. Reuse OPERATION_ID for the same operation.\nBodies come from stdin. Signals are ordinary emails. Verify the sender before replying or reporting progress.`;
16
+ class MailError extends Error {}
17
+ function check(value, message) { if (!value) throw new MailError(message); }
18
+ function address(value) {
19
+ check(typeof value === 'string' && value.length <= 320 && /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(value), 'Expected a bare email address.');
20
+ return value.toLowerCase();
21
+ }
22
+ function header(value) {
23
+ check(typeof value === 'string' && value.trim() && Buffer.byteLength(value, 'utf8') <= 998 && !/[\x00-\x1f\x7f]/.test(value), 'Missing or invalid email threading header.');
24
+ return value;
25
+ }
26
+ function bodyText(value) {
27
+ check(typeof value === 'string' && value.trim() && value.length <= 100000, 'Supply a nonempty message of up to 100,000 characters.');
28
+ return value;
29
+ }
30
+ async function read(path) {
31
+ try { return JSON.parse(await readFile(path, 'utf8')); }
32
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
33
+ }
34
+ async function save(path, value) {
35
+ const temporary = `${path}.${randomUUID()}.tmp`;
36
+ const file = await open(temporary, 'wx', 0o600);
37
+ try {
38
+ await file.writeFile(JSON.stringify(value));
39
+ await file.sync();
40
+ await file.close();
41
+ await rename(temporary, path);
42
+ // Windows FlushFileBuffers requires a writable handle; directories use read handles.
43
+ if (process.platform !== 'win32') {
44
+ const directory = await open(dirname(path), 'r');
45
+ try { await directory.sync(); }
46
+ finally { await directory.close(); }
47
+ }
48
+ } finally {
49
+ await file.close();
50
+ await rm(temporary, { force: true });
51
+ }
52
+ }
53
+ function receipt(data) {
54
+ check(typeof data?.id === 'string' && data.id.length > 0, 'Send outcome is unknown. Reconcile using the same operation ID.');
55
+ return { id: data.id, status: typeof data.status === 'string' ? data.status : 'accepted' };
56
+ }
57
+
58
+ /** Optional adapter: inject the runtime's existing credential transport. Never claim again. */
59
+ export function createMailer({ identity, request, directory, now = Date.now, uuid = randomUUID }) {
60
+ const own = address(identity.address);
61
+ check(typeof identity.org_id === 'string' && identity.org_id, 'Pin the organization from the connection claim.');
62
+ check(typeof directory === 'string' && directory, 'Choose private durable outbox storage.');
63
+ const scope = { address: own, org_id: identity.org_id };
64
+ async function parent(id, signalOnly = false) {
65
+ check(typeof id === 'string' && /^[a-zA-Z0-9-]{1,128}$/.test(id), 'Supply a received email record ID.');
66
+ const message = (await request('GET', `/emails/${encodeURIComponent(id)}`)).data;
67
+ check(address(message.recipient) === own && address(message.from_email) !== own, 'The parent must be incoming mail addressed to this agent.');
68
+ if (signalOnly) {
69
+ const parts = message.parsed?.attachments ?? message.attachments;
70
+ check(Array.isArray(parts), 'Inspect the parent attachment inventory before reporting progress.');
71
+ check(!parts.some(part => part.filename?.toLowerCase() === 'interaction.json'), 'Do not automatically report progress on interaction emails.');
72
+ }
73
+ const references = message.references ?? message.parsed?.references ?? [];
74
+ check(Array.isArray(references), 'Invalid parent references.');
75
+ return {
76
+ accountScope: identity.org_id, from: address(message.from_email), to: own,
77
+ messageId: header(message.message_id), subject: header(message.subject || 'Conversation'),
78
+ references: references.map(header),
79
+ };
80
+ }
81
+ async function operation(id, intent, prepare) {
82
+ check(typeof id === 'string' && /^[a-zA-Z0-9:_-]{1,128}$/.test(id), 'Use a stable operation ID of up to 128 letters, digits, colons, underscores, or hyphens.');
83
+ await mkdir(directory, { recursive: true, mode: 0o700 });
84
+ await chmod(directory, 0o700);
85
+ const hash = createHash('sha256').update(JSON.stringify([scope, id])).digest('hex');
86
+ const path = join(directory, `${hash}.json`), lock = join(directory, `${hash}.lock`);
87
+ try { await mkdir(lock, { mode: 0o700 }); }
88
+ catch (error) {
89
+ if (error.code === 'EEXIST') throw new MailError('This operation is running or was interrupted. Inspect its process before removing the private lock directory.');
90
+ throw error;
91
+ }
92
+ try {
93
+ let record = await read(path);
94
+ if (record) {
95
+ check(JSON.stringify(record.scope) === JSON.stringify(scope) && JSON.stringify(record.intent) === JSON.stringify(intent), 'This operation ID already belongs to a different message.');
96
+ } else {
97
+ record = { scope, intent, prepared: await prepare(), attempted: false, receipt: null };
98
+ await save(path, record);
99
+ }
100
+ if (record.receipt) return record.receipt;
101
+ const prepared = record.prepared;
102
+ if (record.attempted) {
103
+ const found = (await request('GET', `/sent-emails?idempotency_key=${encodeURIComponent(prepared.idempotencyKey)}`)).data;
104
+ const body = JSON.parse(prepared.requestJson);
105
+ check(Array.isArray(found), 'Unable to reconcile this send. Keep its operation ID.');
106
+ const matches = found.filter(row => row.from_address?.toLowerCase() === own && row.to_address?.toLowerCase() === body.to.toLowerCase());
107
+ check(matches.length === 1, 'Send outcome is still unknown. Keep the same operation ID and check again; do not send a duplicate.');
108
+ record.receipt = receipt(matches[0]);
109
+ await save(path, record);
110
+ return record.receipt;
111
+ }
112
+ if (prepared.expiresAtMs !== null && now() >= prepared.expiresAtMs)
113
+ return { status: 'expired' };
114
+ record.attempted = true;
115
+ await save(path, record);
116
+ const send = async (body, key) => {
117
+ try { return (await request('POST', '/send-mail', body, key)).data; }
118
+ catch (error) {
119
+ // Only a typed send refusal proves deletion. Missing reads remain unknown.
120
+ if (!(error instanceof PrimitiveApiError) || error.status !== 410 || error.code !== 'sent_email_deleted') throw error;
121
+ record.receipt = { status: 'deleted' };
122
+ await save(path, record);
123
+ return null;
124
+ }
125
+ };
126
+ const result = intent.kind === 'signal'
127
+ ? await sendPreparedSignal(send, prepared, { accountScope: identity.org_id, now })
128
+ : { status: 'response', result: await send(JSON.parse(prepared.requestJson), prepared.idempotencyKey) };
129
+ if (record.receipt) return record.receipt;
130
+ if (result.status === 'expired') {
131
+ record.attempted = false;
132
+ await save(path, record);
133
+ return { status: 'expired' };
134
+ }
135
+ record.receipt = receipt(result.result);
136
+ await save(path, record);
137
+ return record.receipt;
138
+ } finally { await rm(lock, { recursive: true, force: true }); }
139
+ }
140
+ const preparedMail = body => ({ accountScope: identity.org_id, expiresAtMs: null, idempotencyKey: uuid(), requestJson: JSON.stringify(body) });
141
+ return {
142
+ send: (id, { to, subject, text }) => operation(id, { kind: 'send', to, subject, text }, async () =>
143
+ preparedMail({ from: own, to: address(to), subject: header(subject), body_text: bodyText(text) })),
144
+ reply: (id, emailId, text) => operation(id, { kind: 'reply', emailId, text }, async () => {
145
+ const p = await parent(emailId);
146
+ return preparedMail({ from: own, to: p.from, subject: p.subject,
147
+ body_text: bodyText(text), in_reply_to: p.messageId, references: [...new Set([...p.references, p.messageId])] });
148
+ }),
149
+ signal: (id, emailId, input) => operation(id, { kind: 'signal', emailId, input }, async () => {
150
+ check(input && ['ack', 'read', 'working', 'typing'].includes(input.kind), 'Choose ack, read, working, or typing.');
151
+ const p = await parent(emailId, true);
152
+ const result = prepareSignalEmail({ ...input, parent: p,
153
+ ...(['working', 'typing'].includes(input.kind) ? { expiresAtMs: now() + (input.kind === 'typing' ? 30000 : 60000) } : {}) }, { now, uuid });
154
+ check(result.status === 'prepared', 'Wait for the parent email Message-ID before reporting progress.');
155
+ return result.prepared;
156
+ }),
157
+ };
158
+ }
159
+
160
+ export async function run(args, { input = '', directory, fetcher } = {}) {
161
+ if (!args.length || (args.length === 1 && ['help', '--help'].includes(args[0]))) return help;
162
+ const [command, first, second] = args;
163
+ check(Object.hasOwn(commands, command) && args.length === (command === 'send' ? 2 : 3), help);
164
+ const root = directory ?? process.env.PRIMITIVE_AGENT_STATE_DIR ?? join(homedir(), '.local', 'state', 'primitive-connect');
165
+ const options = { directory: root, fetcher };
166
+ const identity = await connection(['status'], options);
167
+ const mail = createMailer({ identity, directory: join(root, 'outbox'), request: (method, path, body, key) =>
168
+ connection(['request', method, path, ...(key ? [key] : [])], { ...options, input: body ? JSON.stringify(body) : '' }) });
169
+ if (command === 'reply') return mail.reply(second, first, input);
170
+ let value;
171
+ try { value = JSON.parse(input); } catch { throw new MailError('Supply valid JSON on stdin.'); }
172
+ return command === 'send' ? mail.send(first, value) : mail.signal(second, first, value);
173
+ }
174
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
175
+ try {
176
+ const args = process.argv.slice(2);
177
+ let input = '';
178
+ const needsInput = Object.hasOwn(commands, args[0]) && args.length === (args[0] === 'send' ? 2 : 3);
179
+ if (needsInput) for await (const chunk of process.stdin) {
180
+ input += chunk;
181
+ check(input.length <= 1024 * 1024, 'Input is too large.');
182
+ }
183
+ const result = await run(args, { input });
184
+ console.log(typeof result === 'string' ? result : JSON.stringify(result));
185
+ } catch (error) {
186
+ console.error(error instanceof MailError ? error.message : 'Mail operation failed. Inspect private connection state and reconcile using the same operation ID.');
187
+ process.exitCode = 1;
188
+ }
189
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "primitive-connect",
3
+ "version": "743a3ee630e9d621",
4
+ "source": {
5
+ "repository": "https://github.com/primitivedotdev/skills",
6
+ "path": "skills/primitive-connect",
7
+ "commit": "1660f99f967bd0dfbec0547916fa25b1d7e85494"
8
+ },
9
+ "files": {
10
+ "SKILL.md": "b1e03a6fa6c6df81320368924c39f28b311f45154aede9e73085b930eb589a28",
11
+ "package-lock.json": "443289373a7495883a9a3b57bab3bc471cd5dcb4989a9e67f797b35917c1acc5",
12
+ "package.json": "8cc45b2c28ba415960c9b85f6c1449f15f9c10bac0b96922e7907b40d50e3dbf",
13
+ "references/communication.md": "c931aada9553a8592088c28de5b272d5e0a15234aa9c9318ea7d1d5f69b39a79",
14
+ "references/contact-requests.md": "7923bc855a56f11b088c73c73b7d9c5b04fef3f45eb758f28040d44e46a0d9c6",
15
+ "references/native-session.md": "d6d529d87edc68a450be4c8b162488efa666c59fc23c35576fe6887235ff3d33",
16
+ "references/private-api-fallback.md": "19831f63dade9228e82c01a7573c23ae5474d97a79df75958b3e390b5bc4c531",
17
+ "scripts/check-cli-capabilities.mjs": "e6d4c045601f7d3b5cec103bdb3648723b8e66ebd22d87aca606fb78aed844fd",
18
+ "scripts/connection.mjs": "fd7bc74e413d2fb864589dc1b164d907fdb9bf37b46692ee0f89bc583dae9212",
19
+ "scripts/mail.mjs": "bfdfb60a739f1537d0e676dc5ff44fd5a65dba4303e3781dce8754c8d671cf51"
20
+ }
21
+ }
package/man/primitive.1 CHANGED
@@ -20,6 +20,29 @@ Most commands print human-readable output by default. Commands that expose JSON
20
20
  .B --json
21
21
  flag or use generated operation output that can be piped into tools such as
22
22
  .BR jq (1).
23
+ .PP
24
+ With
25
+ .BR --json ,
26
+ stdout is exactly one JSON document, on success and on failure, and stderr
27
+ stays empty, so output merged with 2>&1 still parses. Notices the command
28
+ would print on stderr go in the document's
29
+ .B warnings
30
+ array; a failure adds
31
+ .B error
32
+ and
33
+ .BR exit_code .
34
+ Generated API commands keep their usual stdout shape with
35
+ .BR --json :
36
+ the data payload only. They do not print the
37
+ .B next cursor:
38
+ line then; add
39
+ .B --envelope
40
+ to get the full response envelope, with the pagination cursor in
41
+ .B meta.cursor
42
+ and empty-result hints in
43
+ .BR summary .
44
+ .B primitive listen
45
+ streams JSONL and is not covered by this rule.
23
46
  .SH LOCAL WEBHOOK EVENTS
24
47
  .PP
25
48
  .B primitive listen
@@ -312,6 +335,19 @@ chat sent the message but no reply arrived before the timeout. Wait; do not rese
312
335
  .B 4
313
336
  .BR uncertain :
314
337
  transport error, conflict, server error, or a send record with status unknown. The message may have gone out; check sent history before retrying.
338
+ .PP
339
+ Every
340
+ .B --json
341
+ envelope from these commands carries
342
+ .B sent_email_id
343
+ (null until a send record is known) and
344
+ .BR idempotency_key ,
345
+ including on an uncertain outcome. Reconcile with
346
+ .BR "primitive sent get --idempotency-key KEY" ;
347
+ not_found means no record is visible yet, not that nothing was created, so
348
+ retry only with the same
349
+ .BR --idempotency-key ,
350
+ never a new one.
315
351
  .SH ENVIRONMENT
316
352
  .TP
317
353
  .B PRIMITIVE_API_KEY
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primcli",
3
- "version": "1.34.0",
3
+ "version": "1.36.0",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -145,6 +145,9 @@
145
145
  },
146
146
  "agent:notes": {
147
147
  "description": "Read and maintain organization address notes. Connected profiles default to their own address and can write only their own notes. Use agent notes list|get|set|delete; new notes are private, and updates preserve visibility unless explicitly changed. Commands return JSON by default."
148
+ },
149
+ "agent:working": {
150
+ "description": "Short, expiring work claims stored in the AGENT_WORKING address note. Set a claim when work starts, clear it when work ends, and check a peer's claim with get --address before editing a shared file. Claims are advisory, not locks."
148
151
  }
149
152
  },
150
153
  "topicSeparator": " "