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
@@ -0,0 +1,895 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ readdirSync,
7
+ } from "node:fs";
8
+ import { join, resolve, extname, basename } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { validateAbility } from "../validation/validator.js";
11
+ import { registerAbility } from "../config/store.js";
12
+ import { success, error, warn, info, p, handleCancel } from "../ui/format.js";
13
+
14
+ type TemplateType =
15
+ | "basic"
16
+ | "api"
17
+ | "loop"
18
+ | "email"
19
+ | "background"
20
+ | "alarm"
21
+ | "readwrite"
22
+ | "local"
23
+ | "openclaw";
24
+
25
+ // Templates that require a background.py file (in addition to or instead of main.py)
26
+ const DAEMON_TEMPLATES = new Set<TemplateType>(["background", "alarm"]);
27
+
28
+ function toClassName(name: string): string {
29
+ return name
30
+ .split("-")
31
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
32
+ .join("");
33
+ }
34
+
35
+ // Shared static file templates reused across all types
36
+ const SHARED_INIT = "";
37
+
38
+ function sharedConfig(): string {
39
+ return `{
40
+ "unique_name": "{{UNIQUE_NAME}}",
41
+ "description": "{{DESCRIPTION}}",
42
+ "category": "{{CATEGORY}}",
43
+ "matching_hotwords": {{HOTWORDS}}
44
+ }
45
+ `;
46
+ }
47
+
48
+ function skillReadme(): string {
49
+ return `# {{DISPLAY_NAME}}
50
+
51
+ A custom OpenHome ability.
52
+
53
+ ## Trigger Words
54
+
55
+ {{HOTWORD_LIST}}
56
+ `;
57
+ }
58
+
59
+ function daemonReadme(): string {
60
+ return `# {{DISPLAY_NAME}}
61
+
62
+ A background OpenHome daemon. Runs automatically on session start — no trigger words required.
63
+
64
+ ## Trigger Words
65
+
66
+ {{HOTWORD_LIST}}
67
+ `;
68
+ }
69
+
70
+ function getTemplate(templateType: TemplateType, file: string): string {
71
+ // config.json and __init__.py are identical for every template
72
+ if (file === "config.json") return sharedConfig();
73
+ if (file === "__init__.py") return SHARED_INIT;
74
+
75
+ // README differs only between skill/brain types and daemon types
76
+ if (file === "README.md") {
77
+ return DAEMON_TEMPLATES.has(templateType) ? daemonReadme() : skillReadme();
78
+ }
79
+
80
+ const templates: Record<TemplateType, Partial<Record<string, string>>> = {
81
+ // ── BASIC ────────────────────────────────────────────────────────────
82
+ basic: {
83
+ "main.py": `from src.agent.capability import MatchingCapability
84
+ from src.main import AgentWorker
85
+ from src.agent.capability_worker import CapabilityWorker
86
+
87
+
88
+ class {{CLASS_NAME}}(MatchingCapability):
89
+ worker: AgentWorker = None
90
+ capability_worker: CapabilityWorker = None
91
+
92
+ @classmethod
93
+ def register_capability(cls) -> "MatchingCapability":
94
+ # {{register_capability}}
95
+ pass
96
+
97
+ def call(self, worker: AgentWorker):
98
+ self.worker = worker
99
+ self.capability_worker = CapabilityWorker(self.worker)
100
+ self.worker.session_tasks.create(self.run())
101
+
102
+ async def run(self):
103
+ await self.capability_worker.speak("Hello! This ability is working.")
104
+ self.capability_worker.resume_normal_flow()
105
+ `,
106
+ },
107
+
108
+ // ── API ──────────────────────────────────────────────────────────────
109
+ api: {
110
+ "main.py": `import requests
111
+ from src.agent.capability import MatchingCapability
112
+ from src.main import AgentWorker
113
+ from src.agent.capability_worker import CapabilityWorker
114
+
115
+
116
+ class {{CLASS_NAME}}(MatchingCapability):
117
+ worker: AgentWorker = None
118
+ capability_worker: CapabilityWorker = None
119
+
120
+ @classmethod
121
+ def register_capability(cls) -> "MatchingCapability":
122
+ # {{register_capability}}
123
+ pass
124
+
125
+ def call(self, worker: AgentWorker):
126
+ self.worker = worker
127
+ self.capability_worker = CapabilityWorker(self.worker)
128
+ self.worker.session_tasks.create(self.run())
129
+
130
+ async def run(self):
131
+ api_key = self.capability_worker.get_single_key("api_key")
132
+ response = requests.get(
133
+ "https://api.example.com/data",
134
+ headers={"Authorization": f"Bearer {api_key}"},
135
+ timeout=10,
136
+ )
137
+ data = response.json()
138
+ await self.capability_worker.speak(f"Here's what I found: {data.get('result', 'nothing')}")
139
+ self.capability_worker.resume_normal_flow()
140
+ `,
141
+ },
142
+
143
+ // ── LOOP ─────────────────────────────────────────────────────────────
144
+ loop: {
145
+ "main.py": `import asyncio
146
+ from src.agent.capability import MatchingCapability
147
+ from src.main import AgentWorker
148
+ from src.agent.capability_worker import CapabilityWorker
149
+
150
+
151
+ class {{CLASS_NAME}}(MatchingCapability):
152
+ worker: AgentWorker = None
153
+ capability_worker: CapabilityWorker = None
154
+
155
+ @classmethod
156
+ def register_capability(cls) -> "MatchingCapability":
157
+ # {{register_capability}}
158
+ pass
159
+
160
+ def call(self, worker: AgentWorker):
161
+ self.worker = worker
162
+ self.capability_worker = CapabilityWorker(self.worker)
163
+ self.worker.session_tasks.create(self.run())
164
+
165
+ async def run(self):
166
+ await self.capability_worker.speak("I'll listen and check in periodically.")
167
+
168
+ while True:
169
+ self.capability_worker.start_audio_recording()
170
+ await self.worker.session_tasks.sleep(90)
171
+ self.capability_worker.stop_audio_recording()
172
+
173
+ recording = self.capability_worker.get_audio_recording()
174
+ length = self.capability_worker.get_audio_recording_length()
175
+ self.capability_worker.flush_audio_recording()
176
+
177
+ if length > 2:
178
+ response = self.capability_worker.text_to_text_response(
179
+ f"The user has been speaking for {length:.0f} seconds. "
180
+ "Summarize what you heard and respond helpfully.",
181
+ self.capability_worker.get_full_message_history(),
182
+ )
183
+ await self.capability_worker.speak(response)
184
+
185
+ self.capability_worker.resume_normal_flow()
186
+ `,
187
+ },
188
+
189
+ // ── EMAIL ────────────────────────────────────────────────────────────
190
+ email: {
191
+ "main.py": `import json
192
+ import smtplib
193
+ from email.mime.text import MIMEText
194
+ from src.agent.capability import MatchingCapability
195
+ from src.main import AgentWorker
196
+ from src.agent.capability_worker import CapabilityWorker
197
+
198
+
199
+ class {{CLASS_NAME}}(MatchingCapability):
200
+ worker: AgentWorker = None
201
+ capability_worker: CapabilityWorker = None
202
+
203
+ @classmethod
204
+ def register_capability(cls) -> "MatchingCapability":
205
+ # {{register_capability}}
206
+ pass
207
+
208
+ def call(self, worker: AgentWorker):
209
+ self.worker = worker
210
+ self.capability_worker = CapabilityWorker(self.worker)
211
+ self.worker.session_tasks.create(self.run())
212
+
213
+ async def run(self):
214
+ creds = self.capability_worker.get_single_key("email_config")
215
+ if not creds:
216
+ await self.capability_worker.speak("Email is not configured yet.")
217
+ self.capability_worker.resume_normal_flow()
218
+ return
219
+
220
+ config = json.loads(creds) if isinstance(creds, str) else creds
221
+
222
+ reply = await self.capability_worker.run_io_loop(
223
+ "Who should I send the email to?"
224
+ )
225
+ to_addr = reply.strip()
226
+
227
+ subject = await self.capability_worker.run_io_loop("What's the subject?")
228
+ body = await self.capability_worker.run_io_loop("What should the email say?")
229
+
230
+ confirmed = await self.capability_worker.run_confirmation_loop(
231
+ f"Send email to {to_addr} with subject '{subject}'?"
232
+ )
233
+
234
+ if confirmed:
235
+ msg = MIMEText(body)
236
+ msg["Subject"] = subject
237
+ msg["From"] = config["from"]
238
+ msg["To"] = to_addr
239
+
240
+ try:
241
+ with smtplib.SMTP(config["smtp_host"], config.get("smtp_port", 587)) as server:
242
+ server.starttls()
243
+ server.login(config["from"], config["password"])
244
+ server.send_message(msg)
245
+ await self.capability_worker.speak("Email sent!")
246
+ except Exception as e:
247
+ self.worker.editor_logging_handler.error(f"Email failed: {e}")
248
+ await self.capability_worker.speak("Sorry, the email failed to send.")
249
+ else:
250
+ await self.capability_worker.speak("Email cancelled.")
251
+
252
+ self.capability_worker.resume_normal_flow()
253
+ `,
254
+ },
255
+
256
+ // ── BACKGROUND (daemon) ───────────────────────────────────────────────
257
+ // background.py holds the active logic; main.py is a minimal no-op stub
258
+ background: {
259
+ "main.py": `from src.agent.capability import MatchingCapability
260
+ from src.main import AgentWorker
261
+ from src.agent.capability_worker import CapabilityWorker
262
+
263
+
264
+ class {{CLASS_NAME}}(MatchingCapability):
265
+ worker: AgentWorker = None
266
+ capability_worker: CapabilityWorker = None
267
+
268
+ @classmethod
269
+ def register_capability(cls) -> "MatchingCapability":
270
+ # {{register_capability}}
271
+ pass
272
+
273
+ def call(self, worker: AgentWorker):
274
+ self.worker = worker
275
+ self.capability_worker = CapabilityWorker(self.worker)
276
+ self.worker.session_tasks.create(self.run())
277
+
278
+ async def run(self):
279
+ self.capability_worker.resume_normal_flow()
280
+ `,
281
+ "background.py": `import asyncio
282
+ from src.agent.capability import MatchingCapability
283
+ from src.main import AgentWorker
284
+ from src.agent.capability_worker import CapabilityWorker
285
+
286
+
287
+ class {{CLASS_NAME}}(MatchingCapability):
288
+ worker: AgentWorker = None
289
+ capability_worker: CapabilityWorker = None
290
+
291
+ @classmethod
292
+ def register_capability(cls) -> "MatchingCapability":
293
+ # {{register_capability}}
294
+ pass
295
+
296
+ def call(self, worker: AgentWorker):
297
+ self.worker = worker
298
+ self.capability_worker = CapabilityWorker(self.worker)
299
+ self.worker.session_tasks.create(self.run())
300
+
301
+ async def run(self):
302
+ while True:
303
+ # Your background logic here
304
+ self.worker.editor_logging_handler.info("Background tick")
305
+
306
+ # Example: check something and notify
307
+ # await self.capability_worker.speak("Heads up!")
308
+
309
+ await self.worker.session_tasks.sleep(60)
310
+ `,
311
+ },
312
+
313
+ // ── ALARM (skill + daemon combo) ──────────────────────────────────────
314
+ alarm: {
315
+ "main.py": `from src.agent.capability import MatchingCapability
316
+ from src.main import AgentWorker
317
+ from src.agent.capability_worker import CapabilityWorker
318
+
319
+
320
+ class {{CLASS_NAME}}(MatchingCapability):
321
+ worker: AgentWorker = None
322
+ capability_worker: CapabilityWorker = None
323
+
324
+ @classmethod
325
+ def register_capability(cls) -> "MatchingCapability":
326
+ # {{register_capability}}
327
+ pass
328
+
329
+ def call(self, worker: AgentWorker):
330
+ self.worker = worker
331
+ self.capability_worker = CapabilityWorker(self.worker)
332
+ self.worker.session_tasks.create(self.run())
333
+
334
+ async def run(self):
335
+ reply = await self.capability_worker.run_io_loop(
336
+ "What should I remind you about?"
337
+ )
338
+ minutes = await self.capability_worker.run_io_loop(
339
+ "In how many minutes?"
340
+ )
341
+
342
+ try:
343
+ mins = int(minutes.strip())
344
+ except ValueError:
345
+ await self.capability_worker.speak("I didn't understand the time. Try again.")
346
+ self.capability_worker.resume_normal_flow()
347
+ return
348
+
349
+ self.capability_worker.write_file(
350
+ "pending_alarm.json",
351
+ f'{{"message": "{reply}", "minutes": {mins}}}',
352
+ temp=True,
353
+ )
354
+ await self.capability_worker.speak(f"Got it! I'll remind you in {mins} minutes.")
355
+ self.capability_worker.resume_normal_flow()
356
+ `,
357
+ "background.py": `import json
358
+ from src.agent.capability import MatchingCapability
359
+ from src.main import AgentWorker
360
+ from src.agent.capability_worker import CapabilityWorker
361
+
362
+
363
+ class {{CLASS_NAME}}Background(MatchingCapability):
364
+ worker: AgentWorker = None
365
+ capability_worker: CapabilityWorker = None
366
+
367
+ @classmethod
368
+ def register_capability(cls) -> "MatchingCapability":
369
+ # {{register_capability}}
370
+ pass
371
+
372
+ def call(self, worker: AgentWorker):
373
+ self.worker = worker
374
+ self.capability_worker = CapabilityWorker(self.worker)
375
+ self.worker.session_tasks.create(self.run())
376
+
377
+ async def run(self):
378
+ while True:
379
+ if self.capability_worker.check_if_file_exists("pending_alarm.json", temp=True):
380
+ raw = self.capability_worker.read_file("pending_alarm.json", temp=True)
381
+ alarm = json.loads(raw)
382
+ await self.worker.session_tasks.sleep(alarm["minutes"] * 60)
383
+ await self.capability_worker.speak(f"Reminder: {alarm['message']}")
384
+ self.capability_worker.delete_file("pending_alarm.json", temp=True)
385
+ await self.worker.session_tasks.sleep(10)
386
+ `,
387
+ },
388
+
389
+ // ── READWRITE ────────────────────────────────────────────────────────
390
+ readwrite: {
391
+ "main.py": `import json
392
+ from src.agent.capability import MatchingCapability
393
+ from src.main import AgentWorker
394
+ from src.agent.capability_worker import CapabilityWorker
395
+
396
+
397
+ class {{CLASS_NAME}}(MatchingCapability):
398
+ worker: AgentWorker = None
399
+ capability_worker: CapabilityWorker = None
400
+
401
+ @classmethod
402
+ def register_capability(cls) -> "MatchingCapability":
403
+ # {{register_capability}}
404
+ pass
405
+
406
+ def call(self, worker: AgentWorker):
407
+ self.worker = worker
408
+ self.capability_worker = CapabilityWorker(self.worker)
409
+ self.worker.session_tasks.create(self.run())
410
+
411
+ async def run(self):
412
+ reply = await self.capability_worker.run_io_loop(
413
+ "What would you like me to remember?"
414
+ )
415
+
416
+ # Read existing notes or start fresh
417
+ if self.capability_worker.check_if_file_exists("notes.json", temp=False):
418
+ raw = self.capability_worker.read_file("notes.json", temp=False)
419
+ notes = json.loads(raw)
420
+ else:
421
+ notes = []
422
+
423
+ notes.append(reply.strip())
424
+ self.capability_worker.write_file(
425
+ "notes.json",
426
+ json.dumps(notes, indent=2),
427
+ temp=False,
428
+ mode="w",
429
+ )
430
+
431
+ await self.capability_worker.speak(
432
+ f"Got it! I now have {len(notes)} note{'s' if len(notes) != 1 else ''} saved."
433
+ )
434
+ self.capability_worker.resume_normal_flow()
435
+ `,
436
+ },
437
+
438
+ // ── LOCAL ────────────────────────────────────────────────────────────
439
+ local: {
440
+ "main.py": `from src.agent.capability import MatchingCapability
441
+ from src.main import AgentWorker
442
+ from src.agent.capability_worker import CapabilityWorker
443
+
444
+
445
+ class {{CLASS_NAME}}(MatchingCapability):
446
+ worker: AgentWorker = None
447
+ capability_worker: CapabilityWorker = None
448
+
449
+ @classmethod
450
+ def register_capability(cls) -> "MatchingCapability":
451
+ # {{register_capability}}
452
+ pass
453
+
454
+ def call(self, worker: AgentWorker):
455
+ self.worker = worker
456
+ self.capability_worker = CapabilityWorker(self.worker)
457
+ self.worker.session_tasks.create(self.run())
458
+
459
+ async def run(self):
460
+ reply = await self.capability_worker.run_io_loop(
461
+ "What would you like me to do on your device?"
462
+ )
463
+
464
+ # Use text_to_text to interpret the command
465
+ response = self.capability_worker.text_to_text_response(
466
+ f"The user wants to: {reply}. Generate a helpful response.",
467
+ self.capability_worker.get_full_message_history(),
468
+ )
469
+
470
+ # Send action to DevKit hardware if connected
471
+ self.capability_worker.send_devkit_action({
472
+ "type": "command",
473
+ "payload": reply.strip(),
474
+ })
475
+
476
+ await self.capability_worker.speak(response)
477
+ self.capability_worker.resume_normal_flow()
478
+ `,
479
+ },
480
+
481
+ // ── OPENCLAW ─────────────────────────────────────────────────────────
482
+ openclaw: {
483
+ "main.py": `import requests
484
+ from src.agent.capability import MatchingCapability
485
+ from src.main import AgentWorker
486
+ from src.agent.capability_worker import CapabilityWorker
487
+
488
+
489
+ class {{CLASS_NAME}}(MatchingCapability):
490
+ worker: AgentWorker = None
491
+ capability_worker: CapabilityWorker = None
492
+
493
+ @classmethod
494
+ def register_capability(cls) -> "MatchingCapability":
495
+ # {{register_capability}}
496
+ pass
497
+
498
+ def call(self, worker: AgentWorker):
499
+ self.worker = worker
500
+ self.capability_worker = CapabilityWorker(self.worker)
501
+ self.worker.session_tasks.create(self.run())
502
+
503
+ async def run(self):
504
+ reply = await self.capability_worker.run_io_loop(
505
+ "What would you like me to handle?"
506
+ )
507
+
508
+ gateway_url = self.capability_worker.get_single_key("openclaw_gateway_url")
509
+ gateway_token = self.capability_worker.get_single_key("openclaw_gateway_token")
510
+
511
+ if not gateway_url or not gateway_token:
512
+ await self.capability_worker.speak(
513
+ "OpenClaw gateway is not configured. Add openclaw_gateway_url and openclaw_gateway_token as secrets."
514
+ )
515
+ self.capability_worker.resume_normal_flow()
516
+ return
517
+
518
+ try:
519
+ resp = requests.post(
520
+ f"{gateway_url}/v1/chat",
521
+ headers={
522
+ "Authorization": f"Bearer {gateway_token}",
523
+ "Content-Type": "application/json",
524
+ },
525
+ json={"message": reply.strip()},
526
+ timeout=30,
527
+ )
528
+ data = resp.json()
529
+ answer = data.get("reply", data.get("response", "No response from OpenClaw."))
530
+ await self.capability_worker.speak(answer)
531
+ except Exception as e:
532
+ self.worker.editor_logging_handler.error(f"OpenClaw error: {e}")
533
+ await self.capability_worker.speak("Sorry, I couldn't reach OpenClaw.")
534
+
535
+ self.capability_worker.resume_normal_flow()
536
+ `,
537
+ },
538
+ };
539
+
540
+ return templates[templateType]?.[file] ?? "";
541
+ }
542
+
543
+ function applyTemplate(content: string, vars: Record<string, string>): string {
544
+ let result = content;
545
+ for (const [key, value] of Object.entries(vars)) {
546
+ result = result.replaceAll(`{{${key}}}`, value);
547
+ }
548
+ return result;
549
+ }
550
+
551
+ // Returns the list of files to write for a given template type.
552
+ // config.json, __init__.py, README.md are always included.
553
+ function getFileList(templateType: TemplateType): string[] {
554
+ const base = ["__init__.py", "README.md", "config.json"];
555
+
556
+ if (templateType === "background") {
557
+ // background.py holds the logic; main.py is a stub
558
+ return ["main.py", "background.py", ...base];
559
+ }
560
+ if (templateType === "alarm") {
561
+ // Both files have full logic
562
+ return ["main.py", "background.py", ...base];
563
+ }
564
+ return ["main.py", ...base];
565
+ }
566
+
567
+ // Returns template options filtered by category
568
+ function getTemplateOptions(category: string) {
569
+ if (category === "skill") {
570
+ return [
571
+ {
572
+ value: "basic",
573
+ label: "Basic",
574
+ hint: "Simple ability with speak + user_response",
575
+ },
576
+ {
577
+ value: "api",
578
+ label: "API",
579
+ hint: "Calls an external API using a stored secret",
580
+ },
581
+ {
582
+ value: "loop",
583
+ label: "Loop (ambient observer)",
584
+ hint: "Records audio periodically and checks in",
585
+ },
586
+ {
587
+ value: "email",
588
+ label: "Email",
589
+ hint: "Sends email via SMTP using stored credentials",
590
+ },
591
+ {
592
+ value: "readwrite",
593
+ label: "File Storage",
594
+ hint: "Reads and writes persistent JSON files",
595
+ },
596
+ {
597
+ value: "local",
598
+ label: "Local (DevKit)",
599
+ hint: "Executes commands on the local device via DevKit",
600
+ },
601
+ {
602
+ value: "openclaw",
603
+ label: "OpenClaw",
604
+ hint: "Forwards requests to the OpenClaw gateway",
605
+ },
606
+ ];
607
+ }
608
+ if (category === "brain") {
609
+ return [
610
+ {
611
+ value: "basic",
612
+ label: "Basic",
613
+ hint: "Simple ability with speak + user_response",
614
+ },
615
+ {
616
+ value: "api",
617
+ label: "API",
618
+ hint: "Calls an external API using a stored secret",
619
+ },
620
+ ];
621
+ }
622
+ // daemon
623
+ return [
624
+ {
625
+ value: "background",
626
+ label: "Background (continuous)",
627
+ hint: "Runs a loop from session start, no trigger",
628
+ },
629
+ {
630
+ value: "alarm",
631
+ label: "Alarm (skill + daemon combo)",
632
+ hint: "Skill sets an alarm; background.py fires it",
633
+ },
634
+ ];
635
+ }
636
+
637
+ export async function initCommand(nameArg?: string): Promise<void> {
638
+ p.intro("Create a new OpenHome ability");
639
+
640
+ // Step 1: Name
641
+ let name: string;
642
+ if (nameArg) {
643
+ name = nameArg.trim();
644
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
645
+ error(
646
+ "Invalid name. Use lowercase letters, numbers, and hyphens only. Must start with a letter.",
647
+ );
648
+ process.exit(1);
649
+ }
650
+ } else {
651
+ const nameInput = await p.text({
652
+ message: "What should your ability be called?",
653
+ placeholder: "my-cool-ability",
654
+ validate: (val) => {
655
+ if (!val || !val.trim()) return "Name is required";
656
+ if (!/^[a-z][a-z0-9-]*$/.test(val.trim()))
657
+ return "Use lowercase letters, numbers, and hyphens only. Must start with a letter.";
658
+ },
659
+ });
660
+ handleCancel(nameInput);
661
+ name = (nameInput as string).trim();
662
+ }
663
+
664
+ // Step 2: Ability category
665
+ const category = await p.select({
666
+ message: "What type of ability?",
667
+ options: [
668
+ {
669
+ value: "skill",
670
+ label: "Skill",
671
+ hint: "User-triggered, runs on demand (most common)",
672
+ },
673
+ {
674
+ value: "brain",
675
+ label: "Brain Skill",
676
+ hint: "Auto-triggered by the agent's intelligence",
677
+ },
678
+ {
679
+ value: "daemon",
680
+ label: "Background Daemon",
681
+ hint: "Runs continuously from session start",
682
+ },
683
+ ],
684
+ });
685
+ handleCancel(category);
686
+
687
+ // Step 3: Description
688
+ const descInput = await p.text({
689
+ message: "Short description for the marketplace",
690
+ placeholder: "A fun ability that checks the weather",
691
+ validate: (val) => {
692
+ if (!val || !val.trim()) return "Description is required";
693
+ },
694
+ });
695
+ handleCancel(descInput);
696
+ const description = (descInput as string).trim();
697
+
698
+ // Step 4: Template — filtered by category
699
+ const templateOptions = getTemplateOptions(category as string);
700
+ const templateType = await p.select({
701
+ message: "Choose a template",
702
+ options: templateOptions,
703
+ });
704
+ handleCancel(templateType);
705
+
706
+ // Step 5: Hotwords (optional for daemons but kept for config completeness)
707
+ const hotwordInput = await p.text({
708
+ message: DAEMON_TEMPLATES.has(templateType as TemplateType)
709
+ ? "Trigger words (comma-separated, or leave empty for daemons)"
710
+ : "Trigger words (comma-separated)",
711
+ placeholder: "check weather, weather please",
712
+ validate: (val) => {
713
+ if (!DAEMON_TEMPLATES.has(templateType as TemplateType)) {
714
+ if (!val || !val.trim()) return "At least one trigger word is required";
715
+ }
716
+ },
717
+ });
718
+ handleCancel(hotwordInput);
719
+
720
+ const hotwords = (hotwordInput as string)
721
+ .split(",")
722
+ .map((h) => h.trim())
723
+ .filter(Boolean);
724
+
725
+ // Step 6: Icon image — scan common folders for images
726
+ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg"]);
727
+ const home = homedir();
728
+
729
+ const candidateDirs = [
730
+ process.cwd(),
731
+ join(home, "Desktop"),
732
+ join(home, "Downloads"),
733
+ join(home, "Pictures"),
734
+ join(home, "Images"),
735
+ join(home, ".openhome", "icons"),
736
+ ];
737
+ if (process.env.USERPROFILE) {
738
+ candidateDirs.push(
739
+ join(process.env.USERPROFILE, "Desktop"),
740
+ join(process.env.USERPROFILE, "Downloads"),
741
+ join(process.env.USERPROFILE, "Pictures"),
742
+ );
743
+ }
744
+ const scanDirs = [...new Set(candidateDirs)];
745
+
746
+ const foundImages: { path: string; label: string }[] = [];
747
+ for (const dir of scanDirs) {
748
+ if (!existsSync(dir)) continue;
749
+ try {
750
+ const files = readdirSync(dir);
751
+ for (const file of files) {
752
+ if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
753
+ const full = join(dir, file);
754
+ const shortDir = dir.startsWith(home)
755
+ ? `~${dir.slice(home.length)}`
756
+ : dir;
757
+ foundImages.push({
758
+ path: full,
759
+ label: `${file} (${shortDir})`,
760
+ });
761
+ }
762
+ }
763
+ } catch {
764
+ // skip unreadable dirs
765
+ }
766
+ }
767
+
768
+ let iconSourcePath: string;
769
+
770
+ if (foundImages.length > 0) {
771
+ const imageOptions = [
772
+ ...foundImages.map((img) => ({ value: img.path, label: img.label })),
773
+ { value: "__custom__", label: "Other...", hint: "Enter a path manually" },
774
+ ];
775
+
776
+ const selected = await p.select({
777
+ message: "Select an icon image (PNG or JPG for marketplace)",
778
+ options: imageOptions,
779
+ });
780
+ handleCancel(selected);
781
+
782
+ if (selected === "__custom__") {
783
+ const iconInput = await p.text({
784
+ message: "Path to icon image",
785
+ placeholder: "./icon.png",
786
+ validate: (val) => {
787
+ if (!val || !val.trim()) return "An icon image is required";
788
+ const resolved = resolve(val.trim());
789
+ if (!existsSync(resolved)) return `File not found: ${val.trim()}`;
790
+ const ext = extname(resolved).toLowerCase();
791
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
792
+ },
793
+ });
794
+ handleCancel(iconInput);
795
+ iconSourcePath = resolve((iconInput as string).trim());
796
+ } else {
797
+ iconSourcePath = selected as string;
798
+ }
799
+ } else {
800
+ const iconInput = await p.text({
801
+ message: "Path to icon image (PNG or JPG for marketplace)",
802
+ placeholder: "./icon.png",
803
+ validate: (val) => {
804
+ if (!val || !val.trim()) return "An icon image is required";
805
+ const resolved = resolve(val.trim());
806
+ if (!existsSync(resolved)) return `File not found: ${val.trim()}`;
807
+ const ext = extname(resolved).toLowerCase();
808
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
809
+ },
810
+ });
811
+ handleCancel(iconInput);
812
+ iconSourcePath = resolve((iconInput as string).trim());
813
+ }
814
+
815
+ const iconExt = extname(iconSourcePath).toLowerCase();
816
+ const iconFileName = iconExt === ".jpeg" ? "icon.jpg" : `icon${iconExt}`;
817
+
818
+ // Step 7: Confirm
819
+ // Scaffold into abilities/ subdirectory to keep repo root clean
820
+ const abilitiesDir = resolve("abilities");
821
+ const targetDir = join(abilitiesDir, name);
822
+
823
+ if (existsSync(targetDir)) {
824
+ error(`Directory "abilities/${name}" already exists.`);
825
+ process.exit(1);
826
+ }
827
+
828
+ const confirmed = await p.confirm({
829
+ message: `Create ability "${name}" with ${hotwords.length} trigger word(s)?`,
830
+ });
831
+ handleCancel(confirmed);
832
+
833
+ if (!confirmed) {
834
+ p.cancel("Aborted.");
835
+ process.exit(0);
836
+ }
837
+
838
+ // Step 8: Generate files
839
+ const s = p.spinner();
840
+ s.start("Generating ability files...");
841
+
842
+ mkdirSync(targetDir, { recursive: true });
843
+
844
+ const className = toClassName(name);
845
+ const displayName = name
846
+ .split("-")
847
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
848
+ .join(" ");
849
+
850
+ const vars: Record<string, string> = {
851
+ CLASS_NAME: className,
852
+ UNIQUE_NAME: name,
853
+ DISPLAY_NAME: displayName,
854
+ DESCRIPTION: description,
855
+ CATEGORY: category as string,
856
+ HOTWORDS: JSON.stringify(hotwords),
857
+ HOTWORD_LIST:
858
+ hotwords.length > 0
859
+ ? hotwords.map((h) => `- "${h}"`).join("\n")
860
+ : "_None (daemon)_",
861
+ };
862
+
863
+ const resolvedTemplate = templateType as TemplateType;
864
+ const files = getFileList(resolvedTemplate);
865
+
866
+ for (const file of files) {
867
+ const content = applyTemplate(getTemplate(resolvedTemplate, file), vars);
868
+ writeFileSync(join(targetDir, file), content, "utf8");
869
+ }
870
+
871
+ // Copy icon into ability directory
872
+ copyFileSync(iconSourcePath, join(targetDir, iconFileName));
873
+
874
+ s.stop("Files generated.");
875
+
876
+ // Track ability in config for deploy picker
877
+ registerAbility(name, targetDir);
878
+
879
+ // Auto-validate
880
+ const result = validateAbility(targetDir);
881
+ if (result.passed) {
882
+ success("Validation passed.");
883
+ } else {
884
+ for (const issue of result.errors) {
885
+ error(`${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
886
+ }
887
+ }
888
+ for (const w of result.warnings) {
889
+ warn(`${w.file ? `[${w.file}] ` : ""}${w.message}`);
890
+ }
891
+
892
+ p.note(`cd abilities/${name}\nopenhome deploy`, "Next steps");
893
+
894
+ p.outro(`Ability "${name}" is ready!`);
895
+ }