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.
@@ -1,16 +1,20 @@
1
1
  import {
2
2
  getApiKey,
3
3
  getConfig,
4
+ getJwt,
4
5
  getTrackedAbilities,
5
6
  registerAbility,
6
7
  saveApiKey,
7
- saveConfig
8
- } from "./chunk-Q4UKUXDB.js";
8
+ saveConfig,
9
+ saveJwt
10
+ } from "./chunk-OAKGNZQM.js";
9
11
  export {
10
12
  getApiKey,
11
13
  getConfig,
14
+ getJwt,
12
15
  getTrackedAbilities,
13
16
  registerAbility,
14
17
  saveApiKey,
15
- saveConfig
18
+ saveConfig,
19
+ saveJwt
16
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openhome-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for managing OpenHome voice AI abilities",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api/client.ts CHANGED
@@ -10,6 +10,8 @@ import type {
10
10
  DeleteCapabilityResponse,
11
11
  ToggleCapabilityResponse,
12
12
  AssignCapabilitiesResponse,
13
+ InstalledCapability,
14
+ AbilitySummaryWithExtras,
13
15
  } from "./contracts.js";
14
16
  import { API_BASE, ENDPOINTS } from "./endpoints.js";
15
17
 
@@ -59,6 +61,7 @@ export class ApiClient implements IApiClient {
59
61
  constructor(
60
62
  private readonly apiKey: string,
61
63
  baseUrl?: string,
64
+ private readonly jwt?: string,
62
65
  ) {
63
66
  this.baseUrl = baseUrl ?? API_BASE;
64
67
  if (!this.baseUrl.startsWith("https://")) {
@@ -69,33 +72,42 @@ export class ApiClient implements IApiClient {
69
72
  private async request<T>(
70
73
  path: string,
71
74
  options: RequestInit = {},
75
+ useJwt = false,
72
76
  ): Promise<T> {
77
+ const token = useJwt ? this.jwt : this.apiKey;
73
78
  const url = `${this.baseUrl}${path}`;
74
79
  const response = await fetch(url, {
75
80
  ...options,
76
81
  headers: {
77
- Authorization: `Bearer ${this.apiKey}`,
82
+ Authorization: `Bearer ${token}`,
78
83
  ...(options.headers ?? {}),
79
84
  },
80
85
  });
81
86
 
82
87
  if (!response.ok) {
83
- let body: ApiErrorResponse | null = null;
88
+ if (response.status === 404) {
89
+ throw new NotImplementedError(path);
90
+ }
91
+
92
+ let body: Record<string, unknown> | null = null;
84
93
  try {
85
- body = (await response.json()) as ApiErrorResponse;
94
+ body = (await response.json()) as Record<string, unknown>;
86
95
  } catch {
87
96
  // ignore parse errors
88
97
  }
89
98
 
90
- if (body?.error?.code === "NOT_IMPLEMENTED" || response.status === 404) {
99
+ if (
100
+ (body as ApiErrorResponse | null)?.error?.code === "NOT_IMPLEMENTED"
101
+ ) {
91
102
  throw new NotImplementedError(path);
92
103
  }
93
104
 
94
- throw new ApiError(
95
- body?.error?.code ?? String(response.status),
96
- body?.error?.message ?? response.statusText,
97
- body?.error?.details,
98
- );
105
+ const message =
106
+ (body?.detail as string) ??
107
+ (body as ApiErrorResponse | null)?.error?.message ??
108
+ response.statusText;
109
+
110
+ throw new ApiError(String(response.status), message);
99
111
  }
100
112
 
101
113
  return response.json() as Promise<T>;
@@ -154,15 +166,38 @@ export class ApiClient implements IApiClient {
154
166
  }
155
167
 
156
168
  async listAbilities(): Promise<ListAbilitiesResponse> {
157
- return this.request<ListAbilitiesResponse>(ENDPOINTS.listCapabilities, {
158
- method: "GET",
159
- });
169
+ const data = await this.request<InstalledCapability[]>(
170
+ ENDPOINTS.listCapabilities,
171
+ { method: "GET" },
172
+ true, // uses JWT
173
+ );
174
+ // Normalise to AbilitySummary shape
175
+ return {
176
+ abilities: data.map((c) => ({
177
+ ability_id: String(c.id),
178
+ unique_name: c.name,
179
+ display_name: c.name,
180
+ version: 1,
181
+ status: c.enabled ? "active" : "disabled",
182
+ personality_ids: [],
183
+ created_at: c.last_updated ?? new Date().toISOString(),
184
+ updated_at: c.last_updated ?? new Date().toISOString(),
185
+ trigger_words: c.trigger_words,
186
+ category: c.category,
187
+ })),
188
+ };
160
189
  }
161
190
 
162
191
  async getAbility(id: string): Promise<GetAbilityResponse> {
163
- return this.request<GetAbilityResponse>(ENDPOINTS.getCapability(id), {
164
- method: "GET",
165
- });
192
+ // No single-get endpoint — fetch all and filter
193
+ const { abilities } = await this.listAbilities();
194
+ const found = abilities.find(
195
+ (a) => a.ability_id === id || a.unique_name === id,
196
+ );
197
+ if (!found) {
198
+ throw new ApiError("404", `Ability "${id}" not found.`);
199
+ }
200
+ return { ...found, validation_errors: [], deploy_history: [] };
166
201
  }
167
202
 
168
203
  async verifyApiKey(apiKey: string): Promise<VerifyApiKeyResponse> {
@@ -205,13 +240,26 @@ export class ApiClient implements IApiClient {
205
240
  id: string,
206
241
  enabled: boolean,
207
242
  ): Promise<ToggleCapabilityResponse> {
243
+ // Fetch current state first so we can PUT back the full object
244
+ const { abilities } = await this.listAbilities();
245
+ const current = abilities.find((a) => a.ability_id === id);
246
+ if (!current) {
247
+ throw new ApiError("404", `Ability "${id}" not found.`);
248
+ }
208
249
  return this.request<ToggleCapabilityResponse>(
209
250
  ENDPOINTS.editInstalledCapability(id),
210
251
  {
211
- method: "POST",
252
+ method: "PUT",
212
253
  headers: { "Content-Type": "application/json" },
213
- body: JSON.stringify({ enabled }),
254
+ body: JSON.stringify({
255
+ enabled,
256
+ name: current.unique_name,
257
+ category: (current as AbilitySummaryWithExtras).category ?? "skill",
258
+ trigger_words:
259
+ (current as AbilitySummaryWithExtras).trigger_words ?? [],
260
+ }),
214
261
  },
262
+ true, // uses JWT
215
263
  );
216
264
  }
217
265
 
@@ -219,13 +267,16 @@ export class ApiClient implements IApiClient {
219
267
  personalityId: string,
220
268
  capabilityIds: number[],
221
269
  ): Promise<AssignCapabilitiesResponse> {
222
- return this.request<AssignCapabilitiesResponse>(ENDPOINTS.editPersonality, {
223
- method: "POST",
224
- headers: { "Content-Type": "application/json" },
225
- body: JSON.stringify({
226
- personality_id: personalityId,
227
- matching_capabilities: capabilityIds,
228
- }),
229
- });
270
+ // Uses multipart/form-data — JSON is rejected
271
+ const form = new FormData();
272
+ form.append("personality_id", personalityId);
273
+ for (const capId of capabilityIds) {
274
+ form.append("matching_capabilities", String(capId));
275
+ }
276
+ return this.request<AssignCapabilitiesResponse>(
277
+ ENDPOINTS.editPersonality,
278
+ { method: "PUT", body: form },
279
+ true, // uses JWT
280
+ );
230
281
  }
231
282
  }
@@ -48,6 +48,27 @@ export interface AbilitySummary {
48
48
  updated_at: string;
49
49
  }
50
50
 
51
+ // Extended with fields from the real API
52
+ export interface AbilitySummaryWithExtras extends AbilitySummary {
53
+ trigger_words?: string[];
54
+ category?: string;
55
+ }
56
+
57
+ // Raw shape returned by get-installed-capabilities
58
+ export interface InstalledCapability {
59
+ id: number;
60
+ name: string;
61
+ category: string;
62
+ enabled: boolean;
63
+ trigger_words: string[];
64
+ last_updated?: string;
65
+ image_file?: string;
66
+ default?: boolean;
67
+ system_capability?: boolean;
68
+ agent_capability?: boolean;
69
+ shortcut?: boolean;
70
+ }
71
+
51
72
  export interface ListAbilitiesResponse {
52
73
  abilities: AbilitySummary[];
53
74
  }
@@ -6,8 +6,7 @@ export const ENDPOINTS = {
6
6
  getPersonalities: "/api/sdk/get_personalities",
7
7
  verifyApiKey: "/api/sdk/verify_apikey/",
8
8
  uploadCapability: "/api/capabilities/add-capability/",
9
- listCapabilities: "/api/capabilities/get-all-capability/",
10
- getCapability: (id: string) => `/api/capabilities/get-capability/${id}/`,
9
+ listCapabilities: "/api/capabilities/get-installed-capabilities/",
11
10
  deleteCapability: (id: string) =>
12
11
  `/api/capabilities/delete-capability/${id}/`,
13
12
  bulkDeleteCapabilities: "/api/capabilities/delete-capability/",
package/src/cli.ts CHANGED
@@ -18,6 +18,7 @@ import { triggerCommand } from "./commands/trigger.js";
18
18
  import { whoamiCommand } from "./commands/whoami.js";
19
19
  import { configEditCommand } from "./commands/config-edit.js";
20
20
  import { logsCommand } from "./commands/logs.js";
21
+ import { setJwtCommand } from "./commands/set-jwt.js";
21
22
  import { p, handleCancel } from "./ui/format.js";
22
23
 
23
24
  // Read version from package.json
@@ -58,12 +59,7 @@ async function interactiveMenu(): Promise<void> {
58
59
  {
59
60
  value: "init",
60
61
  label: "✨ Create Ability",
61
- hint: "Scaffold a new ability from templates",
62
- },
63
- {
64
- value: "deploy",
65
- label: "🚀 Deploy",
66
- hint: "Upload ability to OpenHome",
62
+ hint: "Scaffold and deploy a new ability",
67
63
  },
68
64
  {
69
65
  value: "chat",
@@ -134,9 +130,6 @@ async function interactiveMenu(): Promise<void> {
134
130
  case "init":
135
131
  await initCommand();
136
132
  break;
137
- case "deploy":
138
- await deployCommand();
139
- break;
140
133
  case "chat":
141
134
  await chatCommand();
142
135
  break;
@@ -324,6 +317,15 @@ program
324
317
  await whoamiCommand();
325
318
  });
326
319
 
320
+ program
321
+ .command("set-jwt [token]")
322
+ .description(
323
+ "Save a session token to enable management commands (list, delete, toggle, assign)",
324
+ )
325
+ .action(async (token?: string) => {
326
+ await setJwtCommand(token);
327
+ });
328
+
327
329
  // ── Entry point: menu if no args, subcommand otherwise ───────────
328
330
 
329
331
  if (process.argv.length <= 2) {
@@ -1,6 +1,6 @@
1
1
  import { ApiClient, NotImplementedError } from "../api/client.js";
2
2
  import { MockApiClient } from "../api/mock-client.js";
3
- import { getApiKey, getConfig } from "../config/store.js";
3
+ import { getApiKey, getConfig, getJwt } from "../config/store.js";
4
4
  import { error, success, info, p, handleCancel } from "../ui/format.js";
5
5
  import chalk from "chalk";
6
6
 
@@ -14,12 +14,19 @@ export async function assignCommand(
14
14
  if (opts.mock) {
15
15
  client = new MockApiClient();
16
16
  } else {
17
- const apiKey = getApiKey();
18
- if (!apiKey) {
17
+ const apiKey = getApiKey() ?? "";
18
+ const jwt = getJwt() ?? undefined;
19
+ if (!apiKey && !jwt) {
19
20
  error("Not authenticated. Run: openhome login");
20
21
  process.exit(1);
21
22
  }
22
- client = new ApiClient(apiKey, getConfig().api_base_url);
23
+ if (!jwt) {
24
+ error(
25
+ "This command requires a session token.\nGet it from app.openhome.com → DevTools → Application → Local Storage → token\nThen run: openhome set-jwt <token>",
26
+ );
27
+ process.exit(1);
28
+ }
29
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
23
30
  }
24
31
 
25
32
  const s = p.spinner();
@@ -1,6 +1,6 @@
1
1
  import { ApiClient, NotImplementedError } from "../api/client.js";
2
2
  import { MockApiClient } from "../api/mock-client.js";
3
- import { getApiKey, getConfig } from "../config/store.js";
3
+ import { getApiKey, getConfig, getJwt } from "../config/store.js";
4
4
  import { error, success, p, handleCancel } from "../ui/format.js";
5
5
  import chalk from "chalk";
6
6
 
@@ -15,12 +15,19 @@ export async function deleteCommand(
15
15
  if (opts.mock) {
16
16
  client = new MockApiClient();
17
17
  } else {
18
- const apiKey = getApiKey();
19
- if (!apiKey) {
18
+ const apiKey = getApiKey() ?? "";
19
+ const jwt = getJwt() ?? undefined;
20
+ if (!apiKey && !jwt) {
20
21
  error("Not authenticated. Run: openhome login");
21
22
  process.exit(1);
22
23
  }
23
- client = new ApiClient(apiKey, getConfig().api_base_url);
24
+ if (!jwt) {
25
+ error(
26
+ "This command requires a session token.\nGet it from app.openhome.com → DevTools → Application → Local Storage → token\nThen run: openhome set-jwt <token>",
27
+ );
28
+ process.exit(1);
29
+ }
30
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
24
31
  }
25
32
 
26
33
  // Fetch abilities to let user pick
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
10
10
  import { validateAbility } from "../validation/validator.js";
11
11
  import { registerAbility } from "../config/store.js";
12
12
  import { success, error, warn, info, p, handleCancel } from "../ui/format.js";
13
+ import { deployCommand } from "./deploy.js";
13
14
 
14
15
  type TemplateType =
15
16
  | "basic"
@@ -889,7 +890,18 @@ export async function initCommand(nameArg?: string): Promise<void> {
889
890
  warn(`${w.file ? `[${w.file}] ` : ""}${w.message}`);
890
891
  }
891
892
 
892
- p.note(`cd abilities/${name}\nopenhome deploy`, "Next steps");
893
+ if (result.passed) {
894
+ const deployNow = await p.confirm({
895
+ message: "Deploy to OpenHome now?",
896
+ initialValue: true,
897
+ });
898
+ handleCancel(deployNow);
899
+
900
+ if (deployNow) {
901
+ await deployCommand(targetDir);
902
+ return;
903
+ }
904
+ }
893
905
 
894
- p.outro(`Ability "${name}" is ready!`);
906
+ p.outro(`Ability "${name}" is ready! Run: openhome deploy`);
895
907
  }
@@ -1,6 +1,6 @@
1
1
  import { ApiClient, NotImplementedError } from "../api/client.js";
2
2
  import { MockApiClient } from "../api/mock-client.js";
3
- import { getApiKey, getConfig } from "../config/store.js";
3
+ import { getApiKey, getConfig, getJwt } from "../config/store.js";
4
4
  import { error, warn, info, table, p } from "../ui/format.js";
5
5
  import type { TableRow } from "../ui/format.js";
6
6
  import chalk from "chalk";
@@ -30,12 +30,19 @@ export async function listCommand(
30
30
  if (opts.mock) {
31
31
  client = new MockApiClient();
32
32
  } else {
33
- const apiKey = getApiKey();
34
- if (!apiKey) {
33
+ const apiKey = getApiKey() ?? "";
34
+ const jwt = getJwt() ?? undefined;
35
+ if (!apiKey && !jwt) {
35
36
  error("Not authenticated. Run: openhome login");
36
37
  process.exit(1);
37
38
  }
38
- client = new ApiClient(apiKey, getConfig().api_base_url);
39
+ if (!jwt) {
40
+ error(
41
+ "This command requires a session token.\nGet it from app.openhome.com → DevTools → Application → Local Storage → token\nThen run: openhome set-jwt <token>",
42
+ );
43
+ process.exit(1);
44
+ }
45
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
39
46
  }
40
47
 
41
48
  const s = p.spinner();
@@ -1,6 +1,6 @@
1
1
  import { ApiClient } from "../api/client.js";
2
2
  import type { Personality } from "../api/contracts.js";
3
- import { saveApiKey } from "../config/store.js";
3
+ import { saveApiKey, saveJwt } from "../config/store.js";
4
4
  import { success, error, info, p, handleCancel } from "../ui/format.js";
5
5
  import chalk from "chalk";
6
6
 
@@ -0,0 +1,40 @@
1
+ import { saveJwt } from "../config/store.js";
2
+ import { success, error, p } from "../ui/format.js";
3
+
4
+ export async function setJwtCommand(token?: string): Promise<void> {
5
+ p.intro("🔑 Set Session Token");
6
+
7
+ let jwt = token;
8
+
9
+ if (!jwt) {
10
+ const input = await p.text({
11
+ message: "Paste your OpenHome session token",
12
+ placeholder: "eyJ...",
13
+ validate: (val) => {
14
+ if (!val || !val.trim()) return "Token is required";
15
+ if (!val.trim().startsWith("eyJ"))
16
+ return "Doesn't look like a JWT — should start with eyJ";
17
+ },
18
+ });
19
+ if (typeof input === "symbol") {
20
+ p.cancel("Cancelled.");
21
+ return;
22
+ }
23
+ jwt = input;
24
+ }
25
+
26
+ try {
27
+ saveJwt(jwt.trim());
28
+ success("Session token saved.");
29
+ p.note(
30
+ "Management commands (list, delete, toggle, assign) are now unlocked.",
31
+ "Token saved",
32
+ );
33
+ p.outro("Done.");
34
+ } catch (err) {
35
+ error(
36
+ `Failed to save token: ${err instanceof Error ? err.message : String(err)}`,
37
+ );
38
+ process.exit(1);
39
+ }
40
+ }
@@ -105,12 +105,13 @@ export async function statusCommand(
105
105
  if (opts.mock) {
106
106
  client = new MockApiClient();
107
107
  } else {
108
- const apiKey = getApiKey();
109
- if (!apiKey) {
108
+ const apiKey = getApiKey() ?? "";
109
+ const jwt = getJwt() ?? undefined;
110
+ if (!apiKey && !jwt) {
110
111
  error("Not authenticated. Run: openhome login");
111
112
  process.exit(1);
112
113
  }
113
- client = new ApiClient(apiKey, getConfig().api_base_url);
114
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
114
115
  }
115
116
 
116
117
  const s = p.spinner();
@@ -1,6 +1,6 @@
1
1
  import { ApiClient, NotImplementedError } from "../api/client.js";
2
2
  import { MockApiClient } from "../api/mock-client.js";
3
- import { getApiKey, getConfig } from "../config/store.js";
3
+ import { getApiKey, getConfig, getJwt } from "../config/store.js";
4
4
  import { error, success, p, handleCancel } from "../ui/format.js";
5
5
  import chalk from "chalk";
6
6
 
@@ -15,12 +15,19 @@ export async function toggleCommand(
15
15
  if (opts.mock) {
16
16
  client = new MockApiClient();
17
17
  } else {
18
- const apiKey = getApiKey();
19
- if (!apiKey) {
18
+ const apiKey = getApiKey() ?? "";
19
+ const jwt = getJwt() ?? undefined;
20
+ if (!apiKey && !jwt) {
20
21
  error("Not authenticated. Run: openhome login");
21
22
  process.exit(1);
22
23
  }
23
- client = new ApiClient(apiKey, getConfig().api_base_url);
24
+ if (!jwt) {
25
+ error(
26
+ "This command requires a session token.\nGet it from app.openhome.com → DevTools → Application → Local Storage → token\nThen run: openhome set-jwt <token>",
27
+ );
28
+ process.exit(1);
29
+ }
30
+ client = new ApiClient(apiKey, getConfig().api_base_url, jwt);
24
31
  }
25
32
 
26
33
  // Fetch abilities
@@ -19,6 +19,7 @@ export interface CliConfig {
19
19
  api_base_url?: string;
20
20
  default_personality_id?: string;
21
21
  api_key?: string;
22
+ jwt?: string;
22
23
  abilities?: TrackedAbility[];
23
24
  }
24
25
 
@@ -129,9 +130,18 @@ export function getTrackedAbilities(): TrackedAbility[] {
129
130
  export function saveApiKey(key: string): void {
130
131
  const saved = keychainSet(key);
131
132
  if (!saved) {
132
- // Fallback: save in config file (less secure)
133
133
  const config = getConfig();
134
134
  config.api_key = key;
135
135
  saveConfig(config);
136
136
  }
137
137
  }
138
+
139
+ export function getJwt(): string | null {
140
+ return getConfig().jwt ?? null;
141
+ }
142
+
143
+ export function saveJwt(jwt: string): void {
144
+ const config = getConfig();
145
+ config.jwt = jwt;
146
+ saveConfig(config);
147
+ }