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,895 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join, resolve, extname, basename } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { validateAbility } from "../validation/validator.js";
|
|
11
|
+
import { registerAbility } from "../config/store.js";
|
|
12
|
+
import { success, error, warn, info, p, handleCancel } from "../ui/format.js";
|
|
13
|
+
|
|
14
|
+
type TemplateType =
|
|
15
|
+
| "basic"
|
|
16
|
+
| "api"
|
|
17
|
+
| "loop"
|
|
18
|
+
| "email"
|
|
19
|
+
| "background"
|
|
20
|
+
| "alarm"
|
|
21
|
+
| "readwrite"
|
|
22
|
+
| "local"
|
|
23
|
+
| "openclaw";
|
|
24
|
+
|
|
25
|
+
// Templates that require a background.py file (in addition to or instead of main.py)
|
|
26
|
+
const DAEMON_TEMPLATES = new Set<TemplateType>(["background", "alarm"]);
|
|
27
|
+
|
|
28
|
+
function toClassName(name: string): string {
|
|
29
|
+
return name
|
|
30
|
+
.split("-")
|
|
31
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shared static file templates reused across all types
|
|
36
|
+
const SHARED_INIT = "";
|
|
37
|
+
|
|
38
|
+
function sharedConfig(): string {
|
|
39
|
+
return `{
|
|
40
|
+
"unique_name": "{{UNIQUE_NAME}}",
|
|
41
|
+
"description": "{{DESCRIPTION}}",
|
|
42
|
+
"category": "{{CATEGORY}}",
|
|
43
|
+
"matching_hotwords": {{HOTWORDS}}
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function skillReadme(): string {
|
|
49
|
+
return `# {{DISPLAY_NAME}}
|
|
50
|
+
|
|
51
|
+
A custom OpenHome ability.
|
|
52
|
+
|
|
53
|
+
## Trigger Words
|
|
54
|
+
|
|
55
|
+
{{HOTWORD_LIST}}
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function daemonReadme(): string {
|
|
60
|
+
return `# {{DISPLAY_NAME}}
|
|
61
|
+
|
|
62
|
+
A background OpenHome daemon. Runs automatically on session start — no trigger words required.
|
|
63
|
+
|
|
64
|
+
## Trigger Words
|
|
65
|
+
|
|
66
|
+
{{HOTWORD_LIST}}
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getTemplate(templateType: TemplateType, file: string): string {
|
|
71
|
+
// config.json and __init__.py are identical for every template
|
|
72
|
+
if (file === "config.json") return sharedConfig();
|
|
73
|
+
if (file === "__init__.py") return SHARED_INIT;
|
|
74
|
+
|
|
75
|
+
// README differs only between skill/brain types and daemon types
|
|
76
|
+
if (file === "README.md") {
|
|
77
|
+
return DAEMON_TEMPLATES.has(templateType) ? daemonReadme() : skillReadme();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const templates: Record<TemplateType, Partial<Record<string, string>>> = {
|
|
81
|
+
// ── BASIC ────────────────────────────────────────────────────────────
|
|
82
|
+
basic: {
|
|
83
|
+
"main.py": `from src.agent.capability import MatchingCapability
|
|
84
|
+
from src.main import AgentWorker
|
|
85
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
89
|
+
worker: AgentWorker = None
|
|
90
|
+
capability_worker: CapabilityWorker = None
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
94
|
+
# {{register_capability}}
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def call(self, worker: AgentWorker):
|
|
98
|
+
self.worker = worker
|
|
99
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
100
|
+
self.worker.session_tasks.create(self.run())
|
|
101
|
+
|
|
102
|
+
async def run(self):
|
|
103
|
+
await self.capability_worker.speak("Hello! This ability is working.")
|
|
104
|
+
self.capability_worker.resume_normal_flow()
|
|
105
|
+
`,
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// ── API ──────────────────────────────────────────────────────────────
|
|
109
|
+
api: {
|
|
110
|
+
"main.py": `import requests
|
|
111
|
+
from src.agent.capability import MatchingCapability
|
|
112
|
+
from src.main import AgentWorker
|
|
113
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
117
|
+
worker: AgentWorker = None
|
|
118
|
+
capability_worker: CapabilityWorker = None
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
122
|
+
# {{register_capability}}
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def call(self, worker: AgentWorker):
|
|
126
|
+
self.worker = worker
|
|
127
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
128
|
+
self.worker.session_tasks.create(self.run())
|
|
129
|
+
|
|
130
|
+
async def run(self):
|
|
131
|
+
api_key = self.capability_worker.get_single_key("api_key")
|
|
132
|
+
response = requests.get(
|
|
133
|
+
"https://api.example.com/data",
|
|
134
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
135
|
+
timeout=10,
|
|
136
|
+
)
|
|
137
|
+
data = response.json()
|
|
138
|
+
await self.capability_worker.speak(f"Here's what I found: {data.get('result', 'nothing')}")
|
|
139
|
+
self.capability_worker.resume_normal_flow()
|
|
140
|
+
`,
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// ── LOOP ─────────────────────────────────────────────────────────────
|
|
144
|
+
loop: {
|
|
145
|
+
"main.py": `import asyncio
|
|
146
|
+
from src.agent.capability import MatchingCapability
|
|
147
|
+
from src.main import AgentWorker
|
|
148
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
152
|
+
worker: AgentWorker = None
|
|
153
|
+
capability_worker: CapabilityWorker = None
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
157
|
+
# {{register_capability}}
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def call(self, worker: AgentWorker):
|
|
161
|
+
self.worker = worker
|
|
162
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
163
|
+
self.worker.session_tasks.create(self.run())
|
|
164
|
+
|
|
165
|
+
async def run(self):
|
|
166
|
+
await self.capability_worker.speak("I'll listen and check in periodically.")
|
|
167
|
+
|
|
168
|
+
while True:
|
|
169
|
+
self.capability_worker.start_audio_recording()
|
|
170
|
+
await self.worker.session_tasks.sleep(90)
|
|
171
|
+
self.capability_worker.stop_audio_recording()
|
|
172
|
+
|
|
173
|
+
recording = self.capability_worker.get_audio_recording()
|
|
174
|
+
length = self.capability_worker.get_audio_recording_length()
|
|
175
|
+
self.capability_worker.flush_audio_recording()
|
|
176
|
+
|
|
177
|
+
if length > 2:
|
|
178
|
+
response = self.capability_worker.text_to_text_response(
|
|
179
|
+
f"The user has been speaking for {length:.0f} seconds. "
|
|
180
|
+
"Summarize what you heard and respond helpfully.",
|
|
181
|
+
self.capability_worker.get_full_message_history(),
|
|
182
|
+
)
|
|
183
|
+
await self.capability_worker.speak(response)
|
|
184
|
+
|
|
185
|
+
self.capability_worker.resume_normal_flow()
|
|
186
|
+
`,
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// ── EMAIL ────────────────────────────────────────────────────────────
|
|
190
|
+
email: {
|
|
191
|
+
"main.py": `import json
|
|
192
|
+
import smtplib
|
|
193
|
+
from email.mime.text import MIMEText
|
|
194
|
+
from src.agent.capability import MatchingCapability
|
|
195
|
+
from src.main import AgentWorker
|
|
196
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
200
|
+
worker: AgentWorker = None
|
|
201
|
+
capability_worker: CapabilityWorker = None
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
205
|
+
# {{register_capability}}
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
def call(self, worker: AgentWorker):
|
|
209
|
+
self.worker = worker
|
|
210
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
211
|
+
self.worker.session_tasks.create(self.run())
|
|
212
|
+
|
|
213
|
+
async def run(self):
|
|
214
|
+
creds = self.capability_worker.get_single_key("email_config")
|
|
215
|
+
if not creds:
|
|
216
|
+
await self.capability_worker.speak("Email is not configured yet.")
|
|
217
|
+
self.capability_worker.resume_normal_flow()
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
config = json.loads(creds) if isinstance(creds, str) else creds
|
|
221
|
+
|
|
222
|
+
reply = await self.capability_worker.run_io_loop(
|
|
223
|
+
"Who should I send the email to?"
|
|
224
|
+
)
|
|
225
|
+
to_addr = reply.strip()
|
|
226
|
+
|
|
227
|
+
subject = await self.capability_worker.run_io_loop("What's the subject?")
|
|
228
|
+
body = await self.capability_worker.run_io_loop("What should the email say?")
|
|
229
|
+
|
|
230
|
+
confirmed = await self.capability_worker.run_confirmation_loop(
|
|
231
|
+
f"Send email to {to_addr} with subject '{subject}'?"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if confirmed:
|
|
235
|
+
msg = MIMEText(body)
|
|
236
|
+
msg["Subject"] = subject
|
|
237
|
+
msg["From"] = config["from"]
|
|
238
|
+
msg["To"] = to_addr
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
with smtplib.SMTP(config["smtp_host"], config.get("smtp_port", 587)) as server:
|
|
242
|
+
server.starttls()
|
|
243
|
+
server.login(config["from"], config["password"])
|
|
244
|
+
server.send_message(msg)
|
|
245
|
+
await self.capability_worker.speak("Email sent!")
|
|
246
|
+
except Exception as e:
|
|
247
|
+
self.worker.editor_logging_handler.error(f"Email failed: {e}")
|
|
248
|
+
await self.capability_worker.speak("Sorry, the email failed to send.")
|
|
249
|
+
else:
|
|
250
|
+
await self.capability_worker.speak("Email cancelled.")
|
|
251
|
+
|
|
252
|
+
self.capability_worker.resume_normal_flow()
|
|
253
|
+
`,
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// ── BACKGROUND (daemon) ───────────────────────────────────────────────
|
|
257
|
+
// background.py holds the active logic; main.py is a minimal no-op stub
|
|
258
|
+
background: {
|
|
259
|
+
"main.py": `from src.agent.capability import MatchingCapability
|
|
260
|
+
from src.main import AgentWorker
|
|
261
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
265
|
+
worker: AgentWorker = None
|
|
266
|
+
capability_worker: CapabilityWorker = None
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
270
|
+
# {{register_capability}}
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
def call(self, worker: AgentWorker):
|
|
274
|
+
self.worker = worker
|
|
275
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
276
|
+
self.worker.session_tasks.create(self.run())
|
|
277
|
+
|
|
278
|
+
async def run(self):
|
|
279
|
+
self.capability_worker.resume_normal_flow()
|
|
280
|
+
`,
|
|
281
|
+
"background.py": `import asyncio
|
|
282
|
+
from src.agent.capability import MatchingCapability
|
|
283
|
+
from src.main import AgentWorker
|
|
284
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
288
|
+
worker: AgentWorker = None
|
|
289
|
+
capability_worker: CapabilityWorker = None
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
293
|
+
# {{register_capability}}
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
def call(self, worker: AgentWorker):
|
|
297
|
+
self.worker = worker
|
|
298
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
299
|
+
self.worker.session_tasks.create(self.run())
|
|
300
|
+
|
|
301
|
+
async def run(self):
|
|
302
|
+
while True:
|
|
303
|
+
# Your background logic here
|
|
304
|
+
self.worker.editor_logging_handler.info("Background tick")
|
|
305
|
+
|
|
306
|
+
# Example: check something and notify
|
|
307
|
+
# await self.capability_worker.speak("Heads up!")
|
|
308
|
+
|
|
309
|
+
await self.worker.session_tasks.sleep(60)
|
|
310
|
+
`,
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
// ── ALARM (skill + daemon combo) ──────────────────────────────────────
|
|
314
|
+
alarm: {
|
|
315
|
+
"main.py": `from src.agent.capability import MatchingCapability
|
|
316
|
+
from src.main import AgentWorker
|
|
317
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
321
|
+
worker: AgentWorker = None
|
|
322
|
+
capability_worker: CapabilityWorker = None
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
326
|
+
# {{register_capability}}
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
def call(self, worker: AgentWorker):
|
|
330
|
+
self.worker = worker
|
|
331
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
332
|
+
self.worker.session_tasks.create(self.run())
|
|
333
|
+
|
|
334
|
+
async def run(self):
|
|
335
|
+
reply = await self.capability_worker.run_io_loop(
|
|
336
|
+
"What should I remind you about?"
|
|
337
|
+
)
|
|
338
|
+
minutes = await self.capability_worker.run_io_loop(
|
|
339
|
+
"In how many minutes?"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
mins = int(minutes.strip())
|
|
344
|
+
except ValueError:
|
|
345
|
+
await self.capability_worker.speak("I didn't understand the time. Try again.")
|
|
346
|
+
self.capability_worker.resume_normal_flow()
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
self.capability_worker.write_file(
|
|
350
|
+
"pending_alarm.json",
|
|
351
|
+
f'{{"message": "{reply}", "minutes": {mins}}}',
|
|
352
|
+
temp=True,
|
|
353
|
+
)
|
|
354
|
+
await self.capability_worker.speak(f"Got it! I'll remind you in {mins} minutes.")
|
|
355
|
+
self.capability_worker.resume_normal_flow()
|
|
356
|
+
`,
|
|
357
|
+
"background.py": `import json
|
|
358
|
+
from src.agent.capability import MatchingCapability
|
|
359
|
+
from src.main import AgentWorker
|
|
360
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class {{CLASS_NAME}}Background(MatchingCapability):
|
|
364
|
+
worker: AgentWorker = None
|
|
365
|
+
capability_worker: CapabilityWorker = None
|
|
366
|
+
|
|
367
|
+
@classmethod
|
|
368
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
369
|
+
# {{register_capability}}
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
def call(self, worker: AgentWorker):
|
|
373
|
+
self.worker = worker
|
|
374
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
375
|
+
self.worker.session_tasks.create(self.run())
|
|
376
|
+
|
|
377
|
+
async def run(self):
|
|
378
|
+
while True:
|
|
379
|
+
if self.capability_worker.check_if_file_exists("pending_alarm.json", temp=True):
|
|
380
|
+
raw = self.capability_worker.read_file("pending_alarm.json", temp=True)
|
|
381
|
+
alarm = json.loads(raw)
|
|
382
|
+
await self.worker.session_tasks.sleep(alarm["minutes"] * 60)
|
|
383
|
+
await self.capability_worker.speak(f"Reminder: {alarm['message']}")
|
|
384
|
+
self.capability_worker.delete_file("pending_alarm.json", temp=True)
|
|
385
|
+
await self.worker.session_tasks.sleep(10)
|
|
386
|
+
`,
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
// ── READWRITE ────────────────────────────────────────────────────────
|
|
390
|
+
readwrite: {
|
|
391
|
+
"main.py": `import json
|
|
392
|
+
from src.agent.capability import MatchingCapability
|
|
393
|
+
from src.main import AgentWorker
|
|
394
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
398
|
+
worker: AgentWorker = None
|
|
399
|
+
capability_worker: CapabilityWorker = None
|
|
400
|
+
|
|
401
|
+
@classmethod
|
|
402
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
403
|
+
# {{register_capability}}
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
def call(self, worker: AgentWorker):
|
|
407
|
+
self.worker = worker
|
|
408
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
409
|
+
self.worker.session_tasks.create(self.run())
|
|
410
|
+
|
|
411
|
+
async def run(self):
|
|
412
|
+
reply = await self.capability_worker.run_io_loop(
|
|
413
|
+
"What would you like me to remember?"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Read existing notes or start fresh
|
|
417
|
+
if self.capability_worker.check_if_file_exists("notes.json", temp=False):
|
|
418
|
+
raw = self.capability_worker.read_file("notes.json", temp=False)
|
|
419
|
+
notes = json.loads(raw)
|
|
420
|
+
else:
|
|
421
|
+
notes = []
|
|
422
|
+
|
|
423
|
+
notes.append(reply.strip())
|
|
424
|
+
self.capability_worker.write_file(
|
|
425
|
+
"notes.json",
|
|
426
|
+
json.dumps(notes, indent=2),
|
|
427
|
+
temp=False,
|
|
428
|
+
mode="w",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
await self.capability_worker.speak(
|
|
432
|
+
f"Got it! I now have {len(notes)} note{'s' if len(notes) != 1 else ''} saved."
|
|
433
|
+
)
|
|
434
|
+
self.capability_worker.resume_normal_flow()
|
|
435
|
+
`,
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
// ── LOCAL ────────────────────────────────────────────────────────────
|
|
439
|
+
local: {
|
|
440
|
+
"main.py": `from src.agent.capability import MatchingCapability
|
|
441
|
+
from src.main import AgentWorker
|
|
442
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
446
|
+
worker: AgentWorker = None
|
|
447
|
+
capability_worker: CapabilityWorker = None
|
|
448
|
+
|
|
449
|
+
@classmethod
|
|
450
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
451
|
+
# {{register_capability}}
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
def call(self, worker: AgentWorker):
|
|
455
|
+
self.worker = worker
|
|
456
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
457
|
+
self.worker.session_tasks.create(self.run())
|
|
458
|
+
|
|
459
|
+
async def run(self):
|
|
460
|
+
reply = await self.capability_worker.run_io_loop(
|
|
461
|
+
"What would you like me to do on your device?"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Use text_to_text to interpret the command
|
|
465
|
+
response = self.capability_worker.text_to_text_response(
|
|
466
|
+
f"The user wants to: {reply}. Generate a helpful response.",
|
|
467
|
+
self.capability_worker.get_full_message_history(),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Send action to DevKit hardware if connected
|
|
471
|
+
self.capability_worker.send_devkit_action({
|
|
472
|
+
"type": "command",
|
|
473
|
+
"payload": reply.strip(),
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
await self.capability_worker.speak(response)
|
|
477
|
+
self.capability_worker.resume_normal_flow()
|
|
478
|
+
`,
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
// ── OPENCLAW ─────────────────────────────────────────────────────────
|
|
482
|
+
openclaw: {
|
|
483
|
+
"main.py": `import requests
|
|
484
|
+
from src.agent.capability import MatchingCapability
|
|
485
|
+
from src.main import AgentWorker
|
|
486
|
+
from src.agent.capability_worker import CapabilityWorker
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class {{CLASS_NAME}}(MatchingCapability):
|
|
490
|
+
worker: AgentWorker = None
|
|
491
|
+
capability_worker: CapabilityWorker = None
|
|
492
|
+
|
|
493
|
+
@classmethod
|
|
494
|
+
def register_capability(cls) -> "MatchingCapability":
|
|
495
|
+
# {{register_capability}}
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
def call(self, worker: AgentWorker):
|
|
499
|
+
self.worker = worker
|
|
500
|
+
self.capability_worker = CapabilityWorker(self.worker)
|
|
501
|
+
self.worker.session_tasks.create(self.run())
|
|
502
|
+
|
|
503
|
+
async def run(self):
|
|
504
|
+
reply = await self.capability_worker.run_io_loop(
|
|
505
|
+
"What would you like me to handle?"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
gateway_url = self.capability_worker.get_single_key("openclaw_gateway_url")
|
|
509
|
+
gateway_token = self.capability_worker.get_single_key("openclaw_gateway_token")
|
|
510
|
+
|
|
511
|
+
if not gateway_url or not gateway_token:
|
|
512
|
+
await self.capability_worker.speak(
|
|
513
|
+
"OpenClaw gateway is not configured. Add openclaw_gateway_url and openclaw_gateway_token as secrets."
|
|
514
|
+
)
|
|
515
|
+
self.capability_worker.resume_normal_flow()
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
resp = requests.post(
|
|
520
|
+
f"{gateway_url}/v1/chat",
|
|
521
|
+
headers={
|
|
522
|
+
"Authorization": f"Bearer {gateway_token}",
|
|
523
|
+
"Content-Type": "application/json",
|
|
524
|
+
},
|
|
525
|
+
json={"message": reply.strip()},
|
|
526
|
+
timeout=30,
|
|
527
|
+
)
|
|
528
|
+
data = resp.json()
|
|
529
|
+
answer = data.get("reply", data.get("response", "No response from OpenClaw."))
|
|
530
|
+
await self.capability_worker.speak(answer)
|
|
531
|
+
except Exception as e:
|
|
532
|
+
self.worker.editor_logging_handler.error(f"OpenClaw error: {e}")
|
|
533
|
+
await self.capability_worker.speak("Sorry, I couldn't reach OpenClaw.")
|
|
534
|
+
|
|
535
|
+
self.capability_worker.resume_normal_flow()
|
|
536
|
+
`,
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
return templates[templateType]?.[file] ?? "";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function applyTemplate(content: string, vars: Record<string, string>): string {
|
|
544
|
+
let result = content;
|
|
545
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
546
|
+
result = result.replaceAll(`{{${key}}}`, value);
|
|
547
|
+
}
|
|
548
|
+
return result;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Returns the list of files to write for a given template type.
|
|
552
|
+
// config.json, __init__.py, README.md are always included.
|
|
553
|
+
function getFileList(templateType: TemplateType): string[] {
|
|
554
|
+
const base = ["__init__.py", "README.md", "config.json"];
|
|
555
|
+
|
|
556
|
+
if (templateType === "background") {
|
|
557
|
+
// background.py holds the logic; main.py is a stub
|
|
558
|
+
return ["main.py", "background.py", ...base];
|
|
559
|
+
}
|
|
560
|
+
if (templateType === "alarm") {
|
|
561
|
+
// Both files have full logic
|
|
562
|
+
return ["main.py", "background.py", ...base];
|
|
563
|
+
}
|
|
564
|
+
return ["main.py", ...base];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Returns template options filtered by category
|
|
568
|
+
function getTemplateOptions(category: string) {
|
|
569
|
+
if (category === "skill") {
|
|
570
|
+
return [
|
|
571
|
+
{
|
|
572
|
+
value: "basic",
|
|
573
|
+
label: "Basic",
|
|
574
|
+
hint: "Simple ability with speak + user_response",
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
value: "api",
|
|
578
|
+
label: "API",
|
|
579
|
+
hint: "Calls an external API using a stored secret",
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
value: "loop",
|
|
583
|
+
label: "Loop (ambient observer)",
|
|
584
|
+
hint: "Records audio periodically and checks in",
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
value: "email",
|
|
588
|
+
label: "Email",
|
|
589
|
+
hint: "Sends email via SMTP using stored credentials",
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
value: "readwrite",
|
|
593
|
+
label: "File Storage",
|
|
594
|
+
hint: "Reads and writes persistent JSON files",
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
value: "local",
|
|
598
|
+
label: "Local (DevKit)",
|
|
599
|
+
hint: "Executes commands on the local device via DevKit",
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
value: "openclaw",
|
|
603
|
+
label: "OpenClaw",
|
|
604
|
+
hint: "Forwards requests to the OpenClaw gateway",
|
|
605
|
+
},
|
|
606
|
+
];
|
|
607
|
+
}
|
|
608
|
+
if (category === "brain") {
|
|
609
|
+
return [
|
|
610
|
+
{
|
|
611
|
+
value: "basic",
|
|
612
|
+
label: "Basic",
|
|
613
|
+
hint: "Simple ability with speak + user_response",
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
value: "api",
|
|
617
|
+
label: "API",
|
|
618
|
+
hint: "Calls an external API using a stored secret",
|
|
619
|
+
},
|
|
620
|
+
];
|
|
621
|
+
}
|
|
622
|
+
// daemon
|
|
623
|
+
return [
|
|
624
|
+
{
|
|
625
|
+
value: "background",
|
|
626
|
+
label: "Background (continuous)",
|
|
627
|
+
hint: "Runs a loop from session start, no trigger",
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
value: "alarm",
|
|
631
|
+
label: "Alarm (skill + daemon combo)",
|
|
632
|
+
hint: "Skill sets an alarm; background.py fires it",
|
|
633
|
+
},
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export async function initCommand(nameArg?: string): Promise<void> {
|
|
638
|
+
p.intro("Create a new OpenHome ability");
|
|
639
|
+
|
|
640
|
+
// Step 1: Name
|
|
641
|
+
let name: string;
|
|
642
|
+
if (nameArg) {
|
|
643
|
+
name = nameArg.trim();
|
|
644
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
645
|
+
error(
|
|
646
|
+
"Invalid name. Use lowercase letters, numbers, and hyphens only. Must start with a letter.",
|
|
647
|
+
);
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
const nameInput = await p.text({
|
|
652
|
+
message: "What should your ability be called?",
|
|
653
|
+
placeholder: "my-cool-ability",
|
|
654
|
+
validate: (val) => {
|
|
655
|
+
if (!val || !val.trim()) return "Name is required";
|
|
656
|
+
if (!/^[a-z][a-z0-9-]*$/.test(val.trim()))
|
|
657
|
+
return "Use lowercase letters, numbers, and hyphens only. Must start with a letter.";
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
handleCancel(nameInput);
|
|
661
|
+
name = (nameInput as string).trim();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Step 2: Ability category
|
|
665
|
+
const category = await p.select({
|
|
666
|
+
message: "What type of ability?",
|
|
667
|
+
options: [
|
|
668
|
+
{
|
|
669
|
+
value: "skill",
|
|
670
|
+
label: "Skill",
|
|
671
|
+
hint: "User-triggered, runs on demand (most common)",
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
value: "brain",
|
|
675
|
+
label: "Brain Skill",
|
|
676
|
+
hint: "Auto-triggered by the agent's intelligence",
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
value: "daemon",
|
|
680
|
+
label: "Background Daemon",
|
|
681
|
+
hint: "Runs continuously from session start",
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
});
|
|
685
|
+
handleCancel(category);
|
|
686
|
+
|
|
687
|
+
// Step 3: Description
|
|
688
|
+
const descInput = await p.text({
|
|
689
|
+
message: "Short description for the marketplace",
|
|
690
|
+
placeholder: "A fun ability that checks the weather",
|
|
691
|
+
validate: (val) => {
|
|
692
|
+
if (!val || !val.trim()) return "Description is required";
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
handleCancel(descInput);
|
|
696
|
+
const description = (descInput as string).trim();
|
|
697
|
+
|
|
698
|
+
// Step 4: Template — filtered by category
|
|
699
|
+
const templateOptions = getTemplateOptions(category as string);
|
|
700
|
+
const templateType = await p.select({
|
|
701
|
+
message: "Choose a template",
|
|
702
|
+
options: templateOptions,
|
|
703
|
+
});
|
|
704
|
+
handleCancel(templateType);
|
|
705
|
+
|
|
706
|
+
// Step 5: Hotwords (optional for daemons but kept for config completeness)
|
|
707
|
+
const hotwordInput = await p.text({
|
|
708
|
+
message: DAEMON_TEMPLATES.has(templateType as TemplateType)
|
|
709
|
+
? "Trigger words (comma-separated, or leave empty for daemons)"
|
|
710
|
+
: "Trigger words (comma-separated)",
|
|
711
|
+
placeholder: "check weather, weather please",
|
|
712
|
+
validate: (val) => {
|
|
713
|
+
if (!DAEMON_TEMPLATES.has(templateType as TemplateType)) {
|
|
714
|
+
if (!val || !val.trim()) return "At least one trigger word is required";
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
handleCancel(hotwordInput);
|
|
719
|
+
|
|
720
|
+
const hotwords = (hotwordInput as string)
|
|
721
|
+
.split(",")
|
|
722
|
+
.map((h) => h.trim())
|
|
723
|
+
.filter(Boolean);
|
|
724
|
+
|
|
725
|
+
// Step 6: Icon image — scan common folders for images
|
|
726
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg"]);
|
|
727
|
+
const home = homedir();
|
|
728
|
+
|
|
729
|
+
const candidateDirs = [
|
|
730
|
+
process.cwd(),
|
|
731
|
+
join(home, "Desktop"),
|
|
732
|
+
join(home, "Downloads"),
|
|
733
|
+
join(home, "Pictures"),
|
|
734
|
+
join(home, "Images"),
|
|
735
|
+
join(home, ".openhome", "icons"),
|
|
736
|
+
];
|
|
737
|
+
if (process.env.USERPROFILE) {
|
|
738
|
+
candidateDirs.push(
|
|
739
|
+
join(process.env.USERPROFILE, "Desktop"),
|
|
740
|
+
join(process.env.USERPROFILE, "Downloads"),
|
|
741
|
+
join(process.env.USERPROFILE, "Pictures"),
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
const scanDirs = [...new Set(candidateDirs)];
|
|
745
|
+
|
|
746
|
+
const foundImages: { path: string; label: string }[] = [];
|
|
747
|
+
for (const dir of scanDirs) {
|
|
748
|
+
if (!existsSync(dir)) continue;
|
|
749
|
+
try {
|
|
750
|
+
const files = readdirSync(dir);
|
|
751
|
+
for (const file of files) {
|
|
752
|
+
if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
|
|
753
|
+
const full = join(dir, file);
|
|
754
|
+
const shortDir = dir.startsWith(home)
|
|
755
|
+
? `~${dir.slice(home.length)}`
|
|
756
|
+
: dir;
|
|
757
|
+
foundImages.push({
|
|
758
|
+
path: full,
|
|
759
|
+
label: `${file} (${shortDir})`,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
} catch {
|
|
764
|
+
// skip unreadable dirs
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
let iconSourcePath: string;
|
|
769
|
+
|
|
770
|
+
if (foundImages.length > 0) {
|
|
771
|
+
const imageOptions = [
|
|
772
|
+
...foundImages.map((img) => ({ value: img.path, label: img.label })),
|
|
773
|
+
{ value: "__custom__", label: "Other...", hint: "Enter a path manually" },
|
|
774
|
+
];
|
|
775
|
+
|
|
776
|
+
const selected = await p.select({
|
|
777
|
+
message: "Select an icon image (PNG or JPG for marketplace)",
|
|
778
|
+
options: imageOptions,
|
|
779
|
+
});
|
|
780
|
+
handleCancel(selected);
|
|
781
|
+
|
|
782
|
+
if (selected === "__custom__") {
|
|
783
|
+
const iconInput = await p.text({
|
|
784
|
+
message: "Path to icon image",
|
|
785
|
+
placeholder: "./icon.png",
|
|
786
|
+
validate: (val) => {
|
|
787
|
+
if (!val || !val.trim()) return "An icon image is required";
|
|
788
|
+
const resolved = resolve(val.trim());
|
|
789
|
+
if (!existsSync(resolved)) return `File not found: ${val.trim()}`;
|
|
790
|
+
const ext = extname(resolved).toLowerCase();
|
|
791
|
+
if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
handleCancel(iconInput);
|
|
795
|
+
iconSourcePath = resolve((iconInput as string).trim());
|
|
796
|
+
} else {
|
|
797
|
+
iconSourcePath = selected as string;
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
const iconInput = await p.text({
|
|
801
|
+
message: "Path to icon image (PNG or JPG for marketplace)",
|
|
802
|
+
placeholder: "./icon.png",
|
|
803
|
+
validate: (val) => {
|
|
804
|
+
if (!val || !val.trim()) return "An icon image is required";
|
|
805
|
+
const resolved = resolve(val.trim());
|
|
806
|
+
if (!existsSync(resolved)) return `File not found: ${val.trim()}`;
|
|
807
|
+
const ext = extname(resolved).toLowerCase();
|
|
808
|
+
if (!IMAGE_EXTS.has(ext)) return "Image must be PNG or JPG";
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
handleCancel(iconInput);
|
|
812
|
+
iconSourcePath = resolve((iconInput as string).trim());
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const iconExt = extname(iconSourcePath).toLowerCase();
|
|
816
|
+
const iconFileName = iconExt === ".jpeg" ? "icon.jpg" : `icon${iconExt}`;
|
|
817
|
+
|
|
818
|
+
// Step 7: Confirm
|
|
819
|
+
// Scaffold into abilities/ subdirectory to keep repo root clean
|
|
820
|
+
const abilitiesDir = resolve("abilities");
|
|
821
|
+
const targetDir = join(abilitiesDir, name);
|
|
822
|
+
|
|
823
|
+
if (existsSync(targetDir)) {
|
|
824
|
+
error(`Directory "abilities/${name}" already exists.`);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const confirmed = await p.confirm({
|
|
829
|
+
message: `Create ability "${name}" with ${hotwords.length} trigger word(s)?`,
|
|
830
|
+
});
|
|
831
|
+
handleCancel(confirmed);
|
|
832
|
+
|
|
833
|
+
if (!confirmed) {
|
|
834
|
+
p.cancel("Aborted.");
|
|
835
|
+
process.exit(0);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Step 8: Generate files
|
|
839
|
+
const s = p.spinner();
|
|
840
|
+
s.start("Generating ability files...");
|
|
841
|
+
|
|
842
|
+
mkdirSync(targetDir, { recursive: true });
|
|
843
|
+
|
|
844
|
+
const className = toClassName(name);
|
|
845
|
+
const displayName = name
|
|
846
|
+
.split("-")
|
|
847
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
848
|
+
.join(" ");
|
|
849
|
+
|
|
850
|
+
const vars: Record<string, string> = {
|
|
851
|
+
CLASS_NAME: className,
|
|
852
|
+
UNIQUE_NAME: name,
|
|
853
|
+
DISPLAY_NAME: displayName,
|
|
854
|
+
DESCRIPTION: description,
|
|
855
|
+
CATEGORY: category as string,
|
|
856
|
+
HOTWORDS: JSON.stringify(hotwords),
|
|
857
|
+
HOTWORD_LIST:
|
|
858
|
+
hotwords.length > 0
|
|
859
|
+
? hotwords.map((h) => `- "${h}"`).join("\n")
|
|
860
|
+
: "_None (daemon)_",
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const resolvedTemplate = templateType as TemplateType;
|
|
864
|
+
const files = getFileList(resolvedTemplate);
|
|
865
|
+
|
|
866
|
+
for (const file of files) {
|
|
867
|
+
const content = applyTemplate(getTemplate(resolvedTemplate, file), vars);
|
|
868
|
+
writeFileSync(join(targetDir, file), content, "utf8");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Copy icon into ability directory
|
|
872
|
+
copyFileSync(iconSourcePath, join(targetDir, iconFileName));
|
|
873
|
+
|
|
874
|
+
s.stop("Files generated.");
|
|
875
|
+
|
|
876
|
+
// Track ability in config for deploy picker
|
|
877
|
+
registerAbility(name, targetDir);
|
|
878
|
+
|
|
879
|
+
// Auto-validate
|
|
880
|
+
const result = validateAbility(targetDir);
|
|
881
|
+
if (result.passed) {
|
|
882
|
+
success("Validation passed.");
|
|
883
|
+
} else {
|
|
884
|
+
for (const issue of result.errors) {
|
|
885
|
+
error(`${issue.file ? `[${issue.file}] ` : ""}${issue.message}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
for (const w of result.warnings) {
|
|
889
|
+
warn(`${w.file ? `[${w.file}] ` : ""}${w.message}`);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
p.note(`cd abilities/${name}\nopenhome deploy`, "Next steps");
|
|
893
|
+
|
|
894
|
+
p.outro(`Ability "${name}" is ready!`);
|
|
895
|
+
}
|