openhome-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +470 -0
- package/bin/openhome.js +2 -0
- package/dist/chunk-Q4UKUXDB.js +164 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3184 -0
- package/dist/store-DR7EKQ5T.js +16 -0
- package/package.json +44 -0
- package/src/api/client.ts +231 -0
- package/src/api/contracts.ts +103 -0
- package/src/api/endpoints.ts +19 -0
- package/src/api/mock-client.ts +145 -0
- package/src/cli.ts +339 -0
- package/src/commands/agents.ts +88 -0
- package/src/commands/assign.ts +123 -0
- package/src/commands/chat.ts +265 -0
- package/src/commands/config-edit.ts +163 -0
- package/src/commands/delete.ts +107 -0
- package/src/commands/deploy.ts +430 -0
- package/src/commands/init.ts +895 -0
- package/src/commands/list.ts +78 -0
- package/src/commands/login.ts +54 -0
- package/src/commands/logout.ts +14 -0
- package/src/commands/logs.ts +174 -0
- package/src/commands/status.ts +174 -0
- package/src/commands/toggle.ts +118 -0
- package/src/commands/trigger.ts +193 -0
- package/src/commands/validate.ts +53 -0
- package/src/commands/whoami.ts +54 -0
- package/src/config/keychain.ts +62 -0
- package/src/config/store.ts +137 -0
- package/src/ui/format.ts +95 -0
- package/src/util/zip.ts +74 -0
- package/src/validation/rules.ts +71 -0
- package/src/validation/validator.ts +204 -0
- package/tasks/feature-request-sdk-api.md +246 -0
- package/tasks/prd-openhome-cli.md +605 -0
- package/templates/api/README.md.tmpl +11 -0
- package/templates/api/__init__.py.tmpl +0 -0
- package/templates/api/config.json.tmpl +4 -0
- package/templates/api/main.py.tmpl +30 -0
- package/templates/basic/README.md.tmpl +7 -0
- package/templates/basic/__init__.py.tmpl +0 -0
- package/templates/basic/config.json.tmpl +4 -0
- package/templates/basic/main.py.tmpl +22 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { resolve, join, basename, extname } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { validateAbility } from "../validation/validator.js";
|
|
11
|
+
import { createAbilityZip } from "../util/zip.js";
|
|
12
|
+
import { ApiClient, NotImplementedError } from "../api/client.js";
|
|
13
|
+
import { MockApiClient } from "../api/mock-client.js";
|
|
14
|
+
import { getApiKey, getConfig, getTrackedAbilities } from "../config/store.js";
|
|
15
|
+
import type {
|
|
16
|
+
AbilityCategory,
|
|
17
|
+
UploadAbilityMetadata,
|
|
18
|
+
} from "../api/contracts.js";
|
|
19
|
+
import { error, warn, info, p, handleCancel } from "../ui/format.js";
|
|
20
|
+
|
|
21
|
+
interface AbilityConfig {
|
|
22
|
+
unique_name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
category?: string;
|
|
25
|
+
matching_hotwords?: string[];
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg"];
|
|
30
|
+
const ICON_NAMES = IMAGE_EXTENSIONS.flatMap((ext) => [
|
|
31
|
+
`icon.${ext}`,
|
|
32
|
+
`image.${ext}`,
|
|
33
|
+
`logo.${ext}`,
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Find an icon image in the ability directory, or return null. */
|
|
37
|
+
function findIcon(dir: string): string | null {
|
|
38
|
+
for (const name of ICON_NAMES) {
|
|
39
|
+
const p = join(dir, name);
|
|
40
|
+
if (existsSync(p)) return p;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve ability dir: use arg, pick from tracked, detect cwd, or prompt. */
|
|
46
|
+
async function resolveAbilityDir(pathArg?: string): Promise<string> {
|
|
47
|
+
// Explicit path provided (CLI arg)
|
|
48
|
+
if (pathArg && pathArg !== ".") {
|
|
49
|
+
return resolve(pathArg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tracked = getTrackedAbilities();
|
|
53
|
+
const cwd = process.cwd();
|
|
54
|
+
const cwdIsAbility = existsSync(resolve(cwd, "config.json"));
|
|
55
|
+
|
|
56
|
+
// If we're inside an ability dir, just use it
|
|
57
|
+
if (cwdIsAbility) {
|
|
58
|
+
info(`Detected ability in current directory`);
|
|
59
|
+
return cwd;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build picker options from tracked abilities
|
|
63
|
+
const options: { value: string; label: string; hint?: string }[] = [];
|
|
64
|
+
|
|
65
|
+
for (const a of tracked) {
|
|
66
|
+
const home = homedir();
|
|
67
|
+
options.push({
|
|
68
|
+
value: a.path,
|
|
69
|
+
label: a.name,
|
|
70
|
+
hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// One tracked ability → auto-select
|
|
75
|
+
if (options.length === 1) {
|
|
76
|
+
info(`Using ability: ${options[0].label} (${options[0].hint})`);
|
|
77
|
+
return options[0].value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Multiple → show picker
|
|
81
|
+
if (options.length > 0) {
|
|
82
|
+
options.push({
|
|
83
|
+
value: "__custom__",
|
|
84
|
+
label: "Other...",
|
|
85
|
+
hint: "Enter a path manually",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const selected = await p.select({
|
|
89
|
+
message: "Which ability do you want to deploy?",
|
|
90
|
+
options,
|
|
91
|
+
});
|
|
92
|
+
handleCancel(selected);
|
|
93
|
+
|
|
94
|
+
if (selected !== "__custom__") {
|
|
95
|
+
return selected as string;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Fallback: manual path entry
|
|
100
|
+
const pathInput = await p.text({
|
|
101
|
+
message: "Path to ability directory",
|
|
102
|
+
placeholder: "./my-ability",
|
|
103
|
+
validate: (val) => {
|
|
104
|
+
if (!val || !val.trim()) return "Path is required";
|
|
105
|
+
if (!existsSync(resolve(val.trim(), "config.json"))) {
|
|
106
|
+
return `No config.json found in "${val.trim()}"`;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
handleCancel(pathInput);
|
|
111
|
+
return resolve((pathInput as string).trim());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function deployCommand(
|
|
115
|
+
pathArg?: string,
|
|
116
|
+
opts: { dryRun?: boolean; mock?: boolean; personality?: string } = {},
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
p.intro("🚀 Deploy ability");
|
|
119
|
+
const targetDir = await resolveAbilityDir(pathArg);
|
|
120
|
+
|
|
121
|
+
// Step 1: Validate
|
|
122
|
+
const s = p.spinner();
|
|
123
|
+
s.start("Validating ability...");
|
|
124
|
+
|
|
125
|
+
const validation = validateAbility(targetDir);
|
|
126
|
+
if (!validation.passed) {
|
|
127
|
+
s.stop("Validation failed.");
|
|
128
|
+
for (const issue of validation.errors) {
|
|
129
|
+
error(` ${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
s.stop("Validation passed.");
|
|
134
|
+
|
|
135
|
+
if (validation.warnings.length > 0) {
|
|
136
|
+
for (const w of validation.warnings) {
|
|
137
|
+
warn(` ${w.file ? `[${w.file}] ` : ""}${w.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Step 2: Read config
|
|
142
|
+
const configPath = join(targetDir, "config.json");
|
|
143
|
+
let abilityConfig: AbilityConfig;
|
|
144
|
+
try {
|
|
145
|
+
abilityConfig = JSON.parse(
|
|
146
|
+
readFileSync(configPath, "utf8"),
|
|
147
|
+
) as AbilityConfig;
|
|
148
|
+
} catch {
|
|
149
|
+
error("Could not read config.json");
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const uniqueName = abilityConfig.unique_name;
|
|
154
|
+
const hotwords = abilityConfig.matching_hotwords ?? [];
|
|
155
|
+
|
|
156
|
+
// Step 3: Resolve description (from config or prompt)
|
|
157
|
+
let description = abilityConfig.description?.trim();
|
|
158
|
+
if (!description) {
|
|
159
|
+
const descInput = await p.text({
|
|
160
|
+
message: "Ability description (required for marketplace)",
|
|
161
|
+
placeholder: "A fun ability that does something cool",
|
|
162
|
+
validate: (val) => {
|
|
163
|
+
if (!val || !val.trim()) return "Description is required";
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
handleCancel(descInput);
|
|
167
|
+
description = (descInput as string).trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Step 4: Resolve category (from config or prompt)
|
|
171
|
+
let category = abilityConfig.category as AbilityCategory | undefined;
|
|
172
|
+
if (!category || !["skill", "brain", "daemon"].includes(category)) {
|
|
173
|
+
const catChoice = await p.select({
|
|
174
|
+
message: "Ability category",
|
|
175
|
+
options: [
|
|
176
|
+
{ value: "skill", label: "Skill", hint: "User-triggered" },
|
|
177
|
+
{ value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
|
|
178
|
+
{
|
|
179
|
+
value: "daemon",
|
|
180
|
+
label: "Background Daemon",
|
|
181
|
+
hint: "Runs continuously",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
handleCancel(catChoice);
|
|
186
|
+
category = catChoice as AbilityCategory;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Step 5: Resolve image (auto-detect or prompt with picker)
|
|
190
|
+
let imagePath = findIcon(targetDir);
|
|
191
|
+
if (imagePath) {
|
|
192
|
+
info(`Found icon: ${basename(imagePath)}`);
|
|
193
|
+
} else {
|
|
194
|
+
// Scan common folders for images
|
|
195
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg"]);
|
|
196
|
+
const home = homedir();
|
|
197
|
+
const scanDirs = [
|
|
198
|
+
...new Set([
|
|
199
|
+
process.cwd(),
|
|
200
|
+
targetDir,
|
|
201
|
+
join(home, "Desktop"),
|
|
202
|
+
join(home, "Downloads"),
|
|
203
|
+
join(home, "Pictures"),
|
|
204
|
+
join(home, "Images"),
|
|
205
|
+
join(home, ".openhome", "icons"),
|
|
206
|
+
]),
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
const foundImages: { path: string; label: string }[] = [];
|
|
210
|
+
for (const dir of scanDirs) {
|
|
211
|
+
if (!existsSync(dir)) continue;
|
|
212
|
+
try {
|
|
213
|
+
for (const file of readdirSync(dir)) {
|
|
214
|
+
if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
|
|
215
|
+
const full = join(dir, file);
|
|
216
|
+
const shortDir = dir.startsWith(home)
|
|
217
|
+
? `~${dir.slice(home.length)}`
|
|
218
|
+
: dir;
|
|
219
|
+
foundImages.push({
|
|
220
|
+
path: full,
|
|
221
|
+
label: `${file} (${shortDir})`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// skip unreadable dirs
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (foundImages.length > 0) {
|
|
231
|
+
const imageOptions = [
|
|
232
|
+
...foundImages.map((img) => ({ value: img.path, label: img.label })),
|
|
233
|
+
{
|
|
234
|
+
value: "__custom__",
|
|
235
|
+
label: "Other...",
|
|
236
|
+
hint: "Enter a path manually",
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
value: "__skip__",
|
|
240
|
+
label: "Skip",
|
|
241
|
+
hint: "Upload without an icon (optional)",
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const selected = await p.select({
|
|
246
|
+
message: "Select an icon image (optional)",
|
|
247
|
+
options: imageOptions,
|
|
248
|
+
});
|
|
249
|
+
handleCancel(selected);
|
|
250
|
+
|
|
251
|
+
if (selected === "__custom__") {
|
|
252
|
+
const imgInput = await p.text({
|
|
253
|
+
message: "Path to icon image",
|
|
254
|
+
placeholder: "./icon.png",
|
|
255
|
+
validate: (val) => {
|
|
256
|
+
if (!val || !val.trim()) return undefined;
|
|
257
|
+
const resolved = resolve(val.trim());
|
|
258
|
+
if (!existsSync(resolved)) return `File not found: ${val.trim()}`;
|
|
259
|
+
if (!IMAGE_EXTS.has(extname(resolved).toLowerCase()))
|
|
260
|
+
return "Image must be PNG or JPG";
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
handleCancel(imgInput);
|
|
264
|
+
const trimmed = (imgInput as string).trim();
|
|
265
|
+
if (trimmed) imagePath = resolve(trimmed);
|
|
266
|
+
} else if (selected !== "__skip__") {
|
|
267
|
+
imagePath = selected as string;
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
const imgInput = await p.text({
|
|
271
|
+
message:
|
|
272
|
+
"Path to ability icon image (PNG or JPG, optional — press Enter to skip)",
|
|
273
|
+
placeholder: "./icon.png",
|
|
274
|
+
validate: (val) => {
|
|
275
|
+
if (!val || !val.trim()) return undefined;
|
|
276
|
+
const resolved = resolve(val.trim());
|
|
277
|
+
if (!existsSync(resolved)) return `File not found: ${val.trim()}`;
|
|
278
|
+
if (!IMAGE_EXTS.has(extname(resolved).toLowerCase()))
|
|
279
|
+
return "Image must be PNG or JPG";
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
handleCancel(imgInput);
|
|
283
|
+
const trimmed = (imgInput as string).trim();
|
|
284
|
+
if (trimmed) imagePath = resolve(trimmed);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const imageBuffer = imagePath ? readFileSync(imagePath) : null;
|
|
289
|
+
const imageName = imagePath ? basename(imagePath) : null;
|
|
290
|
+
|
|
291
|
+
const personalityId = opts.personality ?? getConfig().default_personality_id;
|
|
292
|
+
|
|
293
|
+
const metadata: UploadAbilityMetadata = {
|
|
294
|
+
name: uniqueName,
|
|
295
|
+
description,
|
|
296
|
+
category,
|
|
297
|
+
matching_hotwords: hotwords,
|
|
298
|
+
personality_id: personalityId,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Step 6: Dry run
|
|
302
|
+
if (opts.dryRun) {
|
|
303
|
+
p.note(
|
|
304
|
+
[
|
|
305
|
+
`Directory: ${targetDir}`,
|
|
306
|
+
`Name: ${uniqueName}`,
|
|
307
|
+
`Description: ${description}`,
|
|
308
|
+
`Category: ${category}`,
|
|
309
|
+
`Image: ${imageName ?? "(none)"}`,
|
|
310
|
+
`Hotwords: ${hotwords.join(", ")}`,
|
|
311
|
+
`Agent: ${personalityId ?? "(none set)"}`,
|
|
312
|
+
].join("\n"),
|
|
313
|
+
"Dry Run — would deploy",
|
|
314
|
+
);
|
|
315
|
+
p.outro("No changes made.");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Step 7: Create zip
|
|
320
|
+
s.start("Creating ability zip...");
|
|
321
|
+
let zipBuffer: Buffer;
|
|
322
|
+
try {
|
|
323
|
+
zipBuffer = await createAbilityZip(targetDir);
|
|
324
|
+
s.stop(`Zip created (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
s.stop("Failed to create zip.");
|
|
327
|
+
error(err instanceof Error ? err.message : String(err));
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Step 8: Deploy
|
|
332
|
+
if (opts.mock) {
|
|
333
|
+
s.start("Uploading ability (mock)...");
|
|
334
|
+
const mockClient = new MockApiClient();
|
|
335
|
+
const result = await mockClient.uploadAbility(
|
|
336
|
+
zipBuffer,
|
|
337
|
+
imageBuffer,
|
|
338
|
+
imageName,
|
|
339
|
+
metadata,
|
|
340
|
+
);
|
|
341
|
+
s.stop("Upload complete.");
|
|
342
|
+
|
|
343
|
+
p.note(
|
|
344
|
+
[
|
|
345
|
+
`Ability ID: ${result.ability_id}`,
|
|
346
|
+
`Status: ${result.status}`,
|
|
347
|
+
`Message: ${result.message}`,
|
|
348
|
+
].join("\n"),
|
|
349
|
+
"Mock Deploy Result",
|
|
350
|
+
);
|
|
351
|
+
p.outro("Mock deploy complete.");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const apiKey = getApiKey();
|
|
356
|
+
if (!apiKey) {
|
|
357
|
+
error("Not authenticated. Run: openhome login");
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Confirm before deploying
|
|
362
|
+
const confirmed = await p.confirm({
|
|
363
|
+
message: `Deploy "${uniqueName}" to OpenHome?`,
|
|
364
|
+
});
|
|
365
|
+
handleCancel(confirmed);
|
|
366
|
+
|
|
367
|
+
if (!confirmed) {
|
|
368
|
+
p.cancel("Aborted.");
|
|
369
|
+
process.exit(0);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
s.start("Uploading ability...");
|
|
373
|
+
try {
|
|
374
|
+
const client = new ApiClient(apiKey, getConfig().api_base_url);
|
|
375
|
+
const result = await client.uploadAbility(
|
|
376
|
+
zipBuffer,
|
|
377
|
+
imageBuffer,
|
|
378
|
+
imageName,
|
|
379
|
+
metadata,
|
|
380
|
+
);
|
|
381
|
+
s.stop("Upload complete.");
|
|
382
|
+
|
|
383
|
+
p.note(
|
|
384
|
+
[
|
|
385
|
+
`Ability ID: ${result.ability_id}`,
|
|
386
|
+
`Version: ${result.version}`,
|
|
387
|
+
`Status: ${result.status}`,
|
|
388
|
+
result.message ? `Message: ${result.message}` : "",
|
|
389
|
+
]
|
|
390
|
+
.filter(Boolean)
|
|
391
|
+
.join("\n"),
|
|
392
|
+
"Deploy Result",
|
|
393
|
+
);
|
|
394
|
+
p.outro("Deployed successfully! 🎉");
|
|
395
|
+
} catch (err) {
|
|
396
|
+
s.stop("Upload failed.");
|
|
397
|
+
|
|
398
|
+
if (err instanceof NotImplementedError) {
|
|
399
|
+
warn("This API endpoint is not yet available on the OpenHome server.");
|
|
400
|
+
|
|
401
|
+
const outDir = join(homedir(), ".openhome");
|
|
402
|
+
mkdirSync(outDir, { recursive: true });
|
|
403
|
+
const outPath = join(outDir, "last-deploy.zip");
|
|
404
|
+
writeFileSync(outPath, zipBuffer);
|
|
405
|
+
|
|
406
|
+
p.note(
|
|
407
|
+
[
|
|
408
|
+
`Your ability was validated and zipped successfully.`,
|
|
409
|
+
`Zip saved to: ${outPath}`,
|
|
410
|
+
``,
|
|
411
|
+
`Upload manually at https://app.openhome.com`,
|
|
412
|
+
].join("\n"),
|
|
413
|
+
"API Not Available Yet",
|
|
414
|
+
);
|
|
415
|
+
p.outro("Zip ready for manual upload.");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
420
|
+
if (msg.toLowerCase().includes("same name")) {
|
|
421
|
+
error(`An ability named "${uniqueName}" already exists.`);
|
|
422
|
+
warn(
|
|
423
|
+
`To update it, delete it first with: openhome delete\nOr rename it in config.json and redeploy.`,
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
error(`Deploy failed: ${msg}`);
|
|
427
|
+
}
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
}
|