@highway1/cli 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Serve Command - CVP-0010 §2.2
3
+ *
4
+ * Registers custom handlers that execute when matching requests arrive.
5
+ *
6
+ * hw1 serve --on "translate" --exec "./translate.sh"
7
+ * hw1 serve --on "code_review" --exec "python review.py"
8
+ * hw1 serve --handlers ./my-handlers/
9
+ */
10
+
11
+ import { Command } from 'commander';
12
+ import { DaemonClient } from '../daemon/client.js';
13
+ import { createLogger } from '@highway1/core';
14
+ import { spawn } from 'child_process';
15
+ import { readdir, stat } from 'node:fs/promises';
16
+ import { join, resolve, basename } from 'node:path';
17
+
18
+ const logger = createLogger('cli:serve');
19
+
20
+ const MAX_PAYLOAD_BYTES = 256 * 1024; // 256 KiB
21
+
22
+ interface HandlerEntry {
23
+ capability: string;
24
+ exec: string;
25
+ }
26
+
27
+ export function registerServeCommand(program: Command): void {
28
+ program
29
+ .command('serve')
30
+ .description('Register handlers for incoming requests (CVP-0010 §2.2)')
31
+ .option('--on <capability>', 'Capability name to handle')
32
+ .option('--exec <script>', 'Script to execute for matching requests')
33
+ .option('--handlers <dir>', 'Directory of handler scripts (filename = capability name)')
34
+ .option('--allow-from <dids...>', 'Only accept requests from these DIDs (default: deny-all except allowlist)')
35
+ .option('--public', 'Accept requests from any agent (overrides --allow-from)')
36
+ .option('--max-concurrency <n>', 'Max concurrent handler executions', '4')
37
+ .option('--timeout <seconds>', 'Handler execution timeout in seconds', '60')
38
+ .option('--format <fmt>', 'Output format: text|json', 'text')
39
+ .action(async (options) => {
40
+ const client = new DaemonClient();
41
+ if (!(await client.isDaemonRunning())) {
42
+ console.error('Daemon not running. Start with: hw1 join');
43
+ process.exit(1);
44
+ }
45
+
46
+ // Build handler list
47
+ const handlers: HandlerEntry[] = [];
48
+
49
+ if (options.on && options.exec) {
50
+ handlers.push({ capability: options.on, exec: resolve(options.exec) });
51
+ }
52
+
53
+ if (options.handlers) {
54
+ const dir = resolve(options.handlers);
55
+ const entries = await readdir(dir);
56
+ for (const entry of entries) {
57
+ const fullPath = join(dir, entry);
58
+ const s = await stat(fullPath);
59
+ if (!s.isFile()) continue;
60
+ // Strip extension to get capability name; skip _default for now
61
+ const cap = basename(entry).replace(/\.[^.]+$/, '');
62
+ handlers.push({ capability: cap, exec: fullPath });
63
+ }
64
+ }
65
+
66
+ if (handlers.length === 0) {
67
+ console.error('No handlers specified. Use --on/--exec or --handlers <dir>');
68
+ process.exit(1);
69
+ }
70
+
71
+ const maxConcurrency = parseInt(options.maxConcurrency, 10);
72
+ const timeoutMs = parseInt(options.timeout, 10) * 1000;
73
+ let activeCount = 0;
74
+
75
+ // Apply allowlist if --allow-from specified
76
+ if (options.allowFrom && !options.public) {
77
+ for (const did of options.allowFrom) {
78
+ await client.send('allowlist', { action: 'add', did, note: 'hw1 serve --allow-from' });
79
+ }
80
+ if (options.format !== 'json') {
81
+ console.log(`Allowlisted ${options.allowFrom.length} DID(s)`);
82
+ }
83
+ }
84
+
85
+ if (options.format !== 'json') {
86
+ console.log(`\nServing ${handlers.length} handler(s):`);
87
+ for (const h of handlers) {
88
+ console.log(` ${h.capability} → ${h.exec}`);
89
+ }
90
+ console.log(`\nMax concurrency: ${maxConcurrency}, timeout: ${options.timeout}s`);
91
+ console.log('Waiting for requests... (Ctrl+C to stop)\n');
92
+ }
93
+
94
+ // Subscribe to incoming messages via queue
95
+ const subscriptionFilter = {
96
+ protocol: handlers.map((h) => `clawiverse/${h.capability}`),
97
+ };
98
+
99
+ // Poll inbox for new messages matching our capabilities
100
+ const poll = async () => {
101
+ try {
102
+ const page = await client.send('inbox', {
103
+ filter: { unreadOnly: true, status: 'pending' },
104
+ pagination: { limit: 10 },
105
+ });
106
+
107
+ for (const msg of page.messages ?? []) {
108
+ const envelope = msg.envelope;
109
+ const protocol: string = envelope.protocol ?? '';
110
+
111
+ // Find matching handler
112
+ const handler = handlers.find((h) =>
113
+ protocol.includes(h.capability) ||
114
+ (envelope.payload as any)?.capability === h.capability
115
+ );
116
+
117
+ if (!handler) continue;
118
+
119
+ // Mark as read immediately to avoid double-processing
120
+ await client.send('mark_read', { id: envelope.id });
121
+
122
+ // Concurrency control
123
+ if (activeCount >= maxConcurrency) {
124
+ if (options.format !== 'json') {
125
+ console.log(`[BUSY] Rejected ${envelope.id.slice(-8)} from ${envelope.from.slice(0, 20)}…`);
126
+ }
127
+ // Send BUSY response
128
+ await client.send('send', {
129
+ to: envelope.from,
130
+ protocol: envelope.protocol,
131
+ payload: { error: 'BUSY', message: 'Server at capacity, try again later' },
132
+ type: 'response',
133
+ });
134
+ continue;
135
+ }
136
+
137
+ // Payload size check
138
+ const payloadStr = JSON.stringify(envelope.payload ?? '');
139
+ if (payloadStr.length > MAX_PAYLOAD_BYTES) {
140
+ await client.send('send', {
141
+ to: envelope.from,
142
+ protocol: envelope.protocol,
143
+ payload: { error: 'PAYLOAD_TOO_LARGE', message: `Max payload is ${MAX_PAYLOAD_BYTES} bytes` },
144
+ type: 'response',
145
+ });
146
+ continue;
147
+ }
148
+
149
+ // Execute handler
150
+ activeCount++;
151
+ const startTime = Date.now();
152
+
153
+ if (options.format !== 'json') {
154
+ console.log(`[${new Date().toLocaleTimeString()}] ${handler.capability} ← ${envelope.from.slice(0, 30)}…`);
155
+ }
156
+
157
+ executeHandler(handler.exec, envelope.payload, timeoutMs)
158
+ .then(async (result) => {
159
+ const latencyMs = Date.now() - startTime;
160
+ await client.send('send', {
161
+ to: envelope.from,
162
+ protocol: envelope.protocol,
163
+ payload: result,
164
+ type: 'response',
165
+ });
166
+
167
+ if (options.format === 'json') {
168
+ console.log(JSON.stringify({
169
+ event: 'handled',
170
+ capability: handler.capability,
171
+ from: envelope.from,
172
+ latencyMs,
173
+ success: true,
174
+ }));
175
+ } else {
176
+ console.log(` → responded in ${latencyMs}ms`);
177
+ }
178
+ })
179
+ .catch(async (err) => {
180
+ const latencyMs = Date.now() - startTime;
181
+ const isTimeout = err.message?.includes('timeout');
182
+ await client.send('send', {
183
+ to: envelope.from,
184
+ protocol: envelope.protocol,
185
+ payload: {
186
+ error: isTimeout ? 'TIMEOUT' : 'HANDLER_ERROR',
187
+ message: err.message,
188
+ },
189
+ type: 'response',
190
+ });
191
+
192
+ if (options.format === 'json') {
193
+ console.log(JSON.stringify({
194
+ event: 'error',
195
+ capability: handler.capability,
196
+ from: envelope.from,
197
+ latencyMs,
198
+ error: err.message,
199
+ }));
200
+ } else {
201
+ console.error(` → error after ${latencyMs}ms: ${err.message}`);
202
+ }
203
+ })
204
+ .finally(() => {
205
+ activeCount--;
206
+ });
207
+ }
208
+ } catch (err) {
209
+ logger.warn('Poll error', err);
210
+ }
211
+ };
212
+
213
+ // Poll every 500ms
214
+ const interval = setInterval(poll, 500);
215
+
216
+ // Graceful shutdown
217
+ const shutdown = () => {
218
+ clearInterval(interval);
219
+ if (options.format !== 'json') console.log('\nStopped serving.');
220
+ process.exit(0);
221
+ };
222
+ process.on('SIGINT', shutdown);
223
+ process.on('SIGTERM', shutdown);
224
+ });
225
+ }
226
+
227
+ async function executeHandler(
228
+ scriptPath: string,
229
+ payload: unknown,
230
+ timeoutMs: number
231
+ ): Promise<unknown> {
232
+ return new Promise((resolve, reject) => {
233
+ const child = spawn(scriptPath, [], {
234
+ stdio: ['pipe', 'pipe', 'pipe'],
235
+ env: { ...process.env },
236
+ });
237
+
238
+ let stdout = '';
239
+ let stderr = '';
240
+
241
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
242
+ child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
243
+
244
+ const timer = setTimeout(() => {
245
+ child.kill('SIGTERM');
246
+ reject(new Error(`Handler timeout after ${timeoutMs}ms`));
247
+ }, timeoutMs);
248
+
249
+ child.on('close', (code) => {
250
+ clearTimeout(timer);
251
+ if (code !== 0) {
252
+ reject(new Error(`Handler exited with code ${code}: ${stderr.trim()}`));
253
+ return;
254
+ }
255
+ try {
256
+ resolve(JSON.parse(stdout.trim()));
257
+ } catch {
258
+ resolve({ result: stdout.trim() });
259
+ }
260
+ });
261
+
262
+ child.on('error', (err) => {
263
+ clearTimeout(timer);
264
+ reject(err);
265
+ });
266
+
267
+ // Write payload to stdin
268
+ child.stdin.write(JSON.stringify(payload));
269
+ child.stdin.end();
270
+ });
271
+ }
@@ -6,10 +6,9 @@ export function registerStatusCommand(program: Command): void {
6
6
  program
7
7
  .command('status')
8
8
  .description('Show current status')
9
- .action(async () => {
9
+ .option('--format <fmt>', 'Output format: text|json', 'text')
10
+ .action(async (options) => {
10
11
  try {
11
- printHeader('Clawiverse Status');
12
-
13
12
  const identity = getIdentity();
14
13
  const card = getAgentCard();
15
14
  const bootstrapPeers = getBootstrapPeers();
@@ -19,6 +18,22 @@ export function registerStatusCommand(program: Command): void {
19
18
  process.exit(1);
20
19
  }
21
20
 
21
+ if (options.format === 'json') {
22
+ console.log(JSON.stringify({
23
+ identity: {
24
+ did: identity.did,
25
+ publicKey: identity.publicKey,
26
+ },
27
+ agentCard: card || null,
28
+ network: {
29
+ bootstrapPeers,
30
+ },
31
+ }, null, 2));
32
+ return;
33
+ }
34
+
35
+ printHeader('Clawiverse Status');
36
+
22
37
  printSection('Identity');
23
38
  printKeyValue('DID', identity.did);
24
39
  printKeyValue('Public Key', identity.publicKey.substring(0, 16) + '...');
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Stop Command - CVP-0010 §2.5
3
+ *
4
+ * Cleaner alternative to `hw1 daemon stop`
5
+ */
6
+
7
+ import { Command } from 'commander';
8
+ import { DaemonClient } from '../daemon/client.js';
9
+ import { existsSync, unlinkSync } from 'fs';
10
+ import { success, info } from '../ui.js';
11
+
12
+ const PID_FILE = '/tmp/clawiverse.pid';
13
+ const SOCKET_PATH = '/tmp/clawiverse.sock';
14
+
15
+ export function registerStopCommand(program: Command): void {
16
+ program
17
+ .command('stop')
18
+ .description('Stop the Highway 1 daemon')
19
+ .action(async () => {
20
+ try {
21
+ const client = new DaemonClient(SOCKET_PATH);
22
+
23
+ if (!(await client.isDaemonRunning())) {
24
+ info('Daemon not running');
25
+ return;
26
+ }
27
+
28
+ await client.send('shutdown', {});
29
+ success('Daemon stopped');
30
+
31
+ // Clean up PID file
32
+ try {
33
+ if (existsSync(PID_FILE)) {
34
+ unlinkSync(PID_FILE);
35
+ }
36
+ } catch {}
37
+
38
+ // Clean up socket file
39
+ try {
40
+ if (existsSync(SOCKET_PATH)) {
41
+ unlinkSync(SOCKET_PATH);
42
+ }
43
+ } catch {}
44
+ } catch (err) {
45
+ console.error(`Failed to stop daemon: ${(err as Error).message}`);
46
+ process.exit(1);
47
+ }
48
+ });
49
+ }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { Command } from 'commander';
6
6
  import { getConfig, getIdentity } from '../config.js';
7
- import { createLogger, createTrustSystem } from '@highway1/core';
7
+ import { createLogger, createTrustSystem, extractPublicKey } from '@highway1/core';
8
8
  import { homedir } from 'os';
9
9
  import { join } from 'path';
10
10
 
@@ -24,7 +24,7 @@ export function createTrustCommand(): Command {
24
24
  const dataDir = join(homedir(), '.clawiverse');
25
25
  const trustSystem = createTrustSystem({
26
26
  dbPath: `${dataDir}/trust`,
27
- getPublicKey: async () => new Uint8Array(), // Placeholder
27
+ getPublicKey: async (did: string) => extractPublicKey(did), // CVP-0010 §4.1 fix
28
28
  });
29
29
 
30
30
  await trustSystem.start();
@@ -71,7 +71,7 @@ export function createTrustCommand(): Command {
71
71
  const dataDir = join(homedir(), '.clawiverse');
72
72
  const trustSystem = createTrustSystem({
73
73
  dbPath: `${dataDir}/trust`,
74
- getPublicKey: async () => new Uint8Array(), // Placeholder
74
+ getPublicKey: async (did: string) => extractPublicKey(did), // CVP-0010 §4.1 fix
75
75
  });
76
76
 
77
77
  await trustSystem.start();
@@ -126,7 +126,7 @@ export function createTrustCommand(): Command {
126
126
  const dataDir = join(homedir(), '.clawiverse');
127
127
  const trustSystem = createTrustSystem({
128
128
  dbPath: `${dataDir}/trust`,
129
- getPublicKey: async () => new Uint8Array(), // Placeholder
129
+ getPublicKey: async (did: string) => extractPublicKey(did), // CVP-0010 §4.1 fix
130
130
  });
131
131
 
132
132
  await trustSystem.start();
@@ -170,7 +170,7 @@ export function createTrustCommand(): Command {
170
170
  const dataDir = join(homedir(), '.clawiverse');
171
171
  const trustSystem = createTrustSystem({
172
172
  dbPath: `${dataDir}/trust`,
173
- getPublicKey: async () => new Uint8Array(), // Placeholder
173
+ getPublicKey: async (did: string) => extractPublicKey(did), // CVP-0010 §4.1 fix
174
174
  });
175
175
 
176
176
  await trustSystem.start();
@@ -211,5 +211,144 @@ export function createTrustCommand(): Command {
211
211
  }
212
212
  });
213
213
 
214
+ // Block an agent (via daemon defense layer)
215
+ trust
216
+ .command('block <did>')
217
+ .description('Block an agent from sending you messages')
218
+ .option('-r, --reason <reason>', 'Reason for blocking', 'Blocked by user')
219
+ .action(async (did: string, options: { reason: string }) => {
220
+ try {
221
+ const { DaemonClient } = await import('../daemon/client.js');
222
+ const client = new DaemonClient();
223
+ if (await client.isDaemonRunning()) {
224
+ await client.send('block', { did, reason: options.reason });
225
+ console.log(`Blocked ${did}`);
226
+ } else {
227
+ // Daemon not running — write directly to storage
228
+ const { MessageStorage } = await import('@highway1/core');
229
+ const storage = new MessageStorage(join(homedir(), '.clawiverse', 'inbox'));
230
+ await storage.open();
231
+ await storage.putBlock({ did, reason: options.reason, blockedAt: Date.now(), blockedBy: 'local' });
232
+ await storage.close();
233
+ console.log(`Blocked ${did}`);
234
+ }
235
+ } catch (error) {
236
+ logger.error('Failed to block agent', error);
237
+ console.error('Error:', error instanceof Error ? error.message : error);
238
+ process.exit(1);
239
+ }
240
+ });
241
+
242
+ // Unblock an agent
243
+ trust
244
+ .command('unblock <did>')
245
+ .description('Unblock a previously blocked agent')
246
+ .action(async (did: string) => {
247
+ try {
248
+ const { DaemonClient } = await import('../daemon/client.js');
249
+ const client = new DaemonClient();
250
+ if (await client.isDaemonRunning()) {
251
+ await client.send('unblock', { did });
252
+ } else {
253
+ const { MessageStorage } = await import('@highway1/core');
254
+ const storage = new MessageStorage(join(homedir(), '.clawiverse', 'inbox'));
255
+ await storage.open();
256
+ await storage.deleteBlock(did);
257
+ await storage.close();
258
+ }
259
+ console.log(`Unblocked ${did}`);
260
+ } catch (error) {
261
+ logger.error('Failed to unblock agent', error);
262
+ console.error('Error:', error instanceof Error ? error.message : error);
263
+ process.exit(1);
264
+ }
265
+ });
266
+
267
+ // List blocked agents
268
+ trust
269
+ .command('list-blocked')
270
+ .description('List all blocked agents')
271
+ .action(async () => {
272
+ try {
273
+ const { MessageStorage } = await import('@highway1/core');
274
+ const storage = new MessageStorage(join(homedir(), '.clawiverse', 'inbox'));
275
+ await storage.open();
276
+ const blocked = await storage.listBlocked();
277
+ await storage.close();
278
+
279
+ if (blocked.length === 0) {
280
+ console.log('No blocked agents.');
281
+ return;
282
+ }
283
+ console.log(`\nBlocked agents (${blocked.length}):\n`);
284
+ for (const entry of blocked) {
285
+ console.log(` ${entry.did}`);
286
+ console.log(` Reason: ${entry.reason}`);
287
+ console.log(` Blocked: ${new Date(entry.blockedAt).toLocaleString()}`);
288
+ }
289
+ console.log();
290
+ } catch (error) {
291
+ logger.error('Failed to list blocked agents', error);
292
+ console.error('Error:', error instanceof Error ? error.message : error);
293
+ process.exit(1);
294
+ }
295
+ });
296
+
297
+ // Add to allowlist
298
+ trust
299
+ .command('allow <did>')
300
+ .description('Add an agent to your allowlist (bypasses all defense checks)')
301
+ .option('-n, --note <note>', 'Note about this agent')
302
+ .action(async (did: string, options: { note?: string }) => {
303
+ try {
304
+ const { DaemonClient } = await import('../daemon/client.js');
305
+ const client = new DaemonClient();
306
+ if (await client.isDaemonRunning()) {
307
+ await client.send('allowlist', { action: 'add', did, note: options.note });
308
+ } else {
309
+ const { MessageStorage } = await import('@highway1/core');
310
+ const storage = new MessageStorage(join(homedir(), '.clawiverse', 'inbox'));
311
+ await storage.open();
312
+ await storage.putAllow({ did, addedAt: Date.now(), note: options.note });
313
+ await storage.close();
314
+ }
315
+ console.log(`Added ${did} to allowlist`);
316
+ } catch (error) {
317
+ logger.error('Failed to allowlist agent', error);
318
+ console.error('Error:', error instanceof Error ? error.message : error);
319
+ process.exit(1);
320
+ }
321
+ });
322
+
323
+ // List allowlisted agents
324
+ trust
325
+ .command('list-allowed')
326
+ .description('List all allowlisted agents')
327
+ .action(async () => {
328
+ try {
329
+ const { MessageStorage } = await import('@highway1/core');
330
+ const storage = new MessageStorage(join(homedir(), '.clawiverse', 'inbox'));
331
+ await storage.open();
332
+ const allowed = await storage.listAllowed();
333
+ await storage.close();
334
+
335
+ if (allowed.length === 0) {
336
+ console.log('No allowlisted agents.');
337
+ return;
338
+ }
339
+ console.log(`\nAllowlisted agents (${allowed.length}):\n`);
340
+ for (const entry of allowed) {
341
+ console.log(` ${entry.did}`);
342
+ if (entry.note) console.log(` Note: ${entry.note}`);
343
+ console.log(` Added: ${new Date(entry.addedAt).toLocaleString()}`);
344
+ }
345
+ console.log();
346
+ } catch (error) {
347
+ logger.error('Failed to list allowed agents', error);
348
+ console.error('Error:', error instanceof Error ? error.message : error);
349
+ process.exit(1);
350
+ }
351
+ });
352
+
214
353
  return trust;
215
354
  }
@@ -16,25 +16,47 @@ export class DaemonClient {
16
16
  const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
17
17
 
18
18
  let responseReceived = false;
19
+ let buffer = ''; // CVP-0010 §1.2: Buffer for NDJSON framing
19
20
 
20
21
  socket.on('connect', () => {
21
22
  const request = { id: requestId, command, params };
22
23
  logger.debug('Sending request', { command, id: requestId });
23
- socket.write(JSON.stringify(request));
24
+ // CVP-0010 §1.2: Send with newline delimiter
25
+ socket.write(JSON.stringify(request) + '\n');
24
26
  });
25
27
 
26
28
  socket.on('data', (data) => {
27
29
  try {
28
- const response = JSON.parse(data.toString());
29
- responseReceived = true;
30
- socket.end();
30
+ // CVP-0010 §1.2: Accumulate data and split by newlines
31
+ buffer += data.toString();
32
+ const lines = buffer.split('\n');
31
33
 
32
- logger.debug('Received response', { success: response.success, id: response.id });
34
+ // Keep incomplete line in buffer
35
+ buffer = lines.pop() || '';
33
36
 
34
- if (response.success) {
35
- resolve(response.data);
36
- } else {
37
- reject(new Error(response.error));
37
+ // Process complete lines
38
+ for (const line of lines) {
39
+ if (!line.trim()) continue;
40
+
41
+ const response = JSON.parse(line);
42
+
43
+ // Match response by ID
44
+ if (response.id !== requestId) {
45
+ logger.debug('Ignoring unmatched response', { responseId: response.id, requestId });
46
+ continue;
47
+ }
48
+
49
+ responseReceived = true;
50
+ socket.end();
51
+
52
+ logger.debug('Received response', { success: response.success, id: response.id });
53
+
54
+ if (response.success) {
55
+ resolve(response.data);
56
+ } else {
57
+ reject(new Error(response.error));
58
+ }
59
+ return;
38
60
  }
39
61
  } catch (error) {
40
62
  reject(new Error(`Failed to parse response: ${(error as Error).message}`));