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/{chunk-Q4UKUXDB.js → chunk-OAKGNZQM.js} +11 -1
- package/dist/cli.js +1196 -1132
- package/dist/{store-DR7EKQ5T.js → store-USDMWKXY.js} +7 -3
- package/package.json +1 -1
- package/src/api/client.ts +76 -25
- package/src/api/contracts.ts +21 -0
- package/src/api/endpoints.ts +1 -2
- package/src/cli.ts +1 -9
- package/src/commands/assign.ts +5 -4
- package/src/commands/delete.ts +5 -4
- package/src/commands/init.ts +14 -2
- package/src/commands/list.ts +5 -4
- package/src/commands/login.ts +23 -1
- package/src/commands/status.ts +4 -3
- package/src/commands/toggle.ts +5 -4
- package/src/config/store.ts +11 -1
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
|
-
|
|
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-
|
|
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 ${
|
|
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"
|
|
78
|
+
if (body?.error?.code === "NOT_IMPLEMENTED") {
|
|
73
79
|
throw new NotImplementedError(path);
|
|
74
80
|
}
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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: "
|
|
197
|
+
method: "PUT",
|
|
168
198
|
headers: { "Content-Type": "application/json" },
|
|
169
|
-
body: JSON.stringify({
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
281
|
-
readdirSync as
|
|
338
|
+
existsSync as existsSync3,
|
|
339
|
+
readdirSync as readdirSync3
|
|
282
340
|
} from "fs";
|
|
283
|
-
import { join as
|
|
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/
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
|
531
|
-
if (
|
|
532
|
-
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
572
|
-
worker: AgentWorker = None
|
|
573
|
-
capability_worker: CapabilityWorker = None
|
|
1065
|
+
## Trigger Words
|
|
574
1066
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1067
|
+
{{HOTWORD_LIST}}
|
|
1068
|
+
`;
|
|
1069
|
+
}
|
|
1070
|
+
function daemonReadme() {
|
|
1071
|
+
return `# {{DISPLAY_NAME}}
|
|
579
1072
|
|
|
580
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
// ──
|
|
598
|
-
|
|
599
|
-
"main.py": `import
|
|
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("
|
|
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
|
-
// ──
|
|
643
|
-
|
|
644
|
-
"main.py": `import
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
765
|
-
|
|
766
|
-
"main.py": `
|
|
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}}
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
// ──
|
|
840
|
-
|
|
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
|
-
"
|
|
1226
|
+
"Who should I send the email to?"
|
|
864
1227
|
)
|
|
1228
|
+
to_addr = reply.strip()
|
|
865
1229
|
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
874
|
-
|
|
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
|
-
|
|
882
|
-
|
|
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
|
-
// ──
|
|
888
|
-
|
|
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
|
-
|
|
910
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
"payload": reply.strip(),
|
|
923
|
-
})
|
|
1289
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
1290
|
+
worker: AgentWorker = None
|
|
1291
|
+
capability_worker: CapabilityWorker = None
|
|
924
1292
|
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
// ──
|
|
930
|
-
|
|
931
|
-
"main.py": `import
|
|
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
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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 (
|
|
1467
|
-
|
|
1468
|
-
return options[0].value;
|
|
1551
|
+
if (templateType === "alarm") {
|
|
1552
|
+
return ["main.py", "background.py", ...base];
|
|
1469
1553
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
|
1499
|
-
p.intro("
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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 "
|
|
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(
|
|
1539
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
-
}
|
|
1630
|
-
|
|
1631
|
-
|
|
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
|
|
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
|
-
|
|
1638
|
-
|
|
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(
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
|
1647
|
-
const
|
|
1648
|
-
const
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
|
|
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: `
|
|
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.
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
);
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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;
|