openhome-cli 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -855,7 +855,44 @@ async function resolveAbilityDir(pathArg) {
855
855
  return resolve(pathInput.trim());
856
856
  }
857
857
  async function deployCommand(pathArg, opts = {}) {
858
- p.intro("\u{1F680} Deploy ability");
858
+ p.intro("\u{1F680} Upload Ability");
859
+ if (pathArg && pathArg.endsWith(".zip") && existsSync2(resolve(pathArg))) {
860
+ await deployZip(resolve(pathArg), opts);
861
+ return;
862
+ }
863
+ if (!pathArg) {
864
+ const mode = await p.select({
865
+ message: "What do you want to upload?",
866
+ options: [
867
+ {
868
+ value: "zip",
869
+ label: "\u{1F4E6} Upload a zip file",
870
+ hint: "I already have a .zip ready"
871
+ },
872
+ {
873
+ value: "folder",
874
+ label: "\u{1F4C1} Upload from a folder",
875
+ hint: "Point me to an ability directory"
876
+ }
877
+ ]
878
+ });
879
+ handleCancel(mode);
880
+ if (mode === "zip") {
881
+ const zipInput = await p.text({
882
+ message: "Path to your zip file",
883
+ placeholder: "./my-ability.zip",
884
+ validate: (val) => {
885
+ if (!val || !val.trim()) return "Path is required";
886
+ const resolved = resolve(val.trim());
887
+ if (!existsSync2(resolved)) return `File not found: ${val.trim()}`;
888
+ if (!resolved.endsWith(".zip")) return "Must be a .zip file";
889
+ }
890
+ });
891
+ handleCancel(zipInput);
892
+ await deployZip(resolve(zipInput.trim()), opts);
893
+ return;
894
+ }
895
+ }
859
896
  const targetDir = await resolveAbilityDir(pathArg);
860
897
  const s = p.spinner();
861
898
  s.start("Validating ability...");
@@ -1059,8 +1096,9 @@ async function deployCommand(pathArg, opts = {}) {
1059
1096
  p.outro("Mock deploy complete.");
1060
1097
  return;
1061
1098
  }
1062
- const apiKey = getApiKey();
1063
- if (!apiKey) {
1099
+ const apiKey = getApiKey() ?? "";
1100
+ const jwt = getJwt() ?? void 0;
1101
+ if (!apiKey && !jwt) {
1064
1102
  error("Not authenticated. Run: openhome login");
1065
1103
  process.exit(1);
1066
1104
  }
@@ -1074,7 +1112,7 @@ async function deployCommand(pathArg, opts = {}) {
1074
1112
  }
1075
1113
  s.start("Uploading ability...");
1076
1114
  try {
1077
- const client = new ApiClient(apiKey, getConfig().api_base_url);
1115
+ const client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
1078
1116
  const result = await client.uploadAbility(
1079
1117
  zipBuffer,
1080
1118
  imageBuffer,
@@ -1125,6 +1163,119 @@ Or rename it in config.json and redeploy.`
1125
1163
  process.exit(1);
1126
1164
  }
1127
1165
  }
1166
+ async function deployZip(zipPath, opts = {}) {
1167
+ const s = p.spinner();
1168
+ const zipName = basename(zipPath, ".zip");
1169
+ const nameInput = await p.text({
1170
+ message: "Ability name (unique, lowercase, hyphens only)",
1171
+ placeholder: zipName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
1172
+ validate: (val) => {
1173
+ if (!val || !val.trim()) return "Name is required";
1174
+ if (!/^[a-z0-9-]+$/.test(val.trim()))
1175
+ return "Lowercase letters, numbers, and hyphens only";
1176
+ }
1177
+ });
1178
+ handleCancel(nameInput);
1179
+ const descInput = await p.text({
1180
+ message: "Description",
1181
+ placeholder: "What does this ability do?",
1182
+ validate: (val) => {
1183
+ if (!val || !val.trim()) return "Description is required";
1184
+ }
1185
+ });
1186
+ handleCancel(descInput);
1187
+ const catChoice = await p.select({
1188
+ message: "Category",
1189
+ options: [
1190
+ { value: "skill", label: "Skill", hint: "User-triggered" },
1191
+ { value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
1192
+ {
1193
+ value: "daemon",
1194
+ label: "Background Daemon",
1195
+ hint: "Runs continuously"
1196
+ }
1197
+ ]
1198
+ });
1199
+ handleCancel(catChoice);
1200
+ const hotwordsInput = await p.text({
1201
+ message: "Trigger words (comma-separated)",
1202
+ placeholder: "hey openhome, start ability",
1203
+ validate: (val) => {
1204
+ if (!val || !val.trim()) return "At least one trigger word is required";
1205
+ }
1206
+ });
1207
+ handleCancel(hotwordsInput);
1208
+ const name = nameInput.trim();
1209
+ const description = descInput.trim();
1210
+ const category = catChoice;
1211
+ const hotwords = hotwordsInput.split(",").map((w) => w.trim()).filter(Boolean);
1212
+ const personalityId = opts.personality ?? getConfig().default_personality_id;
1213
+ const metadata = {
1214
+ name,
1215
+ description,
1216
+ category,
1217
+ matching_hotwords: hotwords,
1218
+ personality_id: personalityId
1219
+ };
1220
+ const zipBuffer = readFileSync2(zipPath);
1221
+ if (opts.dryRun) {
1222
+ p.note(
1223
+ [
1224
+ `Zip: ${zipPath}`,
1225
+ `Name: ${name}`,
1226
+ `Description: ${description}`,
1227
+ `Category: ${category}`,
1228
+ `Hotwords: ${hotwords.join(", ")}`,
1229
+ `Agent: ${personalityId ?? "(none set)"}`
1230
+ ].join("\n"),
1231
+ "Dry Run \u2014 would deploy"
1232
+ );
1233
+ p.outro("No changes made.");
1234
+ return;
1235
+ }
1236
+ const confirmed = await p.confirm({
1237
+ message: `Deploy "${name}" to OpenHome?`
1238
+ });
1239
+ handleCancel(confirmed);
1240
+ if (!confirmed) {
1241
+ p.cancel("Aborted.");
1242
+ return;
1243
+ }
1244
+ if (opts.mock) {
1245
+ s.start("Uploading (mock)...");
1246
+ const mockClient = new MockApiClient();
1247
+ await mockClient.uploadAbility(zipBuffer, null, null, metadata);
1248
+ s.stop("Mock upload complete.");
1249
+ p.outro("Mock deploy complete.");
1250
+ return;
1251
+ }
1252
+ const apiKey = getApiKey() ?? "";
1253
+ const jwt = getJwt() ?? void 0;
1254
+ if (!apiKey && !jwt) {
1255
+ error("Not authenticated. Run: openhome login");
1256
+ process.exit(1);
1257
+ }
1258
+ s.start("Uploading ability...");
1259
+ try {
1260
+ const client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
1261
+ const result = await client.uploadAbility(zipBuffer, null, null, metadata);
1262
+ s.stop("Upload complete.");
1263
+ p.note(
1264
+ [
1265
+ `Ability ID: ${result.ability_id}`,
1266
+ `Version: ${result.version}`,
1267
+ `Status: ${result.status}`,
1268
+ result.message ? `Message: ${result.message}` : ""
1269
+ ].filter(Boolean).join("\n"),
1270
+ "Deploy Result"
1271
+ );
1272
+ p.outro("Deployed successfully! \u{1F389}");
1273
+ } catch (err) {
1274
+ s.stop("Upload failed.");
1275
+ error(`Deploy failed: ${err instanceof Error ? err.message : String(err)}`);
1276
+ process.exit(1);
1277
+ }
1278
+ }
1128
1279
 
1129
1280
  // src/commands/init.ts
1130
1281
  var DAEMON_TEMPLATES = /* @__PURE__ */ new Set(["background", "alarm"]);
@@ -3300,10 +3451,15 @@ async function interactiveMenu() {
3300
3451
  const choice = await p.select({
3301
3452
  message: "What would you like to do?",
3302
3453
  options: [
3454
+ {
3455
+ value: "deploy",
3456
+ label: "\u2B06\uFE0F Upload Ability",
3457
+ hint: "Upload a zip file to OpenHome"
3458
+ },
3303
3459
  {
3304
3460
  value: "init",
3305
- label: "\u2728 Create Ability",
3306
- hint: "Scaffold and deploy a new ability"
3461
+ label: "\u2728 Scaffold Ability",
3462
+ hint: "Generate a new ability from a template"
3307
3463
  },
3308
3464
  {
3309
3465
  value: "list",
@@ -3350,6 +3506,9 @@ async function interactiveMenu() {
3350
3506
  });
3351
3507
  handleCancel(choice);
3352
3508
  switch (choice) {
3509
+ case "deploy":
3510
+ await deployCommand();
3511
+ break;
3353
3512
  case "init":
3354
3513
  await initCommand();
3355
3514
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openhome-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "CLI for managing OpenHome voice AI abilities",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -108,10 +108,15 @@ async function interactiveMenu(): Promise<void> {
108
108
  const choice = await p.select({
109
109
  message: "What would you like to do?",
110
110
  options: [
111
+ {
112
+ value: "deploy",
113
+ label: "⬆️ Upload Ability",
114
+ hint: "Upload a zip file to OpenHome",
115
+ },
111
116
  {
112
117
  value: "init",
113
- label: "✨ Create Ability",
114
- hint: "Scaffold and deploy a new ability",
118
+ label: "✨ Scaffold Ability",
119
+ hint: "Generate a new ability from a template",
115
120
  },
116
121
  {
117
122
  value: "list",
@@ -159,6 +164,9 @@ async function interactiveMenu(): Promise<void> {
159
164
  handleCancel(choice);
160
165
 
161
166
  switch (choice) {
167
+ case "deploy":
168
+ await deployCommand();
169
+ break;
162
170
  case "init":
163
171
  await initCommand();
164
172
  break;
@@ -11,7 +11,12 @@ import { validateAbility } from "../validation/validator.js";
11
11
  import { createAbilityZip } from "../util/zip.js";
12
12
  import { ApiClient, NotImplementedError } from "../api/client.js";
13
13
  import { MockApiClient } from "../api/mock-client.js";
14
- import { getApiKey, getConfig, getTrackedAbilities } from "../config/store.js";
14
+ import {
15
+ getApiKey,
16
+ getConfig,
17
+ getJwt,
18
+ getTrackedAbilities,
19
+ } from "../config/store.js";
15
20
  import type {
16
21
  AbilityCategory,
17
22
  UploadAbilityMetadata,
@@ -115,7 +120,50 @@ export async function deployCommand(
115
120
  pathArg?: string,
116
121
  opts: { dryRun?: boolean; mock?: boolean; personality?: string } = {},
117
122
  ): Promise<void> {
118
- p.intro("🚀 Deploy ability");
123
+ p.intro("🚀 Upload Ability");
124
+
125
+ // Explicit zip file passed
126
+ if (pathArg && pathArg.endsWith(".zip") && existsSync(resolve(pathArg))) {
127
+ await deployZip(resolve(pathArg), opts);
128
+ return;
129
+ }
130
+
131
+ // No arg — ask whether they have a zip or a folder
132
+ if (!pathArg) {
133
+ const mode = await p.select({
134
+ message: "What do you want to upload?",
135
+ options: [
136
+ {
137
+ value: "zip",
138
+ label: "📦 Upload a zip file",
139
+ hint: "I already have a .zip ready",
140
+ },
141
+ {
142
+ value: "folder",
143
+ label: "📁 Upload from a folder",
144
+ hint: "Point me to an ability directory",
145
+ },
146
+ ],
147
+ });
148
+ handleCancel(mode);
149
+
150
+ if (mode === "zip") {
151
+ const zipInput = await p.text({
152
+ message: "Path to your zip file",
153
+ placeholder: "./my-ability.zip",
154
+ validate: (val) => {
155
+ if (!val || !val.trim()) return "Path is required";
156
+ const resolved = resolve(val.trim());
157
+ if (!existsSync(resolved)) return `File not found: ${val.trim()}`;
158
+ if (!resolved.endsWith(".zip")) return "Must be a .zip file";
159
+ },
160
+ });
161
+ handleCancel(zipInput);
162
+ await deployZip(resolve((zipInput as string).trim()), opts);
163
+ return;
164
+ }
165
+ }
166
+
119
167
  const targetDir = await resolveAbilityDir(pathArg);
120
168
 
121
169
  // Step 1: Validate
@@ -352,8 +400,9 @@ export async function deployCommand(
352
400
  return;
353
401
  }
354
402
 
355
- const apiKey = getApiKey();
356
- if (!apiKey) {
403
+ const apiKey = getApiKey() ?? "";
404
+ const jwt = getJwt() ?? undefined;
405
+ if (!apiKey && !jwt) {
357
406
  error("Not authenticated. Run: openhome login");
358
407
  process.exit(1);
359
408
  }
@@ -371,7 +420,7 @@ export async function deployCommand(
371
420
 
372
421
  s.start("Uploading ability...");
373
422
  try {
374
- const client = new ApiClient(apiKey, getConfig().api_base_url);
423
+ const client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
375
424
  const result = await client.uploadAbility(
376
425
  zipBuffer,
377
426
  imageBuffer,
@@ -428,3 +477,139 @@ export async function deployCommand(
428
477
  process.exit(1);
429
478
  }
430
479
  }
480
+
481
+ async function deployZip(
482
+ zipPath: string,
483
+ opts: { dryRun?: boolean; mock?: boolean; personality?: string } = {},
484
+ ): Promise<void> {
485
+ const s = p.spinner();
486
+ const zipName = basename(zipPath, ".zip");
487
+
488
+ // Prompt for required metadata
489
+ const nameInput = await p.text({
490
+ message: "Ability name (unique, lowercase, hyphens only)",
491
+ placeholder: zipName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
492
+ validate: (val) => {
493
+ if (!val || !val.trim()) return "Name is required";
494
+ if (!/^[a-z0-9-]+$/.test(val.trim()))
495
+ return "Lowercase letters, numbers, and hyphens only";
496
+ },
497
+ });
498
+ handleCancel(nameInput);
499
+
500
+ const descInput = await p.text({
501
+ message: "Description",
502
+ placeholder: "What does this ability do?",
503
+ validate: (val) => {
504
+ if (!val || !val.trim()) return "Description is required";
505
+ },
506
+ });
507
+ handleCancel(descInput);
508
+
509
+ const catChoice = await p.select({
510
+ message: "Category",
511
+ options: [
512
+ { value: "skill", label: "Skill", hint: "User-triggered" },
513
+ { value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
514
+ {
515
+ value: "daemon",
516
+ label: "Background Daemon",
517
+ hint: "Runs continuously",
518
+ },
519
+ ],
520
+ });
521
+ handleCancel(catChoice);
522
+
523
+ const hotwordsInput = await p.text({
524
+ message: "Trigger words (comma-separated)",
525
+ placeholder: "hey openhome, start ability",
526
+ validate: (val) => {
527
+ if (!val || !val.trim()) return "At least one trigger word is required";
528
+ },
529
+ });
530
+ handleCancel(hotwordsInput);
531
+
532
+ const name = (nameInput as string).trim();
533
+ const description = (descInput as string).trim();
534
+ const category = catChoice as AbilityCategory;
535
+ const hotwords = (hotwordsInput as string)
536
+ .split(",")
537
+ .map((w) => w.trim())
538
+ .filter(Boolean);
539
+
540
+ const personalityId = opts.personality ?? getConfig().default_personality_id;
541
+
542
+ const metadata: UploadAbilityMetadata = {
543
+ name,
544
+ description,
545
+ category,
546
+ matching_hotwords: hotwords,
547
+ personality_id: personalityId,
548
+ };
549
+
550
+ const zipBuffer = readFileSync(zipPath);
551
+
552
+ if (opts.dryRun) {
553
+ p.note(
554
+ [
555
+ `Zip: ${zipPath}`,
556
+ `Name: ${name}`,
557
+ `Description: ${description}`,
558
+ `Category: ${category}`,
559
+ `Hotwords: ${hotwords.join(", ")}`,
560
+ `Agent: ${personalityId ?? "(none set)"}`,
561
+ ].join("\n"),
562
+ "Dry Run — would deploy",
563
+ );
564
+ p.outro("No changes made.");
565
+ return;
566
+ }
567
+
568
+ const confirmed = await p.confirm({
569
+ message: `Deploy "${name}" to OpenHome?`,
570
+ });
571
+ handleCancel(confirmed);
572
+ if (!confirmed) {
573
+ p.cancel("Aborted.");
574
+ return;
575
+ }
576
+
577
+ if (opts.mock) {
578
+ s.start("Uploading (mock)...");
579
+ const mockClient = new MockApiClient();
580
+ await mockClient.uploadAbility(zipBuffer, null, null, metadata);
581
+ s.stop("Mock upload complete.");
582
+ p.outro("Mock deploy complete.");
583
+ return;
584
+ }
585
+
586
+ const apiKey = getApiKey() ?? "";
587
+ const jwt = getJwt() ?? undefined;
588
+ if (!apiKey && !jwt) {
589
+ error("Not authenticated. Run: openhome login");
590
+ process.exit(1);
591
+ }
592
+
593
+ s.start("Uploading ability...");
594
+ try {
595
+ const client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
596
+ const result = await client.uploadAbility(zipBuffer, null, null, metadata);
597
+ s.stop("Upload complete.");
598
+ p.note(
599
+ [
600
+ `Ability ID: ${result.ability_id}`,
601
+ `Version: ${result.version}`,
602
+ `Status: ${result.status}`,
603
+ result.message ? `Message: ${result.message}` : "",
604
+ ]
605
+ .filter(Boolean)
606
+ .join("\n"),
607
+ "Deploy Result",
608
+ );
609
+ p.outro("Deployed successfully! 🎉");
610
+ } catch (err) {
611
+ s.stop("Upload failed.");
612
+ error(`Deploy failed: ${err instanceof Error ? err.message : String(err)}`);
613
+ process.exit(1);
614
+ }
615
+ }