openhome-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +470 -0
  2. package/bin/openhome.js +2 -0
  3. package/dist/chunk-Q4UKUXDB.js +164 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +3184 -0
  6. package/dist/store-DR7EKQ5T.js +16 -0
  7. package/package.json +44 -0
  8. package/src/api/client.ts +231 -0
  9. package/src/api/contracts.ts +103 -0
  10. package/src/api/endpoints.ts +19 -0
  11. package/src/api/mock-client.ts +145 -0
  12. package/src/cli.ts +339 -0
  13. package/src/commands/agents.ts +88 -0
  14. package/src/commands/assign.ts +123 -0
  15. package/src/commands/chat.ts +265 -0
  16. package/src/commands/config-edit.ts +163 -0
  17. package/src/commands/delete.ts +107 -0
  18. package/src/commands/deploy.ts +430 -0
  19. package/src/commands/init.ts +895 -0
  20. package/src/commands/list.ts +78 -0
  21. package/src/commands/login.ts +54 -0
  22. package/src/commands/logout.ts +14 -0
  23. package/src/commands/logs.ts +174 -0
  24. package/src/commands/status.ts +174 -0
  25. package/src/commands/toggle.ts +118 -0
  26. package/src/commands/trigger.ts +193 -0
  27. package/src/commands/validate.ts +53 -0
  28. package/src/commands/whoami.ts +54 -0
  29. package/src/config/keychain.ts +62 -0
  30. package/src/config/store.ts +137 -0
  31. package/src/ui/format.ts +95 -0
  32. package/src/util/zip.ts +74 -0
  33. package/src/validation/rules.ts +71 -0
  34. package/src/validation/validator.ts +204 -0
  35. package/tasks/feature-request-sdk-api.md +246 -0
  36. package/tasks/prd-openhome-cli.md +605 -0
  37. package/templates/api/README.md.tmpl +11 -0
  38. package/templates/api/__init__.py.tmpl +0 -0
  39. package/templates/api/config.json.tmpl +4 -0
  40. package/templates/api/main.py.tmpl +30 -0
  41. package/templates/basic/README.md.tmpl +7 -0
  42. package/templates/basic/__init__.py.tmpl +0 -0
  43. package/templates/basic/config.json.tmpl +4 -0
  44. package/templates/basic/main.py.tmpl +22 -0
  45. package/tsconfig.json +19 -0
package/dist/cli.js ADDED
@@ -0,0 +1,3184 @@
1
+ import {
2
+ getApiKey,
3
+ getConfig,
4
+ getTrackedAbilities,
5
+ keychainDelete,
6
+ registerAbility,
7
+ saveApiKey,
8
+ saveConfig
9
+ } from "./chunk-Q4UKUXDB.js";
10
+
11
+ // src/cli.ts
12
+ import { Command } from "commander";
13
+ import { fileURLToPath } from "url";
14
+ import { dirname, join as join6 } from "path";
15
+ import { readFileSync as readFileSync5 } from "fs";
16
+
17
+ // src/api/endpoints.ts
18
+ var API_BASE = "https://app.openhome.com";
19
+ var WS_BASE = "wss://app.openhome.com";
20
+ var ENDPOINTS = {
21
+ getPersonalities: "/api/sdk/get_personalities",
22
+ verifyApiKey: "/api/sdk/verify_apikey/",
23
+ uploadCapability: "/api/capabilities/add-capability/",
24
+ listCapabilities: "/api/capabilities/get-all-capability/",
25
+ getCapability: (id) => `/api/capabilities/get-capability/${id}/`,
26
+ deleteCapability: (id) => `/api/capabilities/delete-capability/${id}/`,
27
+ bulkDeleteCapabilities: "/api/capabilities/delete-capability/",
28
+ editInstalledCapability: (id) => `/api/capabilities/edit-installed-capability/${id}/`,
29
+ editPersonality: "/api/personalities/edit-personality/",
30
+ voiceStream: (apiKey, agentId) => `/websocket/voice-stream/${apiKey}/${agentId}`
31
+ };
32
+
33
+ // src/api/client.ts
34
+ var NotImplementedError = class extends Error {
35
+ constructor(endpoint) {
36
+ super(`API endpoint not yet implemented: ${endpoint}`);
37
+ this.name = "NotImplementedError";
38
+ }
39
+ };
40
+ var ApiError = class extends Error {
41
+ constructor(code, message, details) {
42
+ super(message);
43
+ this.code = code;
44
+ this.details = details;
45
+ this.name = "ApiError";
46
+ }
47
+ };
48
+ var ApiClient = class {
49
+ constructor(apiKey, baseUrl) {
50
+ this.apiKey = apiKey;
51
+ this.baseUrl = baseUrl ?? API_BASE;
52
+ if (!this.baseUrl.startsWith("https://")) {
53
+ throw new Error("API base URL must use HTTPS. Got: " + this.baseUrl);
54
+ }
55
+ }
56
+ baseUrl;
57
+ async request(path, options = {}) {
58
+ const url = `${this.baseUrl}${path}`;
59
+ const response = await fetch(url, {
60
+ ...options,
61
+ headers: {
62
+ Authorization: `Bearer ${this.apiKey}`,
63
+ ...options.headers ?? {}
64
+ }
65
+ });
66
+ if (!response.ok) {
67
+ let body = null;
68
+ try {
69
+ body = await response.json();
70
+ } catch {
71
+ }
72
+ if (body?.error?.code === "NOT_IMPLEMENTED" || response.status === 404) {
73
+ throw new NotImplementedError(path);
74
+ }
75
+ throw new ApiError(
76
+ body?.error?.code ?? String(response.status),
77
+ body?.error?.message ?? response.statusText,
78
+ body?.error?.details
79
+ );
80
+ }
81
+ return response.json();
82
+ }
83
+ async getPersonalities() {
84
+ const data = await this.request(
85
+ ENDPOINTS.getPersonalities,
86
+ {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ api_key: this.apiKey, with_image: true })
90
+ }
91
+ );
92
+ return data.personalities;
93
+ }
94
+ async uploadAbility(zipBuffer, imageBuffer, imageName, metadata) {
95
+ const form = new FormData();
96
+ form.append(
97
+ "zip_file",
98
+ new Blob([zipBuffer], {
99
+ type: "application/zip"
100
+ }),
101
+ "ability.zip"
102
+ );
103
+ if (imageBuffer && imageName) {
104
+ const imageExt = imageName.split(".").pop()?.toLowerCase() ?? "png";
105
+ const imageMime = imageExt === "jpg" || imageExt === "jpeg" ? "image/jpeg" : "image/png";
106
+ form.append(
107
+ "image_file",
108
+ new Blob([imageBuffer], { type: imageMime }),
109
+ imageName
110
+ );
111
+ }
112
+ form.append("name", metadata.name);
113
+ form.append("description", metadata.description);
114
+ form.append("category", metadata.category);
115
+ form.append("trigger_words", metadata.matching_hotwords.join(", "));
116
+ if (metadata.personality_id) {
117
+ form.append("personality_id", metadata.personality_id);
118
+ }
119
+ return this.request(ENDPOINTS.uploadCapability, {
120
+ method: "POST",
121
+ body: form
122
+ });
123
+ }
124
+ async listAbilities() {
125
+ return this.request(ENDPOINTS.listCapabilities, {
126
+ method: "GET"
127
+ });
128
+ }
129
+ async getAbility(id) {
130
+ return this.request(ENDPOINTS.getCapability(id), {
131
+ method: "GET"
132
+ });
133
+ }
134
+ async verifyApiKey(apiKey) {
135
+ return this.request(ENDPOINTS.verifyApiKey, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({ api_key: apiKey })
139
+ });
140
+ }
141
+ async deleteCapability(id) {
142
+ try {
143
+ return await this.request(
144
+ ENDPOINTS.bulkDeleteCapabilities,
145
+ {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({
149
+ ids: [Number.isNaN(Number(id)) ? id : Number(id)]
150
+ })
151
+ }
152
+ );
153
+ } catch (err) {
154
+ if (err instanceof NotImplementedError) {
155
+ return this.request(
156
+ ENDPOINTS.deleteCapability(id),
157
+ { method: "DELETE" }
158
+ );
159
+ }
160
+ throw err;
161
+ }
162
+ }
163
+ async toggleCapability(id, enabled) {
164
+ return this.request(
165
+ ENDPOINTS.editInstalledCapability(id),
166
+ {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({ enabled })
170
+ }
171
+ );
172
+ }
173
+ async assignCapabilities(personalityId, capabilityIds) {
174
+ return this.request(ENDPOINTS.editPersonality, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify({
178
+ personality_id: personalityId,
179
+ matching_capabilities: capabilityIds
180
+ })
181
+ });
182
+ }
183
+ };
184
+
185
+ // src/ui/format.ts
186
+ import chalk from "chalk";
187
+ import * as p from "@clack/prompts";
188
+ function success(msg) {
189
+ console.log(chalk.green(`\u2713 ${msg}`));
190
+ }
191
+ function error(msg) {
192
+ console.error(chalk.red(`\u2717 ${msg}`));
193
+ }
194
+ function warn(msg) {
195
+ console.warn(chalk.yellow(`\u26A0 ${msg}`));
196
+ }
197
+ function info(msg) {
198
+ console.log(chalk.cyan(`\u2139 ${msg}`));
199
+ }
200
+ function table(rows) {
201
+ if (rows.length === 0) {
202
+ info("No items to display.");
203
+ return;
204
+ }
205
+ const keys = Object.keys(rows[0]);
206
+ const widths = {};
207
+ for (const key of keys) {
208
+ widths[key] = key.length;
209
+ }
210
+ for (const row of rows) {
211
+ for (const key of keys) {
212
+ const val = String(row[key] ?? "");
213
+ if (val.length > widths[key]) {
214
+ widths[key] = val.length;
215
+ }
216
+ }
217
+ }
218
+ const headerLine = keys.map((k) => chalk.bold(k.padEnd(widths[k]))).join(" ");
219
+ console.log(headerLine);
220
+ console.log(keys.map((k) => "\u2500".repeat(widths[k])).join(" "));
221
+ for (const row of rows) {
222
+ const line = keys.map((k) => String(row[k] ?? "").padEnd(widths[k])).join(" ");
223
+ console.log(line);
224
+ }
225
+ }
226
+ function handleCancel(value) {
227
+ if (p.isCancel(value)) {
228
+ p.cancel("Operation cancelled.");
229
+ process.exit(0);
230
+ }
231
+ }
232
+
233
+ // src/commands/login.ts
234
+ import chalk2 from "chalk";
235
+ async function loginCommand() {
236
+ p.intro("\u{1F511} OpenHome Login");
237
+ const apiKey = await p.password({
238
+ message: "Enter your OpenHome API key",
239
+ validate: (val) => {
240
+ if (!val || !val.trim()) return "API key is required";
241
+ }
242
+ });
243
+ handleCancel(apiKey);
244
+ const s = p.spinner();
245
+ s.start("Verifying API key...");
246
+ let agents;
247
+ try {
248
+ const client = new ApiClient(apiKey);
249
+ const verification = await client.verifyApiKey(apiKey);
250
+ if (!verification.valid) {
251
+ s.stop("Verification failed.");
252
+ error(verification.message ?? "Invalid API key.");
253
+ process.exit(1);
254
+ }
255
+ agents = await client.getPersonalities();
256
+ s.stop("API key verified.");
257
+ } catch (err) {
258
+ s.stop("Verification failed.");
259
+ error(err instanceof Error ? err.message : String(err));
260
+ process.exit(1);
261
+ }
262
+ saveApiKey(apiKey);
263
+ success("API key saved.");
264
+ if (agents.length > 0) {
265
+ p.note(
266
+ agents.map((a) => `${chalk2.bold(a.name)} ${chalk2.gray(a.id)}`).join("\n"),
267
+ `${agents.length} agent(s) on this account`
268
+ );
269
+ } else {
270
+ info("No agents found. Create one at https://app.openhome.com");
271
+ }
272
+ p.outro("Logged in! You're ready to go.");
273
+ }
274
+
275
+ // src/commands/init.ts
276
+ import {
277
+ mkdirSync,
278
+ writeFileSync,
279
+ copyFileSync,
280
+ existsSync as existsSync2,
281
+ readdirSync as readdirSync2
282
+ } from "fs";
283
+ import { join as join2, resolve, extname } from "path";
284
+ import { homedir } from "os";
285
+
286
+ // src/validation/validator.ts
287
+ import { readFileSync, existsSync, readdirSync } from "fs";
288
+ import { join } from "path";
289
+
290
+ // src/validation/rules.ts
291
+ var REQUIRED_FILES = ["main.py", "README.md", "__init__.py"];
292
+ var BLOCKED_IMPORTS = [
293
+ "redis",
294
+ "from src.utils.db_handler",
295
+ "connection_manager",
296
+ "user_config"
297
+ ];
298
+ var BLOCKED_PATTERNS = [
299
+ {
300
+ regex: /\bprint\s*\(/,
301
+ message: "Use self.worker.editor_logging_handler instead of print()"
302
+ },
303
+ {
304
+ regex: /\basyncio\.sleep\s*\(/,
305
+ message: "Use self.worker.session_tasks.sleep() instead"
306
+ },
307
+ {
308
+ regex: /\basyncio\.create_task\s*\(/,
309
+ message: "Use self.worker.session_tasks.create() instead"
310
+ },
311
+ { regex: /\bexec\s*\(/, message: "exec() not allowed" },
312
+ { regex: /\beval\s*\(/, message: "eval() not allowed" },
313
+ { regex: /\bpickle\./, message: "pickle not allowed" },
314
+ { regex: /\bdill\./, message: "dill not allowed" },
315
+ { regex: /\bshelve\./, message: "shelve not allowed" },
316
+ { regex: /\bmarshal\./, message: "marshal not allowed" },
317
+ {
318
+ regex: /\bopen\s*\(/,
319
+ message: "raw open() not allowed \u2014 use capability_worker file helpers"
320
+ },
321
+ { regex: /\bassert\s+/, message: "assert not allowed" },
322
+ { regex: /\bhashlib\.md5\s*\(/, message: "MD5 not allowed" }
323
+ ];
324
+ var REQUIRED_PATTERNS = [
325
+ {
326
+ regex: /resume_normal_flow\s*\(/,
327
+ message: "resume_normal_flow() must be called"
328
+ },
329
+ {
330
+ regex: /class\s+\w+.*MatchingCapability/,
331
+ message: "Class must extend MatchingCapability"
332
+ },
333
+ { regex: /def\s+call\s*\(/, message: "Must have a call() method" },
334
+ {
335
+ regex: /worker\s*:\s*AgentWorker\s*=\s*None/,
336
+ message: "Must declare worker: AgentWorker = None"
337
+ },
338
+ {
339
+ regex: /capability_worker\s*:\s*CapabilityWorker\s*=\s*None/,
340
+ message: "Must declare capability_worker: CapabilityWorker = None"
341
+ }
342
+ ];
343
+ var REGISTER_CAPABILITY_PATTERN = /#\s?\{\{register[_ ]capability\}\}/;
344
+ var HARDCODED_KEY_PATTERN = /(sk_|sk-|key_)[a-zA-Z0-9]{20,}/;
345
+ var MULTIPLE_CLASSES_PATTERN = /^class\s+/gm;
346
+
347
+ // src/validation/validator.ts
348
+ function readFile(filePath) {
349
+ try {
350
+ return readFileSync(filePath, "utf8");
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
355
+ function validateAbility(dirPath) {
356
+ const errors = [];
357
+ const warnings = [];
358
+ for (const required of REQUIRED_FILES) {
359
+ const fullPath = join(dirPath, required);
360
+ if (!existsSync(fullPath)) {
361
+ errors.push({
362
+ severity: "error",
363
+ message: `Missing required file: ${required}`,
364
+ file: required
365
+ });
366
+ }
367
+ }
368
+ const configPath = join(dirPath, "config.json");
369
+ if (existsSync(configPath)) {
370
+ const configContent = readFile(configPath);
371
+ if (configContent) {
372
+ try {
373
+ const config = JSON.parse(configContent);
374
+ if (typeof config.unique_name !== "string" || !config.unique_name) {
375
+ errors.push({
376
+ severity: "error",
377
+ message: "config.json: unique_name must be a non-empty string",
378
+ file: "config.json"
379
+ });
380
+ }
381
+ if (!Array.isArray(config.matching_hotwords) || !config.matching_hotwords.every(
382
+ (h) => typeof h === "string"
383
+ )) {
384
+ errors.push({
385
+ severity: "error",
386
+ message: "config.json: matching_hotwords must be an array of strings",
387
+ file: "config.json"
388
+ });
389
+ }
390
+ } catch {
391
+ errors.push({
392
+ severity: "error",
393
+ message: "config.json: invalid JSON",
394
+ file: "config.json"
395
+ });
396
+ }
397
+ }
398
+ } else {
399
+ errors.push({
400
+ severity: "error",
401
+ message: "Missing required file: config.json",
402
+ file: "config.json"
403
+ });
404
+ }
405
+ const mainPath = join(dirPath, "main.py");
406
+ const mainContent = readFile(mainPath);
407
+ if (mainContent) {
408
+ const lines = mainContent.split("\n");
409
+ for (let i = 0; i < lines.length; i++) {
410
+ const line = lines[i].trim();
411
+ if (!line.startsWith("import ") && !line.startsWith("from ") && !line.includes("import "))
412
+ continue;
413
+ for (const blocked of BLOCKED_IMPORTS) {
414
+ if (line.includes(blocked)) {
415
+ errors.push({
416
+ severity: "error",
417
+ message: `Blocked import "${blocked}" on line ${i + 1}`,
418
+ file: "main.py"
419
+ });
420
+ }
421
+ }
422
+ }
423
+ for (let i = 0; i < lines.length; i++) {
424
+ const line = lines[i];
425
+ for (const { regex, message } of BLOCKED_PATTERNS) {
426
+ if (regex.test(line)) {
427
+ errors.push({
428
+ severity: "error",
429
+ message: `${message} (line ${i + 1})`,
430
+ file: "main.py"
431
+ });
432
+ }
433
+ }
434
+ }
435
+ for (const { regex, message } of REQUIRED_PATTERNS) {
436
+ if (!regex.test(mainContent)) {
437
+ errors.push({ severity: "error", message, file: "main.py" });
438
+ }
439
+ }
440
+ if (!REGISTER_CAPABILITY_PATTERN.test(mainContent)) {
441
+ errors.push({
442
+ severity: "error",
443
+ message: "Missing #{{register_capability}} tag in main.py",
444
+ file: "main.py"
445
+ });
446
+ }
447
+ const keyMatches = mainContent.match(HARDCODED_KEY_PATTERN);
448
+ if (keyMatches) {
449
+ warnings.push({
450
+ severity: "warning",
451
+ message: `Possible hardcoded API key detected in main.py \u2014 use capability_worker.get_single_key() instead`,
452
+ file: "main.py"
453
+ });
454
+ }
455
+ const classMatches = mainContent.match(MULTIPLE_CLASSES_PATTERN);
456
+ if (classMatches && classMatches.length > 1) {
457
+ warnings.push({
458
+ severity: "warning",
459
+ message: `Multiple class definitions found (${classMatches.length}). Only one MatchingCapability class is expected.`,
460
+ file: "main.py"
461
+ });
462
+ }
463
+ }
464
+ let pyFiles = [];
465
+ try {
466
+ pyFiles = readdirSync(dirPath).filter(
467
+ (f) => f.endsWith(".py") && f !== "main.py"
468
+ );
469
+ } catch {
470
+ }
471
+ for (const pyFile of pyFiles) {
472
+ const content = readFile(join(dirPath, pyFile));
473
+ if (!content) continue;
474
+ const lines = content.split("\n");
475
+ for (let i = 0; i < lines.length; i++) {
476
+ const line = lines[i];
477
+ for (const { regex, message } of BLOCKED_PATTERNS) {
478
+ if (regex.test(line)) {
479
+ errors.push({
480
+ severity: "error",
481
+ message: `${message} (line ${i + 1})`,
482
+ file: pyFile
483
+ });
484
+ }
485
+ }
486
+ }
487
+ }
488
+ return {
489
+ passed: errors.length === 0,
490
+ errors,
491
+ warnings
492
+ };
493
+ }
494
+
495
+ // src/commands/init.ts
496
+ var DAEMON_TEMPLATES = /* @__PURE__ */ new Set(["background", "alarm"]);
497
+ function toClassName(name) {
498
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
499
+ }
500
+ var SHARED_INIT = "";
501
+ function sharedConfig() {
502
+ return `{
503
+ "unique_name": "{{UNIQUE_NAME}}",
504
+ "description": "{{DESCRIPTION}}",
505
+ "category": "{{CATEGORY}}",
506
+ "matching_hotwords": {{HOTWORDS}}
507
+ }
508
+ `;
509
+ }
510
+ function skillReadme() {
511
+ return `# {{DISPLAY_NAME}}
512
+
513
+ A custom OpenHome ability.
514
+
515
+ ## Trigger Words
516
+
517
+ {{HOTWORD_LIST}}
518
+ `;
519
+ }
520
+ function daemonReadme() {
521
+ return `# {{DISPLAY_NAME}}
522
+
523
+ A background OpenHome daemon. Runs automatically on session start \u2014 no trigger words required.
524
+
525
+ ## Trigger Words
526
+
527
+ {{HOTWORD_LIST}}
528
+ `;
529
+ }
530
+ function getTemplate(templateType, file) {
531
+ if (file === "config.json") return sharedConfig();
532
+ if (file === "__init__.py") return SHARED_INIT;
533
+ if (file === "README.md") {
534
+ return DAEMON_TEMPLATES.has(templateType) ? daemonReadme() : skillReadme();
535
+ }
536
+ const templates = {
537
+ // ── BASIC ────────────────────────────────────────────────────────────
538
+ basic: {
539
+ "main.py": `from src.agent.capability import MatchingCapability
540
+ from src.main import AgentWorker
541
+ from src.agent.capability_worker import CapabilityWorker
542
+
543
+
544
+ class {{CLASS_NAME}}(MatchingCapability):
545
+ worker: AgentWorker = None
546
+ capability_worker: CapabilityWorker = None
547
+
548
+ @classmethod
549
+ def register_capability(cls) -> "MatchingCapability":
550
+ # {{register_capability}}
551
+ pass
552
+
553
+ def call(self, worker: AgentWorker):
554
+ self.worker = worker
555
+ self.capability_worker = CapabilityWorker(self.worker)
556
+ self.worker.session_tasks.create(self.run())
557
+
558
+ async def run(self):
559
+ await self.capability_worker.speak("Hello! This ability is working.")
560
+ self.capability_worker.resume_normal_flow()
561
+ `
562
+ },
563
+ // ── API ──────────────────────────────────────────────────────────────
564
+ api: {
565
+ "main.py": `import requests
566
+ from src.agent.capability import MatchingCapability
567
+ from src.main import AgentWorker
568
+ from src.agent.capability_worker import CapabilityWorker
569
+
570
+
571
+ class {{CLASS_NAME}}(MatchingCapability):
572
+ worker: AgentWorker = None
573
+ capability_worker: CapabilityWorker = None
574
+
575
+ @classmethod
576
+ def register_capability(cls) -> "MatchingCapability":
577
+ # {{register_capability}}
578
+ pass
579
+
580
+ def call(self, worker: AgentWorker):
581
+ self.worker = worker
582
+ self.capability_worker = CapabilityWorker(self.worker)
583
+ self.worker.session_tasks.create(self.run())
584
+
585
+ async def run(self):
586
+ api_key = self.capability_worker.get_single_key("api_key")
587
+ response = requests.get(
588
+ "https://api.example.com/data",
589
+ headers={"Authorization": f"Bearer {api_key}"},
590
+ timeout=10,
591
+ )
592
+ data = response.json()
593
+ await self.capability_worker.speak(f"Here's what I found: {data.get('result', 'nothing')}")
594
+ self.capability_worker.resume_normal_flow()
595
+ `
596
+ },
597
+ // ── LOOP ─────────────────────────────────────────────────────────────
598
+ loop: {
599
+ "main.py": `import asyncio
600
+ from src.agent.capability import MatchingCapability
601
+ from src.main import AgentWorker
602
+ from src.agent.capability_worker import CapabilityWorker
603
+
604
+
605
+ class {{CLASS_NAME}}(MatchingCapability):
606
+ worker: AgentWorker = None
607
+ capability_worker: CapabilityWorker = None
608
+
609
+ @classmethod
610
+ def register_capability(cls) -> "MatchingCapability":
611
+ # {{register_capability}}
612
+ pass
613
+
614
+ def call(self, worker: AgentWorker):
615
+ self.worker = worker
616
+ self.capability_worker = CapabilityWorker(self.worker)
617
+ self.worker.session_tasks.create(self.run())
618
+
619
+ async def run(self):
620
+ await self.capability_worker.speak("I'll listen and check in periodically.")
621
+
622
+ while True:
623
+ self.capability_worker.start_audio_recording()
624
+ await self.worker.session_tasks.sleep(90)
625
+ self.capability_worker.stop_audio_recording()
626
+
627
+ recording = self.capability_worker.get_audio_recording()
628
+ length = self.capability_worker.get_audio_recording_length()
629
+ self.capability_worker.flush_audio_recording()
630
+
631
+ if length > 2:
632
+ response = self.capability_worker.text_to_text_response(
633
+ f"The user has been speaking for {length:.0f} seconds. "
634
+ "Summarize what you heard and respond helpfully.",
635
+ self.capability_worker.get_full_message_history(),
636
+ )
637
+ await self.capability_worker.speak(response)
638
+
639
+ self.capability_worker.resume_normal_flow()
640
+ `
641
+ },
642
+ // ── EMAIL ────────────────────────────────────────────────────────────
643
+ email: {
644
+ "main.py": `import json
645
+ import smtplib
646
+ from email.mime.text import MIMEText
647
+ from src.agent.capability import MatchingCapability
648
+ from src.main import AgentWorker
649
+ from src.agent.capability_worker import CapabilityWorker
650
+
651
+
652
+ class {{CLASS_NAME}}(MatchingCapability):
653
+ worker: AgentWorker = None
654
+ capability_worker: CapabilityWorker = None
655
+
656
+ @classmethod
657
+ def register_capability(cls) -> "MatchingCapability":
658
+ # {{register_capability}}
659
+ pass
660
+
661
+ def call(self, worker: AgentWorker):
662
+ self.worker = worker
663
+ self.capability_worker = CapabilityWorker(self.worker)
664
+ self.worker.session_tasks.create(self.run())
665
+
666
+ async def run(self):
667
+ creds = self.capability_worker.get_single_key("email_config")
668
+ if not creds:
669
+ await self.capability_worker.speak("Email is not configured yet.")
670
+ self.capability_worker.resume_normal_flow()
671
+ return
672
+
673
+ config = json.loads(creds) if isinstance(creds, str) else creds
674
+
675
+ reply = await self.capability_worker.run_io_loop(
676
+ "Who should I send the email to?"
677
+ )
678
+ to_addr = reply.strip()
679
+
680
+ subject = await self.capability_worker.run_io_loop("What's the subject?")
681
+ body = await self.capability_worker.run_io_loop("What should the email say?")
682
+
683
+ confirmed = await self.capability_worker.run_confirmation_loop(
684
+ f"Send email to {to_addr} with subject '{subject}'?"
685
+ )
686
+
687
+ if confirmed:
688
+ msg = MIMEText(body)
689
+ msg["Subject"] = subject
690
+ msg["From"] = config["from"]
691
+ msg["To"] = to_addr
692
+
693
+ try:
694
+ with smtplib.SMTP(config["smtp_host"], config.get("smtp_port", 587)) as server:
695
+ server.starttls()
696
+ server.login(config["from"], config["password"])
697
+ server.send_message(msg)
698
+ await self.capability_worker.speak("Email sent!")
699
+ except Exception as e:
700
+ self.worker.editor_logging_handler.error(f"Email failed: {e}")
701
+ await self.capability_worker.speak("Sorry, the email failed to send.")
702
+ else:
703
+ await self.capability_worker.speak("Email cancelled.")
704
+
705
+ self.capability_worker.resume_normal_flow()
706
+ `
707
+ },
708
+ // ── BACKGROUND (daemon) ───────────────────────────────────────────────
709
+ // background.py holds the active logic; main.py is a minimal no-op stub
710
+ background: {
711
+ "main.py": `from src.agent.capability import MatchingCapability
712
+ from src.main import AgentWorker
713
+ from src.agent.capability_worker import CapabilityWorker
714
+
715
+
716
+ class {{CLASS_NAME}}(MatchingCapability):
717
+ worker: AgentWorker = None
718
+ capability_worker: CapabilityWorker = None
719
+
720
+ @classmethod
721
+ def register_capability(cls) -> "MatchingCapability":
722
+ # {{register_capability}}
723
+ pass
724
+
725
+ def call(self, worker: AgentWorker):
726
+ self.worker = worker
727
+ self.capability_worker = CapabilityWorker(self.worker)
728
+ self.worker.session_tasks.create(self.run())
729
+
730
+ async def run(self):
731
+ self.capability_worker.resume_normal_flow()
732
+ `,
733
+ "background.py": `import asyncio
734
+ from src.agent.capability import MatchingCapability
735
+ from src.main import AgentWorker
736
+ from src.agent.capability_worker import CapabilityWorker
737
+
738
+
739
+ class {{CLASS_NAME}}(MatchingCapability):
740
+ worker: AgentWorker = None
741
+ capability_worker: CapabilityWorker = None
742
+
743
+ @classmethod
744
+ def register_capability(cls) -> "MatchingCapability":
745
+ # {{register_capability}}
746
+ pass
747
+
748
+ def call(self, worker: AgentWorker):
749
+ self.worker = worker
750
+ self.capability_worker = CapabilityWorker(self.worker)
751
+ self.worker.session_tasks.create(self.run())
752
+
753
+ async def run(self):
754
+ while True:
755
+ # Your background logic here
756
+ self.worker.editor_logging_handler.info("Background tick")
757
+
758
+ # Example: check something and notify
759
+ # await self.capability_worker.speak("Heads up!")
760
+
761
+ await self.worker.session_tasks.sleep(60)
762
+ `
763
+ },
764
+ // ── ALARM (skill + daemon combo) ──────────────────────────────────────
765
+ alarm: {
766
+ "main.py": `from src.agent.capability import MatchingCapability
767
+ from src.main import AgentWorker
768
+ from src.agent.capability_worker import CapabilityWorker
769
+
770
+
771
+ class {{CLASS_NAME}}(MatchingCapability):
772
+ worker: AgentWorker = None
773
+ capability_worker: CapabilityWorker = None
774
+
775
+ @classmethod
776
+ def register_capability(cls) -> "MatchingCapability":
777
+ # {{register_capability}}
778
+ pass
779
+
780
+ def call(self, worker: AgentWorker):
781
+ self.worker = worker
782
+ self.capability_worker = CapabilityWorker(self.worker)
783
+ self.worker.session_tasks.create(self.run())
784
+
785
+ async def run(self):
786
+ reply = await self.capability_worker.run_io_loop(
787
+ "What should I remind you about?"
788
+ )
789
+ minutes = await self.capability_worker.run_io_loop(
790
+ "In how many minutes?"
791
+ )
792
+
793
+ try:
794
+ mins = int(minutes.strip())
795
+ except ValueError:
796
+ await self.capability_worker.speak("I didn't understand the time. Try again.")
797
+ self.capability_worker.resume_normal_flow()
798
+ return
799
+
800
+ self.capability_worker.write_file(
801
+ "pending_alarm.json",
802
+ f'{{"message": "{reply}", "minutes": {mins}}}',
803
+ temp=True,
804
+ )
805
+ await self.capability_worker.speak(f"Got it! I'll remind you in {mins} minutes.")
806
+ self.capability_worker.resume_normal_flow()
807
+ `,
808
+ "background.py": `import json
809
+ from src.agent.capability import MatchingCapability
810
+ from src.main import AgentWorker
811
+ from src.agent.capability_worker import CapabilityWorker
812
+
813
+
814
+ class {{CLASS_NAME}}Background(MatchingCapability):
815
+ worker: AgentWorker = None
816
+ capability_worker: CapabilityWorker = None
817
+
818
+ @classmethod
819
+ def register_capability(cls) -> "MatchingCapability":
820
+ # {{register_capability}}
821
+ pass
822
+
823
+ def call(self, worker: AgentWorker):
824
+ self.worker = worker
825
+ self.capability_worker = CapabilityWorker(self.worker)
826
+ self.worker.session_tasks.create(self.run())
827
+
828
+ async def run(self):
829
+ while True:
830
+ if self.capability_worker.check_if_file_exists("pending_alarm.json", temp=True):
831
+ raw = self.capability_worker.read_file("pending_alarm.json", temp=True)
832
+ alarm = json.loads(raw)
833
+ await self.worker.session_tasks.sleep(alarm["minutes"] * 60)
834
+ await self.capability_worker.speak(f"Reminder: {alarm['message']}")
835
+ self.capability_worker.delete_file("pending_alarm.json", temp=True)
836
+ await self.worker.session_tasks.sleep(10)
837
+ `
838
+ },
839
+ // ── READWRITE ────────────────────────────────────────────────────────
840
+ readwrite: {
841
+ "main.py": `import json
842
+ from src.agent.capability import MatchingCapability
843
+ from src.main import AgentWorker
844
+ from src.agent.capability_worker import CapabilityWorker
845
+
846
+
847
+ class {{CLASS_NAME}}(MatchingCapability):
848
+ worker: AgentWorker = None
849
+ capability_worker: CapabilityWorker = None
850
+
851
+ @classmethod
852
+ def register_capability(cls) -> "MatchingCapability":
853
+ # {{register_capability}}
854
+ pass
855
+
856
+ def call(self, worker: AgentWorker):
857
+ self.worker = worker
858
+ self.capability_worker = CapabilityWorker(self.worker)
859
+ self.worker.session_tasks.create(self.run())
860
+
861
+ async def run(self):
862
+ reply = await self.capability_worker.run_io_loop(
863
+ "What would you like me to remember?"
864
+ )
865
+
866
+ # Read existing notes or start fresh
867
+ if self.capability_worker.check_if_file_exists("notes.json", temp=False):
868
+ raw = self.capability_worker.read_file("notes.json", temp=False)
869
+ notes = json.loads(raw)
870
+ else:
871
+ notes = []
872
+
873
+ notes.append(reply.strip())
874
+ self.capability_worker.write_file(
875
+ "notes.json",
876
+ json.dumps(notes, indent=2),
877
+ temp=False,
878
+ mode="w",
879
+ )
880
+
881
+ await self.capability_worker.speak(
882
+ f"Got it! I now have {len(notes)} note{'s' if len(notes) != 1 else ''} saved."
883
+ )
884
+ self.capability_worker.resume_normal_flow()
885
+ `
886
+ },
887
+ // ── LOCAL ────────────────────────────────────────────────────────────
888
+ local: {
889
+ "main.py": `from src.agent.capability import MatchingCapability
890
+ from src.main import AgentWorker
891
+ from src.agent.capability_worker import CapabilityWorker
892
+
893
+
894
+ class {{CLASS_NAME}}(MatchingCapability):
895
+ worker: AgentWorker = None
896
+ capability_worker: CapabilityWorker = None
897
+
898
+ @classmethod
899
+ def register_capability(cls) -> "MatchingCapability":
900
+ # {{register_capability}}
901
+ pass
902
+
903
+ def call(self, worker: AgentWorker):
904
+ self.worker = worker
905
+ self.capability_worker = CapabilityWorker(self.worker)
906
+ self.worker.session_tasks.create(self.run())
907
+
908
+ async def run(self):
909
+ reply = await self.capability_worker.run_io_loop(
910
+ "What would you like me to do on your device?"
911
+ )
912
+
913
+ # Use text_to_text to interpret the command
914
+ response = self.capability_worker.text_to_text_response(
915
+ f"The user wants to: {reply}. Generate a helpful response.",
916
+ self.capability_worker.get_full_message_history(),
917
+ )
918
+
919
+ # Send action to DevKit hardware if connected
920
+ self.capability_worker.send_devkit_action({
921
+ "type": "command",
922
+ "payload": reply.strip(),
923
+ })
924
+
925
+ await self.capability_worker.speak(response)
926
+ self.capability_worker.resume_normal_flow()
927
+ `
928
+ },
929
+ // ── OPENCLAW ─────────────────────────────────────────────────────────
930
+ openclaw: {
931
+ "main.py": `import requests
932
+ from src.agent.capability import MatchingCapability
933
+ from src.main import AgentWorker
934
+ from src.agent.capability_worker import CapabilityWorker
935
+
936
+
937
+ class {{CLASS_NAME}}(MatchingCapability):
938
+ worker: AgentWorker = None
939
+ capability_worker: CapabilityWorker = None
940
+
941
+ @classmethod
942
+ def register_capability(cls) -> "MatchingCapability":
943
+ # {{register_capability}}
944
+ pass
945
+
946
+ def call(self, worker: AgentWorker):
947
+ self.worker = worker
948
+ self.capability_worker = CapabilityWorker(self.worker)
949
+ self.worker.session_tasks.create(self.run())
950
+
951
+ async def run(self):
952
+ reply = await self.capability_worker.run_io_loop(
953
+ "What would you like me to handle?"
954
+ )
955
+
956
+ gateway_url = self.capability_worker.get_single_key("openclaw_gateway_url")
957
+ gateway_token = self.capability_worker.get_single_key("openclaw_gateway_token")
958
+
959
+ if not gateway_url or not gateway_token:
960
+ await self.capability_worker.speak(
961
+ "OpenClaw gateway is not configured. Add openclaw_gateway_url and openclaw_gateway_token as secrets."
962
+ )
963
+ self.capability_worker.resume_normal_flow()
964
+ return
965
+
966
+ try:
967
+ resp = requests.post(
968
+ f"{gateway_url}/v1/chat",
969
+ headers={
970
+ "Authorization": f"Bearer {gateway_token}",
971
+ "Content-Type": "application/json",
972
+ },
973
+ json={"message": reply.strip()},
974
+ timeout=30,
975
+ )
976
+ data = resp.json()
977
+ answer = data.get("reply", data.get("response", "No response from OpenClaw."))
978
+ await self.capability_worker.speak(answer)
979
+ except Exception as e:
980
+ self.worker.editor_logging_handler.error(f"OpenClaw error: {e}")
981
+ await self.capability_worker.speak("Sorry, I couldn't reach OpenClaw.")
982
+
983
+ self.capability_worker.resume_normal_flow()
984
+ `
985
+ }
986
+ };
987
+ return templates[templateType]?.[file] ?? "";
988
+ }
989
+ function applyTemplate(content, vars) {
990
+ let result = content;
991
+ for (const [key, value] of Object.entries(vars)) {
992
+ result = result.replaceAll(`{{${key}}}`, value);
993
+ }
994
+ return result;
995
+ }
996
+ function getFileList(templateType) {
997
+ const base = ["__init__.py", "README.md", "config.json"];
998
+ if (templateType === "background") {
999
+ return ["main.py", "background.py", ...base];
1000
+ }
1001
+ if (templateType === "alarm") {
1002
+ return ["main.py", "background.py", ...base];
1003
+ }
1004
+ return ["main.py", ...base];
1005
+ }
1006
+ function getTemplateOptions(category) {
1007
+ if (category === "skill") {
1008
+ return [
1009
+ {
1010
+ value: "basic",
1011
+ label: "Basic",
1012
+ hint: "Simple ability with speak + user_response"
1013
+ },
1014
+ {
1015
+ value: "api",
1016
+ label: "API",
1017
+ hint: "Calls an external API using a stored secret"
1018
+ },
1019
+ {
1020
+ value: "loop",
1021
+ label: "Loop (ambient observer)",
1022
+ hint: "Records audio periodically and checks in"
1023
+ },
1024
+ {
1025
+ value: "email",
1026
+ label: "Email",
1027
+ hint: "Sends email via SMTP using stored credentials"
1028
+ },
1029
+ {
1030
+ value: "readwrite",
1031
+ label: "File Storage",
1032
+ hint: "Reads and writes persistent JSON files"
1033
+ },
1034
+ {
1035
+ value: "local",
1036
+ label: "Local (DevKit)",
1037
+ hint: "Executes commands on the local device via DevKit"
1038
+ },
1039
+ {
1040
+ value: "openclaw",
1041
+ label: "OpenClaw",
1042
+ hint: "Forwards requests to the OpenClaw gateway"
1043
+ }
1044
+ ];
1045
+ }
1046
+ if (category === "brain") {
1047
+ return [
1048
+ {
1049
+ value: "basic",
1050
+ label: "Basic",
1051
+ hint: "Simple ability with speak + user_response"
1052
+ },
1053
+ {
1054
+ value: "api",
1055
+ label: "API",
1056
+ hint: "Calls an external API using a stored secret"
1057
+ }
1058
+ ];
1059
+ }
1060
+ return [
1061
+ {
1062
+ value: "background",
1063
+ label: "Background (continuous)",
1064
+ hint: "Runs a loop from session start, no trigger"
1065
+ },
1066
+ {
1067
+ value: "alarm",
1068
+ label: "Alarm (skill + daemon combo)",
1069
+ hint: "Skill sets an alarm; background.py fires it"
1070
+ }
1071
+ ];
1072
+ }
1073
+ async function initCommand(nameArg) {
1074
+ p.intro("Create a new OpenHome ability");
1075
+ let name;
1076
+ if (nameArg) {
1077
+ name = nameArg.trim();
1078
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
1079
+ error(
1080
+ "Invalid name. Use lowercase letters, numbers, and hyphens only. Must start with a letter."
1081
+ );
1082
+ process.exit(1);
1083
+ }
1084
+ } else {
1085
+ const nameInput = await p.text({
1086
+ message: "What should your ability be called?",
1087
+ placeholder: "my-cool-ability",
1088
+ validate: (val) => {
1089
+ if (!val || !val.trim()) return "Name is required";
1090
+ if (!/^[a-z][a-z0-9-]*$/.test(val.trim()))
1091
+ return "Use lowercase letters, numbers, and hyphens only. Must start with a letter.";
1092
+ }
1093
+ });
1094
+ handleCancel(nameInput);
1095
+ name = nameInput.trim();
1096
+ }
1097
+ const category = await p.select({
1098
+ message: "What type of ability?",
1099
+ options: [
1100
+ {
1101
+ value: "skill",
1102
+ label: "Skill",
1103
+ hint: "User-triggered, runs on demand (most common)"
1104
+ },
1105
+ {
1106
+ value: "brain",
1107
+ label: "Brain Skill",
1108
+ hint: "Auto-triggered by the agent's intelligence"
1109
+ },
1110
+ {
1111
+ value: "daemon",
1112
+ label: "Background Daemon",
1113
+ hint: "Runs continuously from session start"
1114
+ }
1115
+ ]
1116
+ });
1117
+ handleCancel(category);
1118
+ const descInput = await p.text({
1119
+ message: "Short description for the marketplace",
1120
+ placeholder: "A fun ability that checks the weather",
1121
+ validate: (val) => {
1122
+ if (!val || !val.trim()) return "Description is required";
1123
+ }
1124
+ });
1125
+ handleCancel(descInput);
1126
+ const description = descInput.trim();
1127
+ const templateOptions = getTemplateOptions(category);
1128
+ const templateType = await p.select({
1129
+ message: "Choose a template",
1130
+ options: templateOptions
1131
+ });
1132
+ handleCancel(templateType);
1133
+ const hotwordInput = await p.text({
1134
+ message: DAEMON_TEMPLATES.has(templateType) ? "Trigger words (comma-separated, or leave empty for daemons)" : "Trigger words (comma-separated)",
1135
+ placeholder: "check weather, weather please",
1136
+ validate: (val) => {
1137
+ if (!DAEMON_TEMPLATES.has(templateType)) {
1138
+ if (!val || !val.trim()) return "At least one trigger word is required";
1139
+ }
1140
+ }
1141
+ });
1142
+ handleCancel(hotwordInput);
1143
+ const hotwords = hotwordInput.split(",").map((h) => h.trim()).filter(Boolean);
1144
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
1145
+ const home = homedir();
1146
+ const candidateDirs = [
1147
+ process.cwd(),
1148
+ join2(home, "Desktop"),
1149
+ join2(home, "Downloads"),
1150
+ join2(home, "Pictures"),
1151
+ join2(home, "Images"),
1152
+ join2(home, ".openhome", "icons")
1153
+ ];
1154
+ if (process.env.USERPROFILE) {
1155
+ candidateDirs.push(
1156
+ join2(process.env.USERPROFILE, "Desktop"),
1157
+ join2(process.env.USERPROFILE, "Downloads"),
1158
+ join2(process.env.USERPROFILE, "Pictures")
1159
+ );
1160
+ }
1161
+ const scanDirs = [...new Set(candidateDirs)];
1162
+ const foundImages = [];
1163
+ for (const dir of scanDirs) {
1164
+ if (!existsSync2(dir)) continue;
1165
+ try {
1166
+ const files2 = readdirSync2(dir);
1167
+ for (const file of files2) {
1168
+ if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
1169
+ const full = join2(dir, file);
1170
+ const shortDir = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
1171
+ foundImages.push({
1172
+ path: full,
1173
+ label: `${file} (${shortDir})`
1174
+ });
1175
+ }
1176
+ }
1177
+ } catch {
1178
+ }
1179
+ }
1180
+ let iconSourcePath;
1181
+ if (foundImages.length > 0) {
1182
+ const imageOptions = [
1183
+ ...foundImages.map((img) => ({ value: img.path, label: img.label })),
1184
+ { value: "__custom__", label: "Other...", hint: "Enter a path manually" }
1185
+ ];
1186
+ const selected = await p.select({
1187
+ message: "Select an icon image (PNG or JPG for marketplace)",
1188
+ options: imageOptions
1189
+ });
1190
+ handleCancel(selected);
1191
+ if (selected === "__custom__") {
1192
+ const iconInput = await p.text({
1193
+ message: "Path to icon image",
1194
+ placeholder: "./icon.png",
1195
+ validate: (val) => {
1196
+ if (!val || !val.trim()) return "An icon image is required";
1197
+ const resolved = resolve(val.trim());
1198
+ if (!existsSync2(resolved)) return `File not found: ${val.trim()}`;
1199
+ const ext = extname(resolved).toLowerCase();
1200
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
1201
+ }
1202
+ });
1203
+ handleCancel(iconInput);
1204
+ iconSourcePath = resolve(iconInput.trim());
1205
+ } else {
1206
+ iconSourcePath = selected;
1207
+ }
1208
+ } else {
1209
+ const iconInput = await p.text({
1210
+ message: "Path to icon image (PNG or JPG for marketplace)",
1211
+ placeholder: "./icon.png",
1212
+ validate: (val) => {
1213
+ if (!val || !val.trim()) return "An icon image is required";
1214
+ const resolved = resolve(val.trim());
1215
+ if (!existsSync2(resolved)) return `File not found: ${val.trim()}`;
1216
+ const ext = extname(resolved).toLowerCase();
1217
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
1218
+ }
1219
+ });
1220
+ handleCancel(iconInput);
1221
+ iconSourcePath = resolve(iconInput.trim());
1222
+ }
1223
+ const iconExt = extname(iconSourcePath).toLowerCase();
1224
+ const iconFileName = iconExt === ".jpeg" ? "icon.jpg" : `icon${iconExt}`;
1225
+ const abilitiesDir = resolve("abilities");
1226
+ const targetDir = join2(abilitiesDir, name);
1227
+ if (existsSync2(targetDir)) {
1228
+ error(`Directory "abilities/${name}" already exists.`);
1229
+ process.exit(1);
1230
+ }
1231
+ const confirmed = await p.confirm({
1232
+ message: `Create ability "${name}" with ${hotwords.length} trigger word(s)?`
1233
+ });
1234
+ handleCancel(confirmed);
1235
+ if (!confirmed) {
1236
+ p.cancel("Aborted.");
1237
+ process.exit(0);
1238
+ }
1239
+ const s = p.spinner();
1240
+ s.start("Generating ability files...");
1241
+ mkdirSync(targetDir, { recursive: true });
1242
+ const className = toClassName(name);
1243
+ const displayName = name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1244
+ const vars = {
1245
+ CLASS_NAME: className,
1246
+ UNIQUE_NAME: name,
1247
+ DISPLAY_NAME: displayName,
1248
+ DESCRIPTION: description,
1249
+ CATEGORY: category,
1250
+ HOTWORDS: JSON.stringify(hotwords),
1251
+ HOTWORD_LIST: hotwords.length > 0 ? hotwords.map((h) => `- "${h}"`).join("\n") : "_None (daemon)_"
1252
+ };
1253
+ const resolvedTemplate = templateType;
1254
+ const files = getFileList(resolvedTemplate);
1255
+ for (const file of files) {
1256
+ const content = applyTemplate(getTemplate(resolvedTemplate, file), vars);
1257
+ writeFileSync(join2(targetDir, file), content, "utf8");
1258
+ }
1259
+ copyFileSync(iconSourcePath, join2(targetDir, iconFileName));
1260
+ s.stop("Files generated.");
1261
+ registerAbility(name, targetDir);
1262
+ const result = validateAbility(targetDir);
1263
+ if (result.passed) {
1264
+ success("Validation passed.");
1265
+ } else {
1266
+ for (const issue of result.errors) {
1267
+ error(`${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
1268
+ }
1269
+ }
1270
+ for (const w of result.warnings) {
1271
+ warn(`${w.file ? `[${w.file}] ` : ""}${w.message}`);
1272
+ }
1273
+ p.note(`cd abilities/${name}
1274
+ openhome deploy`, "Next steps");
1275
+ p.outro(`Ability "${name}" is ready!`);
1276
+ }
1277
+
1278
+ // src/commands/deploy.ts
1279
+ import { resolve as resolve2, join as join3, basename as basename2, extname as extname2 } from "path";
1280
+ import {
1281
+ readFileSync as readFileSync2,
1282
+ writeFileSync as writeFileSync2,
1283
+ mkdirSync as mkdirSync2,
1284
+ existsSync as existsSync3,
1285
+ readdirSync as readdirSync3
1286
+ } from "fs";
1287
+ import { homedir as homedir2 } from "os";
1288
+
1289
+ // src/util/zip.ts
1290
+ import archiver from "archiver";
1291
+ import { createWriteStream } from "fs";
1292
+ import { Writable } from "stream";
1293
+ async function createAbilityZip(dirPath) {
1294
+ return new Promise((resolve5, reject) => {
1295
+ const chunks = [];
1296
+ const writable = new Writable({
1297
+ write(chunk, _encoding, callback) {
1298
+ chunks.push(chunk);
1299
+ callback();
1300
+ }
1301
+ });
1302
+ writable.on("finish", () => {
1303
+ resolve5(Buffer.concat(chunks));
1304
+ });
1305
+ writable.on("error", reject);
1306
+ const archive = archiver("zip", { zlib: { level: 9 } });
1307
+ archive.on("error", reject);
1308
+ archive.pipe(writable);
1309
+ archive.glob("**/*", {
1310
+ cwd: dirPath,
1311
+ ignore: [
1312
+ "**/__pycache__/**",
1313
+ "**/*.pyc",
1314
+ "**/.git/**",
1315
+ "**/.env",
1316
+ "**/.env.*",
1317
+ "**/secrets.*",
1318
+ "**/*.key",
1319
+ "**/*.pem"
1320
+ ]
1321
+ });
1322
+ archive.finalize().catch(reject);
1323
+ });
1324
+ }
1325
+
1326
+ // src/api/mock-client.ts
1327
+ var MOCK_PERSONALITIES = [
1328
+ { id: "pers_alice", name: "Alice", description: "Friendly assistant" },
1329
+ { id: "pers_bob", name: "Bob", description: "Technical expert" },
1330
+ { id: "pers_cara", name: "Cara", description: "Creative companion" }
1331
+ ];
1332
+ var MOCK_ABILITIES = [
1333
+ {
1334
+ ability_id: "abl_weather_001",
1335
+ unique_name: "weather-check",
1336
+ display_name: "Weather Check",
1337
+ version: 3,
1338
+ status: "active",
1339
+ personality_ids: ["pers_alice", "pers_bob"],
1340
+ created_at: "2026-01-10T12:00:00Z",
1341
+ updated_at: "2026-03-01T09:30:00Z"
1342
+ },
1343
+ {
1344
+ ability_id: "abl_timer_002",
1345
+ unique_name: "pomodoro-timer",
1346
+ display_name: "Pomodoro Timer",
1347
+ version: 1,
1348
+ status: "processing",
1349
+ personality_ids: ["pers_cara"],
1350
+ created_at: "2026-03-18T08:00:00Z",
1351
+ updated_at: "2026-03-18T08:05:00Z"
1352
+ },
1353
+ {
1354
+ ability_id: "abl_news_003",
1355
+ unique_name: "news-briefing",
1356
+ display_name: "News Briefing",
1357
+ version: 2,
1358
+ status: "failed",
1359
+ personality_ids: [],
1360
+ created_at: "2026-02-20T14:00:00Z",
1361
+ updated_at: "2026-02-21T10:00:00Z"
1362
+ }
1363
+ ];
1364
+ var MockApiClient = class {
1365
+ async getPersonalities() {
1366
+ return Promise.resolve(MOCK_PERSONALITIES);
1367
+ }
1368
+ async uploadAbility(_zipBuffer, _imageBuffer, _imageName, _metadata) {
1369
+ return Promise.resolve({
1370
+ ability_id: `abl_mock_${Date.now()}`,
1371
+ unique_name: "mock-ability",
1372
+ version: 1,
1373
+ status: "processing",
1374
+ validation_errors: [],
1375
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1376
+ message: "[MOCK] Ability uploaded successfully and is being processed."
1377
+ });
1378
+ }
1379
+ async listAbilities() {
1380
+ return Promise.resolve({ abilities: MOCK_ABILITIES });
1381
+ }
1382
+ async verifyApiKey(_apiKey) {
1383
+ return Promise.resolve({
1384
+ valid: true,
1385
+ message: "[MOCK] API key is valid."
1386
+ });
1387
+ }
1388
+ async deleteCapability(id) {
1389
+ return Promise.resolve({
1390
+ message: `[MOCK] Capability ${id} deleted successfully.`
1391
+ });
1392
+ }
1393
+ async toggleCapability(id, enabled) {
1394
+ return Promise.resolve({
1395
+ enabled,
1396
+ message: `[MOCK] Capability ${id} ${enabled ? "enabled" : "disabled"}.`
1397
+ });
1398
+ }
1399
+ async assignCapabilities(personalityId, capabilityIds) {
1400
+ return Promise.resolve({
1401
+ message: `[MOCK] Agent ${personalityId} updated with ${capabilityIds.length} capability(s).`
1402
+ });
1403
+ }
1404
+ async getAbility(id) {
1405
+ const found = MOCK_ABILITIES.find(
1406
+ (a) => a.ability_id === id || a.unique_name === id
1407
+ );
1408
+ const base = found ?? MOCK_ABILITIES[0];
1409
+ return Promise.resolve({
1410
+ ...base,
1411
+ validation_errors: base.status === "failed" ? ["Missing resume_normal_flow() call in main.py"] : [],
1412
+ deploy_history: [
1413
+ {
1414
+ version: base.version,
1415
+ status: base.status === "active" ? "success" : "failed",
1416
+ timestamp: base.updated_at,
1417
+ message: base.status === "active" ? "Deployed successfully" : "Validation failed"
1418
+ },
1419
+ ...base.version > 1 ? [
1420
+ {
1421
+ version: base.version - 1,
1422
+ status: "success",
1423
+ timestamp: base.created_at,
1424
+ message: "Deployed successfully"
1425
+ }
1426
+ ] : []
1427
+ ]
1428
+ });
1429
+ }
1430
+ };
1431
+
1432
+ // src/commands/deploy.ts
1433
+ var IMAGE_EXTENSIONS = ["png", "jpg", "jpeg"];
1434
+ var ICON_NAMES = IMAGE_EXTENSIONS.flatMap((ext) => [
1435
+ `icon.${ext}`,
1436
+ `image.${ext}`,
1437
+ `logo.${ext}`
1438
+ ]);
1439
+ function findIcon(dir) {
1440
+ for (const name of ICON_NAMES) {
1441
+ const p2 = join3(dir, name);
1442
+ if (existsSync3(p2)) return p2;
1443
+ }
1444
+ return null;
1445
+ }
1446
+ async function resolveAbilityDir(pathArg) {
1447
+ if (pathArg && pathArg !== ".") {
1448
+ return resolve2(pathArg);
1449
+ }
1450
+ const tracked = getTrackedAbilities();
1451
+ const cwd = process.cwd();
1452
+ const cwdIsAbility = existsSync3(resolve2(cwd, "config.json"));
1453
+ if (cwdIsAbility) {
1454
+ info(`Detected ability in current directory`);
1455
+ return cwd;
1456
+ }
1457
+ const options = [];
1458
+ for (const a of tracked) {
1459
+ const home = homedir2();
1460
+ options.push({
1461
+ value: a.path,
1462
+ label: a.name,
1463
+ hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path
1464
+ });
1465
+ }
1466
+ if (options.length === 1) {
1467
+ info(`Using ability: ${options[0].label} (${options[0].hint})`);
1468
+ return options[0].value;
1469
+ }
1470
+ if (options.length > 0) {
1471
+ options.push({
1472
+ value: "__custom__",
1473
+ label: "Other...",
1474
+ hint: "Enter a path manually"
1475
+ });
1476
+ const selected = await p.select({
1477
+ message: "Which ability do you want to deploy?",
1478
+ options
1479
+ });
1480
+ handleCancel(selected);
1481
+ if (selected !== "__custom__") {
1482
+ return selected;
1483
+ }
1484
+ }
1485
+ const pathInput = await p.text({
1486
+ message: "Path to ability directory",
1487
+ placeholder: "./my-ability",
1488
+ validate: (val) => {
1489
+ if (!val || !val.trim()) return "Path is required";
1490
+ if (!existsSync3(resolve2(val.trim(), "config.json"))) {
1491
+ return `No config.json found in "${val.trim()}"`;
1492
+ }
1493
+ }
1494
+ });
1495
+ handleCancel(pathInput);
1496
+ return resolve2(pathInput.trim());
1497
+ }
1498
+ async function deployCommand(pathArg, opts = {}) {
1499
+ p.intro("\u{1F680} Deploy ability");
1500
+ const targetDir = await resolveAbilityDir(pathArg);
1501
+ const s = p.spinner();
1502
+ s.start("Validating ability...");
1503
+ const validation = validateAbility(targetDir);
1504
+ if (!validation.passed) {
1505
+ s.stop("Validation failed.");
1506
+ for (const issue of validation.errors) {
1507
+ error(` ${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
1508
+ }
1509
+ process.exit(1);
1510
+ }
1511
+ s.stop("Validation passed.");
1512
+ if (validation.warnings.length > 0) {
1513
+ for (const w of validation.warnings) {
1514
+ warn(` ${w.file ? `[${w.file}] ` : ""}${w.message}`);
1515
+ }
1516
+ }
1517
+ const configPath = join3(targetDir, "config.json");
1518
+ let abilityConfig;
1519
+ try {
1520
+ abilityConfig = JSON.parse(
1521
+ readFileSync2(configPath, "utf8")
1522
+ );
1523
+ } catch {
1524
+ error("Could not read config.json");
1525
+ process.exit(1);
1526
+ }
1527
+ const uniqueName = abilityConfig.unique_name;
1528
+ const hotwords = abilityConfig.matching_hotwords ?? [];
1529
+ let description = abilityConfig.description?.trim();
1530
+ if (!description) {
1531
+ const descInput = await p.text({
1532
+ message: "Ability description (required for marketplace)",
1533
+ placeholder: "A fun ability that does something cool",
1534
+ validate: (val) => {
1535
+ if (!val || !val.trim()) return "Description is required";
1536
+ }
1537
+ });
1538
+ handleCancel(descInput);
1539
+ description = descInput.trim();
1540
+ }
1541
+ let category = abilityConfig.category;
1542
+ if (!category || !["skill", "brain", "daemon"].includes(category)) {
1543
+ const catChoice = await p.select({
1544
+ message: "Ability category",
1545
+ options: [
1546
+ { value: "skill", label: "Skill", hint: "User-triggered" },
1547
+ { value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
1548
+ {
1549
+ value: "daemon",
1550
+ label: "Background Daemon",
1551
+ hint: "Runs continuously"
1552
+ }
1553
+ ]
1554
+ });
1555
+ handleCancel(catChoice);
1556
+ category = catChoice;
1557
+ }
1558
+ let imagePath = findIcon(targetDir);
1559
+ if (imagePath) {
1560
+ info(`Found icon: ${basename2(imagePath)}`);
1561
+ } else {
1562
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
1563
+ const home = homedir2();
1564
+ const scanDirs = [
1565
+ .../* @__PURE__ */ new Set([
1566
+ process.cwd(),
1567
+ targetDir,
1568
+ join3(home, "Desktop"),
1569
+ join3(home, "Downloads"),
1570
+ join3(home, "Pictures"),
1571
+ join3(home, "Images"),
1572
+ join3(home, ".openhome", "icons")
1573
+ ])
1574
+ ];
1575
+ const foundImages = [];
1576
+ for (const dir of scanDirs) {
1577
+ if (!existsSync3(dir)) continue;
1578
+ try {
1579
+ for (const file of readdirSync3(dir)) {
1580
+ if (IMAGE_EXTS.has(extname2(file).toLowerCase())) {
1581
+ const full = join3(dir, file);
1582
+ const shortDir = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
1583
+ foundImages.push({
1584
+ path: full,
1585
+ label: `${file} (${shortDir})`
1586
+ });
1587
+ }
1588
+ }
1589
+ } catch {
1590
+ }
1591
+ }
1592
+ if (foundImages.length > 0) {
1593
+ const imageOptions = [
1594
+ ...foundImages.map((img) => ({ value: img.path, label: img.label })),
1595
+ {
1596
+ value: "__custom__",
1597
+ label: "Other...",
1598
+ hint: "Enter a path manually"
1599
+ },
1600
+ {
1601
+ value: "__skip__",
1602
+ label: "Skip",
1603
+ hint: "Upload without an icon (optional)"
1604
+ }
1605
+ ];
1606
+ const selected = await p.select({
1607
+ message: "Select an icon image (optional)",
1608
+ options: imageOptions
1609
+ });
1610
+ handleCancel(selected);
1611
+ if (selected === "__custom__") {
1612
+ const imgInput = await p.text({
1613
+ message: "Path to icon image",
1614
+ placeholder: "./icon.png",
1615
+ validate: (val) => {
1616
+ if (!val || !val.trim()) return void 0;
1617
+ const resolved = resolve2(val.trim());
1618
+ if (!existsSync3(resolved)) return `File not found: ${val.trim()}`;
1619
+ if (!IMAGE_EXTS.has(extname2(resolved).toLowerCase()))
1620
+ return "Image must be PNG or JPG";
1621
+ }
1622
+ });
1623
+ handleCancel(imgInput);
1624
+ const trimmed = imgInput.trim();
1625
+ if (trimmed) imagePath = resolve2(trimmed);
1626
+ } else if (selected !== "__skip__") {
1627
+ imagePath = selected;
1628
+ }
1629
+ } else {
1630
+ const imgInput = await p.text({
1631
+ message: "Path to ability icon image (PNG or JPG, optional \u2014 press Enter to skip)",
1632
+ placeholder: "./icon.png",
1633
+ validate: (val) => {
1634
+ if (!val || !val.trim()) return void 0;
1635
+ const resolved = resolve2(val.trim());
1636
+ if (!existsSync3(resolved)) return `File not found: ${val.trim()}`;
1637
+ if (!IMAGE_EXTS.has(extname2(resolved).toLowerCase()))
1638
+ return "Image must be PNG or JPG";
1639
+ }
1640
+ });
1641
+ handleCancel(imgInput);
1642
+ const trimmed = imgInput.trim();
1643
+ if (trimmed) imagePath = resolve2(trimmed);
1644
+ }
1645
+ }
1646
+ const imageBuffer = imagePath ? readFileSync2(imagePath) : null;
1647
+ const imageName = imagePath ? basename2(imagePath) : null;
1648
+ const personalityId = opts.personality ?? getConfig().default_personality_id;
1649
+ const metadata = {
1650
+ name: uniqueName,
1651
+ description,
1652
+ category,
1653
+ matching_hotwords: hotwords,
1654
+ personality_id: personalityId
1655
+ };
1656
+ if (opts.dryRun) {
1657
+ p.note(
1658
+ [
1659
+ `Directory: ${targetDir}`,
1660
+ `Name: ${uniqueName}`,
1661
+ `Description: ${description}`,
1662
+ `Category: ${category}`,
1663
+ `Image: ${imageName ?? "(none)"}`,
1664
+ `Hotwords: ${hotwords.join(", ")}`,
1665
+ `Agent: ${personalityId ?? "(none set)"}`
1666
+ ].join("\n"),
1667
+ "Dry Run \u2014 would deploy"
1668
+ );
1669
+ p.outro("No changes made.");
1670
+ return;
1671
+ }
1672
+ s.start("Creating ability zip...");
1673
+ let zipBuffer;
1674
+ try {
1675
+ zipBuffer = await createAbilityZip(targetDir);
1676
+ s.stop(`Zip created (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
1677
+ } catch (err) {
1678
+ s.stop("Failed to create zip.");
1679
+ error(err instanceof Error ? err.message : String(err));
1680
+ process.exit(1);
1681
+ }
1682
+ if (opts.mock) {
1683
+ s.start("Uploading ability (mock)...");
1684
+ const mockClient = new MockApiClient();
1685
+ const result = await mockClient.uploadAbility(
1686
+ zipBuffer,
1687
+ imageBuffer,
1688
+ imageName,
1689
+ metadata
1690
+ );
1691
+ s.stop("Upload complete.");
1692
+ p.note(
1693
+ [
1694
+ `Ability ID: ${result.ability_id}`,
1695
+ `Status: ${result.status}`,
1696
+ `Message: ${result.message}`
1697
+ ].join("\n"),
1698
+ "Mock Deploy Result"
1699
+ );
1700
+ p.outro("Mock deploy complete.");
1701
+ return;
1702
+ }
1703
+ const apiKey = getApiKey();
1704
+ if (!apiKey) {
1705
+ error("Not authenticated. Run: openhome login");
1706
+ process.exit(1);
1707
+ }
1708
+ const confirmed = await p.confirm({
1709
+ message: `Deploy "${uniqueName}" to OpenHome?`
1710
+ });
1711
+ handleCancel(confirmed);
1712
+ if (!confirmed) {
1713
+ p.cancel("Aborted.");
1714
+ process.exit(0);
1715
+ }
1716
+ s.start("Uploading ability...");
1717
+ try {
1718
+ const client = new ApiClient(apiKey, getConfig().api_base_url);
1719
+ const result = await client.uploadAbility(
1720
+ zipBuffer,
1721
+ imageBuffer,
1722
+ imageName,
1723
+ metadata
1724
+ );
1725
+ s.stop("Upload complete.");
1726
+ p.note(
1727
+ [
1728
+ `Ability ID: ${result.ability_id}`,
1729
+ `Version: ${result.version}`,
1730
+ `Status: ${result.status}`,
1731
+ result.message ? `Message: ${result.message}` : ""
1732
+ ].filter(Boolean).join("\n"),
1733
+ "Deploy Result"
1734
+ );
1735
+ p.outro("Deployed successfully! \u{1F389}");
1736
+ } catch (err) {
1737
+ s.stop("Upload failed.");
1738
+ if (err instanceof NotImplementedError) {
1739
+ warn("This API endpoint is not yet available on the OpenHome server.");
1740
+ const outDir = join3(homedir2(), ".openhome");
1741
+ mkdirSync2(outDir, { recursive: true });
1742
+ const outPath = join3(outDir, "last-deploy.zip");
1743
+ writeFileSync2(outPath, zipBuffer);
1744
+ p.note(
1745
+ [
1746
+ `Your ability was validated and zipped successfully.`,
1747
+ `Zip saved to: ${outPath}`,
1748
+ ``,
1749
+ `Upload manually at https://app.openhome.com`
1750
+ ].join("\n"),
1751
+ "API Not Available Yet"
1752
+ );
1753
+ p.outro("Zip ready for manual upload.");
1754
+ return;
1755
+ }
1756
+ const msg = err instanceof Error ? err.message : String(err);
1757
+ if (msg.toLowerCase().includes("same name")) {
1758
+ error(`An ability named "${uniqueName}" already exists.`);
1759
+ warn(
1760
+ `To update it, delete it first with: openhome delete
1761
+ Or rename it in config.json and redeploy.`
1762
+ );
1763
+ } else {
1764
+ error(`Deploy failed: ${msg}`);
1765
+ }
1766
+ process.exit(1);
1767
+ }
1768
+ }
1769
+
1770
+ // src/commands/delete.ts
1771
+ import chalk3 from "chalk";
1772
+ async function deleteCommand(abilityArg, opts = {}) {
1773
+ p.intro("\u{1F5D1}\uFE0F Delete ability");
1774
+ let client;
1775
+ if (opts.mock) {
1776
+ client = new MockApiClient();
1777
+ } else {
1778
+ const apiKey = getApiKey();
1779
+ if (!apiKey) {
1780
+ error("Not authenticated. Run: openhome login");
1781
+ process.exit(1);
1782
+ }
1783
+ client = new ApiClient(apiKey, getConfig().api_base_url);
1784
+ }
1785
+ const s = p.spinner();
1786
+ s.start("Fetching abilities...");
1787
+ let abilities;
1788
+ try {
1789
+ const result = await client.listAbilities();
1790
+ abilities = result.abilities;
1791
+ s.stop(`Found ${abilities.length} ability(s).`);
1792
+ } catch (err) {
1793
+ s.stop("Failed to fetch abilities.");
1794
+ error(err instanceof Error ? err.message : String(err));
1795
+ process.exit(1);
1796
+ }
1797
+ if (abilities.length === 0) {
1798
+ p.outro("No abilities to delete.");
1799
+ return;
1800
+ }
1801
+ let targetId;
1802
+ let targetName;
1803
+ if (abilityArg) {
1804
+ const match = abilities.find(
1805
+ (a) => a.unique_name === abilityArg || a.display_name === abilityArg || a.ability_id === abilityArg
1806
+ );
1807
+ if (!match) {
1808
+ error(`No ability found matching "${abilityArg}".`);
1809
+ process.exit(1);
1810
+ }
1811
+ targetId = match.ability_id;
1812
+ targetName = match.unique_name;
1813
+ } else {
1814
+ const selected = await p.select({
1815
+ message: "Which ability do you want to delete?",
1816
+ options: abilities.map((a) => ({
1817
+ value: a.ability_id,
1818
+ label: a.unique_name,
1819
+ hint: `${chalk3.gray(a.status)} v${a.version}`
1820
+ }))
1821
+ });
1822
+ handleCancel(selected);
1823
+ targetId = selected;
1824
+ targetName = abilities.find((a) => a.ability_id === targetId)?.unique_name ?? targetId;
1825
+ }
1826
+ const confirmed = await p.confirm({
1827
+ message: `Delete "${targetName}"? This cannot be undone.`,
1828
+ initialValue: false
1829
+ });
1830
+ handleCancel(confirmed);
1831
+ if (!confirmed) {
1832
+ p.cancel("Aborted.");
1833
+ return;
1834
+ }
1835
+ s.start(`Deleting "${targetName}"...`);
1836
+ try {
1837
+ const result = await client.deleteCapability(targetId);
1838
+ s.stop("Deleted.");
1839
+ success(result.message ?? `"${targetName}" deleted successfully.`);
1840
+ p.outro("Done.");
1841
+ } catch (err) {
1842
+ s.stop("Delete failed.");
1843
+ if (err instanceof NotImplementedError) {
1844
+ p.note("API Not Available Yet", "Delete endpoint not yet implemented.");
1845
+ return;
1846
+ }
1847
+ error(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
1848
+ process.exit(1);
1849
+ }
1850
+ }
1851
+
1852
+ // src/commands/toggle.ts
1853
+ import chalk4 from "chalk";
1854
+ async function toggleCommand(abilityArg, opts = {}) {
1855
+ p.intro("\u26A1 Enable / Disable ability");
1856
+ let client;
1857
+ if (opts.mock) {
1858
+ client = new MockApiClient();
1859
+ } else {
1860
+ const apiKey = getApiKey();
1861
+ if (!apiKey) {
1862
+ error("Not authenticated. Run: openhome login");
1863
+ process.exit(1);
1864
+ }
1865
+ client = new ApiClient(apiKey, getConfig().api_base_url);
1866
+ }
1867
+ const s = p.spinner();
1868
+ s.start("Fetching abilities...");
1869
+ let abilities;
1870
+ try {
1871
+ const result = await client.listAbilities();
1872
+ abilities = result.abilities;
1873
+ s.stop(`Found ${abilities.length} ability(s).`);
1874
+ } catch (err) {
1875
+ s.stop("Failed to fetch abilities.");
1876
+ error(err instanceof Error ? err.message : String(err));
1877
+ process.exit(1);
1878
+ }
1879
+ if (abilities.length === 0) {
1880
+ p.outro("No abilities found.");
1881
+ return;
1882
+ }
1883
+ let targetId;
1884
+ let targetName;
1885
+ if (abilityArg) {
1886
+ const match = abilities.find(
1887
+ (a) => a.unique_name === abilityArg || a.display_name === abilityArg || a.ability_id === abilityArg
1888
+ );
1889
+ if (!match) {
1890
+ error(`No ability found matching "${abilityArg}".`);
1891
+ process.exit(1);
1892
+ }
1893
+ targetId = match.ability_id;
1894
+ targetName = match.unique_name;
1895
+ } else {
1896
+ const selected = await p.select({
1897
+ message: "Which ability do you want to toggle?",
1898
+ options: abilities.map((a) => ({
1899
+ value: a.ability_id,
1900
+ label: a.unique_name,
1901
+ hint: `${a.status === "disabled" ? chalk4.gray("disabled") : chalk4.green("enabled")} v${a.version}`
1902
+ }))
1903
+ });
1904
+ handleCancel(selected);
1905
+ targetId = selected;
1906
+ targetName = abilities.find((a) => a.ability_id === targetId)?.unique_name ?? targetId;
1907
+ }
1908
+ let enabled;
1909
+ if (opts.enable) {
1910
+ enabled = true;
1911
+ } else if (opts.disable) {
1912
+ enabled = false;
1913
+ } else {
1914
+ const current = abilities.find((a) => a.ability_id === targetId);
1915
+ const action = await p.select({
1916
+ message: `"${targetName}" is currently ${current?.status ?? "unknown"}. What do you want to do?`,
1917
+ options: [
1918
+ { value: "enable", label: "Enable" },
1919
+ { value: "disable", label: "Disable" }
1920
+ ]
1921
+ });
1922
+ handleCancel(action);
1923
+ enabled = action === "enable";
1924
+ }
1925
+ s.start(`${enabled ? "Enabling" : "Disabling"} "${targetName}"...`);
1926
+ try {
1927
+ const result = await client.toggleCapability(targetId, enabled);
1928
+ s.stop("Done.");
1929
+ success(
1930
+ result.message ?? `"${targetName}" ${enabled ? "enabled" : "disabled"} successfully.`
1931
+ );
1932
+ p.outro("Done.");
1933
+ } catch (err) {
1934
+ s.stop("Failed.");
1935
+ if (err instanceof NotImplementedError) {
1936
+ p.note("Toggle endpoint not yet implemented.", "API Not Available Yet");
1937
+ return;
1938
+ }
1939
+ error(`Toggle failed: ${err instanceof Error ? err.message : String(err)}`);
1940
+ process.exit(1);
1941
+ }
1942
+ }
1943
+
1944
+ // src/commands/assign.ts
1945
+ import chalk5 from "chalk";
1946
+ async function assignCommand(opts = {}) {
1947
+ p.intro("\u{1F517} Assign abilities to agent");
1948
+ let client;
1949
+ if (opts.mock) {
1950
+ client = new MockApiClient();
1951
+ } else {
1952
+ const apiKey = getApiKey();
1953
+ if (!apiKey) {
1954
+ error("Not authenticated. Run: openhome login");
1955
+ process.exit(1);
1956
+ }
1957
+ client = new ApiClient(apiKey, getConfig().api_base_url);
1958
+ }
1959
+ const s = p.spinner();
1960
+ s.start("Fetching agents and abilities...");
1961
+ let personalities;
1962
+ let abilities;
1963
+ try {
1964
+ [personalities, { abilities }] = await Promise.all([
1965
+ client.getPersonalities(),
1966
+ client.listAbilities()
1967
+ ]);
1968
+ s.stop(
1969
+ `Found ${personalities.length} agent(s), ${abilities.length} ability(s).`
1970
+ );
1971
+ } catch (err) {
1972
+ s.stop("Failed to fetch data.");
1973
+ error(err instanceof Error ? err.message : String(err));
1974
+ process.exit(1);
1975
+ }
1976
+ if (personalities.length === 0) {
1977
+ p.outro("No agents found. Create one at https://app.openhome.com");
1978
+ return;
1979
+ }
1980
+ if (abilities.length === 0) {
1981
+ p.outro("No abilities found. Run: openhome deploy");
1982
+ return;
1983
+ }
1984
+ const agentId = await p.select({
1985
+ message: "Which agent do you want to update?",
1986
+ options: personalities.map((pers) => ({
1987
+ value: pers.id,
1988
+ label: pers.name,
1989
+ hint: chalk5.gray(pers.id)
1990
+ }))
1991
+ });
1992
+ handleCancel(agentId);
1993
+ const agentName = personalities.find((p2) => p2.id === agentId)?.name ?? String(agentId);
1994
+ info(
1995
+ `Select abilities to assign to "${agentName}". Deselecting all unassigns everything.`
1996
+ );
1997
+ const selectedIds = await p.multiselect({
1998
+ message: `Abilities for "${agentName}"`,
1999
+ options: abilities.map((a) => ({
2000
+ value: a.ability_id,
2001
+ label: a.unique_name,
2002
+ hint: `${a.status} v${a.version}`
2003
+ })),
2004
+ required: false
2005
+ });
2006
+ handleCancel(selectedIds);
2007
+ const chosenIds = selectedIds;
2008
+ const numericIds = chosenIds.map((id) => Number(id)).filter((id) => !Number.isNaN(id));
2009
+ const capabilityIds = numericIds.length === chosenIds.length ? numericIds : chosenIds;
2010
+ s.start(`Assigning ${chosenIds.length} ability(s) to "${agentName}"...`);
2011
+ try {
2012
+ const result = await client.assignCapabilities(
2013
+ agentId,
2014
+ capabilityIds
2015
+ );
2016
+ s.stop("Done.");
2017
+ success(
2018
+ result.message ?? `"${agentName}" updated with ${chosenIds.length} ability(s).`
2019
+ );
2020
+ p.outro("Done.");
2021
+ } catch (err) {
2022
+ s.stop("Failed.");
2023
+ if (err instanceof NotImplementedError) {
2024
+ p.note("Assign endpoint not yet implemented.", "API Not Available Yet");
2025
+ return;
2026
+ }
2027
+ error(`Assign failed: ${err instanceof Error ? err.message : String(err)}`);
2028
+ process.exit(1);
2029
+ }
2030
+ }
2031
+
2032
+ // src/commands/list.ts
2033
+ import chalk6 from "chalk";
2034
+ function statusColor(status) {
2035
+ switch (status) {
2036
+ case "active":
2037
+ return chalk6.green(status);
2038
+ case "processing":
2039
+ return chalk6.yellow(status);
2040
+ case "failed":
2041
+ return chalk6.red(status);
2042
+ case "disabled":
2043
+ return chalk6.gray(status);
2044
+ default:
2045
+ return status;
2046
+ }
2047
+ }
2048
+ async function listCommand(opts = {}) {
2049
+ p.intro("\u{1F4CB} Your abilities");
2050
+ let client;
2051
+ if (opts.mock) {
2052
+ client = new MockApiClient();
2053
+ } else {
2054
+ const apiKey = getApiKey();
2055
+ if (!apiKey) {
2056
+ error("Not authenticated. Run: openhome login");
2057
+ process.exit(1);
2058
+ }
2059
+ client = new ApiClient(apiKey, getConfig().api_base_url);
2060
+ }
2061
+ const s = p.spinner();
2062
+ s.start("Fetching abilities...");
2063
+ try {
2064
+ const { abilities } = await client.listAbilities();
2065
+ s.stop(`Found ${abilities.length} ability(s).`);
2066
+ if (abilities.length === 0) {
2067
+ info("No abilities found. Run: openhome init");
2068
+ p.outro("Deploy your first ability with: openhome deploy");
2069
+ return;
2070
+ }
2071
+ const rows = abilities.map((a) => ({
2072
+ Name: a.unique_name,
2073
+ Display: a.display_name,
2074
+ Version: a.version,
2075
+ Status: statusColor(a.status),
2076
+ Updated: new Date(a.updated_at).toLocaleDateString()
2077
+ }));
2078
+ console.log("");
2079
+ table(rows);
2080
+ p.outro(`${abilities.length} ability(s) total.`);
2081
+ } catch (err) {
2082
+ s.stop("Failed.");
2083
+ if (err instanceof NotImplementedError) {
2084
+ p.note("Use --mock to see example output.", "API Not Available Yet");
2085
+ p.outro("List endpoint not yet implemented.");
2086
+ return;
2087
+ }
2088
+ error(
2089
+ `Failed to list abilities: ${err instanceof Error ? err.message : String(err)}`
2090
+ );
2091
+ process.exit(1);
2092
+ }
2093
+ }
2094
+
2095
+ // src/commands/status.ts
2096
+ import { join as join4, resolve as resolve3 } from "path";
2097
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
2098
+ import { homedir as homedir3 } from "os";
2099
+ import chalk7 from "chalk";
2100
+ function statusBadge(status) {
2101
+ switch (status) {
2102
+ case "active":
2103
+ return chalk7.bgGreen.black(` ${status.toUpperCase()} `);
2104
+ case "processing":
2105
+ return chalk7.bgYellow.black(` ${status.toUpperCase()} `);
2106
+ case "failed":
2107
+ return chalk7.bgRed.white(` ${status.toUpperCase()} `);
2108
+ case "disabled":
2109
+ return chalk7.bgGray.white(` ${status.toUpperCase()} `);
2110
+ default:
2111
+ return chalk7.bgWhite.black(` ${status.toUpperCase()} `);
2112
+ }
2113
+ }
2114
+ function readAbilityName(dir) {
2115
+ const configPath = join4(dir, "config.json");
2116
+ if (!existsSync4(configPath)) return null;
2117
+ try {
2118
+ const cfg = JSON.parse(readFileSync3(configPath, "utf8"));
2119
+ return cfg.unique_name ?? null;
2120
+ } catch {
2121
+ return null;
2122
+ }
2123
+ }
2124
+ async function resolveAbilityName() {
2125
+ const cwdName = readAbilityName(resolve3("."));
2126
+ if (cwdName) {
2127
+ info(`Detected ability: ${cwdName}`);
2128
+ return cwdName;
2129
+ }
2130
+ const tracked = getTrackedAbilities();
2131
+ const options = [];
2132
+ const home = homedir3();
2133
+ for (const a of tracked) {
2134
+ const name = readAbilityName(a.path);
2135
+ if (name) {
2136
+ options.push({
2137
+ value: name,
2138
+ label: a.name,
2139
+ hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path
2140
+ });
2141
+ }
2142
+ }
2143
+ if (options.length === 1) {
2144
+ info(`Using ability: ${options[0].label}`);
2145
+ return options[0].value;
2146
+ }
2147
+ if (options.length > 0) {
2148
+ const selected = await p.select({
2149
+ message: "Which ability do you want to check?",
2150
+ options
2151
+ });
2152
+ handleCancel(selected);
2153
+ return selected;
2154
+ }
2155
+ return void 0;
2156
+ }
2157
+ async function statusCommand(abilityArg, opts = {}) {
2158
+ let abilityId = abilityArg;
2159
+ if (!abilityId) {
2160
+ abilityId = await resolveAbilityName();
2161
+ }
2162
+ if (!abilityId) {
2163
+ error(
2164
+ "No ability found. Pass a name, run from an ability directory, or create one with: openhome init"
2165
+ );
2166
+ process.exit(1);
2167
+ }
2168
+ p.intro(`\u{1F50D} Status: ${abilityId}`);
2169
+ let client;
2170
+ if (opts.mock) {
2171
+ client = new MockApiClient();
2172
+ } else {
2173
+ const apiKey = getApiKey();
2174
+ if (!apiKey) {
2175
+ error("Not authenticated. Run: openhome login");
2176
+ process.exit(1);
2177
+ }
2178
+ client = new ApiClient(apiKey, getConfig().api_base_url);
2179
+ }
2180
+ const s = p.spinner();
2181
+ s.start("Fetching status...");
2182
+ try {
2183
+ const ability = await client.getAbility(abilityId);
2184
+ s.stop("Status loaded.");
2185
+ p.note(
2186
+ [
2187
+ `Name: ${ability.unique_name}`,
2188
+ `Display: ${ability.display_name}`,
2189
+ `Status: ${statusBadge(ability.status)}`,
2190
+ `Version: v${ability.version}`,
2191
+ `Updated: ${new Date(ability.updated_at).toLocaleString()}`,
2192
+ `Created: ${new Date(ability.created_at).toLocaleString()}`,
2193
+ ability.personality_ids.length > 0 ? `Linked to: ${ability.personality_ids.join(", ")}` : null
2194
+ ].filter(Boolean).join("\n"),
2195
+ "Ability Details"
2196
+ );
2197
+ if (ability.validation_errors.length > 0) {
2198
+ p.note(
2199
+ ability.validation_errors.map((e) => chalk7.red(`\u2717 ${e}`)).join("\n"),
2200
+ "Validation Errors"
2201
+ );
2202
+ }
2203
+ if (ability.deploy_history.length > 0) {
2204
+ const historyLines = ability.deploy_history.map((event) => {
2205
+ const icon = event.status === "success" ? chalk7.green("\u2713") : chalk7.red("\u2717");
2206
+ return `${icon} v${event.version} ${event.message} ${chalk7.gray(new Date(event.timestamp).toLocaleString())}`;
2207
+ });
2208
+ p.note(historyLines.join("\n"), "Deploy History");
2209
+ }
2210
+ p.outro("Done.");
2211
+ } catch (err) {
2212
+ s.stop("Failed.");
2213
+ if (err instanceof NotImplementedError) {
2214
+ p.note("Use --mock to see example output.", "API Not Available Yet");
2215
+ p.outro("Status endpoint not yet implemented.");
2216
+ return;
2217
+ }
2218
+ error(
2219
+ `Failed to get status: ${err instanceof Error ? err.message : String(err)}`
2220
+ );
2221
+ process.exit(1);
2222
+ }
2223
+ }
2224
+
2225
+ // src/commands/agents.ts
2226
+ import chalk8 from "chalk";
2227
+ async function agentsCommand(opts = {}) {
2228
+ p.intro("\u{1F916} Your Agents");
2229
+ let client;
2230
+ if (opts.mock) {
2231
+ client = new MockApiClient();
2232
+ } else {
2233
+ const apiKey = getApiKey();
2234
+ if (!apiKey) {
2235
+ error("Not authenticated. Run: openhome login");
2236
+ process.exit(1);
2237
+ }
2238
+ client = new ApiClient(apiKey, getConfig().api_base_url);
2239
+ }
2240
+ const s = p.spinner();
2241
+ s.start("Fetching agents...");
2242
+ try {
2243
+ const personalities = await client.getPersonalities();
2244
+ s.stop(`Found ${personalities.length} agent(s).`);
2245
+ if (personalities.length === 0) {
2246
+ info("No agents found. Create one at https://app.openhome.com");
2247
+ p.outro("Done.");
2248
+ return;
2249
+ }
2250
+ p.note(
2251
+ personalities.map((pers) => `${chalk8.bold(pers.name)} ${chalk8.gray(pers.id)}`).join("\n"),
2252
+ "Agents"
2253
+ );
2254
+ const config = getConfig();
2255
+ const currentDefault = config.default_personality_id;
2256
+ if (currentDefault) {
2257
+ const match = personalities.find((p2) => p2.id === currentDefault);
2258
+ info(`Default agent: ${match ? match.name : currentDefault}`);
2259
+ }
2260
+ const setDefault = await p.confirm({
2261
+ message: "Set or change your default agent?"
2262
+ });
2263
+ handleCancel(setDefault);
2264
+ if (setDefault) {
2265
+ const selected = await p.select({
2266
+ message: "Choose default agent",
2267
+ options: personalities.map((pers) => ({
2268
+ value: pers.id,
2269
+ label: pers.name,
2270
+ hint: pers.id
2271
+ }))
2272
+ });
2273
+ handleCancel(selected);
2274
+ config.default_personality_id = selected;
2275
+ saveConfig(config);
2276
+ success(`Default agent set: ${String(selected)}`);
2277
+ }
2278
+ p.outro("Done.");
2279
+ } catch (err) {
2280
+ s.stop("Failed.");
2281
+ if (err instanceof NotImplementedError) {
2282
+ p.note("Use --mock to see example output.", "API Not Available Yet");
2283
+ p.outro("Agents endpoint not yet implemented.");
2284
+ return;
2285
+ }
2286
+ error(
2287
+ `Failed to fetch agents: ${err instanceof Error ? err.message : String(err)}`
2288
+ );
2289
+ process.exit(1);
2290
+ }
2291
+ }
2292
+
2293
+ // src/commands/logout.ts
2294
+ async function logoutCommand() {
2295
+ keychainDelete();
2296
+ const config = getConfig();
2297
+ delete config.api_key;
2298
+ delete config.default_personality_id;
2299
+ saveConfig(config);
2300
+ success("Logged out. API key and default agent cleared.");
2301
+ }
2302
+
2303
+ // src/commands/chat.ts
2304
+ import WebSocket from "ws";
2305
+ import chalk9 from "chalk";
2306
+ import * as readline from "readline";
2307
+ var PING_INTERVAL = 3e4;
2308
+ async function chatCommand(agentArg, opts = {}) {
2309
+ p.intro("\u{1F4AC} Chat with your agent");
2310
+ const apiKey = getApiKey();
2311
+ if (!apiKey) {
2312
+ error("Not authenticated. Run: openhome login");
2313
+ process.exit(1);
2314
+ }
2315
+ let agentId = agentArg ?? getConfig().default_personality_id;
2316
+ if (!agentId) {
2317
+ const s = p.spinner();
2318
+ s.start("Fetching agents...");
2319
+ try {
2320
+ const client = new ApiClient(apiKey);
2321
+ const agents = await client.getPersonalities();
2322
+ s.stop(`Found ${agents.length} agent(s).`);
2323
+ if (agents.length === 0) {
2324
+ error("No agents found. Create one at https://app.openhome.com");
2325
+ process.exit(1);
2326
+ }
2327
+ const selected = await p.select({
2328
+ message: "Which agent do you want to chat with?",
2329
+ options: agents.map((a) => ({
2330
+ value: a.id,
2331
+ label: a.name,
2332
+ hint: a.id
2333
+ }))
2334
+ });
2335
+ handleCancel(selected);
2336
+ agentId = selected;
2337
+ } catch (err) {
2338
+ s.stop("Failed.");
2339
+ error(
2340
+ `Could not fetch agents: ${err instanceof Error ? err.message : String(err)}`
2341
+ );
2342
+ process.exit(1);
2343
+ }
2344
+ }
2345
+ const wsUrl = `${WS_BASE}${ENDPOINTS.voiceStream(apiKey, agentId)}`;
2346
+ info(`Connecting to agent ${chalk9.bold(agentId)}...`);
2347
+ await new Promise((resolve5) => {
2348
+ const ws = new WebSocket(wsUrl, {
2349
+ perMessageDeflate: false,
2350
+ headers: {
2351
+ Origin: "https://app.openhome.com",
2352
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
2353
+ }
2354
+ });
2355
+ let connected = false;
2356
+ let currentResponse = "";
2357
+ const rl = readline.createInterface({
2358
+ input: process.stdin,
2359
+ output: process.stdout
2360
+ });
2361
+ function promptUser() {
2362
+ rl.question(chalk9.green("You: "), (input) => {
2363
+ const trimmed = input.trim();
2364
+ if (!trimmed) {
2365
+ promptUser();
2366
+ return;
2367
+ }
2368
+ if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
2369
+ info("Closing connection...");
2370
+ ws.close(1e3);
2371
+ rl.close();
2372
+ return;
2373
+ }
2374
+ if (!connected) {
2375
+ error("Not connected yet. Please wait...");
2376
+ promptUser();
2377
+ return;
2378
+ }
2379
+ ws.send(
2380
+ JSON.stringify({
2381
+ type: "transcribed",
2382
+ data: trimmed
2383
+ })
2384
+ );
2385
+ promptUser();
2386
+ });
2387
+ }
2388
+ let pingInterval = null;
2389
+ ws.on("open", () => {
2390
+ connected = true;
2391
+ pingInterval = setInterval(() => {
2392
+ if (ws.readyState === WebSocket.OPEN) {
2393
+ ws.send(JSON.stringify({ type: "ping" }));
2394
+ }
2395
+ }, PING_INTERVAL);
2396
+ success("Connected! Type a message and press Enter. Type /quit to exit.");
2397
+ console.log(
2398
+ chalk9.gray(
2399
+ " Tip: Send trigger words to activate abilities (e.g. 'play aquaprime')"
2400
+ )
2401
+ );
2402
+ console.log("");
2403
+ promptUser();
2404
+ });
2405
+ ws.on("message", (raw) => {
2406
+ try {
2407
+ const msg = JSON.parse(raw.toString());
2408
+ switch (msg.type) {
2409
+ case "message": {
2410
+ const data = msg.data;
2411
+ if (data.content && data.role === "assistant") {
2412
+ if (data.live && !data.final) {
2413
+ const prefix = `${chalk9.cyan("Agent:")} `;
2414
+ readline.clearLine(process.stdout, 0);
2415
+ readline.cursorTo(process.stdout, 0);
2416
+ process.stdout.write(`${prefix}${data.content}`);
2417
+ currentResponse = data.content;
2418
+ } else {
2419
+ if (currentResponse !== "") {
2420
+ console.log("");
2421
+ } else {
2422
+ console.log(`${chalk9.cyan("Agent:")} ${data.content}`);
2423
+ }
2424
+ currentResponse = "";
2425
+ console.log("");
2426
+ }
2427
+ }
2428
+ break;
2429
+ }
2430
+ case "text": {
2431
+ const textData = msg.data;
2432
+ if (textData === "audio-init") {
2433
+ ws.send(JSON.stringify({ type: "text", data: "bot-speaking" }));
2434
+ } else if (textData === "audio-end") {
2435
+ ws.send(JSON.stringify({ type: "text", data: "bot-speak-end" }));
2436
+ if (currentResponse === "") {
2437
+ console.log(
2438
+ chalk9.gray(" (Agent sent audio \u2014 text-only mode)")
2439
+ );
2440
+ console.log("");
2441
+ }
2442
+ }
2443
+ break;
2444
+ }
2445
+ case "audio":
2446
+ ws.send(JSON.stringify({ type: "ack", data: "audio-received" }));
2447
+ break;
2448
+ case "error-event": {
2449
+ const errData = msg.data;
2450
+ const errMsg = errData?.message || errData?.title || "Unknown error";
2451
+ error(`Server error: ${errMsg}`);
2452
+ break;
2453
+ }
2454
+ case "interrupt":
2455
+ if (currentResponse !== "") {
2456
+ console.log("");
2457
+ currentResponse = "";
2458
+ }
2459
+ break;
2460
+ case "action":
2461
+ case "log":
2462
+ case "question":
2463
+ case "progress":
2464
+ break;
2465
+ default:
2466
+ break;
2467
+ }
2468
+ } catch {
2469
+ }
2470
+ });
2471
+ ws.on("error", (err) => {
2472
+ console.error("");
2473
+ error(`WebSocket error: ${err.message}`);
2474
+ rl.close();
2475
+ resolve5();
2476
+ });
2477
+ ws.on("close", (code) => {
2478
+ if (pingInterval) clearInterval(pingInterval);
2479
+ console.log("");
2480
+ if (code === 1e3) {
2481
+ info("Disconnected.");
2482
+ } else {
2483
+ info(`Connection closed (code: ${code})`);
2484
+ }
2485
+ rl.close();
2486
+ resolve5();
2487
+ });
2488
+ rl.on("close", () => {
2489
+ if (connected) {
2490
+ ws.close(1e3);
2491
+ }
2492
+ });
2493
+ });
2494
+ }
2495
+
2496
+ // src/commands/trigger.ts
2497
+ import WebSocket2 from "ws";
2498
+ import chalk10 from "chalk";
2499
+ var PING_INTERVAL2 = 3e4;
2500
+ var RESPONSE_TIMEOUT = 3e4;
2501
+ async function triggerCommand(phraseArg, opts = {}) {
2502
+ p.intro("\u26A1 Trigger an ability");
2503
+ const apiKey = getApiKey();
2504
+ if (!apiKey) {
2505
+ error("Not authenticated. Run: openhome login");
2506
+ process.exit(1);
2507
+ }
2508
+ let phrase = phraseArg;
2509
+ if (!phrase) {
2510
+ const input = await p.text({
2511
+ message: "Trigger phrase (e.g. 'play aquaprime')",
2512
+ validate: (val) => {
2513
+ if (!val || !val.trim()) return "A trigger phrase is required";
2514
+ }
2515
+ });
2516
+ handleCancel(input);
2517
+ phrase = input.trim();
2518
+ }
2519
+ let agentId = opts.agent ?? getConfig().default_personality_id;
2520
+ if (!agentId) {
2521
+ const s2 = p.spinner();
2522
+ s2.start("Fetching agents...");
2523
+ try {
2524
+ const client = new ApiClient(apiKey);
2525
+ const agents = await client.getPersonalities();
2526
+ s2.stop(`Found ${agents.length} agent(s).`);
2527
+ if (agents.length === 0) {
2528
+ error("No agents found.");
2529
+ process.exit(1);
2530
+ }
2531
+ const selected = await p.select({
2532
+ message: "Which agent?",
2533
+ options: agents.map((a) => ({
2534
+ value: a.id,
2535
+ label: a.name,
2536
+ hint: a.id
2537
+ }))
2538
+ });
2539
+ handleCancel(selected);
2540
+ agentId = selected;
2541
+ } catch (err) {
2542
+ s2.stop("Failed.");
2543
+ error(
2544
+ `Could not fetch agents: ${err instanceof Error ? err.message : String(err)}`
2545
+ );
2546
+ process.exit(1);
2547
+ }
2548
+ }
2549
+ const wsUrl = `${WS_BASE}${ENDPOINTS.voiceStream(apiKey, agentId)}`;
2550
+ info(`Sending "${chalk10.bold(phrase)}" to agent ${chalk10.bold(agentId)}...`);
2551
+ const s = p.spinner();
2552
+ s.start("Waiting for response...");
2553
+ await new Promise((resolve5) => {
2554
+ const ws = new WebSocket2(wsUrl, {
2555
+ perMessageDeflate: false,
2556
+ headers: {
2557
+ Origin: "https://app.openhome.com",
2558
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
2559
+ }
2560
+ });
2561
+ let fullResponse = "";
2562
+ let responseTimer = null;
2563
+ let pingInterval = null;
2564
+ function cleanup() {
2565
+ if (pingInterval) clearInterval(pingInterval);
2566
+ if (responseTimer) clearTimeout(responseTimer);
2567
+ if (ws.readyState === WebSocket2.OPEN) ws.close(1e3);
2568
+ }
2569
+ ws.on("open", () => {
2570
+ ws.send(JSON.stringify({ type: "transcribed", data: phrase }));
2571
+ pingInterval = setInterval(() => {
2572
+ if (ws.readyState === WebSocket2.OPEN) {
2573
+ ws.send(JSON.stringify({ type: "ping" }));
2574
+ }
2575
+ }, PING_INTERVAL2);
2576
+ responseTimer = setTimeout(() => {
2577
+ s.stop("Timed out waiting for response.");
2578
+ if (fullResponse) {
2579
+ console.log(`
2580
+ ${chalk10.cyan("Agent:")} ${fullResponse}`);
2581
+ }
2582
+ cleanup();
2583
+ resolve5();
2584
+ }, RESPONSE_TIMEOUT);
2585
+ });
2586
+ ws.on("message", (raw) => {
2587
+ try {
2588
+ const msg = JSON.parse(raw.toString());
2589
+ switch (msg.type) {
2590
+ case "message": {
2591
+ const data = msg.data;
2592
+ if (data.content && data.role === "assistant") {
2593
+ fullResponse += data.content;
2594
+ if (!data.live || data.final) {
2595
+ s.stop("Response received.");
2596
+ console.log(`
2597
+ ${chalk10.cyan("Agent:")} ${fullResponse}
2598
+ `);
2599
+ cleanup();
2600
+ resolve5();
2601
+ }
2602
+ }
2603
+ break;
2604
+ }
2605
+ case "text": {
2606
+ const textData = msg.data;
2607
+ if (textData === "audio-init") {
2608
+ ws.send(JSON.stringify({ type: "text", data: "bot-speaking" }));
2609
+ } else if (textData === "audio-end") {
2610
+ ws.send(JSON.stringify({ type: "text", data: "bot-speak-end" }));
2611
+ if (fullResponse) {
2612
+ s.stop("Response received.");
2613
+ console.log(`
2614
+ ${chalk10.cyan("Agent:")} ${fullResponse}
2615
+ `);
2616
+ cleanup();
2617
+ resolve5();
2618
+ }
2619
+ }
2620
+ break;
2621
+ }
2622
+ case "audio":
2623
+ ws.send(JSON.stringify({ type: "ack", data: "audio-received" }));
2624
+ break;
2625
+ case "error-event": {
2626
+ const errData = msg.data;
2627
+ s.stop("Error.");
2628
+ error(
2629
+ `Server error: ${errData?.message || errData?.title || "Unknown"}`
2630
+ );
2631
+ cleanup();
2632
+ resolve5();
2633
+ break;
2634
+ }
2635
+ }
2636
+ } catch {
2637
+ }
2638
+ });
2639
+ ws.on("error", (err) => {
2640
+ s.stop("Connection error.");
2641
+ error(err.message);
2642
+ resolve5();
2643
+ });
2644
+ ws.on("close", () => {
2645
+ if (pingInterval) clearInterval(pingInterval);
2646
+ if (responseTimer) clearTimeout(responseTimer);
2647
+ resolve5();
2648
+ });
2649
+ });
2650
+ }
2651
+
2652
+ // src/commands/whoami.ts
2653
+ import chalk11 from "chalk";
2654
+ import { homedir as homedir4 } from "os";
2655
+ async function whoamiCommand() {
2656
+ p.intro("\u{1F464} OpenHome CLI Status");
2657
+ const apiKey = getApiKey();
2658
+ const config = getConfig();
2659
+ const tracked = getTrackedAbilities();
2660
+ const home = homedir4();
2661
+ if (apiKey) {
2662
+ const masked = apiKey.slice(0, 6) + "..." + apiKey.slice(-4);
2663
+ info(`Authenticated: ${chalk11.green("yes")} (key: ${chalk11.gray(masked)})`);
2664
+ } else {
2665
+ info(
2666
+ `Authenticated: ${chalk11.red("no")} \u2014 run ${chalk11.bold("openhome login")}`
2667
+ );
2668
+ }
2669
+ if (config.default_personality_id) {
2670
+ info(`Default agent: ${chalk11.bold(config.default_personality_id)}`);
2671
+ } else {
2672
+ info(
2673
+ `Default agent: ${chalk11.gray("not set")} \u2014 run ${chalk11.bold("openhome agents")}`
2674
+ );
2675
+ }
2676
+ if (config.api_base_url) {
2677
+ info(`API base: ${config.api_base_url}`);
2678
+ }
2679
+ if (tracked.length > 0) {
2680
+ const lines = tracked.map((a) => {
2681
+ const shortPath = a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path;
2682
+ return ` ${chalk11.bold(a.name)} ${chalk11.gray(shortPath)}`;
2683
+ });
2684
+ p.note(lines.join("\n"), `${tracked.length} tracked ability(s)`);
2685
+ } else {
2686
+ info(
2687
+ `Tracked abilities: ${chalk11.gray("none")} \u2014 run ${chalk11.bold("openhome init")}`
2688
+ );
2689
+ }
2690
+ p.outro("Done.");
2691
+ }
2692
+
2693
+ // src/commands/config-edit.ts
2694
+ import { join as join5, resolve as resolve4 } from "path";
2695
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync5 } from "fs";
2696
+ import { homedir as homedir5 } from "os";
2697
+ async function resolveDir() {
2698
+ const cwd = resolve4(".");
2699
+ if (existsSync5(join5(cwd, "config.json"))) {
2700
+ info("Detected ability in current directory");
2701
+ return cwd;
2702
+ }
2703
+ const tracked = getTrackedAbilities();
2704
+ const home = homedir5();
2705
+ const options = tracked.map((a) => ({
2706
+ value: a.path,
2707
+ label: a.name,
2708
+ hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path
2709
+ }));
2710
+ if (options.length === 1) {
2711
+ info(`Using ability: ${options[0].label}`);
2712
+ return options[0].value;
2713
+ }
2714
+ if (options.length > 0) {
2715
+ const selected = await p.select({
2716
+ message: "Which ability do you want to edit?",
2717
+ options
2718
+ });
2719
+ handleCancel(selected);
2720
+ return selected;
2721
+ }
2722
+ return void 0;
2723
+ }
2724
+ async function configEditCommand(pathArg) {
2725
+ p.intro("\u2699\uFE0F Edit ability config");
2726
+ const dir = pathArg ? resolve4(pathArg) : await resolveDir();
2727
+ if (!dir) {
2728
+ error(
2729
+ "No ability found. Run from an ability directory or create one with: openhome init"
2730
+ );
2731
+ process.exit(1);
2732
+ }
2733
+ const configPath = join5(dir, "config.json");
2734
+ if (!existsSync5(configPath)) {
2735
+ error(`No config.json found in ${dir}`);
2736
+ process.exit(1);
2737
+ }
2738
+ let config;
2739
+ try {
2740
+ config = JSON.parse(readFileSync4(configPath, "utf8"));
2741
+ } catch {
2742
+ error("Failed to parse config.json");
2743
+ process.exit(1);
2744
+ }
2745
+ p.note(
2746
+ [
2747
+ `Name: ${config.unique_name}`,
2748
+ `Description: ${config.description}`,
2749
+ `Category: ${config.category}`,
2750
+ `Triggers: ${config.matching_hotwords.join(", ")}`
2751
+ ].join("\n"),
2752
+ "Current config"
2753
+ );
2754
+ const field = await p.select({
2755
+ message: "What do you want to change?",
2756
+ options: [
2757
+ { value: "description", label: "Description" },
2758
+ { value: "hotwords", label: "Trigger words" },
2759
+ { value: "category", label: "Category" },
2760
+ { value: "name", label: "Unique name" }
2761
+ ]
2762
+ });
2763
+ handleCancel(field);
2764
+ switch (field) {
2765
+ case "description": {
2766
+ const input = await p.text({
2767
+ message: "New description",
2768
+ initialValue: config.description,
2769
+ validate: (val) => {
2770
+ if (!val || !val.trim()) return "Description is required";
2771
+ }
2772
+ });
2773
+ handleCancel(input);
2774
+ config.description = input.trim();
2775
+ break;
2776
+ }
2777
+ case "hotwords": {
2778
+ const input = await p.text({
2779
+ message: "Trigger words (comma-separated)",
2780
+ initialValue: config.matching_hotwords.join(", "),
2781
+ validate: (val) => {
2782
+ if (!val || !val.trim())
2783
+ return "At least one trigger word is required";
2784
+ }
2785
+ });
2786
+ handleCancel(input);
2787
+ config.matching_hotwords = input.split(",").map((h) => h.trim()).filter(Boolean);
2788
+ break;
2789
+ }
2790
+ case "category": {
2791
+ const selected = await p.select({
2792
+ message: "New category",
2793
+ options: [
2794
+ { value: "skill", label: "Skill", hint: "User-triggered" },
2795
+ { value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
2796
+ {
2797
+ value: "daemon",
2798
+ label: "Background Daemon",
2799
+ hint: "Runs continuously"
2800
+ }
2801
+ ]
2802
+ });
2803
+ handleCancel(selected);
2804
+ config.category = selected;
2805
+ break;
2806
+ }
2807
+ case "name": {
2808
+ const input = await p.text({
2809
+ message: "New unique name",
2810
+ initialValue: config.unique_name,
2811
+ validate: (val) => {
2812
+ if (!val || !val.trim()) return "Name is required";
2813
+ if (!/^[a-z][a-z0-9-]*$/.test(val.trim()))
2814
+ return "Use lowercase letters, numbers, and hyphens only.";
2815
+ }
2816
+ });
2817
+ handleCancel(input);
2818
+ config.unique_name = input.trim();
2819
+ break;
2820
+ }
2821
+ }
2822
+ writeFileSync3(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
2823
+ success(`Updated ${field} in config.json`);
2824
+ p.outro("Done.");
2825
+ }
2826
+
2827
+ // src/commands/logs.ts
2828
+ import WebSocket3 from "ws";
2829
+ import chalk12 from "chalk";
2830
+ var PING_INTERVAL3 = 3e4;
2831
+ async function logsCommand(opts = {}) {
2832
+ p.intro("\u{1F4E1} Stream agent logs");
2833
+ const apiKey = getApiKey();
2834
+ if (!apiKey) {
2835
+ error("Not authenticated. Run: openhome login");
2836
+ process.exit(1);
2837
+ }
2838
+ let agentId = opts.agent ?? getConfig().default_personality_id;
2839
+ if (!agentId) {
2840
+ const s = p.spinner();
2841
+ s.start("Fetching agents...");
2842
+ try {
2843
+ const client = new ApiClient(apiKey);
2844
+ const agents = await client.getPersonalities();
2845
+ s.stop(`Found ${agents.length} agent(s).`);
2846
+ if (agents.length === 0) {
2847
+ error("No agents found.");
2848
+ process.exit(1);
2849
+ }
2850
+ const selected = await p.select({
2851
+ message: "Which agent?",
2852
+ options: agents.map((a) => ({
2853
+ value: a.id,
2854
+ label: a.name,
2855
+ hint: a.id
2856
+ }))
2857
+ });
2858
+ handleCancel(selected);
2859
+ agentId = selected;
2860
+ } catch (err) {
2861
+ s.stop("Failed.");
2862
+ error(
2863
+ `Could not fetch agents: ${err instanceof Error ? err.message : String(err)}`
2864
+ );
2865
+ process.exit(1);
2866
+ }
2867
+ }
2868
+ const wsUrl = `${WS_BASE}${ENDPOINTS.voiceStream(apiKey, agentId)}`;
2869
+ info(`Streaming logs from agent ${chalk12.bold(agentId)}...`);
2870
+ info(`Press ${chalk12.bold("Ctrl+C")} to stop.
2871
+ `);
2872
+ await new Promise((resolve5) => {
2873
+ const ws = new WebSocket3(wsUrl, {
2874
+ perMessageDeflate: false,
2875
+ headers: {
2876
+ Origin: "https://app.openhome.com",
2877
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
2878
+ }
2879
+ });
2880
+ let pingInterval = null;
2881
+ ws.on("open", () => {
2882
+ success("Connected. Waiting for messages...\n");
2883
+ pingInterval = setInterval(() => {
2884
+ if (ws.readyState === WebSocket3.OPEN) {
2885
+ ws.send(JSON.stringify({ type: "ping" }));
2886
+ }
2887
+ }, PING_INTERVAL3);
2888
+ });
2889
+ ws.on("message", (raw) => {
2890
+ try {
2891
+ const msg = JSON.parse(raw.toString());
2892
+ const ts = chalk12.gray((/* @__PURE__ */ new Date()).toLocaleTimeString());
2893
+ switch (msg.type) {
2894
+ case "log":
2895
+ console.log(
2896
+ `${ts} ${chalk12.blue("[LOG]")} ${JSON.stringify(msg.data)}`
2897
+ );
2898
+ break;
2899
+ case "action":
2900
+ console.log(
2901
+ `${ts} ${chalk12.magenta("[ACTION]")} ${JSON.stringify(msg.data)}`
2902
+ );
2903
+ break;
2904
+ case "progress":
2905
+ console.log(
2906
+ `${ts} ${chalk12.yellow("[PROGRESS]")} ${JSON.stringify(msg.data)}`
2907
+ );
2908
+ break;
2909
+ case "question":
2910
+ console.log(
2911
+ `${ts} ${chalk12.cyan("[QUESTION]")} ${JSON.stringify(msg.data)}`
2912
+ );
2913
+ break;
2914
+ case "message": {
2915
+ const data = msg.data;
2916
+ if (data.content && !data.live) {
2917
+ const role = data.role === "assistant" ? chalk12.cyan("AGENT") : chalk12.green("USER");
2918
+ console.log(`${ts} ${chalk12.white(`[${role}]`)} ${data.content}`);
2919
+ }
2920
+ break;
2921
+ }
2922
+ case "text": {
2923
+ const textData = msg.data;
2924
+ if (textData === "audio-init") {
2925
+ ws.send(JSON.stringify({ type: "text", data: "bot-speaking" }));
2926
+ } else if (textData === "audio-end") {
2927
+ ws.send(JSON.stringify({ type: "text", data: "bot-speak-end" }));
2928
+ }
2929
+ break;
2930
+ }
2931
+ case "audio":
2932
+ ws.send(JSON.stringify({ type: "ack", data: "audio-received" }));
2933
+ break;
2934
+ case "error-event": {
2935
+ const errData = msg.data;
2936
+ console.log(
2937
+ `${ts} ${chalk12.red("[ERROR]")} ${errData?.message || errData?.title || JSON.stringify(msg.data)}`
2938
+ );
2939
+ break;
2940
+ }
2941
+ default:
2942
+ console.log(
2943
+ `${ts} ${chalk12.gray(`[${msg.type}]`)} ${JSON.stringify(msg.data)}`
2944
+ );
2945
+ break;
2946
+ }
2947
+ } catch {
2948
+ }
2949
+ });
2950
+ ws.on("error", (err) => {
2951
+ error(`WebSocket error: ${err.message}`);
2952
+ resolve5();
2953
+ });
2954
+ ws.on("close", (code) => {
2955
+ if (pingInterval) clearInterval(pingInterval);
2956
+ console.log("");
2957
+ info(`Connection closed (code: ${code})`);
2958
+ resolve5();
2959
+ });
2960
+ process.on("SIGINT", () => {
2961
+ console.log("");
2962
+ info("Stopping log stream...");
2963
+ ws.close(1e3);
2964
+ });
2965
+ });
2966
+ }
2967
+
2968
+ // src/cli.ts
2969
+ var __filename = fileURLToPath(import.meta.url);
2970
+ var __dirname = dirname(__filename);
2971
+ var version = "0.1.0";
2972
+ try {
2973
+ const pkg = JSON.parse(
2974
+ readFileSync5(join6(__dirname, "..", "package.json"), "utf8")
2975
+ );
2976
+ version = pkg.version ?? version;
2977
+ } catch {
2978
+ }
2979
+ async function ensureLoggedIn() {
2980
+ const { getApiKey: getApiKey2 } = await import("./store-DR7EKQ5T.js");
2981
+ const key = getApiKey2();
2982
+ if (!key) {
2983
+ await loginCommand();
2984
+ console.log("");
2985
+ }
2986
+ }
2987
+ async function interactiveMenu() {
2988
+ p.intro(`\u{1F3E0} OpenHome CLI v${version}`);
2989
+ await ensureLoggedIn();
2990
+ let running = true;
2991
+ while (running) {
2992
+ const choice = await p.select({
2993
+ message: "What would you like to do?",
2994
+ options: [
2995
+ {
2996
+ value: "init",
2997
+ label: "\u2728 Create Ability",
2998
+ hint: "Scaffold a new ability from templates"
2999
+ },
3000
+ {
3001
+ value: "deploy",
3002
+ label: "\u{1F680} Deploy",
3003
+ hint: "Upload ability to OpenHome"
3004
+ },
3005
+ {
3006
+ value: "chat",
3007
+ label: "\u{1F4AC} Chat",
3008
+ hint: "Talk to your agent"
3009
+ },
3010
+ {
3011
+ value: "trigger",
3012
+ label: "\u26A1 Trigger",
3013
+ hint: "Fire an ability remotely with a phrase"
3014
+ },
3015
+ {
3016
+ value: "list",
3017
+ label: "\u{1F4CB} My Abilities",
3018
+ hint: "List deployed abilities"
3019
+ },
3020
+ {
3021
+ value: "delete",
3022
+ label: "\u{1F5D1}\uFE0F Delete Ability",
3023
+ hint: "Remove a deployed ability"
3024
+ },
3025
+ {
3026
+ value: "toggle",
3027
+ label: "\u26A1 Enable / Disable",
3028
+ hint: "Toggle an ability on or off"
3029
+ },
3030
+ {
3031
+ value: "assign",
3032
+ label: "\u{1F517} Assign to Agent",
3033
+ hint: "Link abilities to an agent"
3034
+ },
3035
+ {
3036
+ value: "agents",
3037
+ label: "\u{1F916} My Agents",
3038
+ hint: "View agents and set default"
3039
+ },
3040
+ {
3041
+ value: "status",
3042
+ label: "\u{1F50D} Status",
3043
+ hint: "Check ability status"
3044
+ },
3045
+ {
3046
+ value: "config",
3047
+ label: "\u2699\uFE0F Edit Config",
3048
+ hint: "Update trigger words, description, category"
3049
+ },
3050
+ {
3051
+ value: "logs",
3052
+ label: "\u{1F4E1} Logs",
3053
+ hint: "Stream live agent messages"
3054
+ },
3055
+ {
3056
+ value: "whoami",
3057
+ label: "\u{1F464} Who Am I",
3058
+ hint: "Show auth, default agent, tracked abilities"
3059
+ },
3060
+ {
3061
+ value: "logout",
3062
+ label: "\u{1F513} Log Out",
3063
+ hint: "Clear credentials and re-authenticate"
3064
+ },
3065
+ { value: "exit", label: "\u{1F44B} Exit", hint: "Quit" }
3066
+ ]
3067
+ });
3068
+ handleCancel(choice);
3069
+ switch (choice) {
3070
+ case "init":
3071
+ await initCommand();
3072
+ break;
3073
+ case "deploy":
3074
+ await deployCommand();
3075
+ break;
3076
+ case "chat":
3077
+ await chatCommand();
3078
+ break;
3079
+ case "trigger":
3080
+ await triggerCommand();
3081
+ break;
3082
+ case "list":
3083
+ await listCommand();
3084
+ break;
3085
+ case "delete":
3086
+ await deleteCommand();
3087
+ break;
3088
+ case "toggle":
3089
+ await toggleCommand();
3090
+ break;
3091
+ case "assign":
3092
+ await assignCommand();
3093
+ break;
3094
+ case "agents":
3095
+ await agentsCommand();
3096
+ break;
3097
+ case "status":
3098
+ await statusCommand();
3099
+ break;
3100
+ case "config":
3101
+ await configEditCommand();
3102
+ break;
3103
+ case "logs":
3104
+ await logsCommand();
3105
+ break;
3106
+ case "whoami":
3107
+ await whoamiCommand();
3108
+ break;
3109
+ case "logout":
3110
+ await logoutCommand();
3111
+ await ensureLoggedIn();
3112
+ break;
3113
+ case "exit":
3114
+ running = false;
3115
+ break;
3116
+ }
3117
+ if (running) {
3118
+ console.log("");
3119
+ }
3120
+ }
3121
+ p.outro("See you next time!");
3122
+ }
3123
+ var program = new Command();
3124
+ program.name("openhome").description("OpenHome CLI \u2014 manage abilities from your terminal").version(version, "-v, --version", "Output the current version");
3125
+ program.command("login").description("Authenticate with your OpenHome API key").action(async () => {
3126
+ await loginCommand();
3127
+ });
3128
+ program.command("logout").description("Log out and clear stored credentials").action(async () => {
3129
+ await logoutCommand();
3130
+ });
3131
+ program.command("init [name]").description("Scaffold a new ability from templates").action(async (name) => {
3132
+ await initCommand(name);
3133
+ });
3134
+ program.command("deploy [path]").description("Validate and deploy an ability to OpenHome").option("--dry-run", "Show what would be deployed without sending").option("--mock", "Use mock API client (no real network calls)").option("--personality <id>", "Agent ID to attach the ability to").action(
3135
+ async (path, opts) => {
3136
+ await deployCommand(path, opts);
3137
+ }
3138
+ );
3139
+ program.command("chat [agent]").description("Chat with an agent via WebSocket").action(async (agent) => {
3140
+ await chatCommand(agent);
3141
+ });
3142
+ program.command("trigger [phrase]").description("Send a trigger phrase to fire an ability remotely").option("--agent <id>", "Agent ID (uses default if not set)").action(async (phrase, opts) => {
3143
+ await triggerCommand(phrase, opts);
3144
+ });
3145
+ program.command("list").description("List all deployed abilities").option("--mock", "Use mock API client").action(async (opts) => {
3146
+ await listCommand(opts);
3147
+ });
3148
+ program.command("delete [ability]").description("Delete a deployed ability").option("--mock", "Use mock API client").action(async (ability, opts) => {
3149
+ await deleteCommand(ability, opts);
3150
+ });
3151
+ program.command("toggle [ability]").description("Enable or disable a deployed ability").option("--enable", "Enable the ability").option("--disable", "Disable the ability").option("--mock", "Use mock API client").action(
3152
+ async (ability, opts) => {
3153
+ await toggleCommand(ability, opts);
3154
+ }
3155
+ );
3156
+ program.command("assign").description("Assign abilities to an agent").option("--mock", "Use mock API client").action(async (opts) => {
3157
+ await assignCommand(opts);
3158
+ });
3159
+ program.command("agents").description("View your agents and set a default").option("--mock", "Use mock API client").action(async (opts) => {
3160
+ await agentsCommand(opts);
3161
+ });
3162
+ program.command("status [ability]").description("Show detailed status of an ability").option("--mock", "Use mock API client").action(async (ability, opts) => {
3163
+ await statusCommand(ability, opts);
3164
+ });
3165
+ program.command("config [path]").description("Edit trigger words, description, or category in config.json").action(async (path) => {
3166
+ await configEditCommand(path);
3167
+ });
3168
+ program.command("logs").description("Stream live agent messages and logs").option("--agent <id>", "Agent ID (uses default if not set)").action(async (opts) => {
3169
+ await logsCommand(opts);
3170
+ });
3171
+ program.command("whoami").description("Show auth status, default agent, and tracked abilities").action(async () => {
3172
+ await whoamiCommand();
3173
+ });
3174
+ if (process.argv.length <= 2) {
3175
+ interactiveMenu().catch((err) => {
3176
+ console.error(err instanceof Error ? err.message : String(err));
3177
+ process.exit(1);
3178
+ });
3179
+ } else {
3180
+ program.parseAsync(process.argv).catch((err) => {
3181
+ console.error(err instanceof Error ? err.message : String(err));
3182
+ process.exit(1);
3183
+ });
3184
+ }