@pingagent/sdk 0.1.0 → 0.1.2

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/bin/pingagent.js CHANGED
@@ -1,1125 +1,1418 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'commander';
3
- import * as fs from 'node:fs';
4
- import * as path from 'node:path';
5
- import * as os from 'node:os';
6
- import {
7
- PingAgentClient,
8
- generateIdentity,
9
- saveIdentity,
10
- loadIdentity,
11
- identityExists,
12
- updateStoredToken,
13
- ensureTokenValid,
14
- LocalStore,
15
- ContactManager,
16
- HistoryManager,
17
- A2AAdapter,
18
- } from '../dist/index.js';
19
- import { ERROR_HINTS, SCHEMA_TEXT } from '@pingagent/schemas';
20
-
21
- const DEFAULT_SERVER = 'http://localhost:8787';
22
- const UPGRADE_URL = 'https://pingagent.chat';
23
- const DEFAULT_IDENTITY_PATH = path.join(os.homedir(), '.pingagent', 'identity.json');
24
-
25
- function resolvePath(p) {
26
- if (!p) return p;
27
- if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1));
28
- return p;
29
- }
30
-
31
- function getEffectiveIdentityPath() {
32
- const dir = program.opts().identityDir;
33
- if (dir) return path.join(resolvePath(dir), 'identity.json');
34
- const envPath = process.env.PINGAGENT_IDENTITY_PATH;
35
- return envPath ? resolvePath(envPath) : DEFAULT_IDENTITY_PATH;
36
- }
37
-
38
- function getStorePath() {
39
- const envStore = process.env.PINGAGENT_STORE_PATH;
40
- if (envStore) return resolvePath(envStore);
41
- return path.join(path.dirname(getEffectiveIdentityPath()), 'store.db');
42
- }
43
-
44
- function printError(err) {
45
- if (!err) return;
46
- console.error(`Error: ${err.code ?? 'unknown'} - ${err.message ?? ''}`);
47
- const hint = err.hint ?? (err.code && ERROR_HINTS[err.code]);
48
- if (hint) console.error(`Hint: ${hint}`);
49
- if (err.code && err.code.startsWith('E_PAYWALL_')) {
50
- console.error(`Upgrade: ${UPGRADE_URL}`);
51
- }
52
- }
53
-
54
- function openStore() {
55
- return new LocalStore(getStorePath());
56
- }
57
-
58
- function makeClient(id, identityPath) {
59
- const p = identityPath ?? getEffectiveIdentityPath();
60
- return new PingAgentClient({
61
- serverUrl: id.serverUrl ?? DEFAULT_SERVER,
62
- identity: id,
63
- accessToken: id.accessToken ?? '',
64
- onTokenRefreshed: (token, expiresAt) => updateStoredToken(token, expiresAt, p),
65
- });
66
- }
67
-
68
- /** Load identity, proactively refresh token if near/past expiry, then return client. */
69
- async function getClient() {
70
- const identityPath = getEffectiveIdentityPath();
71
- let id = loadIdentity(identityPath);
72
- await ensureTokenValid(identityPath, id.serverUrl);
73
- id = loadIdentity(identityPath);
74
- return makeClient(id, identityPath);
75
- }
76
-
77
- const program = new Command();
78
- program
79
- .name('pingagent')
80
- .description('PingAgent Chat CLI')
81
- .version('0.1.0')
82
- .option('--identity-dir <dir>', 'Use identity and store from this directory (e.g. ~/.pingagent/agent1). Overrides PINGAGENT_IDENTITY_PATH for identity file; store is <dir>/store.db unless PINGAGENT_STORE_PATH is set.');
83
-
84
- program
85
- .command('init')
86
- .description('Initialize PingAgent Chat identity')
87
- .option('--server <url>', 'Server URL', DEFAULT_SERVER)
88
- .option('--token <token>', 'Developer token for production mode')
89
- .option('--cursor', 'Auto-configure Cursor MCP')
90
- .action(async (opts) => {
91
- const identityPath = getEffectiveIdentityPath();
92
- if (identityExists(identityPath)) {
93
- const existing = loadIdentity(identityPath);
94
- console.log(`Identity already exists: ${existing.did}`);
95
- return;
96
- }
97
-
98
- console.log('PingAgent Chat Setup');
99
- console.log('====================');
100
- console.log('[1/3] Generating Ed25519 keypair... done');
101
-
102
- const identity = generateIdentity();
103
-
104
- console.log('[2/3] Registering with relay server...');
105
- const client = new PingAgentClient({
106
- serverUrl: opts.server,
107
- identity,
108
- accessToken: '',
109
- });
110
-
111
- const regRes = await client.register(opts.token);
112
- if (!regRes.ok || !regRes.data) {
113
- if (regRes.error) printError(regRes.error);
114
- else console.error('Registration failed:', regRes.error?.message);
115
- process.exit(1);
116
- }
117
-
118
- const { did, access_token, expires_ms, mode } = regRes.data;
119
- console.log(` Server: ${opts.server}`);
120
- console.log(` Mode: ${mode}`);
121
- console.log(` Your DID: ${did}`);
122
-
123
- console.log('[3/3] Saving identity... done');
124
- saveIdentity(identity, {
125
- serverUrl: opts.server,
126
- accessToken: access_token,
127
- tokenExpiresAt: Date.now() + expires_ms,
128
- mode,
129
- }, identityPath);
130
-
131
- console.log(`\nReady! Share your DID with agents you want to communicate with.`);
132
- if (identityPath !== DEFAULT_IDENTITY_PATH) {
133
- console.log(`Identity: ${identityPath}`);
134
- console.log(`Store: ${getStorePath()}`);
135
- }
136
- console.log(`\nRun 'pingagent doctor' to verify setup.`);
137
- });
138
-
139
- program
140
- .command('doctor')
141
- .description('Check identity, token, permissions and server; use --fix to renew token if expired')
142
- .option('--fix', 'Attempt to fix token if expired (runs renew-token)')
143
- .option('--json', 'Output as JSON')
144
- .action(async (opts) => {
145
- const identityPath = getEffectiveIdentityPath();
146
- const storePath = getStorePath();
147
- const identityDir = path.dirname(identityPath);
148
- const storeDir = path.dirname(storePath);
149
- const issues = [];
150
- const ok = [];
151
-
152
- if (!identityExists(identityPath)) {
153
- issues.push({ check: 'identity', message: 'No identity found. Run: pingagent init' });
154
- } else {
155
- ok.push('identity');
156
- try {
157
- const id = loadIdentity(identityPath);
158
- const now = Date.now();
159
- const expiresAt = id.tokenExpiresAt;
160
- const graceMs = 5 * 60 * 1000;
161
- if (expiresAt == null || expiresAt <= now + graceMs) {
162
- issues.push({ check: 'token', message: 'Token expired or expiring soon. Run: pingagent renew-token', fixable: true });
163
- } else {
164
- ok.push('token');
165
- }
166
- } catch (e) {
167
- issues.push({ check: 'identity', message: `Cannot load identity: ${e.message}` });
168
- }
169
- }
170
-
171
- try {
172
- if (!fs.existsSync(identityDir)) {
173
- fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
174
- }
175
- const testFile = path.join(identityDir, '.pingagent-write-test');
176
- fs.writeFileSync(testFile, '');
177
- fs.unlinkSync(testFile);
178
- ok.push('identity_dir_writable');
179
- } catch (e) {
180
- issues.push({ check: 'identity_dir', message: `Identity directory not writable: ${identityDir} (${e.message})` });
181
- }
182
-
183
- try {
184
- if (!fs.existsSync(storeDir)) {
185
- fs.mkdirSync(storeDir, { recursive: true, mode: 0o700 });
186
- }
187
- const store = openStore();
188
- store.close();
189
- ok.push('store_writable');
190
- } catch (e) {
191
- issues.push({ check: 'store', message: `Store path not writable: ${storePath} (${e.message})` });
192
- }
193
-
194
- if (identityExists(identityPath)) {
195
- const id = loadIdentity(identityPath);
196
- const serverUrl = (id.serverUrl ?? DEFAULT_SERVER).replace(/\/$/, '');
197
- try {
198
- const res = await fetch(`${serverUrl}/.well-known/agent.json`, { method: 'GET' });
199
- if (res.ok) ok.push('server_reachable');
200
- else issues.push({ check: 'server', message: `Server returned ${res.status}: ${serverUrl}` });
201
- } catch (e) {
202
- issues.push({ check: 'server', message: `Server not reachable: ${serverUrl} (${e.message})` });
203
- }
204
- }
205
-
206
- if (opts.fix && issues.some(i => i.fixable)) {
207
- const tokenIssue = issues.find(i => i.check === 'token');
208
- if (tokenIssue && identityExists(identityPath)) {
209
- const existing = loadIdentity(identityPath);
210
- const serverUrl = existing.serverUrl ?? DEFAULT_SERVER;
211
-
212
- // Try refresh first, then fallback to re-register
213
- const refreshed = await ensureTokenValid(identityPath, serverUrl);
214
- if (refreshed) {
215
- console.log('Fixed: token refreshed (via /v1/auth/refresh).');
216
- issues.splice(issues.findIndex(i => i.check === 'token'), 1);
217
- ok.push('token');
218
- } else {
219
- const client = new PingAgentClient({ serverUrl, identity: existing, accessToken: '' });
220
- const regRes = await client.register();
221
- if (regRes.ok && regRes.data) {
222
- saveIdentity(existing, {
223
- serverUrl,
224
- accessToken: regRes.data.access_token,
225
- tokenExpiresAt: Date.now() + regRes.data.expires_ms,
226
- mode: regRes.data.mode,
227
- }, identityPath);
228
- console.log('Fixed: token renewed (via re-register).');
229
- issues.splice(issues.findIndex(i => i.check === 'token'), 1);
230
- ok.push('token');
231
- }
232
- }
233
- }
234
- }
235
-
236
- if (opts.json) {
237
- console.log(JSON.stringify({ ok, issues }, null, 2));
238
- process.exit(issues.length > 0 ? 1 : 0);
239
- }
240
-
241
- if (ok.length) console.log('OK:', ok.join(', '));
242
- if (issues.length) {
243
- for (const i of issues) console.error(`Issue [${i.check}]: ${i.message}`);
244
- if (issues.some(i => i.fixable)) console.error('\nRun with --fix to attempt token renewal.');
245
- process.exit(1);
246
- }
247
- console.log('All checks passed.');
248
- });
249
-
250
- program
251
- .command('renew-token')
252
- .description('Renew access token using existing identity (keeps DID and keypair). Tries /v1/auth/refresh first, falls back to re-register.')
253
- .option('--profile <name>', 'Use profile from ~/.pingagent/<name> (e.g. agent1, receiver)')
254
- .option('--server <url>', 'Server URL (defaults to identity server or localhost:8787)')
255
- .option('--token <token>', 'Developer token for production mode (optional)')
256
- .action(async (opts) => {
257
- const identityPath = program.opts().identityDir
258
- ? path.join(resolvePath(program.opts().identityDir), 'identity.json')
259
- : opts.profile
260
- ? path.join(os.homedir(), '.pingagent', opts.profile, 'identity.json')
261
- : getEffectiveIdentityPath();
262
- if (!identityExists(identityPath)) {
263
- console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH) first.');
264
- process.exit(1);
265
- }
266
-
267
- const existing = loadIdentity(identityPath);
268
- const serverUrl = opts.server ?? existing.serverUrl ?? DEFAULT_SERVER;
269
-
270
- // Step 1: try lightweight /v1/auth/refresh (works within grace period)
271
- console.log('Attempting token refresh via /v1/auth/refresh...');
272
- const refreshed = await ensureTokenValid(identityPath, serverUrl);
273
- if (refreshed) {
274
- const updated = loadIdentity(identityPath);
275
- console.log('Token refreshed successfully (via /v1/auth/refresh).');
276
- console.log(`DID: ${updated.did}`);
277
- console.log(`Server: ${serverUrl}`);
278
- return;
279
- }
280
-
281
- // Step 2: fallback to re-register with existing keypair
282
- console.log('Refresh not possible (token beyond grace period). Falling back to re-register...');
283
- const client = new PingAgentClient({
284
- serverUrl,
285
- identity: existing,
286
- accessToken: '',
287
- });
288
-
289
- const regRes = await client.register(opts.token);
290
- if (!regRes.ok || !regRes.data) {
291
- if (regRes.error) printError(regRes.error);
292
- else console.error('Token renewal failed:', regRes.error?.message);
293
- process.exit(1);
294
- }
295
-
296
- const { access_token, expires_ms, mode } = regRes.data;
297
- saveIdentity(existing, {
298
- serverUrl,
299
- accessToken: access_token,
300
- tokenExpiresAt: Date.now() + expires_ms,
301
- mode,
302
- }, identityPath);
303
-
304
- console.log('Token renewed successfully (via re-register).');
305
- console.log(`DID: ${existing.did}`);
306
- console.log(`Server: ${serverUrl}`);
307
- });
308
-
309
- program
310
- .command('status')
311
- .description('Show current identity and subscription status')
312
- .option('--json', 'Output as JSON')
313
- .action(async (opts) => {
314
- if (!identityExists(getEffectiveIdentityPath())) {
315
- console.log('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
316
- return;
317
- }
318
- const client = await getClient();
319
- const id = loadIdentity(getEffectiveIdentityPath());
320
-
321
- const tokenStatus = id.tokenExpiresAt && id.tokenExpiresAt > Date.now() ? 'valid' : 'expired';
322
-
323
- if (opts.json) {
324
- const out = { did: id.did, device_id: id.deviceId, server: id.serverUrl ?? null, mode: id.mode ?? null, token: tokenStatus };
325
- const subRes = await client.getSubscription();
326
- if (subRes.ok && subRes.data) {
327
- out.tier = subRes.data.tier;
328
- out.usage = subRes.data.usage;
329
- out.limits = subRes.data.limits;
330
- }
331
- console.log(JSON.stringify(out, null, 2));
332
- return;
333
- }
334
-
335
- console.log(`DID: ${id.did}`);
336
- console.log(`Device: ${id.deviceId}`);
337
- console.log(`Server: ${id.serverUrl ?? 'not set'}`);
338
- console.log(`Mode: ${id.mode ?? 'unknown'}`);
339
- console.log(`Token: ${tokenStatus}`);
340
-
341
- const subRes = await client.getSubscription();
342
- if (subRes.ok && subRes.data) {
343
- const d = subRes.data;
344
- console.log(`Tier: ${d.tier}`);
345
- console.log(`Relay: ${d.usage.relay_today} / ${d.usage.relay_limit} today`);
346
- console.log(`Artifact: ${(d.usage.artifact_bytes / 1024 / 1024).toFixed(2)} MB / ${d.limits.artifact_storage_mb} MB`);
347
- console.log(`Alias: ${d.usage.alias_count} / ${d.usage.alias_limit}`);
348
- if (d.billing_primary_did && d.billing_primary_did !== id.did) {
349
- console.log(`Billing: linked device (primary: ${d.billing_primary_did})`);
350
- console.log(' Subscription managed on primary. Use primary to create link codes or manage billing.');
351
- } else if (d.linked_device_count > 0) {
352
- console.log(`Billing: primary device (${d.linked_device_count} linked)`);
353
- }
354
- if (d.usage.relay_today >= d.usage.relay_limit) {
355
- console.log(`\nQuota reached. Upgrade: ${UPGRADE_URL}`);
356
- }
357
- } else {
358
- console.log('Subscription: (unable to fetch - check server and token)');
359
- }
360
- });
361
-
362
- program
363
- .command('send')
364
- .description('Send a task to a remote agent')
365
- .requiredOption('--to <did>', 'Target DID or alias')
366
- .requiredOption('--task <title>', 'Task title')
367
- .option('--description <desc>', 'Task description')
368
- .option('--wait', 'Wait for result')
369
- .option('--timeout <seconds>', 'Timeout in seconds', '120')
370
- .option('--json', 'Output as JSON')
371
- .action(async (opts) => {
372
- if (!identityExists(getEffectiveIdentityPath())) {
373
- console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
374
- process.exit(1);
375
- }
376
- const client = await getClient();
377
-
378
- let targetDid = opts.to;
379
- if (targetDid.startsWith('@')) {
380
- const resolved = await client.resolveAlias(targetDid);
381
- if (!resolved.ok || !resolved.data) {
382
- if (resolved.error) printError(resolved.error);
383
- else console.error(`Could not resolve alias: ${targetDid}`);
384
- process.exit(1);
385
- }
386
- targetDid = resolved.data.did;
387
- }
388
-
389
- let trusted = false;
390
- if (opts.wait) {
391
- const result = await client.sendTaskAndWait(
392
- targetDid,
393
- { title: opts.task, description: opts.description },
394
- { timeoutMs: parseInt(opts.timeout) * 1000 },
395
- );
396
- trusted = result.status === 'ok';
397
- if (opts.json) {
398
- console.log(JSON.stringify(result, null, 2));
399
- } else {
400
- console.log(`Task ${result.task_id}: ${result.status}`);
401
- if (result.result?.summary) console.log(`Summary: ${result.result.summary}`);
402
- if (result.error) printError(result.error);
403
- console.log(`Elapsed: ${result.elapsed_ms}ms`);
404
- }
405
- } else {
406
- const openRes = await client.openConversation(targetDid);
407
- if (!openRes.ok || !openRes.data) {
408
- if (openRes.error) printError(openRes.error);
409
- else console.error('Failed to open conversation');
410
- process.exit(1);
411
- }
412
- trusted = openRes.data.trusted;
413
-
414
- const { v7: uuidv7 } = await import('uuid');
415
- const sendRes = await client.sendTask(openRes.data.conversation_id, {
416
- task_id: `t_${uuidv7()}`,
417
- title: opts.task,
418
- description: opts.description,
419
- });
420
-
421
- if (!sendRes.ok) {
422
- if (sendRes.error) printError(sendRes.error);
423
- else console.error('Failed to send task');
424
- process.exit(1);
425
- }
426
-
427
- if (opts.json) {
428
- console.log(JSON.stringify(sendRes, null, 2));
429
- } else {
430
- console.log(`Sent: ${sendRes.data?.message_id} (seq: ${sendRes.data?.seq})`);
431
- }
432
- }
433
-
434
- const store = openStore();
435
- try {
436
- const mgr = new ContactManager(store);
437
- if (!mgr.get(targetDid)) {
438
- mgr.add({ did: targetDid, trusted, notes: 'Added via pingagent send' });
439
- }
440
- } finally {
441
- store.close();
442
- }
443
- });
444
-
445
- program
446
- .command('chat')
447
- .description('Send a text message (same as MCP pingagent_chat). Use "send" for tasks.')
448
- .requiredOption('--to <did>', 'Target DID or alias')
449
- .requiredOption('--message <text>', 'Message text')
450
- .option('--json', 'Output as JSON')
451
- .action(async (opts) => {
452
- if (!identityExists(getEffectiveIdentityPath())) {
453
- console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
454
- process.exit(1);
455
- }
456
- const client = await getClient();
457
-
458
- let targetDid = opts.to;
459
- if (targetDid.startsWith('@')) {
460
- const resolved = await client.resolveAlias(targetDid);
461
- if (!resolved.ok || !resolved.data) {
462
- if (resolved.error) printError(resolved.error);
463
- else console.error(`Could not resolve alias: ${targetDid}`);
464
- process.exit(1);
465
- }
466
- targetDid = resolved.data.did;
467
- }
468
-
469
- const openRes = await client.openConversation(targetDid);
470
- if (!openRes.ok || !openRes.data) {
471
- if (openRes.error) printError(openRes.error);
472
- else console.error('Failed to open conversation');
473
- process.exit(1);
474
- }
475
-
476
- const store = openStore();
477
- try {
478
- const mgr = new ContactManager(store);
479
- if (!mgr.get(targetDid)) {
480
- mgr.add({
481
- did: targetDid,
482
- trusted: openRes.data.trusted,
483
- conversation_id: openRes.data.conversation_id,
484
- notes: openRes.data.trusted ? 'Added via pingagent chat' : 'Added via pingagent chat (pending)',
485
- });
486
- }
487
- } finally {
488
- store.close();
489
- }
490
-
491
- if (!openRes.data.trusted) {
492
- await client.sendContactRequest(openRes.data.conversation_id, opts.message);
493
- if (opts.json) {
494
- console.log(JSON.stringify({ sent: 'contact_request', conversation_id: openRes.data.conversation_id }));
495
- } else {
496
- console.log('Contact request sent with your message. They need to approve before further messages are delivered.');
497
- }
498
- return;
499
- }
500
-
501
- const sendRes = await client.sendMessage(openRes.data.conversation_id, SCHEMA_TEXT, { text: opts.message });
502
- if (!sendRes.ok) {
503
- if (sendRes.error) printError(sendRes.error);
504
- else console.error('Failed to send message');
505
- process.exit(1);
506
- }
507
- if (opts.json) {
508
- console.log(JSON.stringify({ message_id: sendRes.data?.message_id, seq: sendRes.data?.seq }));
509
- } else {
510
- console.log(`Sent: ${sendRes.data?.message_id} (seq: ${sendRes.data?.seq})`);
511
- }
512
- });
513
-
514
- program
515
- .command('inbox')
516
- .description('Fetch inbox messages')
517
- .requiredOption('--conversation <id>', 'Conversation ID')
518
- .option('--since-seq <n>', 'Since sequence number', '0')
519
- .option('--limit <n>', 'Limit', '20')
520
- .option('--box <box>', "Inbox box: ready, strangers, or all (merge both; use 'all' if Web messages don't show)", 'ready')
521
- .option('--json', 'Output as JSON')
522
- .action(async (opts) => {
523
- if (!identityExists(getEffectiveIdentityPath())) {
524
- console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
525
- process.exit(1);
526
- }
527
- const client = await getClient();
528
- const sinceSeq = parseInt(opts.sinceSeq);
529
- const limit = parseInt(opts.limit);
530
-
531
- let messages = [];
532
- let hasMore = false;
533
- if (opts.box === 'all') {
534
- const seen = new Set();
535
- for (const box of ['ready', 'strangers']) {
536
- const res = await client.fetchInbox(opts.conversation, { sinceSeq, limit, box });
537
- if (res.ok && res.data) {
538
- for (const m of res.data.messages) {
539
- const id = m.message_id ?? `${opts.conversation}-${m.seq}`;
540
- if (!seen.has(id)) { seen.add(id); messages.push(m); }
541
- }
542
- if (res.data.has_more) hasMore = true;
543
- }
544
- }
545
- messages.sort((a, b) => (a.ts_ms ?? 0) - (b.ts_ms ?? 0));
546
- } else {
547
- const res = await client.fetchInbox(opts.conversation, { sinceSeq, limit, box: opts.box });
548
- if (!res.ok) {
549
- if (res.error) printError(res.error);
550
- else console.error('Failed to fetch inbox');
551
- process.exit(1);
552
- }
553
- messages = res.data?.messages ?? [];
554
- hasMore = res.data?.has_more ?? false;
555
- }
556
-
557
- if (opts.json) {
558
- console.log(JSON.stringify({ ok: true, data: { messages, has_more: hasMore } }, null, 2));
559
- } else {
560
- for (const msg of messages) {
561
- console.log(`[${msg.seq}] ${msg.schema} from ${msg.sender_did?.slice(0, 20)}... (${msg.status})`);
562
- }
563
- console.log(`${messages.length} message(s), has_more: ${hasMore}`);
564
- }
565
- });
566
-
567
- program
568
- .command('approve')
569
- .description('Approve a contact request')
570
- .argument('<conversation_id>', 'Pending DM conversation ID')
571
- .action(async (conversationId) => {
572
- if (!identityExists(getEffectiveIdentityPath())) {
573
- console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
574
- process.exit(1);
575
- }
576
- const client = await getClient();
577
-
578
- const res = await client.approveContact(conversationId);
579
- if (res.ok && res.data) {
580
- console.log(`Approved. DM conversation: ${res.data.dm_conversation_id}`);
581
- } else {
582
- if (res.error) printError(res.error);
583
- else console.error('Failed:', res.error?.message);
584
- process.exit(1);
585
- }
586
- });
587
-
588
- program
589
- .command('cancel')
590
- .description('Cancel a running task')
591
- .argument('<conversation_id>', 'Conversation ID')
592
- .argument('<task_id>', 'Task ID')
593
- .action(async (conversationId, taskId) => {
594
- if (!identityExists(getEffectiveIdentityPath())) {
595
- console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
596
- process.exit(1);
597
- }
598
- const client = await getClient();
599
-
600
- const res = await client.cancelTask(conversationId, taskId);
601
- if (res.ok && res.data) {
602
- console.log(`Task state: ${res.data.task_state}`);
603
- } else {
604
- if (res.error) printError(res.error);
605
- else console.error('Cancel failed');
606
- process.exit(1);
607
- }
608
- });
609
-
610
- program
611
- .command('resolve')
612
- .description('Resolve alias to DID')
613
- .argument('<alias>', 'Alias (e.g. @my/bot)')
614
- .action(async (alias) => {
615
- if (!identityExists(getEffectiveIdentityPath())) {
616
- console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
617
- process.exit(1);
618
- }
619
- const client = await getClient();
620
-
621
- const res = await client.resolveAlias(alias);
622
- if (res.ok && res.data) {
623
- console.log(`${res.data.alias} ${res.data.did}`);
624
- } else {
625
- if (res.error) printError(res.error);
626
- else console.error('Not found');
627
- process.exit(1);
628
- }
629
- });
630
-
631
- // === Contacts ===
632
- const contacts = program.command('contacts').description('Manage local contacts');
633
-
634
- contacts
635
- .command('list')
636
- .description('List saved contacts')
637
- .option('--tag <tag>', 'Filter by tag')
638
- .option('--trusted', 'Show only trusted contacts')
639
- .option('--json', 'Output as JSON')
640
- .action((opts) => {
641
- const store = openStore();
642
- const mgr = new ContactManager(store);
643
- const list = mgr.list({ tag: opts.tag, trusted: opts.trusted ? true : undefined });
644
- if (opts.json) {
645
- console.log(JSON.stringify(list, null, 2));
646
- } else if (list.length === 0) {
647
- console.log('No contacts found.');
648
- } else {
649
- for (const c of list) {
650
- const name = c.display_name ?? c.alias ?? c.did.slice(0, 30) + '...';
651
- const trust = c.trusted ? ' [trusted]' : '';
652
- console.log(` ${name}${trust} ${c.did}`);
653
- }
654
- console.log(`\n${list.length} contact(s)`);
655
- }
656
- store.close();
657
- });
658
-
659
- contacts
660
- .command('add')
661
- .description('Add a contact')
662
- .argument('<did>', 'Agent DID')
663
- .option('--alias <alias>', 'Alias (e.g. @my/bot)')
664
- .option('--name <name>', 'Display name')
665
- .option('--notes <notes>', 'Notes')
666
- .option('--tag <tag>', 'Tag')
667
- .action((did, opts) => {
668
- const store = openStore();
669
- const mgr = new ContactManager(store);
670
- mgr.add({
671
- did,
672
- alias: opts.alias,
673
- display_name: opts.name,
674
- notes: opts.notes,
675
- trusted: false,
676
- tags: opts.tag ? [opts.tag] : undefined,
677
- });
678
- console.log(`Contact added: ${did}`);
679
- store.close();
680
- });
681
-
682
- contacts
683
- .command('remove')
684
- .description('Remove a contact')
685
- .argument('<did>', 'Agent DID')
686
- .action((did) => {
687
- const store = openStore();
688
- const mgr = new ContactManager(store);
689
- if (mgr.remove(did)) {
690
- console.log(`Contact removed: ${did}`);
691
- } else {
692
- console.log('Contact not found.');
693
- }
694
- store.close();
695
- });
696
-
697
- contacts
698
- .command('update')
699
- .description('Update a contact')
700
- .argument('<did>', 'Agent DID')
701
- .option('--alias <alias>', 'Alias')
702
- .option('--name <name>', 'Display name')
703
- .option('--notes <notes>', 'Notes')
704
- .option('--tag <tag>', 'Tag (replaces existing tags)')
705
- .action((did, opts) => {
706
- const store = openStore();
707
- const mgr = new ContactManager(store);
708
- const updates = {};
709
- if (opts.alias) updates.alias = opts.alias;
710
- if (opts.name) updates.display_name = opts.name;
711
- if (opts.notes) updates.notes = opts.notes;
712
- if (opts.tag) updates.tags = [opts.tag];
713
- const result = mgr.update(did, updates);
714
- if (result) {
715
- console.log(`Contact updated: ${did}`);
716
- } else {
717
- console.log('Contact not found.');
718
- }
719
- store.close();
720
- });
721
-
722
- contacts
723
- .command('search')
724
- .description('Search contacts')
725
- .argument('<query>', 'Search query')
726
- .option('--json', 'Output as JSON')
727
- .action((query, opts) => {
728
- const store = openStore();
729
- const mgr = new ContactManager(store);
730
- const results = mgr.search(query);
731
- if (opts.json) {
732
- console.log(JSON.stringify(results, null, 2));
733
- } else if (results.length === 0) {
734
- console.log('No contacts match.');
735
- } else {
736
- for (const c of results) {
737
- console.log(` ${c.display_name ?? c.alias ?? c.did.slice(0, 30)} ${c.did}`);
738
- }
739
- console.log(`\n${results.length} match(es)`);
740
- }
741
- store.close();
742
- });
743
-
744
- contacts
745
- .command('export')
746
- .description('Export contacts')
747
- .option('--format <format>', 'json or csv', 'json')
748
- .option('--output <file>', 'Output file path')
749
- .action((opts) => {
750
- const store = openStore();
751
- const mgr = new ContactManager(store);
752
- const data = mgr.export(opts.format);
753
- if (opts.output) {
754
- fs.writeFileSync(opts.output, data);
755
- console.log(`Exported to ${opts.output}`);
756
- } else {
757
- console.log(data);
758
- }
759
- store.close();
760
- });
761
-
762
- contacts
763
- .command('import')
764
- .description('Import contacts from file')
765
- .argument('<file>', 'File to import')
766
- .option('--format <format>', 'json or csv', 'json')
767
- .action((file, opts) => {
768
- const store = openStore();
769
- const mgr = new ContactManager(store);
770
- const data = fs.readFileSync(file, 'utf-8');
771
- const result = mgr.import(data, opts.format);
772
- console.log(`Imported: ${result.imported}, Skipped: ${result.skipped}`);
773
- store.close();
774
- });
775
-
776
- // === History ===
777
- const history = program.command('history').description('Manage local chat history');
778
-
779
- history
780
- .command('conversations')
781
- .description('List conversations with local history')
782
- .option('--json', 'Output as JSON')
783
- .action((opts) => {
784
- const store = openStore();
785
- const mgr = new HistoryManager(store);
786
- const convos = mgr.listConversations();
787
- if (opts.json) {
788
- console.log(JSON.stringify(convos, null, 2));
789
- } else if (convos.length === 0) {
790
- console.log('No local history.');
791
- } else {
792
- for (const c of convos) {
793
- const date = new Date(c.last_message_at).toISOString();
794
- console.log(` ${c.conversation_id} ${c.message_count} msg(s) last: ${date}`);
795
- }
796
- }
797
- store.close();
798
- });
799
-
800
- history
801
- .command('list')
802
- .description('List messages in a conversation')
803
- .argument('<conversation_id>', 'Conversation ID')
804
- .option('--limit <n>', 'Limit', '50')
805
- .option('--json', 'Output as JSON')
806
- .action((conversationId, opts) => {
807
- const store = openStore();
808
- const mgr = new HistoryManager(store);
809
- const messages = mgr.list(conversationId, { limit: parseInt(opts.limit) });
810
- if (opts.json) {
811
- console.log(JSON.stringify(messages, null, 2));
812
- } else if (messages.length === 0) {
813
- console.log('No messages found.');
814
- } else {
815
- for (const m of messages) {
816
- const dir = m.direction === 'sent' ? '→' : '←';
817
- const text = m.payload?.text ?? m.payload?.title ?? m.schema;
818
- console.log(` ${dir} [${m.seq ?? '-'}] ${m.schema} ${text}`);
819
- }
820
- console.log(`\n${messages.length} message(s)`);
821
- }
822
- store.close();
823
- });
824
-
825
- history
826
- .command('sync')
827
- .description('Sync messages from server')
828
- .argument('<conversation_id>', 'Conversation ID')
829
- .option('--full', 'Full sync from beginning')
830
- .action(async (conversationId, opts) => {
831
- if (!identityExists(getEffectiveIdentityPath())) {
832
- console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
833
- process.exit(1);
834
- }
835
- const client = await getClient();
836
- const store = openStore();
837
- const mgr = new HistoryManager(store);
838
- const result = await mgr.syncFromServer(client, conversationId, { full: opts.full });
839
- console.log(`Synced ${result.synced} message(s)`);
840
- store.close();
841
- });
842
-
843
- history
844
- .command('search')
845
- .description('Search message history')
846
- .argument('<query>', 'Search query')
847
- .option('--conversation <id>', 'Limit to conversation')
848
- .option('--json', 'Output as JSON')
849
- .action((query, opts) => {
850
- const store = openStore();
851
- const mgr = new HistoryManager(store);
852
- const results = mgr.search(query, { conversationId: opts.conversation });
853
- if (opts.json) {
854
- console.log(JSON.stringify(results, null, 2));
855
- } else if (results.length === 0) {
856
- console.log('No messages match.');
857
- } else {
858
- for (const m of results) {
859
- const dir = m.direction === 'sent' ? '→' : '←';
860
- const text = m.payload?.text ?? m.payload?.title ?? m.schema;
861
- console.log(` ${dir} ${m.conversation_id.slice(0, 15)} ${text}`);
862
- }
863
- console.log(`\n${results.length} match(es)`);
864
- }
865
- store.close();
866
- });
867
-
868
- history
869
- .command('export')
870
- .description('Export chat history')
871
- .option('--conversation <id>', 'Conversation ID (all if omitted)')
872
- .option('--format <format>', 'json or csv', 'json')
873
- .option('--output <file>', 'Output file path')
874
- .action((opts) => {
875
- const store = openStore();
876
- const mgr = new HistoryManager(store);
877
- const data = mgr.export({ conversationId: opts.conversation, format: opts.format });
878
- if (opts.output) {
879
- fs.writeFileSync(opts.output, data);
880
- console.log(`Exported to ${opts.output}`);
881
- } else {
882
- console.log(data);
883
- }
884
- store.close();
885
- });
886
-
887
- history
888
- .command('delete')
889
- .description('Delete history for a conversation')
890
- .argument('<conversation_id>', 'Conversation ID')
891
- .action((conversationId) => {
892
- const store = openStore();
893
- const mgr = new HistoryManager(store);
894
- const count = mgr.delete(conversationId);
895
- console.log(`Deleted ${count} message(s)`);
896
- store.close();
897
- });
898
-
899
- // === A2A (Agent-to-Agent Protocol) ===
900
- const a2a = program.command('a2a').description('Interact with external A2A-compatible agents');
901
-
902
- a2a
903
- .command('discover')
904
- .description('Fetch and display an external agent\'s AgentCard')
905
- .argument('<url>', 'Agent URL (e.g. https://agent.example.com)')
906
- .option('--json', 'Output as JSON')
907
- .action(async (url, opts) => {
908
- const adapter = new A2AAdapter({ agentUrl: url });
909
- const card = await adapter.getAgentCard();
910
- if (opts.json) {
911
- console.log(JSON.stringify(card, null, 2));
912
- } else {
913
- console.log(`Agent: ${card.name}`);
914
- console.log(`Description: ${card.description}`);
915
- console.log(`URL: ${card.url}`);
916
- console.log(`Version: ${card.version} (protocol ${card.protocolVersion})`);
917
- if (card.provider) console.log(`Provider: ${card.provider.organization}`);
918
- console.log(`Capabilities: streaming=${card.capabilities.streaming ?? false}, push=${card.capabilities.pushNotifications ?? false}`);
919
- console.log(`Skills:`);
920
- for (const s of card.skills) {
921
- console.log(` [${s.id}] ${s.name}: ${s.description}`);
922
- if (s.tags.length) console.log(` tags: ${s.tags.join(', ')}`);
923
- }
924
- }
925
- });
926
-
927
- a2a
928
- .command('send')
929
- .description('Send a task to an external A2A agent')
930
- .argument('<url>', 'Agent URL')
931
- .requiredOption('--task <title>', 'Task title/prompt')
932
- .option('--description <desc>', 'Task description')
933
- .option('--wait', 'Wait for task completion')
934
- .option('--timeout <seconds>', 'Timeout in seconds', '120')
935
- .option('--auth <token>', 'Bearer token for the remote agent')
936
- .option('--json', 'Output as JSON')
937
- .action(async (url, opts) => {
938
- const adapter = new A2AAdapter({
939
- agentUrl: url,
940
- authToken: opts.auth,
941
- });
942
-
943
- const result = await adapter.sendTask({
944
- title: opts.task,
945
- description: opts.description,
946
- wait: opts.wait,
947
- timeoutMs: parseInt(opts.timeout) * 1000,
948
- });
949
-
950
- if (opts.json) {
951
- console.log(JSON.stringify(result, null, 2));
952
- } else {
953
- console.log(`Task ID: ${result.taskId}`);
954
- console.log(`State: ${result.state}`);
955
- if (result.summary) console.log(`Summary: ${result.summary}`);
956
- if (result.output) console.log(`Output: ${JSON.stringify(result.output, null, 2)}`);
957
- }
958
- });
959
-
960
- a2a
961
- .command('status')
962
- .description('Get task status from an external A2A agent')
963
- .argument('<url>', 'Agent URL')
964
- .requiredOption('--task-id <id>', 'Task ID')
965
- .option('--auth <token>', 'Bearer token')
966
- .option('--json', 'Output as JSON')
967
- .action(async (url, opts) => {
968
- const adapter = new A2AAdapter({
969
- agentUrl: url,
970
- authToken: opts.auth,
971
- });
972
-
973
- const result = await adapter.getTaskStatus(opts.taskId);
974
- if (opts.json) {
975
- console.log(JSON.stringify(result, null, 2));
976
- } else {
977
- console.log(`Task ID: ${result.taskId}`);
978
- console.log(`State: ${result.state}`);
979
- if (result.summary) console.log(`Summary: ${result.summary}`);
980
- }
981
- });
982
-
983
- a2a
984
- .command('cancel')
985
- .description('Cancel a task on an external A2A agent')
986
- .argument('<url>', 'Agent URL')
987
- .requiredOption('--task-id <id>', 'Task ID')
988
- .option('--auth <token>', 'Bearer token')
989
- .action(async (url, opts) => {
990
- const adapter = new A2AAdapter({
991
- agentUrl: url,
992
- authToken: opts.auth,
993
- });
994
- const result = await adapter.cancelTask(opts.taskId);
995
- console.log(`Task ${result.taskId}: ${result.state}`);
996
- });
997
-
998
- // === Billing ===
999
- const billing = program.command('billing').description('Manage billing group (primary + linked devices)');
1000
-
1001
- billing
1002
- .command('link-code')
1003
- .description('Generate a link code for adding a device to your subscription (primary only)')
1004
- .option('--json', 'Output as JSON')
1005
- .action(async (opts) => {
1006
- const client = await getClient();
1007
- const res = await client.createBillingLinkCode();
1008
- if (!res.ok) {
1009
- if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1010
- console.error(`Error: ${res.error?.message ?? 'Failed to create link code'}`);
1011
- if (res.error?.hint) console.error(`Hint: ${res.error.hint}`);
1012
- }
1013
- process.exit(1);
1014
- }
1015
- if (opts.json) {
1016
- console.log(JSON.stringify(res.data, null, 2));
1017
- } else {
1018
- console.log(`Link code: ${res.data.code}`);
1019
- console.log(`Expires in ${res.data.expires_in_seconds}s`);
1020
- console.log(`\nRun on the new device:\n pingagent billing link --code ${res.data.code}`);
1021
- }
1022
- });
1023
-
1024
- billing
1025
- .command('link')
1026
- .description('Link this device to a primary subscription using a link code')
1027
- .requiredOption('--code <code>', 'Link code from primary device')
1028
- .option('--json', 'Output as JSON')
1029
- .action(async (opts) => {
1030
- const client = await getClient();
1031
- const res = await client.redeemBillingLink(opts.code);
1032
- if (!res.ok) {
1033
- if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1034
- console.error(`Error: ${res.error?.message ?? 'Failed to link device'}`);
1035
- if (res.error?.hint) console.error(`Hint: ${res.error.hint}`);
1036
- }
1037
- process.exit(1);
1038
- }
1039
- if (opts.json) {
1040
- console.log(JSON.stringify(res.data, null, 2));
1041
- } else {
1042
- console.log(`Linked to primary: ${res.data.primary_did}`);
1043
- console.log('This device now shares the primary subscription tier and quotas.');
1044
- }
1045
- });
1046
-
1047
- billing
1048
- .command('linked-devices')
1049
- .description('List all devices in your billing group')
1050
- .option('--json', 'Output as JSON')
1051
- .action(async (opts) => {
1052
- const client = await getClient();
1053
- const res = await client.getLinkedDevices();
1054
- if (!res.ok) {
1055
- if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1056
- console.error(`Error: ${res.error?.message ?? 'Failed to get linked devices'}`);
1057
- }
1058
- process.exit(1);
1059
- }
1060
- if (opts.json) {
1061
- console.log(JSON.stringify(res.data, null, 2));
1062
- } else {
1063
- const d = res.data;
1064
- console.log(`Primary DID: ${d.primary_did}${d.is_primary ? ' (this device)' : ''}`);
1065
- if (d.linked_dids.length === 0) {
1066
- console.log('No linked devices.');
1067
- } else {
1068
- console.log(`Linked devices (${d.linked_dids.length}):`);
1069
- for (const did of d.linked_dids) {
1070
- const marker = did === client.getDid() ? ' (this device)' : '';
1071
- console.log(` - ${did}${marker}`);
1072
- }
1073
- }
1074
- }
1075
- });
1076
-
1077
- billing
1078
- .command('unlink')
1079
- .description('Remove a linked device from your billing group (primary only)')
1080
- .requiredOption('--did <did>', 'DID of the device to unlink')
1081
- .option('--json', 'Output as JSON')
1082
- .action(async (opts) => {
1083
- const client = await getClient();
1084
- const res = await client.unlinkBillingDevice(opts.did);
1085
- if (!res.ok) {
1086
- if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1087
- console.error(`Error: ${res.error?.message ?? 'Failed to unlink device'}`);
1088
- if (res.error?.hint) console.error(`Hint: ${res.error.hint}`);
1089
- }
1090
- process.exit(1);
1091
- }
1092
- if (opts.json) {
1093
- console.log(JSON.stringify({ ok: true }, null, 2));
1094
- } else {
1095
- console.log(`Unlinked: ${opts.did}`);
1096
- console.log('The device will revert to ghost tier.');
1097
- }
1098
- });
1099
-
1100
- program
1101
- .command('web')
1102
- .description('Start local web UI for debugging and audit. By default scans ~/.pingagent for profiles; use --identity-dir to lock to one profile.')
1103
- .option('--port <port>', 'Port for the web server', '3846')
1104
- .action(async (opts) => {
1105
- const serverUrl = process.env.PINGAGENT_SERVER_URL || DEFAULT_SERVER;
1106
- const port = parseInt(opts.port, 10) || 3846;
1107
- const identityDir = program.opts().identityDir;
1108
- const { startWebServer } = await import('../dist/web-server.js');
1109
- if (identityDir) {
1110
- const identityPath = path.join(resolvePath(identityDir), 'identity.json');
1111
- if (!identityExists(identityPath)) {
1112
- console.error('No identity found at', identityPath, '. Run: pingagent init');
1113
- process.exit(1);
1114
- }
1115
- const storePath = process.env.PINGAGENT_STORE_PATH
1116
- ? resolvePath(process.env.PINGAGENT_STORE_PATH)
1117
- : path.join(resolvePath(identityDir), 'store.db');
1118
- await startWebServer({ fixedIdentityPath: identityPath, fixedStorePath: storePath, serverUrl, port });
1119
- } else {
1120
- const rootDir = process.env.PINGAGENT_ROOT_DIR || path.join(os.homedir(), '.pingagent');
1121
- await startWebServer({ rootDir, serverUrl, port });
1122
- }
1123
- });
1124
-
1125
- program.parse();
2
+ import { Command } from 'commander';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import * as os from 'node:os';
6
+ import {
7
+ PingAgentClient,
8
+ generateIdentity,
9
+ saveIdentity,
10
+ loadIdentity,
11
+ identityExists,
12
+ updateStoredToken,
13
+ ensureTokenValid,
14
+ LocalStore,
15
+ ContactManager,
16
+ HistoryManager,
17
+ A2AAdapter,
18
+ } from '../dist/index.js';
19
+ import { ERROR_HINTS, SCHEMA_TEXT } from '@pingagent/schemas';
20
+
21
+ const DEFAULT_SERVER = 'http://localhost:8787';
22
+ const UPGRADE_URL = 'https://pingagent.chat';
23
+ const DEFAULT_IDENTITY_PATH = path.join(os.homedir(), '.pingagent', 'identity.json');
24
+
25
+ function resolvePath(p) {
26
+ if (!p) return p;
27
+ if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1));
28
+ return p;
29
+ }
30
+
31
+ function getEffectiveIdentityPath() {
32
+ const dir = program.opts().identityDir;
33
+ if (dir) return path.join(resolvePath(dir), 'identity.json');
34
+ const envPath = process.env.PINGAGENT_IDENTITY_PATH;
35
+ return envPath ? resolvePath(envPath) : DEFAULT_IDENTITY_PATH;
36
+ }
37
+
38
+ /** Resolve identity path from command opts (--profile) or global (--identity-dir) or default. */
39
+ function getIdentityPathForCommand(opts) {
40
+ if (opts && opts.profile) return path.join(os.homedir(), '.pingagent', opts.profile, 'identity.json');
41
+ return getEffectiveIdentityPath();
42
+ }
43
+
44
+ function getStorePath(identityPath) {
45
+ if (process.env.PINGAGENT_STORE_PATH) return resolvePath(process.env.PINGAGENT_STORE_PATH);
46
+ const base = identityPath != null ? path.dirname(identityPath) : path.dirname(getEffectiveIdentityPath());
47
+ return path.join(base, 'store.db');
48
+ }
49
+
50
+ function printError(err) {
51
+ if (!err) return;
52
+ console.error(`Error: ${err.code ?? 'unknown'} - ${err.message ?? ''}`);
53
+ const hint = err.hint ?? (err.code && ERROR_HINTS[err.code]);
54
+ if (hint) console.error(`Hint: ${hint}`);
55
+ if (err.code && err.code.startsWith('E_PAYWALL_')) {
56
+ console.error(`Upgrade: ${UPGRADE_URL}`);
57
+ }
58
+ }
59
+
60
+ function openStore(identityPath) {
61
+ return new LocalStore(getStorePath(identityPath));
62
+ }
63
+
64
+ /** Format timestamp for display: human-readable by default, raw ms when --raw. */
65
+ function formatTs(tsMs, raw) {
66
+ if (raw) return String(tsMs);
67
+ if (tsMs == null || Number.isNaN(tsMs)) return '-';
68
+ return new Date(tsMs).toLocaleString();
69
+ }
70
+
71
+ function makeClient(id, identityPath) {
72
+ const p = identityPath ?? getEffectiveIdentityPath();
73
+ return new PingAgentClient({
74
+ serverUrl: id.serverUrl ?? DEFAULT_SERVER,
75
+ identity: id,
76
+ accessToken: id.accessToken ?? '',
77
+ onTokenRefreshed: (token, expiresAt) => updateStoredToken(token, expiresAt, p),
78
+ });
79
+ }
80
+
81
+ /** Load identity, proactively refresh token if near/past expiry, then return client. */
82
+ async function getClient(identityPath) {
83
+ const p = identityPath ?? getEffectiveIdentityPath();
84
+ let id = loadIdentity(p);
85
+ await ensureTokenValid(p, id.serverUrl);
86
+ id = loadIdentity(p);
87
+ return makeClient(id, p);
88
+ }
89
+
90
+ const program = new Command();
91
+ program
92
+ .name('pingagent')
93
+ .description('PingAgent Chat CLI')
94
+ .version('0.1.0')
95
+ .option('--identity-dir <dir>', 'Use identity and store from this directory (e.g. ~/.pingagent/agent1). Overrides PINGAGENT_IDENTITY_PATH for identity file; store is <dir>/store.db unless PINGAGENT_STORE_PATH is set.')
96
+ .option('--raw', 'Show raw timestamps (milliseconds) instead of human-readable in non-JSON output.');
97
+
98
+ program
99
+ .command('init')
100
+ .description('Initialize PingAgent Chat identity')
101
+ .option('--server <url>', 'Server URL', DEFAULT_SERVER)
102
+ .option('--token <token>', 'Developer token for production mode')
103
+ .option('--profile <name>', 'Profile name: use ~/.pingagent/<name>/ for identity and set server display_name to <name>')
104
+ .option('--bio <text>', 'Set profile bio on the server after registration')
105
+ .option('--cursor', 'Auto-configure Cursor MCP')
106
+ .action(async (opts) => {
107
+ const identityPath = opts.profile
108
+ ? path.join(os.homedir(), '.pingagent', opts.profile, 'identity.json')
109
+ : getEffectiveIdentityPath();
110
+ const withProfile = opts.profile != null || opts.bio != null;
111
+
112
+ if (identityExists(identityPath)) {
113
+ const existing = loadIdentity(identityPath);
114
+ console.log(`Identity already exists: ${existing.did}`);
115
+ if (withProfile) {
116
+ await ensureTokenValid(identityPath, existing.serverUrl ?? opts.server);
117
+ const id = loadIdentity(identityPath);
118
+ const client = makeClient(id, identityPath);
119
+ const profilePayload = {};
120
+ if (opts.profile != null) profilePayload.display_name = opts.profile;
121
+ if (opts.bio != null) profilePayload.bio = opts.bio;
122
+ console.log('Updating profile (display_name, bio)...');
123
+ const profileRes = await client.updateProfile(profilePayload);
124
+ if (!profileRes.ok) {
125
+ if (profileRes.error) printError(profileRes.error);
126
+ else console.error('Profile update failed.');
127
+ process.exit(1);
128
+ }
129
+ console.log('Profile updated.');
130
+ }
131
+ return;
132
+ }
133
+
134
+ const totalSteps = withProfile ? 4 : 3;
135
+
136
+ console.log('PingAgent Chat Setup');
137
+ console.log('====================');
138
+ console.log(`[1/${totalSteps}] Generating Ed25519 keypair... done`);
139
+
140
+ const identity = generateIdentity();
141
+
142
+ console.log(`[2/${totalSteps}] Registering with relay server...`);
143
+ const client = new PingAgentClient({
144
+ serverUrl: opts.server,
145
+ identity,
146
+ accessToken: '',
147
+ });
148
+
149
+ const regRes = await client.register(opts.token);
150
+ if (!regRes.ok || !regRes.data) {
151
+ if (regRes.error) printError(regRes.error);
152
+ else console.error('Registration failed:', regRes.error?.message);
153
+ process.exit(1);
154
+ }
155
+
156
+ const { did, access_token, expires_ms, mode } = regRes.data;
157
+ console.log(` Server: ${opts.server}`);
158
+ console.log(` Mode: ${mode}`);
159
+ console.log(` Your DID: ${did}`);
160
+
161
+ console.log(`[3/${totalSteps}] Saving identity... done`);
162
+ saveIdentity(identity, {
163
+ serverUrl: opts.server,
164
+ accessToken: access_token,
165
+ tokenExpiresAt: Date.now() + expires_ms,
166
+ mode,
167
+ }, identityPath);
168
+
169
+ if (withProfile) {
170
+ const profilePayload = {};
171
+ if (opts.profile != null) profilePayload.display_name = opts.profile;
172
+ if (opts.bio != null) profilePayload.bio = opts.bio;
173
+ console.log(`[4/${totalSteps}] Setting profile (display_name, bio)...`);
174
+ const clientWithToken = new PingAgentClient({
175
+ serverUrl: opts.server,
176
+ identity,
177
+ accessToken: access_token,
178
+ });
179
+ const profileRes = await clientWithToken.updateProfile(profilePayload);
180
+ if (!profileRes.ok) {
181
+ if (profileRes.error) printError(profileRes.error);
182
+ else console.error('Profile update failed (identity was saved).');
183
+ } else {
184
+ console.log(' done');
185
+ }
186
+ }
187
+
188
+ console.log(`\nReady! Share your DID with agents you want to communicate with.`);
189
+ if (identityPath !== DEFAULT_IDENTITY_PATH) {
190
+ console.log(`Identity: ${identityPath}`);
191
+ console.log(`Store: ${getStorePath()}`);
192
+ }
193
+ console.log(`\nRun 'pingagent doctor' to verify setup.`);
194
+ });
195
+
196
+ program
197
+ .command('doctor')
198
+ .description('Check identity, token, permissions and server; use --fix to renew token if expired')
199
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
200
+ .option('--fix', 'Attempt to fix token if expired (runs renew-token)')
201
+ .option('--json', 'Output as JSON')
202
+ .action(async (opts) => {
203
+ const identityPath = getIdentityPathForCommand(opts);
204
+ const storePath = getStorePath(identityPath);
205
+ const identityDir = path.dirname(identityPath);
206
+ const storeDir = path.dirname(storePath);
207
+ const issues = [];
208
+ const ok = [];
209
+
210
+ if (!identityExists(identityPath)) {
211
+ issues.push({ check: 'identity', message: 'No identity found. Run: pingagent init' });
212
+ } else {
213
+ ok.push('identity');
214
+ try {
215
+ const id = loadIdentity(identityPath);
216
+ const now = Date.now();
217
+ const expiresAt = id.tokenExpiresAt;
218
+ const graceMs = 5 * 60 * 1000;
219
+ if (expiresAt == null || expiresAt <= now + graceMs) {
220
+ issues.push({ check: 'token', message: 'Token expired or expiring soon. Run: pingagent renew-token', fixable: true });
221
+ } else {
222
+ ok.push('token');
223
+ }
224
+ } catch (e) {
225
+ issues.push({ check: 'identity', message: `Cannot load identity: ${e.message}` });
226
+ }
227
+ }
228
+
229
+ try {
230
+ if (!fs.existsSync(identityDir)) {
231
+ fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
232
+ }
233
+ const testFile = path.join(identityDir, '.pingagent-write-test');
234
+ fs.writeFileSync(testFile, '');
235
+ fs.unlinkSync(testFile);
236
+ ok.push('identity_dir_writable');
237
+ } catch (e) {
238
+ issues.push({ check: 'identity_dir', message: `Identity directory not writable: ${identityDir} (${e.message})` });
239
+ }
240
+
241
+ try {
242
+ if (!fs.existsSync(storeDir)) {
243
+ fs.mkdirSync(storeDir, { recursive: true, mode: 0o700 });
244
+ }
245
+ const store = openStore(identityPath);
246
+ store.close();
247
+ ok.push('store_writable');
248
+ } catch (e) {
249
+ issues.push({ check: 'store', message: `Store path not writable: ${storePath} (${e.message})` });
250
+ }
251
+
252
+ if (identityExists(identityPath)) {
253
+ const id = loadIdentity(identityPath);
254
+ const serverUrl = (id.serverUrl ?? DEFAULT_SERVER).replace(/\/$/, '');
255
+ try {
256
+ const res = await fetch(`${serverUrl}/.well-known/agent.json`, { method: 'GET' });
257
+ if (res.ok) ok.push('server_reachable');
258
+ else issues.push({ check: 'server', message: `Server returned ${res.status}: ${serverUrl}` });
259
+ } catch (e) {
260
+ issues.push({ check: 'server', message: `Server not reachable: ${serverUrl} (${e.message})` });
261
+ }
262
+ }
263
+
264
+ if (opts.fix && issues.some(i => i.fixable)) {
265
+ const tokenIssue = issues.find(i => i.check === 'token');
266
+ if (tokenIssue && identityExists(identityPath)) {
267
+ const existing = loadIdentity(identityPath);
268
+ const serverUrl = existing.serverUrl ?? DEFAULT_SERVER;
269
+
270
+ // Try refresh first, then fallback to re-register
271
+ const refreshed = await ensureTokenValid(identityPath, serverUrl);
272
+ if (refreshed) {
273
+ console.log('Fixed: token refreshed (via /v1/auth/refresh).');
274
+ issues.splice(issues.findIndex(i => i.check === 'token'), 1);
275
+ ok.push('token');
276
+ } else {
277
+ const client = new PingAgentClient({ serverUrl, identity: existing, accessToken: '' });
278
+ const regRes = await client.register();
279
+ if (regRes.ok && regRes.data) {
280
+ saveIdentity(existing, {
281
+ serverUrl,
282
+ accessToken: regRes.data.access_token,
283
+ tokenExpiresAt: Date.now() + regRes.data.expires_ms,
284
+ mode: regRes.data.mode,
285
+ }, identityPath);
286
+ console.log('Fixed: token renewed (via re-register).');
287
+ issues.splice(issues.findIndex(i => i.check === 'token'), 1);
288
+ ok.push('token');
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ if (opts.json) {
295
+ console.log(JSON.stringify({ ok, issues }, null, 2));
296
+ process.exit(issues.length > 0 ? 1 : 0);
297
+ }
298
+
299
+ if (ok.length) console.log('OK:', ok.join(', '));
300
+ if (issues.length) {
301
+ for (const i of issues) console.error(`Issue [${i.check}]: ${i.message}`);
302
+ if (issues.some(i => i.fixable)) console.error('\nRun with --fix to attempt token renewal.');
303
+ process.exit(1);
304
+ }
305
+ console.log('All checks passed.');
306
+ });
307
+
308
+ program
309
+ .command('renew-token')
310
+ .description('Renew access token using existing identity (keeps DID and keypair). Tries /v1/auth/refresh first, falls back to re-register.')
311
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name> (e.g. agent1, receiver)')
312
+ .option('--server <url>', 'Server URL (defaults to identity server or localhost:8787)')
313
+ .option('--token <token>', 'Developer token for production mode (optional)')
314
+ .action(async (opts) => {
315
+ const identityPath = program.opts().identityDir
316
+ ? path.join(resolvePath(program.opts().identityDir), 'identity.json')
317
+ : opts.profile
318
+ ? path.join(os.homedir(), '.pingagent', opts.profile, 'identity.json')
319
+ : getEffectiveIdentityPath();
320
+ if (!identityExists(identityPath)) {
321
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH) first.');
322
+ process.exit(1);
323
+ }
324
+
325
+ const existing = loadIdentity(identityPath);
326
+ const serverUrl = opts.server ?? existing.serverUrl ?? DEFAULT_SERVER;
327
+
328
+ // Step 1: try lightweight /v1/auth/refresh (works within grace period)
329
+ console.log('Attempting token refresh via /v1/auth/refresh...');
330
+ const refreshed = await ensureTokenValid(identityPath, serverUrl);
331
+ if (refreshed) {
332
+ const updated = loadIdentity(identityPath);
333
+ console.log('Token refreshed successfully (via /v1/auth/refresh).');
334
+ console.log(`DID: ${updated.did}`);
335
+ console.log(`Server: ${serverUrl}`);
336
+ return;
337
+ }
338
+
339
+ // Step 2: fallback to re-register with existing keypair
340
+ console.log('Refresh not possible (token beyond grace period). Falling back to re-register...');
341
+ const client = new PingAgentClient({
342
+ serverUrl,
343
+ identity: existing,
344
+ accessToken: '',
345
+ });
346
+
347
+ const regRes = await client.register(opts.token);
348
+ if (!regRes.ok || !regRes.data) {
349
+ if (regRes.error) printError(regRes.error);
350
+ else console.error('Token renewal failed:', regRes.error?.message);
351
+ process.exit(1);
352
+ }
353
+
354
+ const { access_token, expires_ms, mode } = regRes.data;
355
+ saveIdentity(existing, {
356
+ serverUrl,
357
+ accessToken: access_token,
358
+ tokenExpiresAt: Date.now() + expires_ms,
359
+ mode,
360
+ }, identityPath);
361
+
362
+ console.log('Token renewed successfully (via re-register).');
363
+ console.log(`DID: ${existing.did}`);
364
+ console.log(`Server: ${serverUrl}`);
365
+ });
366
+
367
+ program
368
+ .command('status')
369
+ .description('Show current identity and subscription status')
370
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
371
+ .option('--json', 'Output as JSON')
372
+ .action(async (opts) => {
373
+ const identityPath = getIdentityPathForCommand(opts);
374
+ if (!identityExists(identityPath)) {
375
+ console.log('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
376
+ return;
377
+ }
378
+ const client = await getClient(identityPath);
379
+ const id = loadIdentity(identityPath);
380
+
381
+ const tokenStatus = id.tokenExpiresAt && id.tokenExpiresAt > Date.now() ? 'valid' : 'expired';
382
+
383
+ if (opts.json) {
384
+ const out = { did: id.did, device_id: id.deviceId, server: id.serverUrl ?? null, mode: id.mode ?? null, token: tokenStatus };
385
+ const subRes = await client.getSubscription();
386
+ if (subRes.ok && subRes.data) {
387
+ out.tier = subRes.data.tier;
388
+ out.usage = subRes.data.usage;
389
+ out.limits = subRes.data.limits;
390
+ }
391
+ console.log(JSON.stringify(out, null, 2));
392
+ return;
393
+ }
394
+
395
+ console.log(`DID: ${id.did}`);
396
+ console.log(`Device: ${id.deviceId}`);
397
+ console.log(`Server: ${id.serverUrl ?? 'not set'}`);
398
+ console.log(`Mode: ${id.mode ?? 'unknown'}`);
399
+ console.log(`Token: ${tokenStatus}`);
400
+
401
+ const subRes = await client.getSubscription();
402
+ if (subRes.ok && subRes.data) {
403
+ const d = subRes.data;
404
+ console.log(`Tier: ${d.tier}`);
405
+ console.log(`Relay: ${d.usage.relay_today} / ${d.usage.relay_limit} today`);
406
+ console.log(`Artifact: ${(d.usage.artifact_bytes / 1024 / 1024).toFixed(2)} MB / ${d.limits.artifact_storage_mb} MB`);
407
+ console.log(`Alias: ${d.usage.alias_count} / ${d.usage.alias_limit}`);
408
+ if (d.billing_primary_did && d.billing_primary_did !== id.did) {
409
+ console.log(`Billing: linked device (primary: ${d.billing_primary_did})`);
410
+ console.log(' Subscription managed on primary. Use primary to create link codes or manage billing.');
411
+ } else if (d.linked_device_count > 0) {
412
+ console.log(`Billing: primary device (${d.linked_device_count} linked)`);
413
+ }
414
+ if (d.usage.relay_today >= d.usage.relay_limit) {
415
+ console.log(`\nQuota reached. Upgrade: ${UPGRADE_URL}`);
416
+ }
417
+ } else {
418
+ console.log('Subscription: (unable to fetch - check server and token)');
419
+ }
420
+ });
421
+
422
+ program
423
+ .command('send')
424
+ .description('Send a task to a remote agent')
425
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
426
+ .option('--to <did>', 'Target DID or alias')
427
+ .option('--target <did>', 'Target DID or alias (alias of --to)')
428
+ .requiredOption('--task <title>', 'Task title')
429
+ .option('--description <desc>', 'Task description')
430
+ .option('--wait', 'Wait for result')
431
+ .option('--timeout <seconds>', 'Timeout in seconds', '120')
432
+ .option('--json', 'Output as JSON')
433
+ .action(async (opts) => {
434
+ const identityPath = getIdentityPathForCommand(opts);
435
+ let targetDid = opts.to ?? opts.target;
436
+ if (!targetDid) {
437
+ console.error('Provide --to or --target (e.g. --to did:agent:xxx or --to @alias)');
438
+ process.exit(1);
439
+ }
440
+ if (!identityExists(identityPath)) {
441
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
442
+ process.exit(1);
443
+ }
444
+ const client = await getClient(identityPath);
445
+ if (targetDid.startsWith('@')) {
446
+ const resolved = await client.resolveAlias(targetDid);
447
+ if (!resolved.ok || !resolved.data) {
448
+ if (resolved.error) printError(resolved.error);
449
+ else console.error(`Could not resolve alias: ${targetDid}`);
450
+ process.exit(1);
451
+ }
452
+ targetDid = resolved.data.did;
453
+ }
454
+
455
+ let trusted = false;
456
+ if (opts.wait) {
457
+ const result = await client.sendTaskAndWait(
458
+ targetDid,
459
+ { title: opts.task, description: opts.description },
460
+ { timeoutMs: parseInt(opts.timeout) * 1000 },
461
+ );
462
+ trusted = result.status === 'ok';
463
+ if (opts.json) {
464
+ console.log(JSON.stringify(result, null, 2));
465
+ } else {
466
+ console.log(`Task ${result.task_id}: ${result.status}`);
467
+ if (result.result?.summary) console.log(`Summary: ${result.result.summary}`);
468
+ if (result.error) printError(result.error);
469
+ console.log(`Elapsed: ${result.elapsed_ms}ms`);
470
+ }
471
+ } else {
472
+ const openRes = await client.openConversation(targetDid);
473
+ if (!openRes.ok || !openRes.data) {
474
+ if (openRes.error) printError(openRes.error);
475
+ else console.error('Failed to open conversation');
476
+ process.exit(1);
477
+ }
478
+ trusted = openRes.data.trusted;
479
+
480
+ const { v7: uuidv7 } = await import('uuid');
481
+ const sendRes = await client.sendTask(openRes.data.conversation_id, {
482
+ task_id: `t_${uuidv7()}`,
483
+ title: opts.task,
484
+ description: opts.description,
485
+ });
486
+
487
+ if (!sendRes.ok) {
488
+ if (sendRes.error) printError(sendRes.error);
489
+ else console.error('Failed to send task');
490
+ process.exit(1);
491
+ }
492
+
493
+ if (opts.json) {
494
+ console.log(JSON.stringify(sendRes, null, 2));
495
+ } else {
496
+ console.log(`Sent: ${sendRes.data?.message_id} (seq: ${sendRes.data?.seq})`);
497
+ }
498
+ }
499
+
500
+ const store = openStore(identityPath);
501
+ try {
502
+ const mgr = new ContactManager(store);
503
+ if (!mgr.get(targetDid)) {
504
+ mgr.add({ did: targetDid, trusted, notes: 'Added via pingagent send' });
505
+ }
506
+ } finally {
507
+ store.close();
508
+ }
509
+ });
510
+
511
+ program
512
+ .command('chat')
513
+ .description('Send a text message (same as MCP pingagent_chat). Use "send" for tasks.')
514
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
515
+ .option('--to <did>', 'Target DID or alias')
516
+ .option('--target <did>', 'Target DID or alias (alias of --to)')
517
+ .requiredOption('--message <text>', 'Message text')
518
+ .option('--json', 'Output as JSON')
519
+ .action(async (opts) => {
520
+ const identityPath = getIdentityPathForCommand(opts);
521
+ let targetDid = opts.to ?? opts.target;
522
+ if (!targetDid) {
523
+ console.error('Provide --to or --target (e.g. --to did:agent:xxx or --to @alias)');
524
+ process.exit(1);
525
+ }
526
+ if (!identityExists(identityPath)) {
527
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
528
+ process.exit(1);
529
+ }
530
+ const client = await getClient(identityPath);
531
+ if (targetDid.startsWith('@')) {
532
+ const resolved = await client.resolveAlias(targetDid);
533
+ if (!resolved.ok || !resolved.data) {
534
+ if (resolved.error) printError(resolved.error);
535
+ else console.error(`Could not resolve alias: ${targetDid}`);
536
+ process.exit(1);
537
+ }
538
+ targetDid = resolved.data.did;
539
+ }
540
+
541
+ const openRes = await client.openConversation(targetDid);
542
+ if (!openRes.ok || !openRes.data) {
543
+ if (openRes.error) printError(openRes.error);
544
+ else console.error('Failed to open conversation');
545
+ process.exit(1);
546
+ }
547
+
548
+ const store = openStore(identityPath);
549
+ try {
550
+ const mgr = new ContactManager(store);
551
+ if (!mgr.get(targetDid)) {
552
+ mgr.add({
553
+ did: targetDid,
554
+ trusted: openRes.data.trusted,
555
+ conversation_id: openRes.data.conversation_id,
556
+ notes: openRes.data.trusted ? 'Added via pingagent chat' : 'Added via pingagent chat (pending)',
557
+ });
558
+ }
559
+ } finally {
560
+ store.close();
561
+ }
562
+
563
+ if (!openRes.data.trusted) {
564
+ await client.sendContactRequest(openRes.data.conversation_id, opts.message);
565
+ if (opts.json) {
566
+ console.log(JSON.stringify({ sent: 'contact_request', conversation_id: openRes.data.conversation_id }));
567
+ } else {
568
+ console.log('Contact request sent with your message. They need to approve before further messages are delivered.');
569
+ }
570
+ return;
571
+ }
572
+
573
+ const sendRes = await client.sendMessage(openRes.data.conversation_id, SCHEMA_TEXT, { text: opts.message });
574
+ if (!sendRes.ok) {
575
+ if (sendRes.error) printError(sendRes.error);
576
+ else console.error('Failed to send message');
577
+ process.exit(1);
578
+ }
579
+ if (opts.json) {
580
+ console.log(JSON.stringify({ message_id: sendRes.data?.message_id, seq: sendRes.data?.seq }));
581
+ } else {
582
+ console.log(`Sent: ${sendRes.data?.message_id} (seq: ${sendRes.data?.seq})`);
583
+ }
584
+ });
585
+
586
+ program
587
+ .command('inbox')
588
+ .description('Fetch inbox messages. Without --conversation, lists conversations with recent activity.')
589
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
590
+ .option('--conversation <id>', 'Conversation ID (omit to list all conversations with new messages)')
591
+ .option('--since-seq <n>', 'Since sequence number', '0')
592
+ .option('--limit <n>', 'Limit', '20')
593
+ .option('--box <box>', "Inbox box: ready, strangers, or all (merge both; use 'all' if Web messages don't show)", 'ready')
594
+ .option('--json', 'Output as JSON')
595
+ .action(async (opts) => {
596
+ const identityPath = getIdentityPathForCommand(opts);
597
+ if (!identityExists(identityPath)) {
598
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
599
+ process.exit(1);
600
+ }
601
+ const client = await getClient(identityPath);
602
+ const sinceSeq = parseInt(opts.sinceSeq);
603
+ const limit = parseInt(opts.limit);
604
+
605
+ if (!opts.conversation) {
606
+ const listRes = await client.listConversations({ type: 'dm' });
607
+ if (!listRes.ok || !listRes.data?.conversations?.length) {
608
+ if (opts.json) {
609
+ console.log(JSON.stringify({ ok: true, data: { conversations: [] } }, null, 2));
610
+ } else {
611
+ console.log('No conversations. Send a message (pingagent chat --to <did>) or approve a contact request first.');
612
+ }
613
+ return;
614
+ }
615
+ const raw = program.opts().raw;
616
+ const convos = listRes.data.conversations;
617
+ if (opts.json) {
618
+ const withPreview = [];
619
+ for (const c of convos) {
620
+ const resReady = await client.fetchInbox(c.conversation_id, { sinceSeq: 0, limit: 1, box: 'ready' });
621
+ const resStrangers = await client.fetchInbox(c.conversation_id, { sinceSeq: 0, limit: 1, box: 'strangers' });
622
+ const lastReady = resReady.ok && resReady.data?.messages?.[0];
623
+ const lastStrangers = resStrangers.ok && resStrangers.data?.messages?.[0];
624
+ withPreview.push({
625
+ conversation_id: c.conversation_id,
626
+ type: c.type,
627
+ target_did: c.target_did,
628
+ trusted: c.trusted,
629
+ last_ready: lastReady ? { seq: lastReady.seq, schema: lastReady.schema, ts_ms: lastReady.ts_ms } : null,
630
+ last_strangers: lastStrangers ? { seq: lastStrangers.seq, schema: lastStrangers.schema, ts_ms: lastStrangers.ts_ms } : null,
631
+ });
632
+ }
633
+ console.log(JSON.stringify({ ok: true, data: { conversations: withPreview } }, null, 2));
634
+ } else {
635
+ for (const c of convos) {
636
+ const resReady = await client.fetchInbox(c.conversation_id, { sinceSeq: 0, limit: 1, box: 'ready' });
637
+ const resStrangers = await client.fetchInbox(c.conversation_id, { sinceSeq: 0, limit: 1, box: 'strangers' });
638
+ const lastReady = resReady.ok && resReady.data?.messages?.[0];
639
+ const lastStrangers = resStrangers.ok && resStrangers.data?.messages?.[0];
640
+ const preview = lastReady?.payload?.text ?? lastStrangers?.payload?.text ?? lastReady?.payload?.title ?? lastStrangers?.payload?.title ?? lastReady?.schema ?? lastStrangers?.schema ?? '-';
641
+ const ts = (lastReady ?? lastStrangers)?.ts_ms != null ? formatTs((lastReady ?? lastStrangers).ts_ms, raw) : '-';
642
+ const from = (lastReady ?? lastStrangers)?.sender_did?.slice(0, 16) ?? '?';
643
+ console.log(` ${c.conversation_id} ${c.trusted ? 'ready' : 'pending'} last: ${ts} from ${from}... "${String(preview).slice(0, 40)}${String(preview).length > 40 ? '...' : ''}"`);
644
+ }
645
+ console.log('');
646
+ console.log('To view messages: pingagent inbox --conversation <conversation_id>');
647
+ }
648
+ return;
649
+ }
650
+
651
+ let messages = [];
652
+ let hasMore = false;
653
+ if (opts.box === 'all') {
654
+ const seen = new Set();
655
+ for (const box of ['ready', 'strangers']) {
656
+ const res = await client.fetchInbox(opts.conversation, { sinceSeq, limit, box });
657
+ if (res.ok && res.data) {
658
+ for (const m of res.data.messages) {
659
+ const id = m.message_id ?? `${opts.conversation}-${m.seq}`;
660
+ if (!seen.has(id)) { seen.add(id); messages.push(m); }
661
+ }
662
+ if (res.data.has_more) hasMore = true;
663
+ }
664
+ }
665
+ messages.sort((a, b) => (a.ts_ms ?? 0) - (b.ts_ms ?? 0));
666
+ } else {
667
+ const res = await client.fetchInbox(opts.conversation, { sinceSeq, limit, box: opts.box });
668
+ if (!res.ok) {
669
+ if (res.error) printError(res.error);
670
+ else console.error('Failed to fetch inbox');
671
+ process.exit(1);
672
+ }
673
+ messages = res.data?.messages ?? [];
674
+ hasMore = res.data?.has_more ?? false;
675
+ }
676
+
677
+ if (opts.json) {
678
+ console.log(JSON.stringify({ ok: true, data: { messages, has_more: hasMore } }, null, 2));
679
+ } else {
680
+ const raw = program.opts().raw;
681
+ for (const msg of messages) {
682
+ const ts = formatTs(msg.ts_ms, raw);
683
+ console.log(` ${ts} [${msg.seq}] ${msg.schema} from ${msg.sender_did?.slice(0, 20)}... (${msg.status})`);
684
+ }
685
+ console.log(`${messages.length} message(s), has_more: ${hasMore}`);
686
+ }
687
+ });
688
+
689
+ program
690
+ .command('approve')
691
+ .description('Approve a contact request')
692
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
693
+ .argument('<conversation_id>', 'Pending DM conversation ID')
694
+ .action(async (conversationId, opts = {}) => {
695
+ const identityPath = getIdentityPathForCommand(opts);
696
+ if (!identityExists(identityPath)) {
697
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
698
+ process.exit(1);
699
+ }
700
+ const client = await getClient(identityPath);
701
+
702
+ const res = await client.approveContact(conversationId);
703
+ if (res.ok && res.data) {
704
+ console.log(`Approved. DM conversation: ${res.data.dm_conversation_id}`);
705
+ } else {
706
+ if (res.error) printError(res.error);
707
+ else console.error('Failed:', res.error?.message);
708
+ process.exit(1);
709
+ }
710
+ });
711
+
712
+ program
713
+ .command('cancel')
714
+ .description('Cancel a running task')
715
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
716
+ .argument('<conversation_id>', 'Conversation ID')
717
+ .argument('<task_id>', 'Task ID')
718
+ .action(async (conversationId, taskId, opts = {}) => {
719
+ const identityPath = getIdentityPathForCommand(opts);
720
+ if (!identityExists(identityPath)) {
721
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
722
+ process.exit(1);
723
+ }
724
+ const client = await getClient(identityPath);
725
+
726
+ const res = await client.cancelTask(conversationId, taskId);
727
+ if (res.ok && res.data) {
728
+ console.log(`Task state: ${res.data.task_state}`);
729
+ } else {
730
+ if (res.error) printError(res.error);
731
+ else console.error('Cancel failed');
732
+ process.exit(1);
733
+ }
734
+ });
735
+
736
+ program
737
+ .command('resolve')
738
+ .description('Resolve alias to DID')
739
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
740
+ .argument('<alias>', 'Alias (e.g. @my/bot)')
741
+ .action(async (alias, opts = {}) => {
742
+ const identityPath = getIdentityPathForCommand(opts);
743
+ if (!identityExists(identityPath)) {
744
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
745
+ process.exit(1);
746
+ }
747
+ const client = await getClient(identityPath);
748
+
749
+ const res = await client.resolveAlias(alias);
750
+ if (res.ok && res.data) {
751
+ console.log(`${res.data.alias} ${res.data.did}`);
752
+ } else {
753
+ if (res.error) printError(res.error);
754
+ else console.error('Not found');
755
+ process.exit(1);
756
+ }
757
+ });
758
+
759
+ // === Conversations (local history management + server view) ===
760
+ const conversations = program.command('conversations').description('Manage conversations (local history and server-side metadata)');
761
+
762
+ conversations
763
+ .command('list')
764
+ .description('List server conversations for the current identity (optionally by profile). Use --type pending_dm to see only pending contact requests.')
765
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
766
+ .option('--type <dm|pending_dm|all>', 'Filter: dm (established only), pending_dm (waiting for approval), or all (default)', 'all')
767
+ .option('--json', 'Output as JSON')
768
+ .action(async (opts = {}) => {
769
+ const identityPath = getIdentityPathForCommand(opts);
770
+ if (!identityExists(identityPath)) {
771
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
772
+ process.exit(1);
773
+ }
774
+ const client = await getClient(identityPath);
775
+ const typeArg = opts.type === 'pending_dm' ? 'pending_dm' : opts.type === 'dm' ? 'dm' : undefined;
776
+ const res = await client.listConversations(typeArg ? { type: typeArg } : undefined);
777
+ const convos = res.ok && res.data?.conversations ? res.data.conversations : [];
778
+ const raw = program.opts().raw;
779
+ if (opts.json) {
780
+ console.log(JSON.stringify({ ok: res.ok, conversations: convos }, null, 2));
781
+ } else if (!res.ok) {
782
+ if (res.error) printError(res.error);
783
+ else console.error('Failed to list conversations');
784
+ process.exit(1);
785
+ } else if (convos.length === 0) {
786
+ const hint = typeArg === 'pending_dm'
787
+ ? 'No pending contact requests.'
788
+ : 'No conversations. Send a message (pingagent chat --to <did>) or approve a contact request first.';
789
+ console.log(hint);
790
+ } else {
791
+ for (const c of convos) {
792
+ const ts = c.last_message_ts_ms != null ? formatTs(c.last_message_ts_ms, raw) : '-';
793
+ const target = c.target_did?.slice(0, 30) ?? '?';
794
+ console.log(` ${c.conversation_id} ${c.trusted ? 'ready' : 'pending'} last: ${ts} target: ${target}`);
795
+ }
796
+ console.log(`\n${convos.length} conversation(s)`);
797
+ }
798
+ });
799
+
800
+ conversations
801
+ .command('show')
802
+ .description('Show details and recent messages for a conversation')
803
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
804
+ .argument('<conversation_id>', 'Conversation ID')
805
+ .option('--limit <n>', 'Number of recent messages to fetch', '20')
806
+ .option('--json', 'Output as JSON')
807
+ .action(async (conversationId, opts = {}) => {
808
+ const identityPath = getIdentityPathForCommand(opts);
809
+ if (!identityExists(identityPath)) {
810
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / --profile / PINGAGENT_IDENTITY_PATH)');
811
+ process.exit(1);
812
+ }
813
+ const client = await getClient(identityPath);
814
+ const metaRes = await client.listConversations({ type: 'dm' });
815
+ const convo = metaRes.ok && metaRes.data?.conversations
816
+ ? metaRes.data.conversations.find(c => c.conversation_id === conversationId)
817
+ : null;
818
+ const limit = parseInt(opts.limit, 10) || 20;
819
+ const inboxRes = await client.fetchInbox(conversationId, { sinceSeq: 0, limit, box: 'all' });
820
+ const messages = inboxRes.ok && inboxRes.data?.messages ? inboxRes.data.messages : [];
821
+ const raw = program.opts().raw;
822
+
823
+ if (opts.json) {
824
+ console.log(JSON.stringify({
825
+ ok: metaRes.ok && inboxRes.ok,
826
+ conversation: convo ?? null,
827
+ messages,
828
+ }, null, 2));
829
+ return;
830
+ }
831
+
832
+ if (!inboxRes.ok) {
833
+ if (inboxRes.error) printError(inboxRes.error);
834
+ else console.error('Failed to fetch messages for conversation');
835
+ process.exit(1);
836
+ }
837
+
838
+ if (convo) {
839
+ console.log(`Conversation: ${convo.conversation_id}`);
840
+ console.log(`Type: ${convo.type}`);
841
+ console.log(`Trusted: ${convo.trusted}`);
842
+ console.log(`Target DID: ${convo.target_did ?? '-'}`);
843
+ if (convo.last_message_ts_ms != null) {
844
+ console.log(`Last message: ${formatTs(convo.last_message_ts_ms, raw)}`);
845
+ }
846
+ console.log('');
847
+ }
848
+
849
+ if (messages.length === 0) {
850
+ console.log('No messages found for this conversation.');
851
+ return;
852
+ }
853
+
854
+ for (const msg of messages) {
855
+ const ts = formatTs(msg.ts_ms, raw);
856
+ const from = msg.sender_did?.slice(0, 20) ?? '?';
857
+ const text = msg.payload?.text ?? msg.payload?.title ?? msg.schema;
858
+ console.log(` ${ts} [${msg.seq}] ${msg.schema} from ${from}... ${text}`);
859
+ }
860
+ console.log(`\n${messages.length} message(s)`);
861
+ });
862
+
863
+ conversations
864
+ .command('delete')
865
+ .description('Delete local history for a conversation (server conversation unchanged)')
866
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
867
+ .argument('<conversation_id>', 'Conversation ID')
868
+ .action((conversationId, opts = {}) => {
869
+ const identityPath = getIdentityPathForCommand(opts);
870
+ const store = openStore(identityPath);
871
+ const mgr = new HistoryManager(store);
872
+ const count = mgr.delete(conversationId);
873
+ console.log(`Deleted ${count} message(s) for conversation ${conversationId}`);
874
+ store.close();
875
+ });
876
+
877
+ conversations
878
+ .command('clear')
879
+ .description('Clear all local history (use --force to skip confirmation)')
880
+ .option('--profile <name>', 'Use profile from ~/.pingagent/<name>')
881
+ .option('--force', 'Skip confirmation')
882
+ .action((opts = {}) => {
883
+ const identityPath = getIdentityPathForCommand(opts);
884
+ const store = openStore(identityPath);
885
+ const mgr = new HistoryManager(store);
886
+ const convos = mgr.listConversations();
887
+ if (convos.length === 0) {
888
+ console.log('No local history to clear.');
889
+ store.close();
890
+ return;
891
+ }
892
+ if (!opts.force) {
893
+ console.log(`This will delete local history for ${convos.length} conversation(s). Run with --force to confirm.`);
894
+ store.close();
895
+ return;
896
+ }
897
+ let total = 0;
898
+ for (const c of convos) {
899
+ total += mgr.delete(c.conversation_id);
900
+ }
901
+ console.log(`Cleared ${total} message(s) from ${convos.length} conversation(s).`);
902
+ store.close();
903
+ });
904
+
905
+ // === Contacts ===
906
+ const contacts = program.command('contacts').description('Manage local contacts');
907
+
908
+ contacts
909
+ .command('list')
910
+ .description('List saved contacts')
911
+ .option('--tag <tag>', 'Filter by tag')
912
+ .option('--trusted', 'Show only trusted contacts')
913
+ .option('--json', 'Output as JSON')
914
+ .action((opts) => {
915
+ const store = openStore();
916
+ const mgr = new ContactManager(store);
917
+ const list = mgr.list({ tag: opts.tag, trusted: opts.trusted ? true : undefined });
918
+ if (opts.json) {
919
+ console.log(JSON.stringify(list, null, 2));
920
+ } else if (list.length === 0) {
921
+ console.log('No contacts found.');
922
+ } else {
923
+ for (const c of list) {
924
+ const name = c.display_name ?? c.alias ?? c.did.slice(0, 30) + '...';
925
+ const trust = c.trusted ? ' [trusted]' : '';
926
+ console.log(` ${name}${trust} ${c.did}`);
927
+ }
928
+ console.log(`\n${list.length} contact(s)`);
929
+ }
930
+ store.close();
931
+ });
932
+
933
+ contacts
934
+ .command('add')
935
+ .description('Add a contact')
936
+ .argument('<did>', 'Agent DID')
937
+ .option('--alias <alias>', 'Alias (e.g. @my/bot)')
938
+ .option('--name <name>', 'Display name')
939
+ .option('--notes <notes>', 'Notes')
940
+ .option('--tag <tag>', 'Tag')
941
+ .action((did, opts) => {
942
+ const store = openStore();
943
+ const mgr = new ContactManager(store);
944
+ mgr.add({
945
+ did,
946
+ alias: opts.alias,
947
+ display_name: opts.name,
948
+ notes: opts.notes,
949
+ trusted: false,
950
+ tags: opts.tag ? [opts.tag] : undefined,
951
+ });
952
+ console.log(`Contact added: ${did}`);
953
+ store.close();
954
+ });
955
+
956
+ contacts
957
+ .command('remove')
958
+ .description('Remove a contact')
959
+ .argument('<did>', 'Agent DID')
960
+ .action((did) => {
961
+ const store = openStore();
962
+ const mgr = new ContactManager(store);
963
+ if (mgr.remove(did)) {
964
+ console.log(`Contact removed: ${did}`);
965
+ } else {
966
+ console.log('Contact not found.');
967
+ }
968
+ store.close();
969
+ });
970
+
971
+ contacts
972
+ .command('update')
973
+ .description('Update a contact')
974
+ .argument('<did>', 'Agent DID')
975
+ .option('--alias <alias>', 'Alias')
976
+ .option('--name <name>', 'Display name')
977
+ .option('--notes <notes>', 'Notes')
978
+ .option('--tag <tag>', 'Tag (replaces existing tags)')
979
+ .action((did, opts) => {
980
+ const store = openStore();
981
+ const mgr = new ContactManager(store);
982
+ const updates = {};
983
+ if (opts.alias) updates.alias = opts.alias;
984
+ if (opts.name) updates.display_name = opts.name;
985
+ if (opts.notes) updates.notes = opts.notes;
986
+ if (opts.tag) updates.tags = [opts.tag];
987
+ const result = mgr.update(did, updates);
988
+ if (result) {
989
+ console.log(`Contact updated: ${did}`);
990
+ } else {
991
+ console.log('Contact not found.');
992
+ }
993
+ store.close();
994
+ });
995
+
996
+ contacts
997
+ .command('search')
998
+ .description('Search contacts')
999
+ .argument('<query>', 'Search query')
1000
+ .option('--json', 'Output as JSON')
1001
+ .action((query, opts) => {
1002
+ const store = openStore();
1003
+ const mgr = new ContactManager(store);
1004
+ const results = mgr.search(query);
1005
+ if (opts.json) {
1006
+ console.log(JSON.stringify(results, null, 2));
1007
+ } else if (results.length === 0) {
1008
+ console.log('No contacts match.');
1009
+ } else {
1010
+ for (const c of results) {
1011
+ console.log(` ${c.display_name ?? c.alias ?? c.did.slice(0, 30)} ${c.did}`);
1012
+ }
1013
+ console.log(`\n${results.length} match(es)`);
1014
+ }
1015
+ store.close();
1016
+ });
1017
+
1018
+ contacts
1019
+ .command('export')
1020
+ .description('Export contacts')
1021
+ .option('--format <format>', 'json or csv', 'json')
1022
+ .option('--output <file>', 'Output file path')
1023
+ .action((opts) => {
1024
+ const store = openStore();
1025
+ const mgr = new ContactManager(store);
1026
+ const data = mgr.export(opts.format);
1027
+ if (opts.output) {
1028
+ fs.writeFileSync(opts.output, data);
1029
+ console.log(`Exported to ${opts.output}`);
1030
+ } else {
1031
+ console.log(data);
1032
+ }
1033
+ store.close();
1034
+ });
1035
+
1036
+ contacts
1037
+ .command('import')
1038
+ .description('Import contacts from file')
1039
+ .argument('<file>', 'File to import')
1040
+ .option('--format <format>', 'json or csv', 'json')
1041
+ .action((file, opts) => {
1042
+ const store = openStore();
1043
+ const mgr = new ContactManager(store);
1044
+ const data = fs.readFileSync(file, 'utf-8');
1045
+ const result = mgr.import(data, opts.format);
1046
+ console.log(`Imported: ${result.imported}, Skipped: ${result.skipped}`);
1047
+ store.close();
1048
+ });
1049
+
1050
+ // === History ===
1051
+ const history = program.command('history').description('Manage local chat history');
1052
+
1053
+ history
1054
+ .command('conversations')
1055
+ .description('List conversations with local history')
1056
+ .option('--json', 'Output as JSON')
1057
+ .action((opts) => {
1058
+ const raw = program.opts().raw;
1059
+ const store = openStore();
1060
+ const mgr = new HistoryManager(store);
1061
+ const convos = mgr.listConversations();
1062
+ if (opts.json) {
1063
+ console.log(JSON.stringify(convos, null, 2));
1064
+ } else if (convos.length === 0) {
1065
+ console.log('No local history.');
1066
+ } else {
1067
+ for (const c of convos) {
1068
+ const date = formatTs(c.last_message_at, raw);
1069
+ console.log(` ${c.conversation_id} ${c.message_count} msg(s) last: ${date}`);
1070
+ }
1071
+ }
1072
+ store.close();
1073
+ });
1074
+
1075
+ history
1076
+ .command('list')
1077
+ .description('List messages in a conversation (default: most recent conversation)')
1078
+ .argument('[conversation_id]', 'Conversation ID (omit to use most recent from local history)')
1079
+ .option('--limit <n>', 'Limit', '50')
1080
+ .option('--json', 'Output as JSON')
1081
+ .action((conversationId, opts) => {
1082
+ const store = openStore();
1083
+ const mgr = new HistoryManager(store);
1084
+ let cid = conversationId;
1085
+ if (!cid) {
1086
+ const convos = mgr.listConversations();
1087
+ if (convos.length === 0) {
1088
+ console.log('No local history. Run "pingagent inbox" then "pingagent history sync <conversation_id>" to sync, or pass a conversation_id.');
1089
+ store.close();
1090
+ return;
1091
+ }
1092
+ cid = convos[0].conversation_id;
1093
+ if (!opts.json) {
1094
+ console.log(`Using most recent conversation: ${cid}\n`);
1095
+ }
1096
+ }
1097
+ const messages = mgr.list(cid, { limit: parseInt(opts.limit) });
1098
+ if (opts.json) {
1099
+ console.log(JSON.stringify(messages, null, 2));
1100
+ } else if (messages.length === 0) {
1101
+ console.log('No messages found.');
1102
+ } else {
1103
+ const raw = program.opts().raw;
1104
+ for (const m of messages) {
1105
+ const dir = m.direction === 'sent' ? '→' : '←';
1106
+ const text = m.payload?.text ?? m.payload?.title ?? m.schema;
1107
+ const ts = formatTs(m.ts_ms, raw);
1108
+ console.log(` ${ts} ${dir} [${m.seq ?? '-'}] ${m.schema} ${text}`);
1109
+ }
1110
+ console.log(`\n${messages.length} message(s)`);
1111
+ }
1112
+ store.close();
1113
+ });
1114
+
1115
+ history
1116
+ .command('sync')
1117
+ .description('Sync messages from server')
1118
+ .argument('<conversation_id>', 'Conversation ID')
1119
+ .option('--full', 'Full sync from beginning')
1120
+ .action(async (conversationId, opts) => {
1121
+ if (!identityExists(getEffectiveIdentityPath())) {
1122
+ console.error('No identity found. Run: pingagent init (or use --identity-dir / PINGAGENT_IDENTITY_PATH)');
1123
+ process.exit(1);
1124
+ }
1125
+ const client = await getClient();
1126
+ const store = openStore();
1127
+ const mgr = new HistoryManager(store);
1128
+ const result = await mgr.syncFromServer(client, conversationId, { full: opts.full });
1129
+ console.log(`Synced ${result.synced} message(s)`);
1130
+ store.close();
1131
+ });
1132
+
1133
+ history
1134
+ .command('search')
1135
+ .description('Search message history')
1136
+ .argument('<query>', 'Search query')
1137
+ .option('--conversation <id>', 'Limit to conversation')
1138
+ .option('--json', 'Output as JSON')
1139
+ .action((query, opts) => {
1140
+ const store = openStore();
1141
+ const mgr = new HistoryManager(store);
1142
+ const results = mgr.search(query, { conversationId: opts.conversation });
1143
+ if (opts.json) {
1144
+ console.log(JSON.stringify(results, null, 2));
1145
+ } else if (results.length === 0) {
1146
+ console.log('No messages match.');
1147
+ } else {
1148
+ for (const m of results) {
1149
+ const dir = m.direction === 'sent' ? '→' : '←';
1150
+ const text = m.payload?.text ?? m.payload?.title ?? m.schema;
1151
+ console.log(` ${dir} ${m.conversation_id.slice(0, 15)} ${text}`);
1152
+ }
1153
+ console.log(`\n${results.length} match(es)`);
1154
+ }
1155
+ store.close();
1156
+ });
1157
+
1158
+ history
1159
+ .command('export')
1160
+ .description('Export chat history')
1161
+ .option('--conversation <id>', 'Conversation ID (all if omitted)')
1162
+ .option('--format <format>', 'json or csv', 'json')
1163
+ .option('--output <file>', 'Output file path')
1164
+ .action((opts) => {
1165
+ const store = openStore();
1166
+ const mgr = new HistoryManager(store);
1167
+ const data = mgr.export({ conversationId: opts.conversation, format: opts.format });
1168
+ if (opts.output) {
1169
+ fs.writeFileSync(opts.output, data);
1170
+ console.log(`Exported to ${opts.output}`);
1171
+ } else {
1172
+ console.log(data);
1173
+ }
1174
+ store.close();
1175
+ });
1176
+
1177
+ history
1178
+ .command('delete')
1179
+ .description('Delete history for a conversation')
1180
+ .argument('<conversation_id>', 'Conversation ID')
1181
+ .action((conversationId) => {
1182
+ const store = openStore();
1183
+ const mgr = new HistoryManager(store);
1184
+ const count = mgr.delete(conversationId);
1185
+ console.log(`Deleted ${count} message(s)`);
1186
+ store.close();
1187
+ });
1188
+
1189
+ // === A2A (Agent-to-Agent Protocol) ===
1190
+ const a2a = program.command('a2a').description('Interact with external A2A-compatible agents');
1191
+
1192
+ a2a
1193
+ .command('discover')
1194
+ .description('Fetch and display an external agent\'s AgentCard')
1195
+ .argument('<url>', 'Agent URL (e.g. https://agent.example.com)')
1196
+ .option('--json', 'Output as JSON')
1197
+ .action(async (url, opts) => {
1198
+ const adapter = new A2AAdapter({ agentUrl: url });
1199
+ const card = await adapter.getAgentCard();
1200
+ if (opts.json) {
1201
+ console.log(JSON.stringify(card, null, 2));
1202
+ } else {
1203
+ console.log(`Agent: ${card.name}`);
1204
+ console.log(`Description: ${card.description}`);
1205
+ console.log(`URL: ${card.url}`);
1206
+ console.log(`Version: ${card.version} (protocol ${card.protocolVersion})`);
1207
+ if (card.provider) console.log(`Provider: ${card.provider.organization}`);
1208
+ console.log(`Capabilities: streaming=${card.capabilities.streaming ?? false}, push=${card.capabilities.pushNotifications ?? false}`);
1209
+ console.log(`Skills:`);
1210
+ for (const s of card.skills) {
1211
+ console.log(` [${s.id}] ${s.name}: ${s.description}`);
1212
+ if (s.tags.length) console.log(` tags: ${s.tags.join(', ')}`);
1213
+ }
1214
+ }
1215
+ });
1216
+
1217
+ a2a
1218
+ .command('send')
1219
+ .description('Send a task to an external A2A agent')
1220
+ .argument('<url>', 'Agent URL')
1221
+ .requiredOption('--task <title>', 'Task title/prompt')
1222
+ .option('--description <desc>', 'Task description')
1223
+ .option('--wait', 'Wait for task completion')
1224
+ .option('--timeout <seconds>', 'Timeout in seconds', '120')
1225
+ .option('--auth <token>', 'Bearer token for the remote agent')
1226
+ .option('--json', 'Output as JSON')
1227
+ .action(async (url, opts) => {
1228
+ const adapter = new A2AAdapter({
1229
+ agentUrl: url,
1230
+ authToken: opts.auth,
1231
+ });
1232
+
1233
+ const result = await adapter.sendTask({
1234
+ title: opts.task,
1235
+ description: opts.description,
1236
+ wait: opts.wait,
1237
+ timeoutMs: parseInt(opts.timeout) * 1000,
1238
+ });
1239
+
1240
+ if (opts.json) {
1241
+ console.log(JSON.stringify(result, null, 2));
1242
+ } else {
1243
+ console.log(`Task ID: ${result.taskId}`);
1244
+ console.log(`State: ${result.state}`);
1245
+ if (result.summary) console.log(`Summary: ${result.summary}`);
1246
+ if (result.output) console.log(`Output: ${JSON.stringify(result.output, null, 2)}`);
1247
+ }
1248
+ });
1249
+
1250
+ a2a
1251
+ .command('status')
1252
+ .description('Get task status from an external A2A agent')
1253
+ .argument('<url>', 'Agent URL')
1254
+ .requiredOption('--task-id <id>', 'Task ID')
1255
+ .option('--auth <token>', 'Bearer token')
1256
+ .option('--json', 'Output as JSON')
1257
+ .action(async (url, opts) => {
1258
+ const adapter = new A2AAdapter({
1259
+ agentUrl: url,
1260
+ authToken: opts.auth,
1261
+ });
1262
+
1263
+ const result = await adapter.getTaskStatus(opts.taskId);
1264
+ if (opts.json) {
1265
+ console.log(JSON.stringify(result, null, 2));
1266
+ } else {
1267
+ console.log(`Task ID: ${result.taskId}`);
1268
+ console.log(`State: ${result.state}`);
1269
+ if (result.summary) console.log(`Summary: ${result.summary}`);
1270
+ }
1271
+ });
1272
+
1273
+ a2a
1274
+ .command('cancel')
1275
+ .description('Cancel a task on an external A2A agent')
1276
+ .argument('<url>', 'Agent URL')
1277
+ .requiredOption('--task-id <id>', 'Task ID')
1278
+ .option('--auth <token>', 'Bearer token')
1279
+ .action(async (url, opts) => {
1280
+ const adapter = new A2AAdapter({
1281
+ agentUrl: url,
1282
+ authToken: opts.auth,
1283
+ });
1284
+ const result = await adapter.cancelTask(opts.taskId);
1285
+ console.log(`Task ${result.taskId}: ${result.state}`);
1286
+ });
1287
+
1288
+ // === Billing ===
1289
+ const billing = program.command('billing').description('Manage billing group (primary + linked devices)');
1290
+
1291
+ billing
1292
+ .command('link-code')
1293
+ .description('Generate a link code for adding a device to your subscription (primary only)')
1294
+ .option('--json', 'Output as JSON')
1295
+ .action(async (opts) => {
1296
+ const client = await getClient();
1297
+ const res = await client.createBillingLinkCode();
1298
+ if (!res.ok) {
1299
+ if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1300
+ console.error(`Error: ${res.error?.message ?? 'Failed to create link code'}`);
1301
+ if (res.error?.hint) console.error(`Hint: ${res.error.hint}`);
1302
+ }
1303
+ process.exit(1);
1304
+ }
1305
+ if (opts.json) {
1306
+ console.log(JSON.stringify(res.data, null, 2));
1307
+ } else {
1308
+ console.log(`Link code: ${res.data.code}`);
1309
+ console.log(`Expires in ${res.data.expires_in_seconds}s`);
1310
+ console.log(`\nRun on the new device:\n pingagent billing link --code ${res.data.code}`);
1311
+ }
1312
+ });
1313
+
1314
+ billing
1315
+ .command('link')
1316
+ .description('Link this device to a primary subscription using a link code')
1317
+ .requiredOption('--code <code>', 'Link code from primary device')
1318
+ .option('--json', 'Output as JSON')
1319
+ .action(async (opts) => {
1320
+ const client = await getClient();
1321
+ const res = await client.redeemBillingLink(opts.code);
1322
+ if (!res.ok) {
1323
+ if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1324
+ console.error(`Error: ${res.error?.message ?? 'Failed to link device'}`);
1325
+ if (res.error?.hint) console.error(`Hint: ${res.error.hint}`);
1326
+ }
1327
+ process.exit(1);
1328
+ }
1329
+ if (opts.json) {
1330
+ console.log(JSON.stringify(res.data, null, 2));
1331
+ } else {
1332
+ console.log(`Linked to primary: ${res.data.primary_did}`);
1333
+ console.log('This device now shares the primary subscription tier and quotas.');
1334
+ }
1335
+ });
1336
+
1337
+ billing
1338
+ .command('linked-devices')
1339
+ .description('List all devices in your billing group')
1340
+ .option('--json', 'Output as JSON')
1341
+ .action(async (opts) => {
1342
+ const client = await getClient();
1343
+ const res = await client.getLinkedDevices();
1344
+ if (!res.ok) {
1345
+ if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1346
+ console.error(`Error: ${res.error?.message ?? 'Failed to get linked devices'}`);
1347
+ }
1348
+ process.exit(1);
1349
+ }
1350
+ if (opts.json) {
1351
+ console.log(JSON.stringify(res.data, null, 2));
1352
+ } else {
1353
+ const d = res.data;
1354
+ console.log(`Primary DID: ${d.primary_did}${d.is_primary ? ' (this device)' : ''}`);
1355
+ if (d.linked_dids.length === 0) {
1356
+ console.log('No linked devices.');
1357
+ } else {
1358
+ console.log(`Linked devices (${d.linked_dids.length}):`);
1359
+ for (const did of d.linked_dids) {
1360
+ const marker = did === client.getDid() ? ' (this device)' : '';
1361
+ console.log(` - ${did}${marker}`);
1362
+ }
1363
+ }
1364
+ }
1365
+ });
1366
+
1367
+ billing
1368
+ .command('unlink')
1369
+ .description('Remove a linked device from your billing group (primary only)')
1370
+ .requiredOption('--did <did>', 'DID of the device to unlink')
1371
+ .option('--json', 'Output as JSON')
1372
+ .action(async (opts) => {
1373
+ const client = await getClient();
1374
+ const res = await client.unlinkBillingDevice(opts.did);
1375
+ if (!res.ok) {
1376
+ if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
1377
+ console.error(`Error: ${res.error?.message ?? 'Failed to unlink device'}`);
1378
+ if (res.error?.hint) console.error(`Hint: ${res.error.hint}`);
1379
+ }
1380
+ process.exit(1);
1381
+ }
1382
+ if (opts.json) {
1383
+ console.log(JSON.stringify({ ok: true }, null, 2));
1384
+ } else {
1385
+ console.log(`Unlinked: ${opts.did}`);
1386
+ console.log('The device will revert to ghost tier.');
1387
+ }
1388
+ });
1389
+
1390
+ program
1391
+ .command('web')
1392
+ .description('Start local web UI for debugging and audit. By default scans ~/.pingagent for profiles; use --identity-dir to lock to one profile.')
1393
+ .option('--port <port>', 'Port for the web server', '3846')
1394
+ .action(async (opts) => {
1395
+ const serverUrl = process.env.PINGAGENT_SERVER_URL || DEFAULT_SERVER;
1396
+ const port = parseInt(opts.port, 10) || 3846;
1397
+ const identityDir = program.opts().identityDir;
1398
+ const defaultRoot = path.join(os.homedir(), '.pingagent');
1399
+ const resolvedRoot = resolvePath(defaultRoot);
1400
+ const { startWebServer } = await import('../dist/web-server.js');
1401
+ const useSingleProfile = identityDir && path.resolve(resolvePath(identityDir)) !== resolvedRoot;
1402
+ if (useSingleProfile) {
1403
+ const identityPath = path.join(resolvePath(identityDir), 'identity.json');
1404
+ if (!identityExists(identityPath)) {
1405
+ console.error('No identity found at', identityPath, '. Run: pingagent init');
1406
+ process.exit(1);
1407
+ }
1408
+ const storePath = process.env.PINGAGENT_STORE_PATH
1409
+ ? resolvePath(process.env.PINGAGENT_STORE_PATH)
1410
+ : path.join(resolvePath(identityDir), 'store.db');
1411
+ await startWebServer({ fixedIdentityPath: identityPath, fixedStorePath: storePath, serverUrl, port });
1412
+ } else {
1413
+ const rootDir = process.env.PINGAGENT_ROOT_DIR || defaultRoot;
1414
+ await startWebServer({ rootDir, serverUrl, port });
1415
+ }
1416
+ });
1417
+
1418
+ program.parse();