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.
Files changed (45) hide show
  1. package/README.md +470 -0
  2. package/bin/openhome.js +2 -0
  3. package/dist/chunk-Q4UKUXDB.js +164 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +3184 -0
  6. package/dist/store-DR7EKQ5T.js +16 -0
  7. package/package.json +44 -0
  8. package/src/api/client.ts +231 -0
  9. package/src/api/contracts.ts +103 -0
  10. package/src/api/endpoints.ts +19 -0
  11. package/src/api/mock-client.ts +145 -0
  12. package/src/cli.ts +339 -0
  13. package/src/commands/agents.ts +88 -0
  14. package/src/commands/assign.ts +123 -0
  15. package/src/commands/chat.ts +265 -0
  16. package/src/commands/config-edit.ts +163 -0
  17. package/src/commands/delete.ts +107 -0
  18. package/src/commands/deploy.ts +430 -0
  19. package/src/commands/init.ts +895 -0
  20. package/src/commands/list.ts +78 -0
  21. package/src/commands/login.ts +54 -0
  22. package/src/commands/logout.ts +14 -0
  23. package/src/commands/logs.ts +174 -0
  24. package/src/commands/status.ts +174 -0
  25. package/src/commands/toggle.ts +118 -0
  26. package/src/commands/trigger.ts +193 -0
  27. package/src/commands/validate.ts +53 -0
  28. package/src/commands/whoami.ts +54 -0
  29. package/src/config/keychain.ts +62 -0
  30. package/src/config/store.ts +137 -0
  31. package/src/ui/format.ts +95 -0
  32. package/src/util/zip.ts +74 -0
  33. package/src/validation/rules.ts +71 -0
  34. package/src/validation/validator.ts +204 -0
  35. package/tasks/feature-request-sdk-api.md +246 -0
  36. package/tasks/prd-openhome-cli.md +605 -0
  37. package/templates/api/README.md.tmpl +11 -0
  38. package/templates/api/__init__.py.tmpl +0 -0
  39. package/templates/api/config.json.tmpl +4 -0
  40. package/templates/api/main.py.tmpl +30 -0
  41. package/templates/basic/README.md.tmpl +7 -0
  42. package/templates/basic/__init__.py.tmpl +0 -0
  43. package/templates/basic/config.json.tmpl +4 -0
  44. package/templates/basic/main.py.tmpl +22 -0
  45. package/tsconfig.json +19 -0
@@ -0,0 +1,265 @@
1
+ import WebSocket from "ws";
2
+ import { getApiKey, getConfig } from "../config/store.js";
3
+ import { ApiClient } from "../api/client.js";
4
+ import { WS_BASE, ENDPOINTS } from "../api/endpoints.js";
5
+ import { error, success, info, p, handleCancel } from "../ui/format.js";
6
+ import chalk from "chalk";
7
+ import * as readline from "node:readline";
8
+
9
+ interface WsMessage {
10
+ type: string;
11
+ data: unknown;
12
+ }
13
+
14
+ interface WsTextData {
15
+ content: string;
16
+ role: string;
17
+ live?: boolean;
18
+ final?: boolean;
19
+ }
20
+
21
+ const PING_INTERVAL = 30_000;
22
+
23
+ export async function chatCommand(
24
+ agentArg?: string,
25
+ opts: { mock?: boolean } = {},
26
+ ): Promise<void> {
27
+ p.intro("💬 Chat with your agent");
28
+
29
+ const apiKey = getApiKey();
30
+ if (!apiKey) {
31
+ error("Not authenticated. Run: openhome login");
32
+ process.exit(1);
33
+ }
34
+
35
+ // Resolve agent ID
36
+ let agentId = agentArg ?? getConfig().default_personality_id;
37
+
38
+ if (!agentId) {
39
+ // Fetch agents and let user pick
40
+ const s = p.spinner();
41
+ s.start("Fetching agents...");
42
+
43
+ try {
44
+ const client = new ApiClient(apiKey);
45
+ const agents = await client.getPersonalities();
46
+ s.stop(`Found ${agents.length} agent(s).`);
47
+
48
+ if (agents.length === 0) {
49
+ error("No agents found. Create one at https://app.openhome.com");
50
+ process.exit(1);
51
+ }
52
+
53
+ const selected = await p.select({
54
+ message: "Which agent do you want to chat with?",
55
+ options: agents.map((a) => ({
56
+ value: a.id,
57
+ label: a.name,
58
+ hint: a.id,
59
+ })),
60
+ });
61
+ handleCancel(selected);
62
+ agentId = selected as string;
63
+ } catch (err) {
64
+ s.stop("Failed.");
65
+ error(
66
+ `Could not fetch agents: ${err instanceof Error ? err.message : String(err)}`,
67
+ );
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ // Connect WebSocket — wrap in a Promise so the menu waits for chat to end
73
+ const wsUrl = `${WS_BASE}${ENDPOINTS.voiceStream(apiKey, agentId)}`;
74
+ info(`Connecting to agent ${chalk.bold(agentId)}...`);
75
+
76
+ await new Promise<void>((resolve) => {
77
+ const ws = new WebSocket(wsUrl, {
78
+ perMessageDeflate: false,
79
+ headers: {
80
+ Origin: "https://app.openhome.com",
81
+ "User-Agent":
82
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
83
+ },
84
+ });
85
+
86
+ let connected = false;
87
+ let currentResponse = "";
88
+
89
+ // Readline for user input
90
+ const rl = readline.createInterface({
91
+ input: process.stdin,
92
+ output: process.stdout,
93
+ });
94
+
95
+ function promptUser(): void {
96
+ rl.question(chalk.green("You: "), (input) => {
97
+ const trimmed = input.trim();
98
+
99
+ if (!trimmed) {
100
+ promptUser();
101
+ return;
102
+ }
103
+
104
+ if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
105
+ info("Closing connection...");
106
+ ws.close(1000);
107
+ rl.close();
108
+ return;
109
+ }
110
+
111
+ if (!connected) {
112
+ error("Not connected yet. Please wait...");
113
+ promptUser();
114
+ return;
115
+ }
116
+
117
+ // Send text message to agent (same as useOpenHomeVoice.sendText)
118
+ ws.send(
119
+ JSON.stringify({
120
+ type: "transcribed",
121
+ data: trimmed,
122
+ }),
123
+ );
124
+
125
+ // Prompt again immediately for next input
126
+ promptUser();
127
+ });
128
+ }
129
+
130
+ // Keepalive ping every 30s (matches browser implementation)
131
+ let pingInterval: ReturnType<typeof setInterval> | null = null;
132
+
133
+ ws.on("open", () => {
134
+ connected = true;
135
+
136
+ // Keepalive ping — matches useOpenHomeVoice pattern
137
+ pingInterval = setInterval(() => {
138
+ if (ws.readyState === WebSocket.OPEN) {
139
+ ws.send(JSON.stringify({ type: "ping" }));
140
+ }
141
+ }, PING_INTERVAL);
142
+
143
+ success("Connected! Type a message and press Enter. Type /quit to exit.");
144
+ console.log(
145
+ chalk.gray(
146
+ " Tip: Send trigger words to activate abilities (e.g. 'play aquaprime')",
147
+ ),
148
+ );
149
+ console.log("");
150
+ promptUser();
151
+ });
152
+
153
+ ws.on("message", (raw: WebSocket.Data) => {
154
+ try {
155
+ const msg = JSON.parse(raw.toString()) as WsMessage;
156
+
157
+ switch (msg.type) {
158
+ case "message": {
159
+ const data = msg.data as WsTextData;
160
+ if (data.content && data.role === "assistant") {
161
+ if (data.live && !data.final) {
162
+ // Streaming — OpenHome sends the full accumulated text each time,
163
+ // not just the new token. Clear the line and rewrite.
164
+ const prefix = `${chalk.cyan("Agent:")} `;
165
+ readline.clearLine(process.stdout, 0);
166
+ readline.cursorTo(process.stdout, 0);
167
+ process.stdout.write(`${prefix}${data.content}`);
168
+ currentResponse = data.content;
169
+ } else {
170
+ // Final message
171
+ if (currentResponse !== "") {
172
+ // End of stream — just add newline after the streamed line
173
+ console.log("");
174
+ } else {
175
+ // Non-streamed complete message
176
+ console.log(`${chalk.cyan("Agent:")} ${data.content}`);
177
+ }
178
+ currentResponse = "";
179
+ console.log("");
180
+ }
181
+ }
182
+ break;
183
+ }
184
+ case "text": {
185
+ // Control messages from server
186
+ const textData = msg.data as string;
187
+ if (textData === "audio-init") {
188
+ // Server starting audio — tell it we're "playing"
189
+ ws.send(JSON.stringify({ type: "text", data: "bot-speaking" }));
190
+ } else if (textData === "audio-end") {
191
+ // Server done with audio — tell it we finished
192
+ ws.send(JSON.stringify({ type: "text", data: "bot-speak-end" }));
193
+ // If no text was streamed, show a note
194
+ if (currentResponse === "") {
195
+ console.log(
196
+ chalk.gray(" (Agent sent audio — text-only mode)"),
197
+ );
198
+ console.log("");
199
+ }
200
+ }
201
+ break;
202
+ }
203
+ case "audio":
204
+ // Acknowledge audio receipt (protocol requirement)
205
+ ws.send(JSON.stringify({ type: "ack", data: "audio-received" }));
206
+ break;
207
+ case "error-event": {
208
+ const errData = msg.data as {
209
+ message?: string;
210
+ title?: string;
211
+ close_connection?: boolean;
212
+ };
213
+ const errMsg =
214
+ errData?.message || errData?.title || "Unknown error";
215
+ error(`Server error: ${errMsg}`);
216
+ break;
217
+ }
218
+ case "interrupt":
219
+ // Agent was interrupted
220
+ if (currentResponse !== "") {
221
+ console.log(""); // newline
222
+ currentResponse = "";
223
+ }
224
+ break;
225
+ case "action":
226
+ case "log":
227
+ case "question":
228
+ case "progress":
229
+ // Informational — ignore in CLI
230
+ break;
231
+ default:
232
+ break;
233
+ }
234
+ } catch {
235
+ // Not JSON — ignore
236
+ }
237
+ });
238
+
239
+ ws.on("error", (err: Error) => {
240
+ console.error("");
241
+ error(`WebSocket error: ${err.message}`);
242
+ rl.close();
243
+ resolve();
244
+ });
245
+
246
+ ws.on("close", (code: number) => {
247
+ if (pingInterval) clearInterval(pingInterval);
248
+ console.log("");
249
+ if (code === 1000) {
250
+ info("Disconnected.");
251
+ } else {
252
+ info(`Connection closed (code: ${code})`);
253
+ }
254
+ rl.close();
255
+ resolve();
256
+ });
257
+
258
+ // Handle Ctrl+C
259
+ rl.on("close", () => {
260
+ if (connected) {
261
+ ws.close(1000);
262
+ }
263
+ });
264
+ });
265
+ }
@@ -0,0 +1,163 @@
1
+ import { join, resolve } from "node:path";
2
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { getTrackedAbilities } from "../config/store.js";
5
+ import { error, success, info, p, handleCancel } from "../ui/format.js";
6
+ import chalk from "chalk";
7
+
8
+ interface AbilityConfig {
9
+ unique_name: string;
10
+ description: string;
11
+ category: string;
12
+ matching_hotwords: string[];
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ /** Resolve an ability directory from tracked abilities or cwd. */
17
+ async function resolveDir(): Promise<string | undefined> {
18
+ const cwd = resolve(".");
19
+ if (existsSync(join(cwd, "config.json"))) {
20
+ info("Detected ability in current directory");
21
+ return cwd;
22
+ }
23
+
24
+ const tracked = getTrackedAbilities();
25
+ const home = homedir();
26
+ const options = tracked.map((a) => ({
27
+ value: a.path,
28
+ label: a.name,
29
+ hint: a.path.startsWith(home) ? `~${a.path.slice(home.length)}` : a.path,
30
+ }));
31
+
32
+ if (options.length === 1) {
33
+ info(`Using ability: ${options[0].label}`);
34
+ return options[0].value;
35
+ }
36
+
37
+ if (options.length > 0) {
38
+ const selected = await p.select({
39
+ message: "Which ability do you want to edit?",
40
+ options,
41
+ });
42
+ handleCancel(selected);
43
+ return selected as string;
44
+ }
45
+
46
+ return undefined;
47
+ }
48
+
49
+ export async function configEditCommand(pathArg?: string): Promise<void> {
50
+ p.intro("⚙️ Edit ability config");
51
+
52
+ const dir = pathArg ? resolve(pathArg) : await resolveDir();
53
+ if (!dir) {
54
+ error(
55
+ "No ability found. Run from an ability directory or create one with: openhome init",
56
+ );
57
+ process.exit(1);
58
+ }
59
+
60
+ const configPath = join(dir, "config.json");
61
+ if (!existsSync(configPath)) {
62
+ error(`No config.json found in ${dir}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ let config: AbilityConfig;
67
+ try {
68
+ config = JSON.parse(readFileSync(configPath, "utf8")) as AbilityConfig;
69
+ } catch {
70
+ error("Failed to parse config.json");
71
+ process.exit(1);
72
+ }
73
+
74
+ // Show current config
75
+ p.note(
76
+ [
77
+ `Name: ${config.unique_name}`,
78
+ `Description: ${config.description}`,
79
+ `Category: ${config.category}`,
80
+ `Triggers: ${config.matching_hotwords.join(", ")}`,
81
+ ].join("\n"),
82
+ "Current config",
83
+ );
84
+
85
+ // What to edit?
86
+ const field = await p.select({
87
+ message: "What do you want to change?",
88
+ options: [
89
+ { value: "description", label: "Description" },
90
+ { value: "hotwords", label: "Trigger words" },
91
+ { value: "category", label: "Category" },
92
+ { value: "name", label: "Unique name" },
93
+ ],
94
+ });
95
+ handleCancel(field);
96
+
97
+ switch (field) {
98
+ case "description": {
99
+ const input = await p.text({
100
+ message: "New description",
101
+ initialValue: config.description,
102
+ validate: (val) => {
103
+ if (!val || !val.trim()) return "Description is required";
104
+ },
105
+ });
106
+ handleCancel(input);
107
+ config.description = (input as string).trim();
108
+ break;
109
+ }
110
+ case "hotwords": {
111
+ const input = await p.text({
112
+ message: "Trigger words (comma-separated)",
113
+ initialValue: config.matching_hotwords.join(", "),
114
+ validate: (val) => {
115
+ if (!val || !val.trim())
116
+ return "At least one trigger word is required";
117
+ },
118
+ });
119
+ handleCancel(input);
120
+ config.matching_hotwords = (input as string)
121
+ .split(",")
122
+ .map((h) => h.trim())
123
+ .filter(Boolean);
124
+ break;
125
+ }
126
+ case "category": {
127
+ const selected = await p.select({
128
+ message: "New category",
129
+ options: [
130
+ { value: "skill", label: "Skill", hint: "User-triggered" },
131
+ { value: "brain", label: "Brain Skill", hint: "Auto-triggered" },
132
+ {
133
+ value: "daemon",
134
+ label: "Background Daemon",
135
+ hint: "Runs continuously",
136
+ },
137
+ ],
138
+ });
139
+ handleCancel(selected);
140
+ config.category = selected as string;
141
+ break;
142
+ }
143
+ case "name": {
144
+ const input = await p.text({
145
+ message: "New unique name",
146
+ initialValue: config.unique_name,
147
+ validate: (val) => {
148
+ if (!val || !val.trim()) return "Name is required";
149
+ if (!/^[a-z][a-z0-9-]*$/.test(val.trim()))
150
+ return "Use lowercase letters, numbers, and hyphens only.";
151
+ },
152
+ });
153
+ handleCancel(input);
154
+ config.unique_name = (input as string).trim();
155
+ break;
156
+ }
157
+ }
158
+
159
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
160
+ success(`Updated ${field as string} in config.json`);
161
+
162
+ p.outro("Done.");
163
+ }
@@ -0,0 +1,107 @@
1
+ import { ApiClient, NotImplementedError } from "../api/client.js";
2
+ import { MockApiClient } from "../api/mock-client.js";
3
+ import { getApiKey, getConfig } from "../config/store.js";
4
+ import { error, success, p, handleCancel } from "../ui/format.js";
5
+ import chalk from "chalk";
6
+
7
+ export async function deleteCommand(
8
+ abilityArg?: string,
9
+ opts: { mock?: boolean } = {},
10
+ ): Promise<void> {
11
+ p.intro("🗑️ Delete ability");
12
+
13
+ let client: ApiClient | MockApiClient;
14
+
15
+ if (opts.mock) {
16
+ client = new MockApiClient();
17
+ } else {
18
+ const apiKey = getApiKey();
19
+ if (!apiKey) {
20
+ error("Not authenticated. Run: openhome login");
21
+ process.exit(1);
22
+ }
23
+ client = new ApiClient(apiKey, getConfig().api_base_url);
24
+ }
25
+
26
+ // Fetch abilities to let user pick
27
+ const s = p.spinner();
28
+ s.start("Fetching abilities...");
29
+
30
+ let abilities: Awaited<ReturnType<typeof client.listAbilities>>["abilities"];
31
+ try {
32
+ const result = await client.listAbilities();
33
+ abilities = result.abilities;
34
+ s.stop(`Found ${abilities.length} ability(s).`);
35
+ } catch (err) {
36
+ s.stop("Failed to fetch abilities.");
37
+ error(err instanceof Error ? err.message : String(err));
38
+ process.exit(1);
39
+ }
40
+
41
+ if (abilities.length === 0) {
42
+ p.outro("No abilities to delete.");
43
+ return;
44
+ }
45
+
46
+ // Resolve target: arg → match by name, or prompt
47
+ let targetId: string;
48
+ let targetName: string;
49
+
50
+ if (abilityArg) {
51
+ const match = abilities.find(
52
+ (a) =>
53
+ a.unique_name === abilityArg ||
54
+ a.display_name === abilityArg ||
55
+ a.ability_id === abilityArg,
56
+ );
57
+ if (!match) {
58
+ error(`No ability found matching "${abilityArg}".`);
59
+ process.exit(1);
60
+ }
61
+ targetId = match.ability_id;
62
+ targetName = match.unique_name;
63
+ } else {
64
+ const selected = await p.select({
65
+ message: "Which ability do you want to delete?",
66
+ options: abilities.map((a) => ({
67
+ value: a.ability_id,
68
+ label: a.unique_name,
69
+ hint: `${chalk.gray(a.status)} v${a.version}`,
70
+ })),
71
+ });
72
+ handleCancel(selected);
73
+ targetId = selected as string;
74
+ targetName =
75
+ abilities.find((a) => a.ability_id === targetId)?.unique_name ?? targetId;
76
+ }
77
+
78
+ // Confirm
79
+ const confirmed = await p.confirm({
80
+ message: `Delete "${targetName}"? This cannot be undone.`,
81
+ initialValue: false,
82
+ });
83
+ handleCancel(confirmed);
84
+
85
+ if (!confirmed) {
86
+ p.cancel("Aborted.");
87
+ return;
88
+ }
89
+
90
+ s.start(`Deleting "${targetName}"...`);
91
+ try {
92
+ const result = await client.deleteCapability(targetId);
93
+ s.stop("Deleted.");
94
+ success(result.message ?? `"${targetName}" deleted successfully.`);
95
+ p.outro("Done.");
96
+ } catch (err) {
97
+ s.stop("Delete failed.");
98
+
99
+ if (err instanceof NotImplementedError) {
100
+ p.note("API Not Available Yet", "Delete endpoint not yet implemented.");
101
+ return;
102
+ }
103
+
104
+ error(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
105
+ process.exit(1);
106
+ }
107
+ }