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