primcli 1.35.0 → 1.37.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/README.md +73 -10
- package/bin/run.js +37 -0
- package/dist/auto-signals-UPcJmV3j.js +702 -0
- package/dist/{cli-config-DmhlcS3s.js → cli-config-UOlrfcPz.js} +2 -711
- package/dist/connect-skill-DednypYJ.js +1084 -0
- package/dist/connected-agent-profile-8tpOMdq3.js +730 -0
- package/dist/{listen-background-D6UhIbkO.js → listen-background-Bsofqq_6.js} +243 -50
- package/dist/oclif/auto-signal-worker.js +167 -0
- package/dist/oclif/auto-signals.js +2 -0
- package/dist/oclif/index.js +31501 -48161
- package/dist/oclif/listen-supervisor.js +1 -1
- package/dist/oclif/proxy-auto-detect.js +1 -1
- package/dist/oclif/receiver-heal.js +91 -0
- package/dist/oclif/root-signup-hint.js +1 -1
- package/dist/repeats-CQmyvUGQ-CMnOmWgL.js +10442 -0
- package/dist/sdk.gen-CugwPoCf.js +3281 -0
- package/dist/{shared-mail-files-Cbwnf79a.js → shared-mail-files-CZqI1kj6.js} +5 -2
- package/dist/signal-command-CKaEqXro.js +4514 -0
- package/dist/skills/primitive-connect/SKILL.md +896 -0
- package/dist/skills/primitive-connect/package-lock.json +1033 -0
- package/dist/skills/primitive-connect/package.json +8 -0
- package/dist/skills/primitive-connect/references/communication.md +207 -0
- package/dist/skills/primitive-connect/references/contact-requests.md +161 -0
- package/dist/skills/primitive-connect/references/native-session.md +165 -0
- package/dist/skills/primitive-connect/references/private-api-fallback.md +107 -0
- package/dist/skills/primitive-connect/scripts/check-cli-capabilities.mjs +48 -0
- package/dist/skills/primitive-connect/scripts/connection.mjs +197 -0
- package/dist/skills/primitive-connect/scripts/mail.mjs +189 -0
- package/dist/skills/primitive-connect.json +21 -0
- package/man/primitive.1 +35 -4
- package/package.json +10 -1
- /package/dist/{json-output-KAIuP9rG.js → json-output-BVRgl6jf.js} +0 -0
|
@@ -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,197 @@
|
|
|
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
|
+
// Send reports and questions here: the owner's personal mailbox. Null when
|
|
68
|
+
// there is none or the server does not report it yet.
|
|
69
|
+
owner_member_address: typeof state.owner_member_address === 'string' ? state.owner_member_address : null,
|
|
70
|
+
owner_address: state.owner_address,
|
|
71
|
+
org_id: state.org_id,
|
|
72
|
+
credential_saved: true,
|
|
73
|
+
verification: 'Confirm with the owner app after replying using this credential.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async function call(fetcher, url, options) {
|
|
77
|
+
let response;
|
|
78
|
+
try { response = await fetcher(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(35000) }); }
|
|
79
|
+
catch { fail('Request outcome is unknown. Do not blindly retry a claim or send; reconcile or obtain a fresh invitation.'); }
|
|
80
|
+
let envelope;
|
|
81
|
+
try { envelope = await response.json(); }
|
|
82
|
+
catch { fail('Unreadable API response. A mutation may have succeeded; do not blindly retry it.'); }
|
|
83
|
+
if (!response.ok || envelope.success === false) {
|
|
84
|
+
throw new PrimitiveApiError(response.status, envelope?.error?.code);
|
|
85
|
+
}
|
|
86
|
+
return envelope;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function run(args, { input = '', directory, fetcher = fetch } = {}) {
|
|
90
|
+
const stateDir = directory ?? process.env.PRIMITIVE_AGENT_STATE_DIR ?? join(homedir(), '.local', 'state', 'primitive-connect');
|
|
91
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
92
|
+
await chmod(stateDir, 0o700);
|
|
93
|
+
const statePath = join(stateDir, 'connection.json');
|
|
94
|
+
const [command, method, path, idempotencyKey] = args;
|
|
95
|
+
if (command === 'claim') {
|
|
96
|
+
if (args.length !== 1) fail('Pass the setup instruction on stdin, never as a command argument.');
|
|
97
|
+
const token = invitationToken(input);
|
|
98
|
+
const digest = createHash('sha256').update(token).digest('hex');
|
|
99
|
+
const lockPath = join(stateDir, 'claim.lock');
|
|
100
|
+
try { await mkdir(lockPath, { mode: 0o700 }); }
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code === 'EEXIST') fail('A claim is running or was interrupted. Check that process before removing the private claim.lock directory.');
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const previousText = await optionalFile(statePath);
|
|
107
|
+
const previous = previousText ? validateState(json(previousText)) : null;
|
|
108
|
+
if (previous?.invitation_sha256 === digest) return summary(previous);
|
|
109
|
+
const pendingPath = join(stateDir, 'pending-claim.json');
|
|
110
|
+
const pending = await optionalFile(pendingPath);
|
|
111
|
+
if (pending && json(pending).invitation_sha256 === digest) {
|
|
112
|
+
fail('This invitation was already attempted. Its one-time response may be lost; ask the owner for a fresh invitation.');
|
|
113
|
+
}
|
|
114
|
+
await writePrivate(pendingPath, { invitation_sha256: digest, attempted_at: new Date().toISOString() });
|
|
115
|
+
const envelope = await call(fetcher, `${API}/agent-connections/claim`, {
|
|
116
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
117
|
+
body: JSON.stringify({ token }),
|
|
118
|
+
});
|
|
119
|
+
const state = validateState(envelope.data);
|
|
120
|
+
if (previous && (previous.org_id !== state.org_id || previous.connection.address !== state.connection.address ||
|
|
121
|
+
previous.owner_address !== state.owner_address)) {
|
|
122
|
+
fail('The invitation changes the saved identity or owner. Existing state was preserved. Confirm the intended identity with the owner.');
|
|
123
|
+
}
|
|
124
|
+
await writePrivate(statePath, { ...state, invitation_sha256: digest });
|
|
125
|
+
await rm(pendingPath, { force: true });
|
|
126
|
+
return summary(state);
|
|
127
|
+
} finally { await rm(lockPath, { recursive: true, force: true }); }
|
|
128
|
+
}
|
|
129
|
+
const saved = await optionalFile(statePath);
|
|
130
|
+
if (!saved) fail('No saved connection. Claim the owner-provided setup instruction first.');
|
|
131
|
+
const state = validateState(json(saved));
|
|
132
|
+
if (command === 'status' && args.length === 1) return summary(state);
|
|
133
|
+
if (command !== 'request' || !method || !path || args.length > 4) {
|
|
134
|
+
fail('Usage: connection.mjs claim | status | request METHOD /path [idempotency-key]. JSON bodies come from stdin.');
|
|
135
|
+
}
|
|
136
|
+
const url = new URL(`${API}${path}`);
|
|
137
|
+
if (!url.pathname.startsWith('/v1/')) fail('This helper supports only the documented address-scoped email and notes paths.');
|
|
138
|
+
const pathname = url.pathname.slice('/v1'.length);
|
|
139
|
+
let ownNote = false;
|
|
140
|
+
if (method === 'PUT') {
|
|
141
|
+
const parts = pathname.split('/');
|
|
142
|
+
if (parts.length === 4 && parts[1] === 'address-notes') {
|
|
143
|
+
let address, name;
|
|
144
|
+
try { address = decodeURIComponent(parts[2]); name = decodeURIComponent(parts[3]); }
|
|
145
|
+
catch { fail('Invalid address-note path encoding.'); }
|
|
146
|
+
ownNote = address === state.connection.address && Boolean(name) && !name.includes('/');
|
|
147
|
+
if (ownNote) url.pathname = `/v1/address-notes/${encodeURIComponent(address)}/${encodeURIComponent(name)}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
151
|
+
const conversationRead = method === 'GET' && !url.search && (
|
|
152
|
+
new RegExp(`^/threads/${uuid}$`, 'i').test(pathname) ||
|
|
153
|
+
new RegExp(`^/emails/${uuid}/conversation$`, 'i').test(pathname)
|
|
154
|
+
);
|
|
155
|
+
const allowed = url.pathname.startsWith('/v1/') && url.origin === ORIGIN && !url.hash && !url.username && !url.password && path.startsWith('/') && (
|
|
156
|
+
(method === 'GET' && (/^\/(emails|sent-emails)(\/[^/]+)?$/.test(pathname) || pathname === '/address-notes' ||
|
|
157
|
+
(pathname === '/agent-connections/me' && !url.search))) ||
|
|
158
|
+
conversationRead ||
|
|
159
|
+
(method === 'POST' && pathname === '/send-mail') ||
|
|
160
|
+
(method === 'PUT' && ownNote)
|
|
161
|
+
);
|
|
162
|
+
if (!allowed) fail('This helper supports only the documented address-scoped email, thread and notes paths.');
|
|
163
|
+
const headers = { Authorization: `Bearer ${state.api_key}`, Accept: 'application/json' };
|
|
164
|
+
let body;
|
|
165
|
+
if (method !== 'GET') {
|
|
166
|
+
body = json(input);
|
|
167
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) fail('Expected a JSON object on stdin.');
|
|
168
|
+
if (method === 'POST') {
|
|
169
|
+
if (!idempotencyKey || /[\r\n]/.test(idempotencyKey)) fail('Provide a stable idempotency key for sending.');
|
|
170
|
+
if (body.from && body.from !== state.connection.address) fail('The sender must match the saved connection address.');
|
|
171
|
+
body.from = state.connection.address;
|
|
172
|
+
headers['Idempotency-Key'] = idempotencyKey;
|
|
173
|
+
}
|
|
174
|
+
headers['Content-Type'] = 'application/json';
|
|
175
|
+
}
|
|
176
|
+
return call(fetcher, url.href, { method, headers, ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
180
|
+
try {
|
|
181
|
+
const args = process.argv.slice(2);
|
|
182
|
+
const needsInput = args[0] === 'claim' || (args[0] === 'request' && args[1] !== 'GET');
|
|
183
|
+
let input = '';
|
|
184
|
+
if (needsInput) {
|
|
185
|
+
for await (const chunk of process.stdin) {
|
|
186
|
+
input += chunk;
|
|
187
|
+
if (input.length > 1024 * 1024) fail('Input is too large.');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
console.log(JSON.stringify(await run(args, { input }), null, 2));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Never print response bodies, request bodies, URLs, or raw runtime exceptions.
|
|
193
|
+
const known = error instanceof PrimitiveApiError || (error instanceof Error && error.constructor === Error);
|
|
194
|
+
console.error(known ? error.message : 'Local connection operation failed. Check private storage and permissions.');
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -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": "7d4b2d633a05bb12",
|
|
4
|
+
"source": {
|
|
5
|
+
"repository": "https://github.com/primitivedotdev/skills",
|
|
6
|
+
"path": "skills/primitive-connect",
|
|
7
|
+
"commit": "5d5d80f7bea82cd5018c0e25262aa32fa7cad708"
|
|
8
|
+
},
|
|
9
|
+
"files": {
|
|
10
|
+
"SKILL.md": "aca1e46fe388dc184f2eb3f3b5e39e7cec70b1bf4c490d0bd1013df51b2b79b4",
|
|
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": "63b5d8c8ab765561fc6031298462469a8f56fd348ac14c5003e214f780e9329b",
|
|
17
|
+
"scripts/check-cli-capabilities.mjs": "e6d4c045601f7d3b5cec103bdb3648723b8e66ebd22d87aca606fb78aed844fd",
|
|
18
|
+
"scripts/connection.mjs": "2c96eda1e9a277a241555f98dd858523b8f77a9ddd82d52a6ac34e844206c3d6",
|
|
19
|
+
"scripts/mail.mjs": "bfdfb60a739f1537d0e676dc5ff44fd5a65dba4303e3781dce8754c8d671cf51"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/man/primitive.1
CHANGED
|
@@ -240,6 +240,18 @@ Redeem a credit code for your organization.
|
|
|
240
240
|
.B primitive credits balance
|
|
241
241
|
Show the organization credit balance.
|
|
242
242
|
.TP
|
|
243
|
+
.B primitive send --to ADDRESS --body TEXT --repeat-every MINUTES
|
|
244
|
+
Send the message now and then every MINUTES minutes in the same thread. Also
|
|
245
|
+
on reply. Add --only-if-idle MINUTES, --max-sends N, --until TIME, or
|
|
246
|
+
--no-recipient-stop. Recipients must be in your organization.
|
|
247
|
+
.TP
|
|
248
|
+
.B primitive repeats list|get|pause|resume|cancel
|
|
249
|
+
Manage the repeating sends you created.
|
|
250
|
+
.TP
|
|
251
|
+
.B primitive repeat stop --id ID [--reason TEXT]
|
|
252
|
+
As the recipient, stop a repeat. ID is the repeat id from the message footer
|
|
253
|
+
or the id of a received message of the repeat.
|
|
254
|
+
.TP
|
|
243
255
|
.B primitive list-operations
|
|
244
256
|
Print the generated API operation manifest.
|
|
245
257
|
.TP
|
|
@@ -287,10 +299,29 @@ profile selected with PRIMITIVE_AGENT_PROFILE and pin its sender identity.
|
|
|
287
299
|
Read and acknowledgement deduplicate by parent, kind and acknowledgement status.
|
|
288
300
|
Unexpired activity deduplicates; a new explicit invocation after a known outcome
|
|
289
301
|
expires may send fresh activity. Unknown sends must reconcile before renewal;
|
|
290
|
-
expired unsent activity is never replayed. No
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
302
|
+
expired unsent activity is never replayed. No task authority is granted. Signal
|
|
303
|
+
and interaction parents are refused to prevent reply loops. JSON outcomes are
|
|
304
|
+
sent, already_sent, expired (exit 0), not_sent (exit 1), and uncertain (exit 4).
|
|
305
|
+
Ordinary CLI argument errors exit 2.
|
|
306
|
+
|
|
307
|
+
.SS Automatic acknowledgement
|
|
308
|
+
When a Claude Code wake or a native background receiver surfaces a new plain
|
|
309
|
+
email from the verified owner, an active organization member or a
|
|
310
|
+
same-organization agent, addressed only to the connected agent, the CLI sends
|
|
311
|
+
.B read
|
|
312
|
+
for it once, in the background. Reading that email with
|
|
313
|
+
.B primitive emails get --id EMAIL_ID --brief
|
|
314
|
+
then reports
|
|
315
|
+
.B working
|
|
316
|
+
(55 seconds), renewed every 40 seconds until the agent replies, sends an fyi or
|
|
317
|
+
a message to that sender, sends ack will_not_process, a plain answer appears in
|
|
318
|
+
the thread, or 15 minutes pass. External, unverified and contact-request mail,
|
|
319
|
+
signals, fyi acknowledgements and the agent's own mail are never acknowledged.
|
|
320
|
+
Signal failures never delay or fail the wake or the read. Renewal runs in a
|
|
321
|
+
detached process; if the runtime ends it, the next primitive command restarts
|
|
322
|
+
it. Set PRIMITIVE_NO_AUTO_SIGNALS=1 to turn all of this off, or pass
|
|
323
|
+
.B --no-signal
|
|
324
|
+
to one brief read.
|
|
294
325
|
|
|
295
326
|
.SH GLOBAL OPTIONS
|
|
296
327
|
.TP
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primcli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.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,
|
|
@@ -131,6 +131,15 @@
|
|
|
131
131
|
"routes": {
|
|
132
132
|
"description": "Manage recipient routes. Bind an address pattern to an endpoint or function: `primitive routes add alice@d --function <id>`, `primitive routes list`, `primitive routes test <recipient>`, `primitive routes remove <id>`."
|
|
133
133
|
},
|
|
134
|
+
"repeat": {
|
|
135
|
+
"description": "Act on a message that repeats to you. Use `primitive repeat stop --id <email-id>` when it is no longer needed."
|
|
136
|
+
},
|
|
137
|
+
"repeats": {
|
|
138
|
+
"description": "Manage repeating sends you created with `primitive send` or `primitive reply` and --repeat-every. Use `primitive repeats list|get|pause|resume|cancel`."
|
|
139
|
+
},
|
|
140
|
+
"repeating-sends": {
|
|
141
|
+
"description": "Generated API operations for repeating sends. Prefer `primitive repeats ...` (sender) and `primitive repeat stop` (recipient)."
|
|
142
|
+
},
|
|
134
143
|
"wake": {
|
|
135
144
|
"description": "Schedule and send wake commands to your own functions over signed email, and manage which senders may wake them. Use `primitive wake schedules list|create|get|update|delete|run`, `primitive wake authorizations list|create|update|delete`, and `primitive wake dispatches list`."
|
|
136
145
|
},
|
|
File without changes
|