openhome-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -274,14 +314,14 @@ async function loginCommand() {
274
314
 
275
315
  // src/commands/init.ts
276
316
  import {
277
- mkdirSync,
278
- writeFileSync,
317
+ mkdirSync as mkdirSync2,
318
+ writeFileSync as writeFileSync2,
279
319
  copyFileSync,
280
- existsSync as existsSync2,
281
- readdirSync as readdirSync2
320
+ existsSync as existsSync3,
321
+ readdirSync as readdirSync3
282
322
  } from "fs";
283
- import { join as join2, resolve, extname } from "path";
284
- import { homedir } from "os";
323
+ import { join as join3, resolve as resolve2, extname as extname2 } from "path";
324
+ import { homedir as homedir2 } from "os";
285
325
 
286
326
  // src/validation/validator.ts
287
327
  import { readFileSync, existsSync, readdirSync } from "fs";
@@ -492,73 +532,565 @@ function validateAbility(dirPath) {
492
532
  };
493
533
  }
494
534
 
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
535
+ // src/commands/deploy.ts
536
+ import { resolve, join as join2, basename, extname } from "path";
537
+ import {
538
+ readFileSync as readFileSync2,
539
+ writeFileSync,
540
+ mkdirSync,
541
+ existsSync as existsSync2,
542
+ readdirSync as readdirSync2
543
+ } from "fs";
544
+ import { homedir } from "os";
516
545
 
517
- {{HOTWORD_LIST}}
518
- `;
546
+ // src/util/zip.ts
547
+ import archiver from "archiver";
548
+ import { createWriteStream } from "fs";
549
+ import { Writable } from "stream";
550
+ async function createAbilityZip(dirPath) {
551
+ return new Promise((resolve5, reject) => {
552
+ const chunks = [];
553
+ const writable = new Writable({
554
+ write(chunk, _encoding, callback) {
555
+ chunks.push(chunk);
556
+ callback();
557
+ }
558
+ });
559
+ writable.on("finish", () => {
560
+ resolve5(Buffer.concat(chunks));
561
+ });
562
+ writable.on("error", reject);
563
+ const archive = archiver("zip", { zlib: { level: 9 } });
564
+ archive.on("error", reject);
565
+ archive.pipe(writable);
566
+ archive.glob("**/*", {
567
+ cwd: dirPath,
568
+ ignore: [
569
+ "**/__pycache__/**",
570
+ "**/*.pyc",
571
+ "**/.git/**",
572
+ "**/.env",
573
+ "**/.env.*",
574
+ "**/secrets.*",
575
+ "**/*.key",
576
+ "**/*.pem"
577
+ ]
578
+ });
579
+ archive.finalize().catch(reject);
580
+ });
519
581
  }
520
- function daemonReadme() {
521
- return `# {{DISPLAY_NAME}}
522
-
523
- A background OpenHome daemon. Runs automatically on session start \u2014 no trigger words required.
524
582
 
525
- ## Trigger Words
583
+ // src/api/mock-client.ts
584
+ var MOCK_PERSONALITIES = [
585
+ { id: "pers_alice", name: "Alice", description: "Friendly assistant" },
586
+ { id: "pers_bob", name: "Bob", description: "Technical expert" },
587
+ { id: "pers_cara", name: "Cara", description: "Creative companion" }
588
+ ];
589
+ var MOCK_ABILITIES = [
590
+ {
591
+ ability_id: "abl_weather_001",
592
+ unique_name: "weather-check",
593
+ display_name: "Weather Check",
594
+ version: 3,
595
+ status: "active",
596
+ personality_ids: ["pers_alice", "pers_bob"],
597
+ created_at: "2026-01-10T12:00:00Z",
598
+ updated_at: "2026-03-01T09:30:00Z"
599
+ },
600
+ {
601
+ ability_id: "abl_timer_002",
602
+ unique_name: "pomodoro-timer",
603
+ display_name: "Pomodoro Timer",
604
+ version: 1,
605
+ status: "processing",
606
+ personality_ids: ["pers_cara"],
607
+ created_at: "2026-03-18T08:00:00Z",
608
+ updated_at: "2026-03-18T08:05:00Z"
609
+ },
610
+ {
611
+ ability_id: "abl_news_003",
612
+ unique_name: "news-briefing",
613
+ display_name: "News Briefing",
614
+ version: 2,
615
+ status: "failed",
616
+ personality_ids: [],
617
+ created_at: "2026-02-20T14:00:00Z",
618
+ updated_at: "2026-02-21T10:00:00Z"
619
+ }
620
+ ];
621
+ var MockApiClient = class {
622
+ async getPersonalities() {
623
+ return Promise.resolve(MOCK_PERSONALITIES);
624
+ }
625
+ async uploadAbility(_zipBuffer, _imageBuffer, _imageName, _metadata) {
626
+ return Promise.resolve({
627
+ ability_id: `abl_mock_${Date.now()}`,
628
+ unique_name: "mock-ability",
629
+ version: 1,
630
+ status: "processing",
631
+ validation_errors: [],
632
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
633
+ message: "[MOCK] Ability uploaded successfully and is being processed."
634
+ });
635
+ }
636
+ async listAbilities() {
637
+ return Promise.resolve({ abilities: MOCK_ABILITIES });
638
+ }
639
+ async verifyApiKey(_apiKey) {
640
+ return Promise.resolve({
641
+ valid: true,
642
+ message: "[MOCK] API key is valid."
643
+ });
644
+ }
645
+ async deleteCapability(id) {
646
+ return Promise.resolve({
647
+ message: `[MOCK] Capability ${id} deleted successfully.`
648
+ });
649
+ }
650
+ async toggleCapability(id, enabled) {
651
+ return Promise.resolve({
652
+ enabled,
653
+ message: `[MOCK] Capability ${id} ${enabled ? "enabled" : "disabled"}.`
654
+ });
655
+ }
656
+ async assignCapabilities(personalityId, capabilityIds) {
657
+ return Promise.resolve({
658
+ message: `[MOCK] Agent ${personalityId} updated with ${capabilityIds.length} capability(s).`
659
+ });
660
+ }
661
+ async getAbility(id) {
662
+ const found = MOCK_ABILITIES.find(
663
+ (a) => a.ability_id === id || a.unique_name === id
664
+ );
665
+ const base = found ?? MOCK_ABILITIES[0];
666
+ return Promise.resolve({
667
+ ...base,
668
+ validation_errors: base.status === "failed" ? ["Missing resume_normal_flow() call in main.py"] : [],
669
+ deploy_history: [
670
+ {
671
+ version: base.version,
672
+ status: base.status === "active" ? "success" : "failed",
673
+ timestamp: base.updated_at,
674
+ message: base.status === "active" ? "Deployed successfully" : "Validation failed"
675
+ },
676
+ ...base.version > 1 ? [
677
+ {
678
+ version: base.version - 1,
679
+ status: "success",
680
+ timestamp: base.created_at,
681
+ message: "Deployed successfully"
682
+ }
683
+ ] : []
684
+ ]
685
+ });
686
+ }
687
+ };
526
688
 
527
- {{HOTWORD_LIST}}
528
- `;
689
+ // src/commands/deploy.ts
690
+ var IMAGE_EXTENSIONS = ["png", "jpg", "jpeg"];
691
+ var ICON_NAMES = IMAGE_EXTENSIONS.flatMap((ext) => [
692
+ `icon.${ext}`,
693
+ `image.${ext}`,
694
+ `logo.${ext}`
695
+ ]);
696
+ function findIcon(dir) {
697
+ for (const name of ICON_NAMES) {
698
+ const p2 = join2(dir, name);
699
+ if (existsSync2(p2)) return p2;
700
+ }
701
+ return null;
529
702
  }
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();
703
+ async function resolveAbilityDir(pathArg) {
704
+ if (pathArg && pathArg !== ".") {
705
+ return resolve(pathArg);
535
706
  }
536
- const templates = {
537
- // ── BASIC ────────────────────────────────────────────────────────────
538
- basic: {
539
- "main.py": `from src.agent.capability import MatchingCapability
540
- from src.main import AgentWorker
541
- from src.agent.capability_worker import CapabilityWorker
542
-
543
-
544
- class {{CLASS_NAME}}(MatchingCapability):
545
- worker: AgentWorker = None
546
- capability_worker: CapabilityWorker = None
547
-
548
- @classmethod
549
- def register_capability(cls) -> "MatchingCapability":
550
- # {{register_capability}}
551
- pass
552
-
553
- def call(self, worker: AgentWorker):
554
- self.worker = worker
555
- self.capability_worker = CapabilityWorker(self.worker)
556
- self.worker.session_tasks.create(self.run())
557
-
558
- async def run(self):
559
- await self.capability_worker.speak("Hello! This ability is working.")
560
- self.capability_worker.resume_normal_flow()
561
- `
707
+ const tracked = getTrackedAbilities();
708
+ const cwd = process.cwd();
709
+ const cwdIsAbility = existsSync2(resolve(cwd, "config.json"));
710
+ if (cwdIsAbility) {
711
+ info(`Detected ability in current directory`);
712
+ return cwd;
713
+ }
714
+ const options = [];
715
+ for (const a of tracked) {
716
+ const home = homedir();
717
+ options.push({
718
+ value: a.path,
719
+ label: a.name,
720
+ hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path
721
+ });
722
+ }
723
+ if (options.length === 1) {
724
+ info(`Using ability: ${options[0].label} (${options[0].hint})`);
725
+ return options[0].value;
726
+ }
727
+ if (options.length > 0) {
728
+ options.push({
729
+ value: "__custom__",
730
+ label: "Other...",
731
+ hint: "Enter a path manually"
732
+ });
733
+ const selected = await p.select({
734
+ message: "Which ability do you want to deploy?",
735
+ options
736
+ });
737
+ handleCancel(selected);
738
+ if (selected !== "__custom__") {
739
+ return selected;
740
+ }
741
+ }
742
+ const pathInput = await p.text({
743
+ message: "Path to ability directory",
744
+ placeholder: "./my-ability",
745
+ validate: (val) => {
746
+ if (!val || !val.trim()) return "Path is required";
747
+ if (!existsSync2(resolve(val.trim(), "config.json"))) {
748
+ return `No config.json found in "${val.trim()}"`;
749
+ }
750
+ }
751
+ });
752
+ handleCancel(pathInput);
753
+ return resolve(pathInput.trim());
754
+ }
755
+ async function deployCommand(pathArg, opts = {}) {
756
+ p.intro("\u{1F680} Deploy ability");
757
+ const targetDir = await resolveAbilityDir(pathArg);
758
+ const s = p.spinner();
759
+ s.start("Validating ability...");
760
+ const validation = validateAbility(targetDir);
761
+ if (!validation.passed) {
762
+ s.stop("Validation failed.");
763
+ for (const issue of validation.errors) {
764
+ error(` ${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
765
+ }
766
+ process.exit(1);
767
+ }
768
+ s.stop("Validation passed.");
769
+ if (validation.warnings.length > 0) {
770
+ for (const w of validation.warnings) {
771
+ warn(` ${w.file ? `[${w.file}] ` : ""}${w.message}`);
772
+ }
773
+ }
774
+ const configPath = join2(targetDir, "config.json");
775
+ let abilityConfig;
776
+ try {
777
+ abilityConfig = JSON.parse(
778
+ readFileSync2(configPath, "utf8")
779
+ );
780
+ } catch {
781
+ error("Could not read config.json");
782
+ process.exit(1);
783
+ }
784
+ const uniqueName = abilityConfig.unique_name;
785
+ const hotwords = abilityConfig.matching_hotwords ?? [];
786
+ let description = abilityConfig.description?.trim();
787
+ if (!description) {
788
+ const descInput = await p.text({
789
+ message: "Ability description (required for marketplace)",
790
+ placeholder: "A fun ability that does something cool",
791
+ validate: (val) => {
792
+ if (!val || !val.trim()) return "Description is required";
793
+ }
794
+ });
795
+ handleCancel(descInput);
796
+ description = descInput.trim();
797
+ }
798
+ let category = abilityConfig.category;
799
+ if (!category || !["skill", "brain", "daemon"].includes(category)) {
800
+ const catChoice = await p.select({
801
+ message: "Ability category",
802
+ options: [
803
+ { value: "skill", label: "Skill", hint: "User-triggered" },
804
+ { value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
805
+ {
806
+ value: "daemon",
807
+ label: "Background Daemon",
808
+ hint: "Runs continuously"
809
+ }
810
+ ]
811
+ });
812
+ handleCancel(catChoice);
813
+ category = catChoice;
814
+ }
815
+ let imagePath = findIcon(targetDir);
816
+ if (imagePath) {
817
+ info(`Found icon: ${basename(imagePath)}`);
818
+ } else {
819
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
820
+ const home = homedir();
821
+ const scanDirs = [
822
+ .../* @__PURE__ */ new Set([
823
+ process.cwd(),
824
+ targetDir,
825
+ join2(home, "Desktop"),
826
+ join2(home, "Downloads"),
827
+ join2(home, "Pictures"),
828
+ join2(home, "Images"),
829
+ join2(home, ".openhome", "icons")
830
+ ])
831
+ ];
832
+ const foundImages = [];
833
+ for (const dir of scanDirs) {
834
+ if (!existsSync2(dir)) continue;
835
+ try {
836
+ for (const file of readdirSync2(dir)) {
837
+ if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
838
+ const full = join2(dir, file);
839
+ const shortDir = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
840
+ foundImages.push({
841
+ path: full,
842
+ label: `${file} (${shortDir})`
843
+ });
844
+ }
845
+ }
846
+ } catch {
847
+ }
848
+ }
849
+ if (foundImages.length > 0) {
850
+ const imageOptions = [
851
+ ...foundImages.map((img) => ({ value: img.path, label: img.label })),
852
+ {
853
+ value: "__custom__",
854
+ label: "Other...",
855
+ hint: "Enter a path manually"
856
+ },
857
+ {
858
+ value: "__skip__",
859
+ label: "Skip",
860
+ hint: "Upload without an icon (optional)"
861
+ }
862
+ ];
863
+ const selected = await p.select({
864
+ message: "Select an icon image (optional)",
865
+ options: imageOptions
866
+ });
867
+ handleCancel(selected);
868
+ if (selected === "__custom__") {
869
+ const imgInput = await p.text({
870
+ message: "Path to icon image",
871
+ placeholder: "./icon.png",
872
+ validate: (val) => {
873
+ if (!val || !val.trim()) return void 0;
874
+ const resolved = resolve(val.trim());
875
+ if (!existsSync2(resolved)) return `File not found: ${val.trim()}`;
876
+ if (!IMAGE_EXTS.has(extname(resolved).toLowerCase()))
877
+ return "Image must be PNG or JPG";
878
+ }
879
+ });
880
+ handleCancel(imgInput);
881
+ const trimmed = imgInput.trim();
882
+ if (trimmed) imagePath = resolve(trimmed);
883
+ } else if (selected !== "__skip__") {
884
+ imagePath = selected;
885
+ }
886
+ } else {
887
+ const imgInput = await p.text({
888
+ message: "Path to ability icon image (PNG or JPG, optional \u2014 press Enter to skip)",
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
+ }
902
+ }
903
+ const imageBuffer = imagePath ? readFileSync2(imagePath) : null;
904
+ const imageName = imagePath ? basename(imagePath) : null;
905
+ const personalityId = opts.personality ?? getConfig().default_personality_id;
906
+ const metadata = {
907
+ name: uniqueName,
908
+ description,
909
+ category,
910
+ matching_hotwords: hotwords,
911
+ personality_id: personalityId
912
+ };
913
+ if (opts.dryRun) {
914
+ p.note(
915
+ [
916
+ `Directory: ${targetDir}`,
917
+ `Name: ${uniqueName}`,
918
+ `Description: ${description}`,
919
+ `Category: ${category}`,
920
+ `Image: ${imageName ?? "(none)"}`,
921
+ `Hotwords: ${hotwords.join(", ")}`,
922
+ `Agent: ${personalityId ?? "(none set)"}`
923
+ ].join("\n"),
924
+ "Dry Run \u2014 would deploy"
925
+ );
926
+ p.outro("No changes made.");
927
+ return;
928
+ }
929
+ s.start("Creating ability zip...");
930
+ let zipBuffer;
931
+ try {
932
+ zipBuffer = await createAbilityZip(targetDir);
933
+ s.stop(`Zip created (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
934
+ } catch (err) {
935
+ s.stop("Failed to create zip.");
936
+ error(err instanceof Error ? err.message : String(err));
937
+ process.exit(1);
938
+ }
939
+ if (opts.mock) {
940
+ s.start("Uploading ability (mock)...");
941
+ const mockClient = new MockApiClient();
942
+ const result = await mockClient.uploadAbility(
943
+ zipBuffer,
944
+ imageBuffer,
945
+ imageName,
946
+ metadata
947
+ );
948
+ s.stop("Upload complete.");
949
+ p.note(
950
+ [
951
+ `Ability ID: ${result.ability_id}`,
952
+ `Status: ${result.status}`,
953
+ `Message: ${result.message}`
954
+ ].join("\n"),
955
+ "Mock Deploy Result"
956
+ );
957
+ p.outro("Mock deploy complete.");
958
+ return;
959
+ }
960
+ const apiKey = getApiKey();
961
+ if (!apiKey) {
962
+ error("Not authenticated. Run: openhome login");
963
+ process.exit(1);
964
+ }
965
+ const confirmed = await p.confirm({
966
+ message: `Deploy "${uniqueName}" to OpenHome?`
967
+ });
968
+ handleCancel(confirmed);
969
+ if (!confirmed) {
970
+ p.cancel("Aborted.");
971
+ process.exit(0);
972
+ }
973
+ s.start("Uploading ability...");
974
+ try {
975
+ const client = new ApiClient(apiKey, getConfig().api_base_url);
976
+ const result = await client.uploadAbility(
977
+ zipBuffer,
978
+ imageBuffer,
979
+ imageName,
980
+ metadata
981
+ );
982
+ s.stop("Upload complete.");
983
+ p.note(
984
+ [
985
+ `Ability ID: ${result.ability_id}`,
986
+ `Version: ${result.version}`,
987
+ `Status: ${result.status}`,
988
+ result.message ? `Message: ${result.message}` : ""
989
+ ].filter(Boolean).join("\n"),
990
+ "Deploy Result"
991
+ );
992
+ p.outro("Deployed successfully! \u{1F389}");
993
+ } catch (err) {
994
+ s.stop("Upload failed.");
995
+ if (err instanceof NotImplementedError) {
996
+ warn("This API endpoint is not yet available on the OpenHome server.");
997
+ const outDir = join2(homedir(), ".openhome");
998
+ mkdirSync(outDir, { recursive: true });
999
+ const outPath = join2(outDir, "last-deploy.zip");
1000
+ writeFileSync(outPath, zipBuffer);
1001
+ p.note(
1002
+ [
1003
+ `Your ability was validated and zipped successfully.`,
1004
+ `Zip saved to: ${outPath}`,
1005
+ ``,
1006
+ `Upload manually at https://app.openhome.com`
1007
+ ].join("\n"),
1008
+ "API Not Available Yet"
1009
+ );
1010
+ p.outro("Zip ready for manual upload.");
1011
+ return;
1012
+ }
1013
+ const msg = err instanceof Error ? err.message : String(err);
1014
+ if (msg.toLowerCase().includes("same name")) {
1015
+ error(`An ability named "${uniqueName}" already exists.`);
1016
+ warn(
1017
+ `To update it, delete it first with: openhome delete
1018
+ Or rename it in config.json and redeploy.`
1019
+ );
1020
+ } else {
1021
+ error(`Deploy failed: ${msg}`);
1022
+ }
1023
+ process.exit(1);
1024
+ }
1025
+ }
1026
+
1027
+ // src/commands/init.ts
1028
+ var DAEMON_TEMPLATES = /* @__PURE__ */ new Set(["background", "alarm"]);
1029
+ function toClassName(name) {
1030
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1031
+ }
1032
+ var SHARED_INIT = "";
1033
+ function sharedConfig() {
1034
+ return `{
1035
+ "unique_name": "{{UNIQUE_NAME}}",
1036
+ "description": "{{DESCRIPTION}}",
1037
+ "category": "{{CATEGORY}}",
1038
+ "matching_hotwords": {{HOTWORDS}}
1039
+ }
1040
+ `;
1041
+ }
1042
+ function skillReadme() {
1043
+ return `# {{DISPLAY_NAME}}
1044
+
1045
+ A custom OpenHome ability.
1046
+
1047
+ ## Trigger Words
1048
+
1049
+ {{HOTWORD_LIST}}
1050
+ `;
1051
+ }
1052
+ function daemonReadme() {
1053
+ return `# {{DISPLAY_NAME}}
1054
+
1055
+ A background OpenHome daemon. Runs automatically on session start \u2014 no trigger words required.
1056
+
1057
+ ## Trigger Words
1058
+
1059
+ {{HOTWORD_LIST}}
1060
+ `;
1061
+ }
1062
+ function getTemplate(templateType, file) {
1063
+ if (file === "config.json") return sharedConfig();
1064
+ if (file === "__init__.py") return SHARED_INIT;
1065
+ if (file === "README.md") {
1066
+ return DAEMON_TEMPLATES.has(templateType) ? daemonReadme() : skillReadme();
1067
+ }
1068
+ const templates = {
1069
+ // ── BASIC ────────────────────────────────────────────────────────────
1070
+ basic: {
1071
+ "main.py": `from src.agent.capability import MatchingCapability
1072
+ from src.main import AgentWorker
1073
+ from src.agent.capability_worker import CapabilityWorker
1074
+
1075
+
1076
+ class {{CLASS_NAME}}(MatchingCapability):
1077
+ worker: AgentWorker = None
1078
+ capability_worker: CapabilityWorker = None
1079
+
1080
+ @classmethod
1081
+ def register_capability(cls) -> "MatchingCapability":
1082
+ # {{register_capability}}
1083
+ pass
1084
+
1085
+ def call(self, worker: AgentWorker):
1086
+ self.worker = worker
1087
+ self.capability_worker = CapabilityWorker(self.worker)
1088
+ self.worker.session_tasks.create(self.run())
1089
+
1090
+ async def run(self):
1091
+ await self.capability_worker.speak("Hello! This ability is working.")
1092
+ self.capability_worker.resume_normal_flow()
1093
+ `
562
1094
  },
563
1095
  // ── API ──────────────────────────────────────────────────────────────
564
1096
  api: {
@@ -843,928 +1375,445 @@ from src.agent.capability import MatchingCapability
843
1375
  from src.main import AgentWorker
844
1376
  from src.agent.capability_worker import CapabilityWorker
845
1377
 
846
-
847
- class {{CLASS_NAME}}(MatchingCapability):
848
- worker: AgentWorker = None
849
- capability_worker: CapabilityWorker = None
850
-
851
- @classmethod
852
- def register_capability(cls) -> "MatchingCapability":
853
- # {{register_capability}}
854
- pass
855
-
856
- def call(self, worker: AgentWorker):
857
- self.worker = worker
858
- self.capability_worker = CapabilityWorker(self.worker)
859
- self.worker.session_tasks.create(self.run())
860
-
861
- async def run(self):
862
- reply = await self.capability_worker.run_io_loop(
863
- "What would you like me to remember?"
864
- )
865
-
866
- # Read existing notes or start fresh
867
- if self.capability_worker.check_if_file_exists("notes.json", temp=False):
868
- raw = self.capability_worker.read_file("notes.json", temp=False)
869
- notes = json.loads(raw)
870
- else:
871
- notes = []
872
-
873
- notes.append(reply.strip())
874
- self.capability_worker.write_file(
875
- "notes.json",
876
- json.dumps(notes, indent=2),
877
- temp=False,
878
- mode="w",
879
- )
880
-
881
- await self.capability_worker.speak(
882
- f"Got it! I now have {len(notes)} note{'s' if len(notes) != 1 else ''} saved."
883
- )
884
- self.capability_worker.resume_normal_flow()
885
- `
886
- },
887
- // ── LOCAL ────────────────────────────────────────────────────────────
888
- local: {
889
- "main.py": `from src.agent.capability import MatchingCapability
890
- from src.main import AgentWorker
891
- from src.agent.capability_worker import CapabilityWorker
892
-
893
-
894
- class {{CLASS_NAME}}(MatchingCapability):
895
- worker: AgentWorker = None
896
- capability_worker: CapabilityWorker = None
897
-
898
- @classmethod
899
- def register_capability(cls) -> "MatchingCapability":
900
- # {{register_capability}}
901
- pass
902
-
903
- def call(self, worker: AgentWorker):
904
- self.worker = worker
905
- self.capability_worker = CapabilityWorker(self.worker)
906
- self.worker.session_tasks.create(self.run())
907
-
908
- async def run(self):
909
- reply = await self.capability_worker.run_io_loop(
910
- "What would you like me to do on your device?"
911
- )
912
-
913
- # Use text_to_text to interpret the command
914
- response = self.capability_worker.text_to_text_response(
915
- f"The user wants to: {reply}. Generate a helpful response.",
916
- self.capability_worker.get_full_message_history(),
917
- )
918
-
919
- # Send action to DevKit hardware if connected
920
- self.capability_worker.send_devkit_action({
921
- "type": "command",
922
- "payload": reply.strip(),
923
- })
924
-
925
- await self.capability_worker.speak(response)
926
- self.capability_worker.resume_normal_flow()
927
- `
928
- },
929
- // ── OPENCLAW ─────────────────────────────────────────────────────────
930
- openclaw: {
931
- "main.py": `import requests
932
- from src.agent.capability import MatchingCapability
933
- from src.main import AgentWorker
934
- from src.agent.capability_worker import CapabilityWorker
935
-
936
-
937
- class {{CLASS_NAME}}(MatchingCapability):
938
- worker: AgentWorker = None
939
- capability_worker: CapabilityWorker = None
940
-
941
- @classmethod
942
- def register_capability(cls) -> "MatchingCapability":
943
- # {{register_capability}}
944
- pass
945
-
946
- def call(self, worker: AgentWorker):
947
- self.worker = worker
948
- self.capability_worker = CapabilityWorker(self.worker)
949
- self.worker.session_tasks.create(self.run())
950
-
951
- async def run(self):
952
- reply = await self.capability_worker.run_io_loop(
953
- "What would you like me to handle?"
954
- )
955
-
956
- gateway_url = self.capability_worker.get_single_key("openclaw_gateway_url")
957
- gateway_token = self.capability_worker.get_single_key("openclaw_gateway_token")
958
-
959
- if not gateway_url or not gateway_token:
960
- await self.capability_worker.speak(
961
- "OpenClaw gateway is not configured. Add openclaw_gateway_url and openclaw_gateway_token as secrets."
962
- )
963
- self.capability_worker.resume_normal_flow()
964
- return
965
-
966
- try:
967
- resp = requests.post(
968
- f"{gateway_url}/v1/chat",
969
- headers={
970
- "Authorization": f"Bearer {gateway_token}",
971
- "Content-Type": "application/json",
972
- },
973
- json={"message": reply.strip()},
974
- timeout=30,
975
- )
976
- data = resp.json()
977
- answer = data.get("reply", data.get("response", "No response from OpenClaw."))
978
- await self.capability_worker.speak(answer)
979
- except Exception as e:
980
- self.worker.editor_logging_handler.error(f"OpenClaw error: {e}")
981
- await self.capability_worker.speak("Sorry, I couldn't reach OpenClaw.")
982
-
983
- self.capability_worker.resume_normal_flow()
984
- `
985
- }
986
- };
987
- return templates[templateType]?.[file] ?? "";
988
- }
989
- function applyTemplate(content, vars) {
990
- let result = content;
991
- for (const [key, value] of Object.entries(vars)) {
992
- result = result.replaceAll(`{{${key}}}`, value);
993
- }
994
- return result;
995
- }
996
- function getFileList(templateType) {
997
- const base = ["__init__.py", "README.md", "config.json"];
998
- if (templateType === "background") {
999
- return ["main.py", "background.py", ...base];
1000
- }
1001
- if (templateType === "alarm") {
1002
- return ["main.py", "background.py", ...base];
1003
- }
1004
- return ["main.py", ...base];
1005
- }
1006
- function getTemplateOptions(category) {
1007
- if (category === "skill") {
1008
- return [
1009
- {
1010
- value: "basic",
1011
- label: "Basic",
1012
- hint: "Simple ability with speak + user_response"
1013
- },
1014
- {
1015
- value: "api",
1016
- label: "API",
1017
- hint: "Calls an external API using a stored secret"
1018
- },
1019
- {
1020
- value: "loop",
1021
- label: "Loop (ambient observer)",
1022
- hint: "Records audio periodically and checks in"
1023
- },
1024
- {
1025
- value: "email",
1026
- label: "Email",
1027
- hint: "Sends email via SMTP using stored credentials"
1028
- },
1029
- {
1030
- value: "readwrite",
1031
- label: "File Storage",
1032
- hint: "Reads and writes persistent JSON files"
1033
- },
1034
- {
1035
- value: "local",
1036
- label: "Local (DevKit)",
1037
- hint: "Executes commands on the local device via DevKit"
1038
- },
1039
- {
1040
- value: "openclaw",
1041
- label: "OpenClaw",
1042
- hint: "Forwards requests to the OpenClaw gateway"
1043
- }
1044
- ];
1045
- }
1046
- if (category === "brain") {
1047
- return [
1048
- {
1049
- value: "basic",
1050
- label: "Basic",
1051
- hint: "Simple ability with speak + user_response"
1052
- },
1053
- {
1054
- value: "api",
1055
- label: "API",
1056
- hint: "Calls an external API using a stored secret"
1057
- }
1058
- ];
1059
- }
1060
- return [
1061
- {
1062
- value: "background",
1063
- label: "Background (continuous)",
1064
- hint: "Runs a loop from session start, no trigger"
1378
+
1379
+ class {{CLASS_NAME}}(MatchingCapability):
1380
+ worker: AgentWorker = None
1381
+ capability_worker: CapabilityWorker = None
1382
+
1383
+ @classmethod
1384
+ def register_capability(cls) -> "MatchingCapability":
1385
+ # {{register_capability}}
1386
+ pass
1387
+
1388
+ def call(self, worker: AgentWorker):
1389
+ self.worker = worker
1390
+ self.capability_worker = CapabilityWorker(self.worker)
1391
+ self.worker.session_tasks.create(self.run())
1392
+
1393
+ async def run(self):
1394
+ reply = await self.capability_worker.run_io_loop(
1395
+ "What would you like me to remember?"
1396
+ )
1397
+
1398
+ # Read existing notes or start fresh
1399
+ if self.capability_worker.check_if_file_exists("notes.json", temp=False):
1400
+ raw = self.capability_worker.read_file("notes.json", temp=False)
1401
+ notes = json.loads(raw)
1402
+ else:
1403
+ notes = []
1404
+
1405
+ notes.append(reply.strip())
1406
+ self.capability_worker.write_file(
1407
+ "notes.json",
1408
+ json.dumps(notes, indent=2),
1409
+ temp=False,
1410
+ mode="w",
1411
+ )
1412
+
1413
+ await self.capability_worker.speak(
1414
+ f"Got it! I now have {len(notes)} note{'s' if len(notes) != 1 else ''} saved."
1415
+ )
1416
+ self.capability_worker.resume_normal_flow()
1417
+ `
1065
1418
  },
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
- }
1419
+ // ── LOCAL ────────────────────────────────────────────────────────────
1420
+ local: {
1421
+ "main.py": `from src.agent.capability import MatchingCapability
1422
+ from src.main import AgentWorker
1423
+ from src.agent.capability_worker import CapabilityWorker
1424
+
1425
+
1426
+ class {{CLASS_NAME}}(MatchingCapability):
1427
+ worker: AgentWorker = None
1428
+ capability_worker: CapabilityWorker = None
1429
+
1430
+ @classmethod
1431
+ def register_capability(cls) -> "MatchingCapability":
1432
+ # {{register_capability}}
1433
+ pass
1434
+
1435
+ def call(self, worker: AgentWorker):
1436
+ self.worker = worker
1437
+ self.capability_worker = CapabilityWorker(self.worker)
1438
+ self.worker.session_tasks.create(self.run())
1439
+
1440
+ async def run(self):
1441
+ reply = await self.capability_worker.run_io_loop(
1442
+ "What would you like me to do on your device?"
1443
+ )
1444
+
1445
+ # Use text_to_text to interpret the command
1446
+ response = self.capability_worker.text_to_text_response(
1447
+ f"The user wants to: {reply}. Generate a helpful response.",
1448
+ self.capability_worker.get_full_message_history(),
1449
+ )
1450
+
1451
+ # Send action to DevKit hardware if connected
1452
+ self.capability_worker.send_devkit_action({
1453
+ "type": "command",
1454
+ "payload": reply.strip(),
1455
+ })
1277
1456
 
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";
1457
+ await self.capability_worker.speak(response)
1458
+ self.capability_worker.resume_normal_flow()
1459
+ `
1460
+ },
1461
+ // ── OPENCLAW ─────────────────────────────────────────────────────────
1462
+ openclaw: {
1463
+ "main.py": `import requests
1464
+ from src.agent.capability import MatchingCapability
1465
+ from src.main import AgentWorker
1466
+ from src.agent.capability_worker import CapabilityWorker
1288
1467
 
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
1468
 
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
- };
1469
+ class {{CLASS_NAME}}(MatchingCapability):
1470
+ worker: AgentWorker = None
1471
+ capability_worker: CapabilityWorker = None
1431
1472
 
1432
- // src/commands/deploy.ts
1433
- var IMAGE_EXTENSIONS = ["png", "jpg", "jpeg"];
1434
- var ICON_NAMES = IMAGE_EXTENSIONS.flatMap((ext) => [
1435
- `icon.${ext}`,
1436
- `image.${ext}`,
1437
- `logo.${ext}`
1438
- ]);
1439
- function findIcon(dir) {
1440
- for (const name of ICON_NAMES) {
1441
- const p2 = join3(dir, name);
1442
- if (existsSync3(p2)) return p2;
1443
- }
1444
- return null;
1445
- }
1446
- async function resolveAbilityDir(pathArg) {
1447
- if (pathArg && pathArg !== ".") {
1448
- return resolve2(pathArg);
1449
- }
1450
- const tracked = getTrackedAbilities();
1451
- const cwd = process.cwd();
1452
- const cwdIsAbility = existsSync3(resolve2(cwd, "config.json"));
1453
- if (cwdIsAbility) {
1454
- info(`Detected ability in current directory`);
1455
- return cwd;
1456
- }
1457
- const options = [];
1458
- for (const a of tracked) {
1459
- const home = homedir2();
1460
- options.push({
1461
- value: a.path,
1462
- label: a.name,
1463
- hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path
1464
- });
1465
- }
1466
- if (options.length === 1) {
1467
- info(`Using ability: ${options[0].label} (${options[0].hint})`);
1468
- return options[0].value;
1469
- }
1470
- if (options.length > 0) {
1471
- options.push({
1472
- value: "__custom__",
1473
- label: "Other...",
1474
- hint: "Enter a path manually"
1475
- });
1476
- const selected = await p.select({
1477
- message: "Which ability do you want to deploy?",
1478
- options
1479
- });
1480
- handleCancel(selected);
1481
- if (selected !== "__custom__") {
1482
- return selected;
1473
+ @classmethod
1474
+ def register_capability(cls) -> "MatchingCapability":
1475
+ # {{register_capability}}
1476
+ pass
1477
+
1478
+ def call(self, worker: AgentWorker):
1479
+ self.worker = worker
1480
+ self.capability_worker = CapabilityWorker(self.worker)
1481
+ self.worker.session_tasks.create(self.run())
1482
+
1483
+ async def run(self):
1484
+ reply = await self.capability_worker.run_io_loop(
1485
+ "What would you like me to handle?"
1486
+ )
1487
+
1488
+ gateway_url = self.capability_worker.get_single_key("openclaw_gateway_url")
1489
+ gateway_token = self.capability_worker.get_single_key("openclaw_gateway_token")
1490
+
1491
+ if not gateway_url or not gateway_token:
1492
+ await self.capability_worker.speak(
1493
+ "OpenClaw gateway is not configured. Add openclaw_gateway_url and openclaw_gateway_token as secrets."
1494
+ )
1495
+ self.capability_worker.resume_normal_flow()
1496
+ return
1497
+
1498
+ try:
1499
+ resp = requests.post(
1500
+ f"{gateway_url}/v1/chat",
1501
+ headers={
1502
+ "Authorization": f"Bearer {gateway_token}",
1503
+ "Content-Type": "application/json",
1504
+ },
1505
+ json={"message": reply.strip()},
1506
+ timeout=30,
1507
+ )
1508
+ data = resp.json()
1509
+ answer = data.get("reply", data.get("response", "No response from OpenClaw."))
1510
+ await self.capability_worker.speak(answer)
1511
+ except Exception as e:
1512
+ self.worker.editor_logging_handler.error(f"OpenClaw error: {e}")
1513
+ await self.capability_worker.speak("Sorry, I couldn't reach OpenClaw.")
1514
+
1515
+ self.capability_worker.resume_normal_flow()
1516
+ `
1483
1517
  }
1518
+ };
1519
+ return templates[templateType]?.[file] ?? "";
1520
+ }
1521
+ function applyTemplate(content, vars) {
1522
+ let result = content;
1523
+ for (const [key, value] of Object.entries(vars)) {
1524
+ result = result.replaceAll(`{{${key}}}`, value);
1484
1525
  }
1485
- const pathInput = await p.text({
1486
- message: "Path to ability directory",
1487
- placeholder: "./my-ability",
1488
- validate: (val) => {
1489
- if (!val || !val.trim()) return "Path is required";
1490
- if (!existsSync3(resolve2(val.trim(), "config.json"))) {
1491
- return `No config.json found in "${val.trim()}"`;
1492
- }
1493
- }
1494
- });
1495
- handleCancel(pathInput);
1496
- return resolve2(pathInput.trim());
1526
+ return result;
1497
1527
  }
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);
1528
+ function getFileList(templateType) {
1529
+ const base = ["__init__.py", "README.md", "config.json"];
1530
+ if (templateType === "background") {
1531
+ return ["main.py", "background.py", ...base];
1510
1532
  }
1511
- s.stop("Validation passed.");
1512
- if (validation.warnings.length > 0) {
1513
- for (const w of validation.warnings) {
1514
- warn(` ${w.file ? `[${w.file}] ` : ""}${w.message}`);
1515
- }
1533
+ if (templateType === "alarm") {
1534
+ return ["main.py", "background.py", ...base];
1516
1535
  }
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);
1536
+ return ["main.py", ...base];
1537
+ }
1538
+ function getTemplateOptions(category) {
1539
+ if (category === "skill") {
1540
+ return [
1541
+ {
1542
+ value: "basic",
1543
+ label: "Basic",
1544
+ hint: "Simple ability with speak + user_response"
1545
+ },
1546
+ {
1547
+ value: "api",
1548
+ label: "API",
1549
+ hint: "Calls an external API using a stored secret"
1550
+ },
1551
+ {
1552
+ value: "loop",
1553
+ label: "Loop (ambient observer)",
1554
+ hint: "Records audio periodically and checks in"
1555
+ },
1556
+ {
1557
+ value: "email",
1558
+ label: "Email",
1559
+ hint: "Sends email via SMTP using stored credentials"
1560
+ },
1561
+ {
1562
+ value: "readwrite",
1563
+ label: "File Storage",
1564
+ hint: "Reads and writes persistent JSON files"
1565
+ },
1566
+ {
1567
+ value: "local",
1568
+ label: "Local (DevKit)",
1569
+ hint: "Executes commands on the local device via DevKit"
1570
+ },
1571
+ {
1572
+ value: "openclaw",
1573
+ label: "OpenClaw",
1574
+ hint: "Forwards requests to the OpenClaw gateway"
1575
+ }
1576
+ ];
1526
1577
  }
1527
- const uniqueName = abilityConfig.unique_name;
1528
- const hotwords = abilityConfig.matching_hotwords ?? [];
1529
- let description = abilityConfig.description?.trim();
1530
- if (!description) {
1531
- const descInput = await p.text({
1532
- message: "Ability description (required for marketplace)",
1533
- placeholder: "A fun ability that does something cool",
1534
- validate: (val) => {
1535
- if (!val || !val.trim()) return "Description is required";
1578
+ if (category === "brain") {
1579
+ return [
1580
+ {
1581
+ value: "basic",
1582
+ label: "Basic",
1583
+ hint: "Simple ability with speak + user_response"
1584
+ },
1585
+ {
1586
+ value: "api",
1587
+ label: "API",
1588
+ hint: "Calls an external API using a stored secret"
1536
1589
  }
1537
- });
1538
- handleCancel(descInput);
1539
- description = descInput.trim();
1590
+ ];
1540
1591
  }
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
- ]
1592
+ return [
1593
+ {
1594
+ value: "background",
1595
+ label: "Background (continuous)",
1596
+ hint: "Runs a loop from session start, no trigger"
1597
+ },
1598
+ {
1599
+ value: "alarm",
1600
+ label: "Alarm (skill + daemon combo)",
1601
+ hint: "Skill sets an alarm; background.py fires it"
1602
+ }
1603
+ ];
1604
+ }
1605
+ async function initCommand(nameArg) {
1606
+ p.intro("Create a new OpenHome ability");
1607
+ let name;
1608
+ if (nameArg) {
1609
+ name = nameArg.trim();
1610
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
1611
+ error(
1612
+ "Invalid name. Use lowercase letters, numbers, and hyphens only. Must start with a letter."
1613
+ );
1614
+ process.exit(1);
1615
+ }
1616
+ } else {
1617
+ const nameInput = await p.text({
1618
+ message: "What should your ability be called?",
1619
+ placeholder: "my-cool-ability",
1620
+ validate: (val) => {
1621
+ if (!val || !val.trim()) return "Name is required";
1622
+ if (!/^[a-z][a-z0-9-]*$/.test(val.trim()))
1623
+ return "Use lowercase letters, numbers, and hyphens only. Must start with a letter.";
1624
+ }
1554
1625
  });
1555
- handleCancel(catChoice);
1556
- category = catChoice;
1626
+ handleCancel(nameInput);
1627
+ name = nameInput.trim();
1557
1628
  }
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 {
1629
+ const category = await p.select({
1630
+ message: "What type of ability?",
1631
+ options: [
1632
+ {
1633
+ value: "skill",
1634
+ label: "Skill",
1635
+ hint: "User-triggered, runs on demand (most common)"
1636
+ },
1637
+ {
1638
+ value: "brain",
1639
+ label: "Brain Skill",
1640
+ hint: "Auto-triggered by the agent's intelligence"
1641
+ },
1642
+ {
1643
+ value: "daemon",
1644
+ label: "Background Daemon",
1645
+ hint: "Runs continuously from session start"
1590
1646
  }
1647
+ ]
1648
+ });
1649
+ handleCancel(category);
1650
+ const descInput = await p.text({
1651
+ message: "Short description for the marketplace",
1652
+ placeholder: "A fun ability that checks the weather",
1653
+ validate: (val) => {
1654
+ if (!val || !val.trim()) return "Description is required";
1591
1655
  }
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)"
1656
+ });
1657
+ handleCancel(descInput);
1658
+ const description = descInput.trim();
1659
+ const templateOptions = getTemplateOptions(category);
1660
+ const templateType = await p.select({
1661
+ message: "Choose a template",
1662
+ options: templateOptions
1663
+ });
1664
+ handleCancel(templateType);
1665
+ const hotwordInput = await p.text({
1666
+ message: DAEMON_TEMPLATES.has(templateType) ? "Trigger words (comma-separated, or leave empty for daemons)" : "Trigger words (comma-separated)",
1667
+ placeholder: "check weather, weather please",
1668
+ validate: (val) => {
1669
+ if (!DAEMON_TEMPLATES.has(templateType)) {
1670
+ if (!val || !val.trim()) return "At least one trigger word is required";
1671
+ }
1672
+ }
1673
+ });
1674
+ handleCancel(hotwordInput);
1675
+ const hotwords = hotwordInput.split(",").map((h) => h.trim()).filter(Boolean);
1676
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
1677
+ const home = homedir2();
1678
+ const candidateDirs = [
1679
+ process.cwd(),
1680
+ join3(home, "Desktop"),
1681
+ join3(home, "Downloads"),
1682
+ join3(home, "Pictures"),
1683
+ join3(home, "Images"),
1684
+ join3(home, ".openhome", "icons")
1685
+ ];
1686
+ if (process.env.USERPROFILE) {
1687
+ candidateDirs.push(
1688
+ join3(process.env.USERPROFILE, "Desktop"),
1689
+ join3(process.env.USERPROFILE, "Downloads"),
1690
+ join3(process.env.USERPROFILE, "Pictures")
1691
+ );
1692
+ }
1693
+ const scanDirs = [...new Set(candidateDirs)];
1694
+ const foundImages = [];
1695
+ for (const dir of scanDirs) {
1696
+ if (!existsSync3(dir)) continue;
1697
+ try {
1698
+ const files2 = readdirSync3(dir);
1699
+ for (const file of files2) {
1700
+ if (IMAGE_EXTS.has(extname2(file).toLowerCase())) {
1701
+ const full = join3(dir, file);
1702
+ const shortDir = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
1703
+ foundImages.push({
1704
+ path: full,
1705
+ label: `${file} (${shortDir})`
1706
+ });
1604
1707
  }
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
1708
  }
1629
- } else {
1630
- const imgInput = await p.text({
1631
- message: "Path to ability icon image (PNG or JPG, optional \u2014 press Enter to skip)",
1709
+ } catch {
1710
+ }
1711
+ }
1712
+ let iconSourcePath;
1713
+ if (foundImages.length > 0) {
1714
+ const imageOptions = [
1715
+ ...foundImages.map((img) => ({ value: img.path, label: img.label })),
1716
+ { value: "__custom__", label: "Other...", hint: "Enter a path manually" }
1717
+ ];
1718
+ const selected = await p.select({
1719
+ message: "Select an icon image (PNG or JPG for marketplace)",
1720
+ options: imageOptions
1721
+ });
1722
+ handleCancel(selected);
1723
+ if (selected === "__custom__") {
1724
+ const iconInput = await p.text({
1725
+ message: "Path to icon image",
1632
1726
  placeholder: "./icon.png",
1633
1727
  validate: (val) => {
1634
- if (!val || !val.trim()) return void 0;
1728
+ if (!val || !val.trim()) return "An icon image is required";
1635
1729
  const resolved = resolve2(val.trim());
1636
1730
  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";
1731
+ const ext = extname2(resolved).toLowerCase();
1732
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
1639
1733
  }
1640
1734
  });
1641
- handleCancel(imgInput);
1642
- const trimmed = imgInput.trim();
1643
- if (trimmed) imagePath = resolve2(trimmed);
1735
+ handleCancel(iconInput);
1736
+ iconSourcePath = resolve2(iconInput.trim());
1737
+ } else {
1738
+ iconSourcePath = selected;
1644
1739
  }
1740
+ } else {
1741
+ const iconInput = await p.text({
1742
+ message: "Path to icon image (PNG or JPG for marketplace)",
1743
+ placeholder: "./icon.png",
1744
+ validate: (val) => {
1745
+ if (!val || !val.trim()) return "An icon image is required";
1746
+ const resolved = resolve2(val.trim());
1747
+ if (!existsSync3(resolved)) return `File not found: ${val.trim()}`;
1748
+ const ext = extname2(resolved).toLowerCase();
1749
+ if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
1750
+ }
1751
+ });
1752
+ handleCancel(iconInput);
1753
+ iconSourcePath = resolve2(iconInput.trim());
1645
1754
  }
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");
1755
+ const iconExt = extname2(iconSourcePath).toLowerCase();
1756
+ const iconFileName = iconExt === ".jpeg" ? "icon.jpg" : `icon${iconExt}`;
1757
+ const abilitiesDir = resolve2("abilities");
1758
+ const targetDir = join3(abilitiesDir, name);
1759
+ if (existsSync3(targetDir)) {
1760
+ error(`Directory "abilities/${name}" already exists.`);
1706
1761
  process.exit(1);
1707
1762
  }
1708
1763
  const confirmed = await p.confirm({
1709
- message: `Deploy "${uniqueName}" to OpenHome?`
1764
+ message: `Create ability "${name}" with ${hotwords.length} trigger word(s)?`
1710
1765
  });
1711
1766
  handleCancel(confirmed);
1712
1767
  if (!confirmed) {
1713
1768
  p.cancel("Aborted.");
1714
1769
  process.exit(0);
1715
1770
  }
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;
1771
+ const s = p.spinner();
1772
+ s.start("Generating ability files...");
1773
+ mkdirSync2(targetDir, { recursive: true });
1774
+ const className = toClassName(name);
1775
+ const displayName = name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1776
+ const vars = {
1777
+ CLASS_NAME: className,
1778
+ UNIQUE_NAME: name,
1779
+ DISPLAY_NAME: displayName,
1780
+ DESCRIPTION: description,
1781
+ CATEGORY: category,
1782
+ HOTWORDS: JSON.stringify(hotwords),
1783
+ HOTWORD_LIST: hotwords.length > 0 ? hotwords.map((h) => `- "${h}"`).join("\n") : "_None (daemon)_"
1784
+ };
1785
+ const resolvedTemplate = templateType;
1786
+ const files = getFileList(resolvedTemplate);
1787
+ for (const file of files) {
1788
+ const content = applyTemplate(getTemplate(resolvedTemplate, file), vars);
1789
+ writeFileSync2(join3(targetDir, file), content, "utf8");
1790
+ }
1791
+ copyFileSync(iconSourcePath, join3(targetDir, iconFileName));
1792
+ s.stop("Files generated.");
1793
+ registerAbility(name, targetDir);
1794
+ const result = validateAbility(targetDir);
1795
+ if (result.passed) {
1796
+ success("Validation passed.");
1797
+ } else {
1798
+ for (const issue of result.errors) {
1799
+ error(`${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
1755
1800
  }
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}`);
1801
+ }
1802
+ for (const w of result.warnings) {
1803
+ warn(`${w.file ? `[${w.file}] ` : ""}${w.message}`);
1804
+ }
1805
+ if (result.passed) {
1806
+ const deployNow = await p.confirm({
1807
+ message: "Deploy to OpenHome now?",
1808
+ initialValue: true
1809
+ });
1810
+ handleCancel(deployNow);
1811
+ if (deployNow) {
1812
+ await deployCommand(targetDir);
1813
+ return;
1765
1814
  }
1766
- process.exit(1);
1767
1815
  }
1816
+ p.outro(`Ability "${name}" is ready! Run: openhome deploy`);
1768
1817
  }
1769
1818
 
1770
1819
  // src/commands/delete.ts
@@ -1775,12 +1824,19 @@ async function deleteCommand(abilityArg, opts = {}) {
1775
1824
  if (opts.mock) {
1776
1825
  client = new MockApiClient();
1777
1826
  } else {
1778
- const apiKey = getApiKey();
1779
- if (!apiKey) {
1827
+ const apiKey = getApiKey() ?? "";
1828
+ const jwt = getJwt2() ?? void 0;
1829
+ if (!apiKey && !jwt) {
1780
1830
  error("Not authenticated. Run: openhome login");
1781
1831
  process.exit(1);
1782
1832
  }
1783
- client = new ApiClient(apiKey, getConfig().api_base_url);
1833
+ if (!jwt) {
1834
+ error(
1835
+ "This command requires a session token.\nGet it from app.openhome.com \u2192 DevTools \u2192 Application \u2192 Local Storage \u2192 token\nThen run: openhome set-jwt <token>"
1836
+ );
1837
+ process.exit(1);
1838
+ }
1839
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
1784
1840
  }
1785
1841
  const s = p.spinner();
1786
1842
  s.start("Fetching abilities...");
@@ -1857,12 +1913,19 @@ async function toggleCommand(abilityArg, opts = {}) {
1857
1913
  if (opts.mock) {
1858
1914
  client = new MockApiClient();
1859
1915
  } else {
1860
- const apiKey = getApiKey();
1861
- if (!apiKey) {
1916
+ const apiKey = getApiKey() ?? "";
1917
+ const jwt = getJwt2() ?? void 0;
1918
+ if (!apiKey && !jwt) {
1862
1919
  error("Not authenticated. Run: openhome login");
1863
1920
  process.exit(1);
1864
1921
  }
1865
- client = new ApiClient(apiKey, getConfig().api_base_url);
1922
+ if (!jwt) {
1923
+ error(
1924
+ "This command requires a session token.\nGet it from app.openhome.com \u2192 DevTools \u2192 Application \u2192 Local Storage \u2192 token\nThen run: openhome set-jwt <token>"
1925
+ );
1926
+ process.exit(1);
1927
+ }
1928
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
1866
1929
  }
1867
1930
  const s = p.spinner();
1868
1931
  s.start("Fetching abilities...");
@@ -1949,12 +2012,19 @@ async function assignCommand(opts = {}) {
1949
2012
  if (opts.mock) {
1950
2013
  client = new MockApiClient();
1951
2014
  } else {
1952
- const apiKey = getApiKey();
1953
- if (!apiKey) {
2015
+ const apiKey = getApiKey() ?? "";
2016
+ const jwt = getJwt2() ?? void 0;
2017
+ if (!apiKey && !jwt) {
1954
2018
  error("Not authenticated. Run: openhome login");
1955
2019
  process.exit(1);
1956
2020
  }
1957
- client = new ApiClient(apiKey, getConfig().api_base_url);
2021
+ if (!jwt) {
2022
+ error(
2023
+ "This command requires a session token.\nGet it from app.openhome.com \u2192 DevTools \u2192 Application \u2192 Local Storage \u2192 token\nThen run: openhome set-jwt <token>"
2024
+ );
2025
+ process.exit(1);
2026
+ }
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,19 @@ 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
+ if (!jwt) {
2131
+ error(
2132
+ "This command requires a session token.\nGet it from app.openhome.com \u2192 DevTools \u2192 Application \u2192 Local Storage \u2192 token\nThen run: openhome set-jwt <token>"
2133
+ );
2134
+ process.exit(1);
2135
+ }
2136
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
2060
2137
  }
2061
2138
  const s = p.spinner();
2062
2139
  s.start("Fetching abilities...");
@@ -2170,12 +2247,13 @@ async function statusCommand(abilityArg, opts = {}) {
2170
2247
  if (opts.mock) {
2171
2248
  client = new MockApiClient();
2172
2249
  } else {
2173
- const apiKey = getApiKey();
2174
- if (!apiKey) {
2250
+ const apiKey = getApiKey() ?? "";
2251
+ const jwt = getJwt() ?? void 0;
2252
+ if (!apiKey && !jwt) {
2175
2253
  error("Not authenticated. Run: openhome login");
2176
2254
  process.exit(1);
2177
2255
  }
2178
- client = new ApiClient(apiKey, getConfig().api_base_url);
2256
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
2179
2257
  }
2180
2258
  const s = p.spinner();
2181
2259
  s.start("Fetching status...");
@@ -2965,6 +3043,42 @@ async function logsCommand(opts = {}) {
2965
3043
  });
2966
3044
  }
2967
3045
 
3046
+ // src/commands/set-jwt.ts
3047
+ async function setJwtCommand(token) {
3048
+ p.intro("\u{1F511} Set Session Token");
3049
+ let jwt = token;
3050
+ if (!jwt) {
3051
+ const input = await p.text({
3052
+ message: "Paste your OpenHome session token",
3053
+ placeholder: "eyJ...",
3054
+ validate: (val) => {
3055
+ if (!val || !val.trim()) return "Token is required";
3056
+ if (!val.trim().startsWith("eyJ"))
3057
+ return "Doesn't look like a JWT \u2014 should start with eyJ";
3058
+ }
3059
+ });
3060
+ if (typeof input === "symbol") {
3061
+ p.cancel("Cancelled.");
3062
+ return;
3063
+ }
3064
+ jwt = input;
3065
+ }
3066
+ try {
3067
+ saveJwt(jwt.trim());
3068
+ success("Session token saved.");
3069
+ p.note(
3070
+ "Management commands (list, delete, toggle, assign) are now unlocked.",
3071
+ "Token saved"
3072
+ );
3073
+ p.outro("Done.");
3074
+ } catch (err) {
3075
+ error(
3076
+ `Failed to save token: ${err instanceof Error ? err.message : String(err)}`
3077
+ );
3078
+ process.exit(1);
3079
+ }
3080
+ }
3081
+
2968
3082
  // src/cli.ts
2969
3083
  var __filename = fileURLToPath(import.meta.url);
2970
3084
  var __dirname = dirname(__filename);
@@ -2977,7 +3091,7 @@ try {
2977
3091
  } catch {
2978
3092
  }
2979
3093
  async function ensureLoggedIn() {
2980
- const { getApiKey: getApiKey2 } = await import("./store-DR7EKQ5T.js");
3094
+ const { getApiKey: getApiKey2 } = await import("./store-USDMWKXY.js");
2981
3095
  const key = getApiKey2();
2982
3096
  if (!key) {
2983
3097
  await loginCommand();
@@ -2995,12 +3109,7 @@ async function interactiveMenu() {
2995
3109
  {
2996
3110
  value: "init",
2997
3111
  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"
3112
+ hint: "Scaffold and deploy a new ability"
3004
3113
  },
3005
3114
  {
3006
3115
  value: "chat",
@@ -3070,9 +3179,6 @@ async function interactiveMenu() {
3070
3179
  case "init":
3071
3180
  await initCommand();
3072
3181
  break;
3073
- case "deploy":
3074
- await deployCommand();
3075
- break;
3076
3182
  case "chat":
3077
3183
  await chatCommand();
3078
3184
  break;
@@ -3171,6 +3277,11 @@ program.command("logs").description("Stream live agent messages and logs").optio
3171
3277
  program.command("whoami").description("Show auth status, default agent, and tracked abilities").action(async () => {
3172
3278
  await whoamiCommand();
3173
3279
  });
3280
+ program.command("set-jwt [token]").description(
3281
+ "Save a session token to enable management commands (list, delete, toggle, assign)"
3282
+ ).action(async (token) => {
3283
+ await setJwtCommand(token);
3284
+ });
3174
3285
  if (process.argv.length <= 2) {
3175
3286
  interactiveMenu().catch((err) => {
3176
3287
  console.error(err instanceof Error ? err.message : String(err));