openhome-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  getApiKey,
3
3
  getConfig,
4
+ getJwt as getJwt2,
4
5
  getTrackedAbilities,
5
6
  keychainDelete,
6
7
  registerAbility,
7
8
  saveApiKey,
8
- saveConfig
9
- } from "./chunk-Q4UKUXDB.js";
9
+ saveConfig,
10
+ saveJwt
11
+ } from "./chunk-OAKGNZQM.js";
10
12
 
11
13
  // src/cli.ts
12
14
  import { Command } from "commander";
@@ -21,8 +23,7 @@ var ENDPOINTS = {
21
23
  getPersonalities: "/api/sdk/get_personalities",
22
24
  verifyApiKey: "/api/sdk/verify_apikey/",
23
25
  uploadCapability: "/api/capabilities/add-capability/",
24
- listCapabilities: "/api/capabilities/get-all-capability/",
25
- getCapability: (id) => `/api/capabilities/get-capability/${id}/`,
26
+ listCapabilities: "/api/capabilities/get-installed-capabilities/",
26
27
  deleteCapability: (id) => `/api/capabilities/delete-capability/${id}/`,
27
28
  bulkDeleteCapabilities: "/api/capabilities/delete-capability/",
28
29
  editInstalledCapability: (id) => `/api/capabilities/edit-installed-capability/${id}/`,
@@ -46,37 +47,39 @@ var ApiError = class extends Error {
46
47
  }
47
48
  };
48
49
  var ApiClient = class {
49
- constructor(apiKey, baseUrl) {
50
+ constructor(apiKey, baseUrl, jwt) {
50
51
  this.apiKey = apiKey;
52
+ this.jwt = jwt;
51
53
  this.baseUrl = baseUrl ?? API_BASE;
52
54
  if (!this.baseUrl.startsWith("https://")) {
53
55
  throw new Error("API base URL must use HTTPS. Got: " + this.baseUrl);
54
56
  }
55
57
  }
56
58
  baseUrl;
57
- async request(path, options = {}) {
59
+ async request(path, options = {}, useJwt = false) {
60
+ const token = useJwt ? this.jwt : this.apiKey;
58
61
  const url = `${this.baseUrl}${path}`;
59
62
  const response = await fetch(url, {
60
63
  ...options,
61
64
  headers: {
62
- Authorization: `Bearer ${this.apiKey}`,
65
+ Authorization: `Bearer ${token}`,
63
66
  ...options.headers ?? {}
64
67
  }
65
68
  });
66
69
  if (!response.ok) {
70
+ if (response.status === 404) {
71
+ throw new NotImplementedError(path);
72
+ }
67
73
  let body = null;
68
74
  try {
69
75
  body = await response.json();
70
76
  } catch {
71
77
  }
72
- if (body?.error?.code === "NOT_IMPLEMENTED" || response.status === 404) {
78
+ if (body?.error?.code === "NOT_IMPLEMENTED") {
73
79
  throw new NotImplementedError(path);
74
80
  }
75
- throw new ApiError(
76
- body?.error?.code ?? String(response.status),
77
- body?.error?.message ?? response.statusText,
78
- body?.error?.details
79
- );
81
+ const message = body?.detail ?? body?.error?.message ?? response.statusText;
82
+ throw new ApiError(String(response.status), message);
80
83
  }
81
84
  return response.json();
82
85
  }
@@ -122,14 +125,36 @@ var ApiClient = class {
122
125
  });
123
126
  }
124
127
  async listAbilities() {
125
- return this.request(ENDPOINTS.listCapabilities, {
126
- method: "GET"
127
- });
128
+ const data = await this.request(
129
+ ENDPOINTS.listCapabilities,
130
+ { method: "GET" },
131
+ true
132
+ // uses JWT
133
+ );
134
+ return {
135
+ abilities: data.map((c) => ({
136
+ ability_id: String(c.id),
137
+ unique_name: c.name,
138
+ display_name: c.name,
139
+ version: 1,
140
+ status: c.enabled ? "active" : "disabled",
141
+ personality_ids: [],
142
+ created_at: c.last_updated ?? (/* @__PURE__ */ new Date()).toISOString(),
143
+ updated_at: c.last_updated ?? (/* @__PURE__ */ new Date()).toISOString(),
144
+ trigger_words: c.trigger_words,
145
+ category: c.category
146
+ }))
147
+ };
128
148
  }
129
149
  async getAbility(id) {
130
- return this.request(ENDPOINTS.getCapability(id), {
131
- method: "GET"
132
- });
150
+ const { abilities } = await this.listAbilities();
151
+ const found = abilities.find(
152
+ (a) => a.ability_id === id || a.unique_name === id
153
+ );
154
+ if (!found) {
155
+ throw new ApiError("404", `Ability "${id}" not found.`);
156
+ }
157
+ return { ...found, validation_errors: [], deploy_history: [] };
133
158
  }
134
159
  async verifyApiKey(apiKey) {
135
160
  return this.request(ENDPOINTS.verifyApiKey, {
@@ -161,24 +186,39 @@ var ApiClient = class {
161
186
  }
162
187
  }
163
188
  async toggleCapability(id, enabled) {
189
+ const { abilities } = await this.listAbilities();
190
+ const current = abilities.find((a) => a.ability_id === id);
191
+ if (!current) {
192
+ throw new ApiError("404", `Ability "${id}" not found.`);
193
+ }
164
194
  return this.request(
165
195
  ENDPOINTS.editInstalledCapability(id),
166
196
  {
167
- method: "POST",
197
+ method: "PUT",
168
198
  headers: { "Content-Type": "application/json" },
169
- body: JSON.stringify({ enabled })
170
- }
199
+ body: JSON.stringify({
200
+ enabled,
201
+ name: current.unique_name,
202
+ category: current.category ?? "skill",
203
+ trigger_words: current.trigger_words ?? []
204
+ })
205
+ },
206
+ true
207
+ // uses JWT
171
208
  );
172
209
  }
173
210
  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
- });
211
+ const form = new FormData();
212
+ form.append("personality_id", personalityId);
213
+ for (const capId of capabilityIds) {
214
+ form.append("matching_capabilities", String(capId));
215
+ }
216
+ return this.request(
217
+ ENDPOINTS.editPersonality,
218
+ { method: "PUT", body: form },
219
+ true
220
+ // uses JWT
221
+ );
182
222
  }
183
223
  };
184
224
 
@@ -261,6 +301,24 @@ async function loginCommand() {
261
301
  }
262
302
  saveApiKey(apiKey);
263
303
  success("API key saved.");
304
+ p.note(
305
+ [
306
+ "Some features (list, toggle, delete, assign) require a session token.",
307
+ "To get it: go to app.openhome.com \u2192 open DevTools Console \u2192 run:",
308
+ " localStorage.getItem('access_token')"
309
+ ].join("\n"),
310
+ "Optional: Session Token"
311
+ );
312
+ const jwtInput = await p.text({
313
+ message: "Paste your session token (or press Enter to skip)",
314
+ placeholder: "eyJhbGci..."
315
+ });
316
+ handleCancel(jwtInput);
317
+ const jwt = jwtInput?.trim();
318
+ if (jwt) {
319
+ saveJwt(jwt);
320
+ success("Session token saved.");
321
+ }
264
322
  if (agents.length > 0) {
265
323
  p.note(
266
324
  agents.map((a) => `${chalk2.bold(a.name)} ${chalk2.gray(a.id)}`).join("\n"),
@@ -274,14 +332,14 @@ async function loginCommand() {
274
332
 
275
333
  // src/commands/init.ts
276
334
  import {
277
- mkdirSync,
278
- writeFileSync,
335
+ mkdirSync as mkdirSync2,
336
+ writeFileSync as writeFileSync2,
279
337
  copyFileSync,
280
- existsSync as existsSync2,
281
- readdirSync as readdirSync2
338
+ existsSync as existsSync3,
339
+ readdirSync as readdirSync3
282
340
  } from "fs";
283
- import { join as join2, resolve, extname } from "path";
284
- import { homedir } from "os";
341
+ import { join as join3, resolve as resolve2, extname as extname2 } from "path";
342
+ import { homedir as homedir2 } from "os";
285
343
 
286
344
  // src/validation/validator.ts
287
345
  import { readFileSync, existsSync, readdirSync } from "fs";
@@ -492,112 +550,543 @@ function validateAbility(dirPath) {
492
550
  };
493
551
  }
494
552
 
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
553
+ // src/commands/deploy.ts
554
+ import { resolve, join as join2, basename, extname } from "path";
555
+ import {
556
+ readFileSync as readFileSync2,
557
+ writeFileSync,
558
+ mkdirSync,
559
+ existsSync as existsSync2,
560
+ readdirSync as readdirSync2
561
+ } from "fs";
562
+ import { homedir } from "os";
516
563
 
517
- {{HOTWORD_LIST}}
518
- `;
564
+ // src/util/zip.ts
565
+ import archiver from "archiver";
566
+ import { createWriteStream } from "fs";
567
+ import { Writable } from "stream";
568
+ async function createAbilityZip(dirPath) {
569
+ return new Promise((resolve5, reject) => {
570
+ const chunks = [];
571
+ const writable = new Writable({
572
+ write(chunk, _encoding, callback) {
573
+ chunks.push(chunk);
574
+ callback();
575
+ }
576
+ });
577
+ writable.on("finish", () => {
578
+ resolve5(Buffer.concat(chunks));
579
+ });
580
+ writable.on("error", reject);
581
+ const archive = archiver("zip", { zlib: { level: 9 } });
582
+ archive.on("error", reject);
583
+ archive.pipe(writable);
584
+ archive.glob("**/*", {
585
+ cwd: dirPath,
586
+ ignore: [
587
+ "**/__pycache__/**",
588
+ "**/*.pyc",
589
+ "**/.git/**",
590
+ "**/.env",
591
+ "**/.env.*",
592
+ "**/secrets.*",
593
+ "**/*.key",
594
+ "**/*.pem"
595
+ ]
596
+ });
597
+ archive.finalize().catch(reject);
598
+ });
519
599
  }
520
- function daemonReadme() {
521
- return `# {{DISPLAY_NAME}}
522
600
 
523
- A background OpenHome daemon. Runs automatically on session start \u2014 no trigger words required.
524
-
525
- ## Trigger Words
601
+ // src/api/mock-client.ts
602
+ var MOCK_PERSONALITIES = [
603
+ { id: "pers_alice", name: "Alice", description: "Friendly assistant" },
604
+ { id: "pers_bob", name: "Bob", description: "Technical expert" },
605
+ { id: "pers_cara", name: "Cara", description: "Creative companion" }
606
+ ];
607
+ var MOCK_ABILITIES = [
608
+ {
609
+ ability_id: "abl_weather_001",
610
+ unique_name: "weather-check",
611
+ display_name: "Weather Check",
612
+ version: 3,
613
+ status: "active",
614
+ personality_ids: ["pers_alice", "pers_bob"],
615
+ created_at: "2026-01-10T12:00:00Z",
616
+ updated_at: "2026-03-01T09:30:00Z"
617
+ },
618
+ {
619
+ ability_id: "abl_timer_002",
620
+ unique_name: "pomodoro-timer",
621
+ display_name: "Pomodoro Timer",
622
+ version: 1,
623
+ status: "processing",
624
+ personality_ids: ["pers_cara"],
625
+ created_at: "2026-03-18T08:00:00Z",
626
+ updated_at: "2026-03-18T08:05:00Z"
627
+ },
628
+ {
629
+ ability_id: "abl_news_003",
630
+ unique_name: "news-briefing",
631
+ display_name: "News Briefing",
632
+ version: 2,
633
+ status: "failed",
634
+ personality_ids: [],
635
+ created_at: "2026-02-20T14:00:00Z",
636
+ updated_at: "2026-02-21T10:00:00Z"
637
+ }
638
+ ];
639
+ var MockApiClient = class {
640
+ async getPersonalities() {
641
+ return Promise.resolve(MOCK_PERSONALITIES);
642
+ }
643
+ async uploadAbility(_zipBuffer, _imageBuffer, _imageName, _metadata) {
644
+ return Promise.resolve({
645
+ ability_id: `abl_mock_${Date.now()}`,
646
+ unique_name: "mock-ability",
647
+ version: 1,
648
+ status: "processing",
649
+ validation_errors: [],
650
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
651
+ message: "[MOCK] Ability uploaded successfully and is being processed."
652
+ });
653
+ }
654
+ async listAbilities() {
655
+ return Promise.resolve({ abilities: MOCK_ABILITIES });
656
+ }
657
+ async verifyApiKey(_apiKey) {
658
+ return Promise.resolve({
659
+ valid: true,
660
+ message: "[MOCK] API key is valid."
661
+ });
662
+ }
663
+ async deleteCapability(id) {
664
+ return Promise.resolve({
665
+ message: `[MOCK] Capability ${id} deleted successfully.`
666
+ });
667
+ }
668
+ async toggleCapability(id, enabled) {
669
+ return Promise.resolve({
670
+ enabled,
671
+ message: `[MOCK] Capability ${id} ${enabled ? "enabled" : "disabled"}.`
672
+ });
673
+ }
674
+ async assignCapabilities(personalityId, capabilityIds) {
675
+ return Promise.resolve({
676
+ message: `[MOCK] Agent ${personalityId} updated with ${capabilityIds.length} capability(s).`
677
+ });
678
+ }
679
+ async getAbility(id) {
680
+ const found = MOCK_ABILITIES.find(
681
+ (a) => a.ability_id === id || a.unique_name === id
682
+ );
683
+ const base = found ?? MOCK_ABILITIES[0];
684
+ return Promise.resolve({
685
+ ...base,
686
+ validation_errors: base.status === "failed" ? ["Missing resume_normal_flow() call in main.py"] : [],
687
+ deploy_history: [
688
+ {
689
+ version: base.version,
690
+ status: base.status === "active" ? "success" : "failed",
691
+ timestamp: base.updated_at,
692
+ message: base.status === "active" ? "Deployed successfully" : "Validation failed"
693
+ },
694
+ ...base.version > 1 ? [
695
+ {
696
+ version: base.version - 1,
697
+ status: "success",
698
+ timestamp: base.created_at,
699
+ message: "Deployed successfully"
700
+ }
701
+ ] : []
702
+ ]
703
+ });
704
+ }
705
+ };
526
706
 
527
- {{HOTWORD_LIST}}
528
- `;
707
+ // src/commands/deploy.ts
708
+ var IMAGE_EXTENSIONS = ["png", "jpg", "jpeg"];
709
+ var ICON_NAMES = IMAGE_EXTENSIONS.flatMap((ext) => [
710
+ `icon.${ext}`,
711
+ `image.${ext}`,
712
+ `logo.${ext}`
713
+ ]);
714
+ function findIcon(dir) {
715
+ for (const name of ICON_NAMES) {
716
+ const p2 = join2(dir, name);
717
+ if (existsSync2(p2)) return p2;
718
+ }
719
+ return null;
529
720
  }
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();
721
+ async function resolveAbilityDir(pathArg) {
722
+ if (pathArg && pathArg !== ".") {
723
+ return resolve(pathArg);
535
724
  }
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())
725
+ const tracked = getTrackedAbilities();
726
+ const cwd = process.cwd();
727
+ const cwdIsAbility = existsSync2(resolve(cwd, "config.json"));
728
+ if (cwdIsAbility) {
729
+ info(`Detected ability in current directory`);
730
+ return cwd;
731
+ }
732
+ const options = [];
733
+ for (const a of tracked) {
734
+ const home = homedir();
735
+ options.push({
736
+ value: a.path,
737
+ label: a.name,
738
+ hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path
739
+ });
740
+ }
741
+ if (options.length === 1) {
742
+ info(`Using ability: ${options[0].label} (${options[0].hint})`);
743
+ return options[0].value;
744
+ }
745
+ if (options.length > 0) {
746
+ options.push({
747
+ value: "__custom__",
748
+ label: "Other...",
749
+ hint: "Enter a path manually"
750
+ });
751
+ const selected = await p.select({
752
+ message: "Which ability do you want to deploy?",
753
+ options
754
+ });
755
+ handleCancel(selected);
756
+ if (selected !== "__custom__") {
757
+ return selected;
758
+ }
759
+ }
760
+ const pathInput = await p.text({
761
+ message: "Path to ability directory",
762
+ placeholder: "./my-ability",
763
+ validate: (val) => {
764
+ if (!val || !val.trim()) return "Path is required";
765
+ if (!existsSync2(resolve(val.trim(), "config.json"))) {
766
+ return `No config.json found in "${val.trim()}"`;
767
+ }
768
+ }
769
+ });
770
+ handleCancel(pathInput);
771
+ return resolve(pathInput.trim());
772
+ }
773
+ async function deployCommand(pathArg, opts = {}) {
774
+ p.intro("\u{1F680} Deploy ability");
775
+ const targetDir = await resolveAbilityDir(pathArg);
776
+ const s = p.spinner();
777
+ s.start("Validating ability...");
778
+ const validation = validateAbility(targetDir);
779
+ if (!validation.passed) {
780
+ s.stop("Validation failed.");
781
+ for (const issue of validation.errors) {
782
+ error(` ${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
783
+ }
784
+ process.exit(1);
785
+ }
786
+ s.stop("Validation passed.");
787
+ if (validation.warnings.length > 0) {
788
+ for (const w of validation.warnings) {
789
+ warn(` ${w.file ? `[${w.file}] ` : ""}${w.message}`);
790
+ }
791
+ }
792
+ const configPath = join2(targetDir, "config.json");
793
+ let abilityConfig;
794
+ try {
795
+ abilityConfig = JSON.parse(
796
+ readFileSync2(configPath, "utf8")
797
+ );
798
+ } catch {
799
+ error("Could not read config.json");
800
+ process.exit(1);
801
+ }
802
+ const uniqueName = abilityConfig.unique_name;
803
+ const hotwords = abilityConfig.matching_hotwords ?? [];
804
+ let description = abilityConfig.description?.trim();
805
+ if (!description) {
806
+ const descInput = await p.text({
807
+ message: "Ability description (required for marketplace)",
808
+ placeholder: "A fun ability that does something cool",
809
+ validate: (val) => {
810
+ if (!val || !val.trim()) return "Description is required";
811
+ }
812
+ });
813
+ handleCancel(descInput);
814
+ description = descInput.trim();
815
+ }
816
+ let category = abilityConfig.category;
817
+ if (!category || !["skill", "brain", "daemon"].includes(category)) {
818
+ const catChoice = await p.select({
819
+ message: "Ability category",
820
+ options: [
821
+ { value: "skill", label: "Skill", hint: "User-triggered" },
822
+ { value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
823
+ {
824
+ value: "daemon",
825
+ label: "Background Daemon",
826
+ hint: "Runs continuously"
827
+ }
828
+ ]
829
+ });
830
+ handleCancel(catChoice);
831
+ category = catChoice;
832
+ }
833
+ let imagePath = findIcon(targetDir);
834
+ if (imagePath) {
835
+ info(`Found icon: ${basename(imagePath)}`);
836
+ } else {
837
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
838
+ const home = homedir();
839
+ const scanDirs = [
840
+ .../* @__PURE__ */ new Set([
841
+ process.cwd(),
842
+ targetDir,
843
+ join2(home, "Desktop"),
844
+ join2(home, "Downloads"),
845
+ join2(home, "Pictures"),
846
+ join2(home, "Images"),
847
+ join2(home, ".openhome", "icons")
848
+ ])
849
+ ];
850
+ const foundImages = [];
851
+ for (const dir of scanDirs) {
852
+ if (!existsSync2(dir)) continue;
853
+ try {
854
+ for (const file of readdirSync2(dir)) {
855
+ if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
856
+ const full = join2(dir, file);
857
+ const shortDir = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
858
+ foundImages.push({
859
+ path: full,
860
+ label: `${file} (${shortDir})`
861
+ });
862
+ }
863
+ }
864
+ } catch {
865
+ }
866
+ }
867
+ if (foundImages.length > 0) {
868
+ const imageOptions = [
869
+ ...foundImages.map((img) => ({ value: img.path, label: img.label })),
870
+ {
871
+ value: "__custom__",
872
+ label: "Other...",
873
+ hint: "Enter a path manually"
874
+ },
875
+ {
876
+ value: "__skip__",
877
+ label: "Skip",
878
+ hint: "Upload without an icon (optional)"
879
+ }
880
+ ];
881
+ const selected = await p.select({
882
+ message: "Select an icon image (optional)",
883
+ options: imageOptions
884
+ });
885
+ handleCancel(selected);
886
+ if (selected === "__custom__") {
887
+ const imgInput = await p.text({
888
+ message: "Path to icon image",
889
+ placeholder: "./icon.png",
890
+ validate: (val) => {
891
+ if (!val || !val.trim()) return void 0;
892
+ const resolved = resolve(val.trim());
893
+ if (!existsSync2(resolved)) return `File not found: ${val.trim()}`;
894
+ if (!IMAGE_EXTS.has(extname(resolved).toLowerCase()))
895
+ return "Image must be PNG or JPG";
896
+ }
897
+ });
898
+ handleCancel(imgInput);
899
+ const trimmed = imgInput.trim();
900
+ if (trimmed) imagePath = resolve(trimmed);
901
+ } else if (selected !== "__skip__") {
902
+ imagePath = selected;
903
+ }
904
+ } else {
905
+ const imgInput = await p.text({
906
+ message: "Path to ability icon image (PNG or JPG, optional \u2014 press Enter to skip)",
907
+ placeholder: "./icon.png",
908
+ validate: (val) => {
909
+ if (!val || !val.trim()) return void 0;
910
+ const resolved = resolve(val.trim());
911
+ if (!existsSync2(resolved)) return `File not found: ${val.trim()}`;
912
+ if (!IMAGE_EXTS.has(extname(resolved).toLowerCase()))
913
+ return "Image must be PNG or JPG";
914
+ }
915
+ });
916
+ handleCancel(imgInput);
917
+ const trimmed = imgInput.trim();
918
+ if (trimmed) imagePath = resolve(trimmed);
919
+ }
920
+ }
921
+ const imageBuffer = imagePath ? readFileSync2(imagePath) : null;
922
+ const imageName = imagePath ? basename(imagePath) : null;
923
+ const personalityId = opts.personality ?? getConfig().default_personality_id;
924
+ const metadata = {
925
+ name: uniqueName,
926
+ description,
927
+ category,
928
+ matching_hotwords: hotwords,
929
+ personality_id: personalityId
930
+ };
931
+ if (opts.dryRun) {
932
+ p.note(
933
+ [
934
+ `Directory: ${targetDir}`,
935
+ `Name: ${uniqueName}`,
936
+ `Description: ${description}`,
937
+ `Category: ${category}`,
938
+ `Image: ${imageName ?? "(none)"}`,
939
+ `Hotwords: ${hotwords.join(", ")}`,
940
+ `Agent: ${personalityId ?? "(none set)"}`
941
+ ].join("\n"),
942
+ "Dry Run \u2014 would deploy"
943
+ );
944
+ p.outro("No changes made.");
945
+ return;
946
+ }
947
+ s.start("Creating ability zip...");
948
+ let zipBuffer;
949
+ try {
950
+ zipBuffer = await createAbilityZip(targetDir);
951
+ s.stop(`Zip created (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
952
+ } catch (err) {
953
+ s.stop("Failed to create zip.");
954
+ error(err instanceof Error ? err.message : String(err));
955
+ process.exit(1);
956
+ }
957
+ if (opts.mock) {
958
+ s.start("Uploading ability (mock)...");
959
+ const mockClient = new MockApiClient();
960
+ const result = await mockClient.uploadAbility(
961
+ zipBuffer,
962
+ imageBuffer,
963
+ imageName,
964
+ metadata
965
+ );
966
+ s.stop("Upload complete.");
967
+ p.note(
968
+ [
969
+ `Ability ID: ${result.ability_id}`,
970
+ `Status: ${result.status}`,
971
+ `Message: ${result.message}`
972
+ ].join("\n"),
973
+ "Mock Deploy Result"
974
+ );
975
+ p.outro("Mock deploy complete.");
976
+ return;
977
+ }
978
+ const apiKey = getApiKey();
979
+ if (!apiKey) {
980
+ error("Not authenticated. Run: openhome login");
981
+ process.exit(1);
982
+ }
983
+ const confirmed = await p.confirm({
984
+ message: `Deploy "${uniqueName}" to OpenHome?`
985
+ });
986
+ handleCancel(confirmed);
987
+ if (!confirmed) {
988
+ p.cancel("Aborted.");
989
+ process.exit(0);
990
+ }
991
+ s.start("Uploading ability...");
992
+ try {
993
+ const client = new ApiClient(apiKey, getConfig().api_base_url);
994
+ const result = await client.uploadAbility(
995
+ zipBuffer,
996
+ imageBuffer,
997
+ imageName,
998
+ metadata
999
+ );
1000
+ s.stop("Upload complete.");
1001
+ p.note(
1002
+ [
1003
+ `Ability ID: ${result.ability_id}`,
1004
+ `Version: ${result.version}`,
1005
+ `Status: ${result.status}`,
1006
+ result.message ? `Message: ${result.message}` : ""
1007
+ ].filter(Boolean).join("\n"),
1008
+ "Deploy Result"
1009
+ );
1010
+ p.outro("Deployed successfully! \u{1F389}");
1011
+ } catch (err) {
1012
+ s.stop("Upload failed.");
1013
+ if (err instanceof NotImplementedError) {
1014
+ warn("This API endpoint is not yet available on the OpenHome server.");
1015
+ const outDir = join2(homedir(), ".openhome");
1016
+ mkdirSync(outDir, { recursive: true });
1017
+ const outPath = join2(outDir, "last-deploy.zip");
1018
+ writeFileSync(outPath, zipBuffer);
1019
+ p.note(
1020
+ [
1021
+ `Your ability was validated and zipped successfully.`,
1022
+ `Zip saved to: ${outPath}`,
1023
+ ``,
1024
+ `Upload manually at https://app.openhome.com`
1025
+ ].join("\n"),
1026
+ "API Not Available Yet"
1027
+ );
1028
+ p.outro("Zip ready for manual upload.");
1029
+ return;
1030
+ }
1031
+ const msg = err instanceof Error ? err.message : String(err);
1032
+ if (msg.toLowerCase().includes("same name")) {
1033
+ error(`An ability named "${uniqueName}" already exists.`);
1034
+ warn(
1035
+ `To update it, delete it first with: openhome delete
1036
+ Or rename it in config.json and redeploy.`
1037
+ );
1038
+ } else {
1039
+ error(`Deploy failed: ${msg}`);
1040
+ }
1041
+ process.exit(1);
1042
+ }
1043
+ }
557
1044
 
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
1045
+ // src/commands/init.ts
1046
+ var DAEMON_TEMPLATES = /* @__PURE__ */ new Set(["background", "alarm"]);
1047
+ function toClassName(name) {
1048
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1049
+ }
1050
+ var SHARED_INIT = "";
1051
+ function sharedConfig() {
1052
+ return `{
1053
+ "unique_name": "{{UNIQUE_NAME}}",
1054
+ "description": "{{DESCRIPTION}}",
1055
+ "category": "{{CATEGORY}}",
1056
+ "matching_hotwords": {{HOTWORDS}}
1057
+ }
1058
+ `;
1059
+ }
1060
+ function skillReadme() {
1061
+ return `# {{DISPLAY_NAME}}
569
1062
 
1063
+ A custom OpenHome ability.
570
1064
 
571
- class {{CLASS_NAME}}(MatchingCapability):
572
- worker: AgentWorker = None
573
- capability_worker: CapabilityWorker = None
1065
+ ## Trigger Words
574
1066
 
575
- @classmethod
576
- def register_capability(cls) -> "MatchingCapability":
577
- # {{register_capability}}
578
- pass
1067
+ {{HOTWORD_LIST}}
1068
+ `;
1069
+ }
1070
+ function daemonReadme() {
1071
+ return `# {{DISPLAY_NAME}}
579
1072
 
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())
1073
+ A background OpenHome daemon. Runs automatically on session start \u2014 no trigger words required.
584
1074
 
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
1075
+ ## Trigger Words
1076
+
1077
+ {{HOTWORD_LIST}}
1078
+ `;
1079
+ }
1080
+ function getTemplate(templateType, file) {
1081
+ if (file === "config.json") return sharedConfig();
1082
+ if (file === "__init__.py") return SHARED_INIT;
1083
+ if (file === "README.md") {
1084
+ return DAEMON_TEMPLATES.has(templateType) ? daemonReadme() : skillReadme();
1085
+ }
1086
+ const templates = {
1087
+ // ── BASIC ────────────────────────────────────────────────────────────
1088
+ basic: {
1089
+ "main.py": `from src.agent.capability import MatchingCapability
601
1090
  from src.main import AgentWorker
602
1091
  from src.agent.capability_worker import CapabilityWorker
603
1092
 
@@ -617,33 +1106,13 @@ class {{CLASS_NAME}}(MatchingCapability):
617
1106
  self.worker.session_tasks.create(self.run())
618
1107
 
619
1108
  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
-
1109
+ await self.capability_worker.speak("Hello! This ability is working.")
639
1110
  self.capability_worker.resume_normal_flow()
640
1111
  `
641
1112
  },
642
- // ── EMAIL ────────────────────────────────────────────────────────────
643
- email: {
644
- "main.py": `import json
645
- import smtplib
646
- from email.mime.text import MIMEText
1113
+ // ── API ──────────────────────────────────────────────────────────────
1114
+ api: {
1115
+ "main.py": `import requests
647
1116
  from src.agent.capability import MatchingCapability
648
1117
  from src.main import AgentWorker
649
1118
  from src.agent.capability_worker import CapabilityWorker
@@ -664,154 +1133,26 @@ class {{CLASS_NAME}}(MatchingCapability):
664
1133
  self.worker.session_tasks.create(self.run())
665
1134
 
666
1135
  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}'?"
1136
+ api_key = self.capability_worker.get_single_key("api_key")
1137
+ response = requests.get(
1138
+ "https://api.example.com/data",
1139
+ headers={"Authorization": f"Bearer {api_key}"},
1140
+ timeout=10,
685
1141
  )
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):
1142
+ data = response.json()
1143
+ await self.capability_worker.speak(f"Here's what I found: {data.get('result', 'nothing')}")
731
1144
  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
1145
  `
763
1146
  },
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
1147
+ // ── LOOP ─────────────────────────────────────────────────────────────
1148
+ loop: {
1149
+ "main.py": `import asyncio
809
1150
  from src.agent.capability import MatchingCapability
810
1151
  from src.main import AgentWorker
811
1152
  from src.agent.capability_worker import CapabilityWorker
812
1153
 
813
1154
 
814
- class {{CLASS_NAME}}Background(MatchingCapability):
1155
+ class {{CLASS_NAME}}(MatchingCapability):
815
1156
  worker: AgentWorker = None
816
1157
  capability_worker: CapabilityWorker = None
817
1158
 
@@ -826,19 +1167,33 @@ class {{CLASS_NAME}}Background(MatchingCapability):
826
1167
  self.worker.session_tasks.create(self.run())
827
1168
 
828
1169
  async def run(self):
1170
+ await self.capability_worker.speak("I'll listen and check in periodically.")
1171
+
829
1172
  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)
1173
+ self.capability_worker.start_audio_recording()
1174
+ await self.worker.session_tasks.sleep(90)
1175
+ self.capability_worker.stop_audio_recording()
1176
+
1177
+ recording = self.capability_worker.get_audio_recording()
1178
+ length = self.capability_worker.get_audio_recording_length()
1179
+ self.capability_worker.flush_audio_recording()
1180
+
1181
+ if length > 2:
1182
+ response = self.capability_worker.text_to_text_response(
1183
+ f"The user has been speaking for {length:.0f} seconds. "
1184
+ "Summarize what you heard and respond helpfully.",
1185
+ self.capability_worker.get_full_message_history(),
1186
+ )
1187
+ await self.capability_worker.speak(response)
1188
+
1189
+ self.capability_worker.resume_normal_flow()
837
1190
  `
838
1191
  },
839
- // ── READWRITE ────────────────────────────────────────────────────────
840
- readwrite: {
1192
+ // ── EMAIL ────────────────────────────────────────────────────────────
1193
+ email: {
841
1194
  "main.py": `import json
1195
+ import smtplib
1196
+ from email.mime.text import MIMEText
842
1197
  from src.agent.capability import MatchingCapability
843
1198
  from src.main import AgentWorker
844
1199
  from src.agent.capability_worker import CapabilityWorker
@@ -859,33 +1214,50 @@ class {{CLASS_NAME}}(MatchingCapability):
859
1214
  self.worker.session_tasks.create(self.run())
860
1215
 
861
1216
  async def run(self):
1217
+ creds = self.capability_worker.get_single_key("email_config")
1218
+ if not creds:
1219
+ await self.capability_worker.speak("Email is not configured yet.")
1220
+ self.capability_worker.resume_normal_flow()
1221
+ return
1222
+
1223
+ config = json.loads(creds) if isinstance(creds, str) else creds
1224
+
862
1225
  reply = await self.capability_worker.run_io_loop(
863
- "What would you like me to remember?"
1226
+ "Who should I send the email to?"
864
1227
  )
1228
+ to_addr = reply.strip()
865
1229
 
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 = []
1230
+ subject = await self.capability_worker.run_io_loop("What's the subject?")
1231
+ body = await self.capability_worker.run_io_loop("What should the email say?")
872
1232
 
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",
1233
+ confirmed = await self.capability_worker.run_confirmation_loop(
1234
+ f"Send email to {to_addr} with subject '{subject}'?"
879
1235
  )
880
1236
 
881
- await self.capability_worker.speak(
882
- f"Got it! I now have {len(notes)} note{'s' if len(notes) != 1 else ''} saved."
883
- )
1237
+ if confirmed:
1238
+ msg = MIMEText(body)
1239
+ msg["Subject"] = subject
1240
+ msg["From"] = config["from"]
1241
+ msg["To"] = to_addr
1242
+
1243
+ try:
1244
+ with smtplib.SMTP(config["smtp_host"], config.get("smtp_port", 587)) as server:
1245
+ server.starttls()
1246
+ server.login(config["from"], config["password"])
1247
+ server.send_message(msg)
1248
+ await self.capability_worker.speak("Email sent!")
1249
+ except Exception as e:
1250
+ self.worker.editor_logging_handler.error(f"Email failed: {e}")
1251
+ await self.capability_worker.speak("Sorry, the email failed to send.")
1252
+ else:
1253
+ await self.capability_worker.speak("Email cancelled.")
1254
+
884
1255
  self.capability_worker.resume_normal_flow()
885
1256
  `
886
1257
  },
887
- // ── LOCAL ────────────────────────────────────────────────────────────
888
- local: {
1258
+ // ── BACKGROUND (daemon) ───────────────────────────────────────────────
1259
+ // background.py holds the active logic; main.py is a minimal no-op stub
1260
+ background: {
889
1261
  "main.py": `from src.agent.capability import MatchingCapability
890
1262
  from src.main import AgentWorker
891
1263
  from src.agent.capability_worker import CapabilityWorker
@@ -906,30 +1278,42 @@ class {{CLASS_NAME}}(MatchingCapability):
906
1278
  self.worker.session_tasks.create(self.run())
907
1279
 
908
1280
  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
- )
1281
+ self.capability_worker.resume_normal_flow()
1282
+ `,
1283
+ "background.py": `import asyncio
1284
+ from src.agent.capability import MatchingCapability
1285
+ from src.main import AgentWorker
1286
+ from src.agent.capability_worker import CapabilityWorker
912
1287
 
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
1288
 
919
- # Send action to DevKit hardware if connected
920
- self.capability_worker.send_devkit_action({
921
- "type": "command",
922
- "payload": reply.strip(),
923
- })
1289
+ class {{CLASS_NAME}}(MatchingCapability):
1290
+ worker: AgentWorker = None
1291
+ capability_worker: CapabilityWorker = None
924
1292
 
925
- await self.capability_worker.speak(response)
926
- self.capability_worker.resume_normal_flow()
1293
+ @classmethod
1294
+ def register_capability(cls) -> "MatchingCapability":
1295
+ # {{register_capability}}
1296
+ pass
1297
+
1298
+ def call(self, worker: AgentWorker):
1299
+ self.worker = worker
1300
+ self.capability_worker = CapabilityWorker(self.worker)
1301
+ self.worker.session_tasks.create(self.run())
1302
+
1303
+ async def run(self):
1304
+ while True:
1305
+ # Your background logic here
1306
+ self.worker.editor_logging_handler.info("Background tick")
1307
+
1308
+ # Example: check something and notify
1309
+ # await self.capability_worker.speak("Heads up!")
1310
+
1311
+ await self.worker.session_tasks.sleep(60)
927
1312
  `
928
1313
  },
929
- // ── OPENCLAW ─────────────────────────────────────────────────────────
930
- openclaw: {
931
- "main.py": `import requests
932
- from src.agent.capability import MatchingCapability
1314
+ // ── ALARM (skill + daemon combo) ──────────────────────────────────────
1315
+ alarm: {
1316
+ "main.py": `from src.agent.capability import MatchingCapability
933
1317
  from src.main import AgentWorker
934
1318
  from src.agent.capability_worker import CapabilityWorker
935
1319
 
@@ -950,821 +1334,504 @@ class {{CLASS_NAME}}(MatchingCapability):
950
1334
 
951
1335
  async def run(self):
952
1336
  reply = await self.capability_worker.run_io_loop(
953
- "What would you like me to handle?"
1337
+ "What should I remind you about?"
1338
+ )
1339
+ minutes = await self.capability_worker.run_io_loop(
1340
+ "In how many minutes?"
954
1341
  )
955
1342
 
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
- )
1343
+ try:
1344
+ mins = int(minutes.strip())
1345
+ except ValueError:
1346
+ await self.capability_worker.speak("I didn't understand the time. Try again.")
963
1347
  self.capability_worker.resume_normal_flow()
964
1348
  return
965
1349
 
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"
1350
+ self.capability_worker.write_file(
1351
+ "pending_alarm.json",
1352
+ f'{{"message": "{reply}", "minutes": {mins}}}',
1353
+ temp=True,
1354
+ )
1355
+ await self.capability_worker.speak(f"Got it! I'll remind you in {mins} minutes.")
1356
+ self.capability_worker.resume_normal_flow()
1357
+ `,
1358
+ "background.py": `import json
1359
+ from src.agent.capability import MatchingCapability
1360
+ from src.main import AgentWorker
1361
+ from src.agent.capability_worker import CapabilityWorker
1362
+
1363
+
1364
+ class {{CLASS_NAME}}Background(MatchingCapability):
1365
+ worker: AgentWorker = None
1366
+ capability_worker: CapabilityWorker = None
1367
+
1368
+ @classmethod
1369
+ def register_capability(cls) -> "MatchingCapability":
1370
+ # {{register_capability}}
1371
+ pass
1372
+
1373
+ def call(self, worker: AgentWorker):
1374
+ self.worker = worker
1375
+ self.capability_worker = CapabilityWorker(self.worker)
1376
+ self.worker.session_tasks.create(self.run())
1377
+
1378
+ async def run(self):
1379
+ while True:
1380
+ if self.capability_worker.check_if_file_exists("pending_alarm.json", temp=True):
1381
+ raw = self.capability_worker.read_file("pending_alarm.json", temp=True)
1382
+ alarm = json.loads(raw)
1383
+ await self.worker.session_tasks.sleep(alarm["minutes"] * 60)
1384
+ await self.capability_worker.speak(f"Reminder: {alarm['message']}")
1385
+ self.capability_worker.delete_file("pending_alarm.json", temp=True)
1386
+ await self.worker.session_tasks.sleep(10)
1387
+ `
1065
1388
  },
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
- }
1389
+ // ── READWRITE ────────────────────────────────────────────────────────
1390
+ readwrite: {
1391
+ "main.py": `import json
1392
+ from src.agent.capability import MatchingCapability
1393
+ from src.main import AgentWorker
1394
+ from src.agent.capability_worker import CapabilityWorker
1395
+
1396
+
1397
+ class {{CLASS_NAME}}(MatchingCapability):
1398
+ worker: AgentWorker = None
1399
+ capability_worker: CapabilityWorker = None
1400
+
1401
+ @classmethod
1402
+ def register_capability(cls) -> "MatchingCapability":
1403
+ # {{register_capability}}
1404
+ pass
1405
+
1406
+ def call(self, worker: AgentWorker):
1407
+ self.worker = worker
1408
+ self.capability_worker = CapabilityWorker(self.worker)
1409
+ self.worker.session_tasks.create(self.run())
1410
+
1411
+ async def run(self):
1412
+ reply = await self.capability_worker.run_io_loop(
1413
+ "What would you like me to remember?"
1414
+ )
1415
+
1416
+ # Read existing notes or start fresh
1417
+ if self.capability_worker.check_if_file_exists("notes.json", temp=False):
1418
+ raw = self.capability_worker.read_file("notes.json", temp=False)
1419
+ notes = json.loads(raw)
1420
+ else:
1421
+ notes = []
1422
+
1423
+ notes.append(reply.strip())
1424
+ self.capability_worker.write_file(
1425
+ "notes.json",
1426
+ json.dumps(notes, indent=2),
1427
+ temp=False,
1428
+ mode="w",
1429
+ )
1277
1430
 
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";
1431
+ await self.capability_worker.speak(
1432
+ f"Got it! I now have {len(notes)} note{'s' if len(notes) != 1 else ''} saved."
1433
+ )
1434
+ self.capability_worker.resume_normal_flow()
1435
+ `
1436
+ },
1437
+ // ── LOCAL ────────────────────────────────────────────────────────────
1438
+ local: {
1439
+ "main.py": `from src.agent.capability import MatchingCapability
1440
+ from src.main import AgentWorker
1441
+ from src.agent.capability_worker import CapabilityWorker
1288
1442
 
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
1443
 
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
- };
1444
+ class {{CLASS_NAME}}(MatchingCapability):
1445
+ worker: AgentWorker = None
1446
+ capability_worker: CapabilityWorker = None
1431
1447
 
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;
1448
+ @classmethod
1449
+ def register_capability(cls) -> "MatchingCapability":
1450
+ # {{register_capability}}
1451
+ pass
1452
+
1453
+ def call(self, worker: AgentWorker):
1454
+ self.worker = worker
1455
+ self.capability_worker = CapabilityWorker(self.worker)
1456
+ self.worker.session_tasks.create(self.run())
1457
+
1458
+ async def run(self):
1459
+ reply = await self.capability_worker.run_io_loop(
1460
+ "What would you like me to do on your device?"
1461
+ )
1462
+
1463
+ # Use text_to_text to interpret the command
1464
+ response = self.capability_worker.text_to_text_response(
1465
+ f"The user wants to: {reply}. Generate a helpful response.",
1466
+ self.capability_worker.get_full_message_history(),
1467
+ )
1468
+
1469
+ # Send action to DevKit hardware if connected
1470
+ self.capability_worker.send_devkit_action({
1471
+ "type": "command",
1472
+ "payload": reply.strip(),
1473
+ })
1474
+
1475
+ await self.capability_worker.speak(response)
1476
+ self.capability_worker.resume_normal_flow()
1477
+ `
1478
+ },
1479
+ // ── OPENCLAW ─────────────────────────────────────────────────────────
1480
+ openclaw: {
1481
+ "main.py": `import requests
1482
+ from src.agent.capability import MatchingCapability
1483
+ from src.main import AgentWorker
1484
+ from src.agent.capability_worker import CapabilityWorker
1485
+
1486
+
1487
+ class {{CLASS_NAME}}(MatchingCapability):
1488
+ worker: AgentWorker = None
1489
+ capability_worker: CapabilityWorker = None
1490
+
1491
+ @classmethod
1492
+ def register_capability(cls) -> "MatchingCapability":
1493
+ # {{register_capability}}
1494
+ pass
1495
+
1496
+ def call(self, worker: AgentWorker):
1497
+ self.worker = worker
1498
+ self.capability_worker = CapabilityWorker(self.worker)
1499
+ self.worker.session_tasks.create(self.run())
1500
+
1501
+ async def run(self):
1502
+ reply = await self.capability_worker.run_io_loop(
1503
+ "What would you like me to handle?"
1504
+ )
1505
+
1506
+ gateway_url = self.capability_worker.get_single_key("openclaw_gateway_url")
1507
+ gateway_token = self.capability_worker.get_single_key("openclaw_gateway_token")
1508
+
1509
+ if not gateway_url or not gateway_token:
1510
+ await self.capability_worker.speak(
1511
+ "OpenClaw gateway is not configured. Add openclaw_gateway_url and openclaw_gateway_token as secrets."
1512
+ )
1513
+ self.capability_worker.resume_normal_flow()
1514
+ return
1515
+
1516
+ try:
1517
+ resp = requests.post(
1518
+ f"{gateway_url}/v1/chat",
1519
+ headers={
1520
+ "Authorization": f"Bearer {gateway_token}",
1521
+ "Content-Type": "application/json",
1522
+ },
1523
+ json={"message": reply.strip()},
1524
+ timeout=30,
1525
+ )
1526
+ data = resp.json()
1527
+ answer = data.get("reply", data.get("response", "No response from OpenClaw."))
1528
+ await self.capability_worker.speak(answer)
1529
+ except Exception as e:
1530
+ self.worker.editor_logging_handler.error(f"OpenClaw error: {e}")
1531
+ await self.capability_worker.speak("Sorry, I couldn't reach OpenClaw.")
1532
+
1533
+ self.capability_worker.resume_normal_flow()
1534
+ `
1535
+ }
1536
+ };
1537
+ return templates[templateType]?.[file] ?? "";
1445
1538
  }
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;
1539
+ function applyTemplate(content, vars) {
1540
+ let result = content;
1541
+ for (const [key, value] of Object.entries(vars)) {
1542
+ result = result.replaceAll(`{{${key}}}`, value);
1456
1543
  }
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
- });
1544
+ return result;
1545
+ }
1546
+ function getFileList(templateType) {
1547
+ const base = ["__init__.py", "README.md", "config.json"];
1548
+ if (templateType === "background") {
1549
+ return ["main.py", "background.py", ...base];
1465
1550
  }
1466
- if (options.length === 1) {
1467
- info(`Using ability: ${options[0].label} (${options[0].hint})`);
1468
- return options[0].value;
1551
+ if (templateType === "alarm") {
1552
+ return ["main.py", "background.py", ...base];
1469
1553
  }
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
- }
1554
+ return ["main.py", ...base];
1555
+ }
1556
+ function getTemplateOptions(category) {
1557
+ if (category === "skill") {
1558
+ return [
1559
+ {
1560
+ value: "basic",
1561
+ label: "Basic",
1562
+ hint: "Simple ability with speak + user_response"
1563
+ },
1564
+ {
1565
+ value: "api",
1566
+ label: "API",
1567
+ hint: "Calls an external API using a stored secret"
1568
+ },
1569
+ {
1570
+ value: "loop",
1571
+ label: "Loop (ambient observer)",
1572
+ hint: "Records audio periodically and checks in"
1573
+ },
1574
+ {
1575
+ value: "email",
1576
+ label: "Email",
1577
+ hint: "Sends email via SMTP using stored credentials"
1578
+ },
1579
+ {
1580
+ value: "readwrite",
1581
+ label: "File Storage",
1582
+ hint: "Reads and writes persistent JSON files"
1583
+ },
1584
+ {
1585
+ value: "local",
1586
+ label: "Local (DevKit)",
1587
+ hint: "Executes commands on the local device via DevKit"
1588
+ },
1589
+ {
1590
+ value: "openclaw",
1591
+ label: "OpenClaw",
1592
+ hint: "Forwards requests to the OpenClaw gateway"
1593
+ }
1594
+ ];
1484
1595
  }
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()}"`;
1596
+ if (category === "brain") {
1597
+ return [
1598
+ {
1599
+ value: "basic",
1600
+ label: "Basic",
1601
+ hint: "Simple ability with speak + user_response"
1602
+ },
1603
+ {
1604
+ value: "api",
1605
+ label: "API",
1606
+ hint: "Calls an external API using a stored secret"
1492
1607
  }
1608
+ ];
1609
+ }
1610
+ return [
1611
+ {
1612
+ value: "background",
1613
+ label: "Background (continuous)",
1614
+ hint: "Runs a loop from session start, no trigger"
1615
+ },
1616
+ {
1617
+ value: "alarm",
1618
+ label: "Alarm (skill + daemon combo)",
1619
+ hint: "Skill sets an alarm; background.py fires it"
1493
1620
  }
1494
- });
1495
- handleCancel(pathInput);
1496
- return resolve2(pathInput.trim());
1621
+ ];
1497
1622
  }
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}`);
1623
+ async function initCommand(nameArg) {
1624
+ p.intro("Create a new OpenHome ability");
1625
+ let name;
1626
+ if (nameArg) {
1627
+ name = nameArg.trim();
1628
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
1629
+ error(
1630
+ "Invalid name. Use lowercase letters, numbers, and hyphens only. Must start with a letter."
1631
+ );
1632
+ process.exit(1);
1515
1633
  }
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",
1634
+ } else {
1635
+ const nameInput = await p.text({
1636
+ message: "What should your ability be called?",
1637
+ placeholder: "my-cool-ability",
1534
1638
  validate: (val) => {
1535
- if (!val || !val.trim()) return "Description is required";
1639
+ if (!val || !val.trim()) return "Name is required";
1640
+ if (!/^[a-z][a-z0-9-]*$/.test(val.trim()))
1641
+ return "Use lowercase letters, numbers, and hyphens only. Must start with a letter.";
1536
1642
  }
1537
1643
  });
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;
1644
+ handleCancel(nameInput);
1645
+ name = nameInput.trim();
1557
1646
  }
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 {
1647
+ const category = await p.select({
1648
+ message: "What type of ability?",
1649
+ options: [
1650
+ {
1651
+ value: "skill",
1652
+ label: "Skill",
1653
+ hint: "User-triggered, runs on demand (most common)"
1654
+ },
1655
+ {
1656
+ value: "brain",
1657
+ label: "Brain Skill",
1658
+ hint: "Auto-triggered by the agent's intelligence"
1659
+ },
1660
+ {
1661
+ value: "daemon",
1662
+ label: "Background Daemon",
1663
+ hint: "Runs continuously from session start"
1664
+ }
1665
+ ]
1666
+ });
1667
+ handleCancel(category);
1668
+ const descInput = await p.text({
1669
+ message: "Short description for the marketplace",
1670
+ placeholder: "A fun ability that checks the weather",
1671
+ validate: (val) => {
1672
+ if (!val || !val.trim()) return "Description is required";
1673
+ }
1674
+ });
1675
+ handleCancel(descInput);
1676
+ const description = descInput.trim();
1677
+ const templateOptions = getTemplateOptions(category);
1678
+ const templateType = await p.select({
1679
+ message: "Choose a template",
1680
+ options: templateOptions
1681
+ });
1682
+ handleCancel(templateType);
1683
+ const hotwordInput = await p.text({
1684
+ message: DAEMON_TEMPLATES.has(templateType) ? "Trigger words (comma-separated, or leave empty for daemons)" : "Trigger words (comma-separated)",
1685
+ placeholder: "check weather, weather please",
1686
+ validate: (val) => {
1687
+ if (!DAEMON_TEMPLATES.has(templateType)) {
1688
+ if (!val || !val.trim()) return "At least one trigger word is required";
1590
1689
  }
1591
1690
  }
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)"
1691
+ });
1692
+ handleCancel(hotwordInput);
1693
+ const hotwords = hotwordInput.split(",").map((h) => h.trim()).filter(Boolean);
1694
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
1695
+ const home = homedir2();
1696
+ const candidateDirs = [
1697
+ process.cwd(),
1698
+ join3(home, "Desktop"),
1699
+ join3(home, "Downloads"),
1700
+ join3(home, "Pictures"),
1701
+ join3(home, "Images"),
1702
+ join3(home, ".openhome", "icons")
1703
+ ];
1704
+ if (process.env.USERPROFILE) {
1705
+ candidateDirs.push(
1706
+ join3(process.env.USERPROFILE, "Desktop"),
1707
+ join3(process.env.USERPROFILE, "Downloads"),
1708
+ join3(process.env.USERPROFILE, "Pictures")
1709
+ );
1710
+ }
1711
+ const scanDirs = [...new Set(candidateDirs)];
1712
+ const foundImages = [];
1713
+ for (const dir of scanDirs) {
1714
+ if (!existsSync3(dir)) continue;
1715
+ try {
1716
+ const files2 = readdirSync3(dir);
1717
+ for (const file of files2) {
1718
+ if (IMAGE_EXTS.has(extname2(file).toLowerCase())) {
1719
+ const full = join3(dir, file);
1720
+ const shortDir = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
1721
+ foundImages.push({
1722
+ path: full,
1723
+ label: `${file} (${shortDir})`
1724
+ });
1604
1725
  }
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
1726
  }
1629
- } else {
1630
- const imgInput = await p.text({
1631
- message: "Path to ability icon image (PNG or JPG, optional \u2014 press Enter to skip)",
1727
+ } catch {
1728
+ }
1729
+ }
1730
+ let iconSourcePath;
1731
+ if (foundImages.length > 0) {
1732
+ const imageOptions = [
1733
+ ...foundImages.map((img) => ({ value: img.path, label: img.label })),
1734
+ { value: "__custom__", label: "Other...", hint: "Enter a path manually" }
1735
+ ];
1736
+ const selected = await p.select({
1737
+ message: "Select an icon image (PNG or JPG for marketplace)",
1738
+ options: imageOptions
1739
+ });
1740
+ handleCancel(selected);
1741
+ if (selected === "__custom__") {
1742
+ const iconInput = await p.text({
1743
+ message: "Path to icon image",
1632
1744
  placeholder: "./icon.png",
1633
1745
  validate: (val) => {
1634
- if (!val || !val.trim()) return void 0;
1746
+ if (!val || !val.trim()) return "An icon image is required";
1635
1747
  const resolved = resolve2(val.trim());
1636
1748
  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";
1749
+ const ext = extname2(resolved).toLowerCase();
1750
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
1639
1751
  }
1640
1752
  });
1641
- handleCancel(imgInput);
1642
- const trimmed = imgInput.trim();
1643
- if (trimmed) imagePath = resolve2(trimmed);
1753
+ handleCancel(iconInput);
1754
+ iconSourcePath = resolve2(iconInput.trim());
1755
+ } else {
1756
+ iconSourcePath = selected;
1644
1757
  }
1758
+ } else {
1759
+ const iconInput = await p.text({
1760
+ message: "Path to icon image (PNG or JPG for marketplace)",
1761
+ placeholder: "./icon.png",
1762
+ validate: (val) => {
1763
+ if (!val || !val.trim()) return "An icon image is required";
1764
+ const resolved = resolve2(val.trim());
1765
+ if (!existsSync3(resolved)) return `File not found: ${val.trim()}`;
1766
+ const ext = extname2(resolved).toLowerCase();
1767
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
1768
+ }
1769
+ });
1770
+ handleCancel(iconInput);
1771
+ iconSourcePath = resolve2(iconInput.trim());
1645
1772
  }
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");
1773
+ const iconExt = extname2(iconSourcePath).toLowerCase();
1774
+ const iconFileName = iconExt === ".jpeg" ? "icon.jpg" : `icon${iconExt}`;
1775
+ const abilitiesDir = resolve2("abilities");
1776
+ const targetDir = join3(abilitiesDir, name);
1777
+ if (existsSync3(targetDir)) {
1778
+ error(`Directory "abilities/${name}" already exists.`);
1706
1779
  process.exit(1);
1707
1780
  }
1708
1781
  const confirmed = await p.confirm({
1709
- message: `Deploy "${uniqueName}" to OpenHome?`
1782
+ message: `Create ability "${name}" with ${hotwords.length} trigger word(s)?`
1710
1783
  });
1711
1784
  handleCancel(confirmed);
1712
1785
  if (!confirmed) {
1713
1786
  p.cancel("Aborted.");
1714
1787
  process.exit(0);
1715
1788
  }
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;
1789
+ const s = p.spinner();
1790
+ s.start("Generating ability files...");
1791
+ mkdirSync2(targetDir, { recursive: true });
1792
+ const className = toClassName(name);
1793
+ const displayName = name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1794
+ const vars = {
1795
+ CLASS_NAME: className,
1796
+ UNIQUE_NAME: name,
1797
+ DISPLAY_NAME: displayName,
1798
+ DESCRIPTION: description,
1799
+ CATEGORY: category,
1800
+ HOTWORDS: JSON.stringify(hotwords),
1801
+ HOTWORD_LIST: hotwords.length > 0 ? hotwords.map((h) => `- "${h}"`).join("\n") : "_None (daemon)_"
1802
+ };
1803
+ const resolvedTemplate = templateType;
1804
+ const files = getFileList(resolvedTemplate);
1805
+ for (const file of files) {
1806
+ const content = applyTemplate(getTemplate(resolvedTemplate, file), vars);
1807
+ writeFileSync2(join3(targetDir, file), content, "utf8");
1808
+ }
1809
+ copyFileSync(iconSourcePath, join3(targetDir, iconFileName));
1810
+ s.stop("Files generated.");
1811
+ registerAbility(name, targetDir);
1812
+ const result = validateAbility(targetDir);
1813
+ if (result.passed) {
1814
+ success("Validation passed.");
1815
+ } else {
1816
+ for (const issue of result.errors) {
1817
+ error(`${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
1755
1818
  }
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}`);
1819
+ }
1820
+ for (const w of result.warnings) {
1821
+ warn(`${w.file ? `[${w.file}] ` : ""}${w.message}`);
1822
+ }
1823
+ if (result.passed) {
1824
+ const deployNow = await p.confirm({
1825
+ message: "Deploy to OpenHome now?",
1826
+ initialValue: true
1827
+ });
1828
+ handleCancel(deployNow);
1829
+ if (deployNow) {
1830
+ await deployCommand(targetDir);
1831
+ return;
1765
1832
  }
1766
- process.exit(1);
1767
1833
  }
1834
+ p.outro(`Ability "${name}" is ready! Run: openhome deploy`);
1768
1835
  }
1769
1836
 
1770
1837
  // src/commands/delete.ts
@@ -1775,12 +1842,13 @@ async function deleteCommand(abilityArg, opts = {}) {
1775
1842
  if (opts.mock) {
1776
1843
  client = new MockApiClient();
1777
1844
  } else {
1778
- const apiKey = getApiKey();
1779
- if (!apiKey) {
1845
+ const apiKey = getApiKey() ?? "";
1846
+ const jwt = getJwt2() ?? void 0;
1847
+ if (!apiKey && !jwt) {
1780
1848
  error("Not authenticated. Run: openhome login");
1781
1849
  process.exit(1);
1782
1850
  }
1783
- client = new ApiClient(apiKey, getConfig().api_base_url);
1851
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
1784
1852
  }
1785
1853
  const s = p.spinner();
1786
1854
  s.start("Fetching abilities...");
@@ -1857,12 +1925,13 @@ async function toggleCommand(abilityArg, opts = {}) {
1857
1925
  if (opts.mock) {
1858
1926
  client = new MockApiClient();
1859
1927
  } else {
1860
- const apiKey = getApiKey();
1861
- if (!apiKey) {
1928
+ const apiKey = getApiKey() ?? "";
1929
+ const jwt = getJwt2() ?? void 0;
1930
+ if (!apiKey && !jwt) {
1862
1931
  error("Not authenticated. Run: openhome login");
1863
1932
  process.exit(1);
1864
1933
  }
1865
- client = new ApiClient(apiKey, getConfig().api_base_url);
1934
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
1866
1935
  }
1867
1936
  const s = p.spinner();
1868
1937
  s.start("Fetching abilities...");
@@ -1949,12 +2018,13 @@ async function assignCommand(opts = {}) {
1949
2018
  if (opts.mock) {
1950
2019
  client = new MockApiClient();
1951
2020
  } else {
1952
- const apiKey = getApiKey();
1953
- if (!apiKey) {
2021
+ const apiKey = getApiKey() ?? "";
2022
+ const jwt = getJwt2() ?? void 0;
2023
+ if (!apiKey && !jwt) {
1954
2024
  error("Not authenticated. Run: openhome login");
1955
2025
  process.exit(1);
1956
2026
  }
1957
- client = new ApiClient(apiKey, getConfig().api_base_url);
2027
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
1958
2028
  }
1959
2029
  const s = p.spinner();
1960
2030
  s.start("Fetching agents and abilities...");
@@ -2051,12 +2121,13 @@ async function listCommand(opts = {}) {
2051
2121
  if (opts.mock) {
2052
2122
  client = new MockApiClient();
2053
2123
  } else {
2054
- const apiKey = getApiKey();
2055
- if (!apiKey) {
2124
+ const apiKey = getApiKey() ?? "";
2125
+ const jwt = getJwt2() ?? void 0;
2126
+ if (!apiKey && !jwt) {
2056
2127
  error("Not authenticated. Run: openhome login");
2057
2128
  process.exit(1);
2058
2129
  }
2059
- client = new ApiClient(apiKey, getConfig().api_base_url);
2130
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
2060
2131
  }
2061
2132
  const s = p.spinner();
2062
2133
  s.start("Fetching abilities...");
@@ -2170,12 +2241,13 @@ async function statusCommand(abilityArg, opts = {}) {
2170
2241
  if (opts.mock) {
2171
2242
  client = new MockApiClient();
2172
2243
  } else {
2173
- const apiKey = getApiKey();
2174
- if (!apiKey) {
2244
+ const apiKey = getApiKey() ?? "";
2245
+ const jwt = getJwt() ?? void 0;
2246
+ if (!apiKey && !jwt) {
2175
2247
  error("Not authenticated. Run: openhome login");
2176
2248
  process.exit(1);
2177
2249
  }
2178
- client = new ApiClient(apiKey, getConfig().api_base_url);
2250
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
2179
2251
  }
2180
2252
  const s = p.spinner();
2181
2253
  s.start("Fetching status...");
@@ -2977,7 +3049,7 @@ try {
2977
3049
  } catch {
2978
3050
  }
2979
3051
  async function ensureLoggedIn() {
2980
- const { getApiKey: getApiKey2 } = await import("./store-DR7EKQ5T.js");
3052
+ const { getApiKey: getApiKey2 } = await import("./store-USDMWKXY.js");
2981
3053
  const key = getApiKey2();
2982
3054
  if (!key) {
2983
3055
  await loginCommand();
@@ -2995,12 +3067,7 @@ async function interactiveMenu() {
2995
3067
  {
2996
3068
  value: "init",
2997
3069
  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"
3070
+ hint: "Scaffold and deploy a new ability"
3004
3071
  },
3005
3072
  {
3006
3073
  value: "chat",
@@ -3070,9 +3137,6 @@ async function interactiveMenu() {
3070
3137
  case "init":
3071
3138
  await initCommand();
3072
3139
  break;
3073
- case "deploy":
3074
- await deployCommand();
3075
- break;
3076
3140
  case "chat":
3077
3141
  await chatCommand();
3078
3142
  break;