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,71 @@
1
+ export const REQUIRED_FILES = ["main.py", "README.md", "__init__.py"];
2
+
3
+ export const BLOCKED_IMPORTS = [
4
+ "redis",
5
+ "from src.utils.db_handler",
6
+ "connection_manager",
7
+ "user_config",
8
+ ];
9
+
10
+ export interface BlockedPattern {
11
+ regex: RegExp;
12
+ message: string;
13
+ }
14
+
15
+ export const BLOCKED_PATTERNS: BlockedPattern[] = [
16
+ {
17
+ regex: /\bprint\s*\(/,
18
+ message: "Use self.worker.editor_logging_handler instead of print()",
19
+ },
20
+ {
21
+ regex: /\basyncio\.sleep\s*\(/,
22
+ message: "Use self.worker.session_tasks.sleep() instead",
23
+ },
24
+ {
25
+ regex: /\basyncio\.create_task\s*\(/,
26
+ message: "Use self.worker.session_tasks.create() instead",
27
+ },
28
+ { regex: /\bexec\s*\(/, message: "exec() not allowed" },
29
+ { regex: /\beval\s*\(/, message: "eval() not allowed" },
30
+ { regex: /\bpickle\./, message: "pickle not allowed" },
31
+ { regex: /\bdill\./, message: "dill not allowed" },
32
+ { regex: /\bshelve\./, message: "shelve not allowed" },
33
+ { regex: /\bmarshal\./, message: "marshal not allowed" },
34
+ {
35
+ regex: /\bopen\s*\(/,
36
+ message: "raw open() not allowed — use capability_worker file helpers",
37
+ },
38
+ { regex: /\bassert\s+/, message: "assert not allowed" },
39
+ { regex: /\bhashlib\.md5\s*\(/, message: "MD5 not allowed" },
40
+ ];
41
+
42
+ export interface RequiredPattern {
43
+ regex: RegExp;
44
+ message: string;
45
+ }
46
+
47
+ export const REQUIRED_PATTERNS: RequiredPattern[] = [
48
+ {
49
+ regex: /resume_normal_flow\s*\(/,
50
+ message: "resume_normal_flow() must be called",
51
+ },
52
+ {
53
+ regex: /class\s+\w+.*MatchingCapability/,
54
+ message: "Class must extend MatchingCapability",
55
+ },
56
+ { regex: /def\s+call\s*\(/, message: "Must have a call() method" },
57
+ {
58
+ regex: /worker\s*:\s*AgentWorker\s*=\s*None/,
59
+ message: "Must declare worker: AgentWorker = None",
60
+ },
61
+ {
62
+ regex: /capability_worker\s*:\s*CapabilityWorker\s*=\s*None/,
63
+ message: "Must declare capability_worker: CapabilityWorker = None",
64
+ },
65
+ ];
66
+
67
+ export const REGISTER_CAPABILITY_PATTERN = /#\s?\{\{register[_ ]capability\}\}/;
68
+
69
+ export const HARDCODED_KEY_PATTERN = /(sk_|sk-|key_)[a-zA-Z0-9]{20,}/;
70
+
71
+ export const MULTIPLE_CLASSES_PATTERN = /^class\s+/gm;
@@ -0,0 +1,204 @@
1
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ REQUIRED_FILES,
5
+ BLOCKED_IMPORTS,
6
+ BLOCKED_PATTERNS,
7
+ REQUIRED_PATTERNS,
8
+ REGISTER_CAPABILITY_PATTERN,
9
+ HARDCODED_KEY_PATTERN,
10
+ MULTIPLE_CLASSES_PATTERN,
11
+ } from "./rules.js";
12
+
13
+ export interface ValidationIssue {
14
+ severity: "error" | "warning";
15
+ message: string;
16
+ file?: string;
17
+ }
18
+
19
+ export interface ValidationResult {
20
+ passed: boolean;
21
+ errors: ValidationIssue[];
22
+ warnings: ValidationIssue[];
23
+ }
24
+
25
+ function readFile(filePath: string): string | null {
26
+ try {
27
+ return readFileSync(filePath, "utf8");
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function validateAbility(dirPath: string): ValidationResult {
34
+ const errors: ValidationIssue[] = [];
35
+ const warnings: ValidationIssue[] = [];
36
+
37
+ // 1. Check required files exist
38
+ for (const required of REQUIRED_FILES) {
39
+ const fullPath = join(dirPath, required);
40
+ if (!existsSync(fullPath)) {
41
+ errors.push({
42
+ severity: "error",
43
+ message: `Missing required file: ${required}`,
44
+ file: required,
45
+ });
46
+ }
47
+ }
48
+
49
+ // 2. Validate config.json
50
+ const configPath = join(dirPath, "config.json");
51
+ if (existsSync(configPath)) {
52
+ const configContent = readFile(configPath);
53
+ if (configContent) {
54
+ try {
55
+ const config = JSON.parse(configContent) as Record<string, unknown>;
56
+ if (typeof config.unique_name !== "string" || !config.unique_name) {
57
+ errors.push({
58
+ severity: "error",
59
+ message: "config.json: unique_name must be a non-empty string",
60
+ file: "config.json",
61
+ });
62
+ }
63
+ if (
64
+ !Array.isArray(config.matching_hotwords) ||
65
+ !(config.matching_hotwords as unknown[]).every(
66
+ (h) => typeof h === "string",
67
+ )
68
+ ) {
69
+ errors.push({
70
+ severity: "error",
71
+ message:
72
+ "config.json: matching_hotwords must be an array of strings",
73
+ file: "config.json",
74
+ });
75
+ }
76
+ } catch {
77
+ errors.push({
78
+ severity: "error",
79
+ message: "config.json: invalid JSON",
80
+ file: "config.json",
81
+ });
82
+ }
83
+ }
84
+ } else {
85
+ errors.push({
86
+ severity: "error",
87
+ message: "Missing required file: config.json",
88
+ file: "config.json",
89
+ });
90
+ }
91
+
92
+ // 3. Validate main.py in detail
93
+ const mainPath = join(dirPath, "main.py");
94
+ const mainContent = readFile(mainPath);
95
+
96
+ if (mainContent) {
97
+ const lines = mainContent.split("\n");
98
+
99
+ // Check blocked imports — only match actual import/from statements, not substrings in strings
100
+ for (let i = 0; i < lines.length; i++) {
101
+ const line = lines[i].trim();
102
+ // Skip lines that are clearly string content (inside quotes)
103
+ if (
104
+ !line.startsWith("import ") &&
105
+ !line.startsWith("from ") &&
106
+ !line.includes("import ")
107
+ )
108
+ continue;
109
+ for (const blocked of BLOCKED_IMPORTS) {
110
+ if (line.includes(blocked)) {
111
+ errors.push({
112
+ severity: "error",
113
+ message: `Blocked import "${blocked}" on line ${i + 1}`,
114
+ file: "main.py",
115
+ });
116
+ }
117
+ }
118
+ }
119
+
120
+ // Check blocked patterns (line by line)
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i];
123
+ for (const { regex, message } of BLOCKED_PATTERNS) {
124
+ if (regex.test(line)) {
125
+ errors.push({
126
+ severity: "error",
127
+ message: `${message} (line ${i + 1})`,
128
+ file: "main.py",
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ // Check required patterns (whole file)
135
+ for (const { regex, message } of REQUIRED_PATTERNS) {
136
+ if (!regex.test(mainContent)) {
137
+ errors.push({ severity: "error", message, file: "main.py" });
138
+ }
139
+ }
140
+
141
+ // Check register_capability tag
142
+ if (!REGISTER_CAPABILITY_PATTERN.test(mainContent)) {
143
+ errors.push({
144
+ severity: "error",
145
+ message: "Missing #{{register_capability}} tag in main.py",
146
+ file: "main.py",
147
+ });
148
+ }
149
+
150
+ // Check for hardcoded keys (warning)
151
+ const keyMatches = mainContent.match(HARDCODED_KEY_PATTERN);
152
+ if (keyMatches) {
153
+ warnings.push({
154
+ severity: "warning",
155
+ message: `Possible hardcoded API key detected in main.py — use capability_worker.get_single_key() instead`,
156
+ file: "main.py",
157
+ });
158
+ }
159
+
160
+ // Check for multiple classes (warning)
161
+ const classMatches = mainContent.match(MULTIPLE_CLASSES_PATTERN);
162
+ if (classMatches && classMatches.length > 1) {
163
+ warnings.push({
164
+ severity: "warning",
165
+ message: `Multiple class definitions found (${classMatches.length}). Only one MatchingCapability class is expected.`,
166
+ file: "main.py",
167
+ });
168
+ }
169
+ }
170
+
171
+ // 4. Scan all .py files for blocked patterns
172
+ let pyFiles: string[] = [];
173
+ try {
174
+ pyFiles = readdirSync(dirPath).filter(
175
+ (f) => f.endsWith(".py") && f !== "main.py",
176
+ );
177
+ } catch {
178
+ // ignore
179
+ }
180
+
181
+ for (const pyFile of pyFiles) {
182
+ const content = readFile(join(dirPath, pyFile));
183
+ if (!content) continue;
184
+ const lines = content.split("\n");
185
+ for (let i = 0; i < lines.length; i++) {
186
+ const line = lines[i];
187
+ for (const { regex, message } of BLOCKED_PATTERNS) {
188
+ if (regex.test(line)) {
189
+ errors.push({
190
+ severity: "error",
191
+ message: `${message} (line ${i + 1})`,
192
+ file: pyFile,
193
+ });
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ return {
200
+ passed: errors.length === 0,
201
+ errors,
202
+ warnings,
203
+ };
204
+ }
@@ -0,0 +1,246 @@
1
+ # Feature Request: SDK API Endpoints for Ability Management
2
+
3
+ **From:** OpenHome CLI Team
4
+ **Date:** 2026-03-18
5
+ **Priority:** High
6
+ **Context:** Building an open-source CLI tool (`openhome-cli`) for developers to manage abilities from the terminal
7
+
8
+ ---
9
+
10
+ ## Summary
11
+
12
+ The OpenHome SDK currently exposes one endpoint (`POST /api/sdk/get_personalities`). To enable CLI-based ability deployment, we need CRUD endpoints for abilities.
13
+
14
+ The CLI is fully built and ready to call these endpoints — it currently falls back to saving a zip locally with instructions to upload via the web dashboard.
15
+
16
+ ---
17
+
18
+ ## Current State
19
+
20
+ | Capability | Web Dashboard | SDK/API | CLI Ready? |
21
+ |-----------|--------------|---------|------------|
22
+ | List agents | Yes | **Yes** (`get_personalities`) | Yes |
23
+ | Create ability | Yes | No | Yes (blocked) |
24
+ | List abilities | Yes | No | Yes (blocked) |
25
+ | Get ability detail | Yes | No | Yes (blocked) |
26
+ | Delete ability | Yes | No | Not yet |
27
+ | Update ability code | Yes (Live Editor) | No | Not yet |
28
+
29
+ ---
30
+
31
+ ## Requested Endpoints
32
+
33
+ ### 1. `POST /api/sdk/abilities` — Create/Upload Ability
34
+
35
+ Upload a new ability with all metadata matching the web form.
36
+
37
+ **Request:** `multipart/form-data`
38
+
39
+ | Field | Type | Required | Description |
40
+ |-------|------|----------|-------------|
41
+ | `api_key` | string | Yes | Authentication |
42
+ | `ability` | file (zip) | Yes | Ability code archive |
43
+ | `image` | file (png/jpg) | Yes | Marketplace icon |
44
+ | `name` | string | Yes | Unique ability name |
45
+ | `description` | string | Yes | Marketplace description |
46
+ | `category` | enum | Yes | `skill`, `brain_skill`, `background_daemon` |
47
+ | `matching_hotwords` | JSON string | Yes | Array of trigger words |
48
+ | `personality_id` | string | No | Agent to attach ability to |
49
+
50
+ **Response (200):**
51
+ ```json
52
+ {
53
+ "ability_id": "abl_abc123",
54
+ "unique_name": "my-weather-bot",
55
+ "version": 1,
56
+ "status": "processing",
57
+ "message": "Ability uploaded successfully"
58
+ }
59
+ ```
60
+
61
+ **Errors:**
62
+ - `401` — Invalid API key
63
+ - `400 VALIDATION_FAILED` — Missing files, bad config, blocked imports
64
+ - `409` — Ability with this name already exists (use update endpoint instead)
65
+
66
+ ---
67
+
68
+ ### 2. `GET /api/sdk/abilities` — List User's Abilities
69
+
70
+ **Request:**
71
+ ```
72
+ POST /api/sdk/abilities/list
73
+ Content-Type: application/json
74
+
75
+ {
76
+ "api_key": "..."
77
+ }
78
+ ```
79
+
80
+ (Using POST with `api_key` in body to match the existing `get_personalities` pattern.)
81
+
82
+ **Response (200):**
83
+ ```json
84
+ {
85
+ "abilities": [
86
+ {
87
+ "ability_id": "abl_abc123",
88
+ "unique_name": "weather-check",
89
+ "name": "Weather Check",
90
+ "description": "Check the weather by city",
91
+ "category": "skill",
92
+ "version": 3,
93
+ "status": "active",
94
+ "personality_ids": ["pers_alice"],
95
+ "created_at": "2026-01-10T12:00:00Z",
96
+ "updated_at": "2026-03-01T09:30:00Z"
97
+ }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ### 3. `POST /api/sdk/abilities/get` — Get Ability Detail
105
+
106
+ **Request:**
107
+ ```json
108
+ {
109
+ "api_key": "...",
110
+ "ability_id": "abl_abc123"
111
+ }
112
+ ```
113
+
114
+ **Response:** Same as list item plus:
115
+ ```json
116
+ {
117
+ "matching_hotwords": ["check weather", "weather please"],
118
+ "validation_errors": [],
119
+ "deploy_history": [
120
+ {
121
+ "version": 3,
122
+ "status": "success",
123
+ "timestamp": "2026-03-01T09:30:00Z"
124
+ }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ ---
130
+
131
+ ### 4. `POST /api/sdk/abilities/delete` — Delete Ability
132
+
133
+ **Request:**
134
+ ```json
135
+ {
136
+ "api_key": "...",
137
+ "ability_id": "abl_abc123"
138
+ }
139
+ ```
140
+
141
+ **Response (200):**
142
+ ```json
143
+ {
144
+ "message": "Ability deleted",
145
+ "ability_id": "abl_abc123"
146
+ }
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Authentication Pattern
152
+
153
+ Following the existing `get_personalities` pattern: `api_key` in the JSON body rather than Bearer header. This keeps the SDK consistent. (The CLI also sends a Bearer header for forward compatibility.)
154
+
155
+ ---
156
+
157
+ ## Why This Matters
158
+
159
+ 1. **Developer experience** — Developers want to stay in their terminal during ability development: edit code → deploy → test → iterate
160
+ 2. **CI/CD** — Teams can automate ability deployment in their pipelines
161
+ 3. **Open source adoption** — A CLI lowers the barrier for the developer community
162
+ 4. **Parity with modern platforms** — Vercel, Netlify, Cloudflare Workers, etc. all have CLI deploy tools
163
+
164
+ ---
165
+
166
+ ## What's Already Built
167
+
168
+ The CLI (`openhome-cli`) handles:
169
+ - Login/logout with API key (macOS Keychain + config fallback)
170
+ - Agent listing (using existing `get_personalities`)
171
+ - Ability scaffolding with templates (Skill, Brain Skill, Background Daemon)
172
+ - Validation (required files, Python patterns, blocked imports)
173
+ - ZIP creation with security exclusions
174
+ - Multipart form upload with name, description, image, category, trigger words
175
+ - Graceful fallback when endpoints return NOT_IMPLEMENTED
176
+ - Interactive arrow-key menu (bare `openhome` command)
177
+ - Direct subcommands (`openhome deploy ./my-ability --dry-run`)
178
+ - Mock mode for testing without network
179
+
180
+ **The CLI is ready to ship the moment these endpoints are live.**
181
+
182
+ ---
183
+
184
+ ## Already Working: WebSocket Chat + Ability Triggering
185
+
186
+ We discovered the WebSocket endpoint is live:
187
+
188
+ ```
189
+ wss://app.openhome.com/websocket/voice-stream/{API_KEY}/{AGENT_ID}
190
+ ```
191
+
192
+ The CLI now has an `openhome chat` command that connects to this WebSocket and lets developers:
193
+ - Send text messages to their agent from the terminal
194
+ - **Trigger abilities by sending trigger words** (e.g., typing "play aquaprime" activates the ability)
195
+ - Receive text responses back in real-time
196
+ - Test ability trigger words without needing voice/browser
197
+
198
+ This is a powerful development tool — developers can test whether their trigger words activate abilities correctly without leaving the terminal.
199
+
200
+ ---
201
+
202
+ ## Additional Feature Requests: Device/Agent Control
203
+
204
+ The dashboard exposes these controls that would be valuable as SDK endpoints:
205
+
206
+ ### 5. Agent Control Endpoints (Proposed)
207
+
208
+ | Endpoint | Purpose |
209
+ |----------|---------|
210
+ | `POST /api/sdk/agents/restart` | Restart an agent (equivalent to "Restart Agent" button) |
211
+ | `POST /api/sdk/agents/settings` | Update agent settings (voice, LLM, STT provider, temperature, etc.) |
212
+ | `GET /api/sdk/agents/settings` | Read current agent settings |
213
+
214
+ These would enable CLI commands like:
215
+ - `openhome restart [agent]` — restart an agent remotely
216
+ - `openhome config [agent] --voice <id>` — change voice settings
217
+ - `openhome config [agent] --temperature 0.7` — adjust LLM temperature
218
+
219
+ ### 6. DevKit Hardware Control (Future)
220
+
221
+ For OpenHome DevKit (Raspberry Pi) users, these would enable remote management:
222
+ - Volume control
223
+ - Start/stop agent session
224
+ - Reboot device
225
+
226
+ ---
227
+
228
+ ## Category Enum Clarification
229
+
230
+ The web form uses three buttons: **Skill**, **Brain Skill**, **Background Daemon**. What string values should we send in the API?
231
+
232
+ Suggested: `"skill"`, `"brain_skill"`, `"background_daemon"`
233
+
234
+ Please confirm the exact enum values the backend expects.
235
+
236
+ ---
237
+
238
+ ## Open Questions
239
+
240
+ 1. What are the exact category enum values? (`skill` / `brain_skill` / `background_daemon`?)
241
+ 2. Is there a max file size for the zip upload?
242
+ 3. Is there a max image size or required dimensions for the icon?
243
+ 4. Should `name` be globally unique or scoped to the user's account?
244
+ 5. Can an ability be updated (new version) via the same create endpoint, or is a separate update endpoint needed?
245
+ 6. Are there rate limits on the upload endpoint?
246
+ 7. Should the response include a URL to the ability in the web editor?