lazyclaw 6.0.0 → 6.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.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +10 -21
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +254 -0
- package/commands/chat.mjs +1253 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +275 -0
- package/commands/gateway.mjs +194 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +490 -0
- package/commands/service.mjs +113 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +742 -0
- package/commands/setup_channels.mjs +207 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +42 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +435 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +165 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +51 -0
- package/first_run.mjs +24 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/registry_boot.mjs +55 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +15 -3
- package/providers/probe.mjs +28 -0
- package/secure_write.mjs +46 -0
- package/tui/editor.mjs +18 -2
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// commands/setup_channels.mjs — Hermes-style "where will you run it?"
|
|
2
|
+
// onboarding step plus the outbound-webhook step, split out of
|
|
3
|
+
// commands/setup.mjs (CLAUDE.md §7: one file = one responsibility, and to
|
|
4
|
+
// keep setup.mjs under its size-gate ceiling).
|
|
5
|
+
//
|
|
6
|
+
// Credentials are written to <cfgDir>/.env (0600) — the same file the
|
|
7
|
+
// channel modules read via dotenv_min. Metadata (enabled / boundAgent) is
|
|
8
|
+
// written to cfg.channels.<name>, the exact shape daemon/routes/ops.mjs
|
|
9
|
+
// and the dashboard read. Plugin channels are recorded the same way with an
|
|
10
|
+
// honest "needs a plugin package" notice — we never pretend a channel works
|
|
11
|
+
// when it requires an uninstalled package or an external binary.
|
|
12
|
+
import { readConfig, writeConfig } from '../lib/config.mjs';
|
|
13
|
+
import { writeDotenvMerge } from '../dotenv_min.mjs';
|
|
14
|
+
import { messageAdd } from '../config_features.mjs';
|
|
15
|
+
|
|
16
|
+
// fields[].key is the answer key; .env is the env var it maps to; .secret
|
|
17
|
+
// masks it on echo; .optional lets the user skip it.
|
|
18
|
+
export const CHANNEL_CATALOG = [
|
|
19
|
+
{ name: 'slack', builtin: true, label: 'Slack',
|
|
20
|
+
fields: [
|
|
21
|
+
{ key: 'token', env: 'SLACK_BOT_TOKEN', prompt: 'Bot token (xoxb-…)', secret: true },
|
|
22
|
+
{ key: 'appToken', env: 'SLACK_APP_TOKEN', prompt: 'App-level token (xapp-…, for inbound / Socket Mode)', secret: true, optional: true },
|
|
23
|
+
] },
|
|
24
|
+
{ name: 'telegram', builtin: true, label: 'Telegram',
|
|
25
|
+
fields: [{ key: 'token', env: 'TELEGRAM_BOT_TOKEN', prompt: 'Bot token (from @BotFather)', secret: true }] },
|
|
26
|
+
{ name: 'matrix', builtin: true, label: 'Matrix',
|
|
27
|
+
fields: [
|
|
28
|
+
{ key: 'homeserver', env: 'MATRIX_HOMESERVER', prompt: 'Homeserver URL (https://matrix.org)' },
|
|
29
|
+
{ key: 'token', env: 'MATRIX_ACCESS_TOKEN', prompt: 'Access token', secret: true },
|
|
30
|
+
{ key: 'userId', env: 'MATRIX_USER_ID', prompt: 'User id (@you:matrix.org)' },
|
|
31
|
+
] },
|
|
32
|
+
{ name: 'http', builtin: true, label: 'HTTP (generic inbound endpoint)', fields: [] },
|
|
33
|
+
{ name: 'discord', builtin: false, plugin: '@lazyclaw/channel-discord', label: 'Discord',
|
|
34
|
+
fields: [{ key: 'token', env: 'DISCORD_BOT_TOKEN', prompt: 'Bot token', secret: true }] },
|
|
35
|
+
{ name: 'email', builtin: false, plugin: '@lazyclaw/channel-email', label: 'Email (IMAP/SMTP)',
|
|
36
|
+
fields: [
|
|
37
|
+
{ key: 'host', env: 'EMAIL_IMAP_HOST', prompt: 'IMAP host' },
|
|
38
|
+
{ key: 'user', env: 'EMAIL_IMAP_USER', prompt: 'IMAP user' },
|
|
39
|
+
{ key: 'pass', env: 'EMAIL_IMAP_PASS', prompt: 'IMAP password', secret: true },
|
|
40
|
+
] },
|
|
41
|
+
{ name: 'signal', builtin: false, plugin: '@lazyclaw/channel-signal', label: 'Signal (needs signal-cli)',
|
|
42
|
+
fields: [{ key: 'account', env: 'SIGNAL_ACCOUNT', prompt: 'Signal account (+15551234567)' }] },
|
|
43
|
+
{ name: 'voice', builtin: false, plugin: '@lazyclaw/channel-voice', label: 'Voice (Whisper transcription)',
|
|
44
|
+
fields: [{ key: 'apiKey', env: 'OPENAI_API_KEY', prompt: 'OpenAI API key (whisper)', secret: true }] },
|
|
45
|
+
{ name: 'whatsapp', builtin: false, plugin: '@lazyclaw/channel-whatsapp', label: 'WhatsApp (web session)',
|
|
46
|
+
fields: [] },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export function channelByName(name) {
|
|
50
|
+
return CHANNEL_CATALOG.find((c) => c.name === name) || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Pure: turn collected answers into { envVars, channelConfig, needsPlugin }.
|
|
54
|
+
// Empty/whitespace answers are dropped so a skipped optional field doesn't
|
|
55
|
+
// write an empty env var.
|
|
56
|
+
export function buildChannelEntry(name, answers = {}) {
|
|
57
|
+
const spec = channelByName(name);
|
|
58
|
+
if (!spec) throw new Error(`unknown channel: ${name}`);
|
|
59
|
+
const envVars = {};
|
|
60
|
+
for (const f of spec.fields) {
|
|
61
|
+
const v = (answers[f.key] == null ? '' : String(answers[f.key])).trim();
|
|
62
|
+
if (v) envVars[f.env] = v;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
envVars,
|
|
66
|
+
channelConfig: { enabled: true },
|
|
67
|
+
needsPlugin: spec.builtin ? null : spec.plugin,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Side-effecting persist used by the interactive step (and unit-tested with a
|
|
72
|
+
// temp cfgDir). Writes creds to <cfgDir>/.env and metadata to cfg.channels.
|
|
73
|
+
// readConfig()/writeConfig() resolve configPath() fresh on every call (it
|
|
74
|
+
// reads LAZYCLAW_CONFIG_DIR at use-time, nothing import-cached), so they
|
|
75
|
+
// target <cfgDir>/config.json when the caller has pointed the env var there.
|
|
76
|
+
export function persistChannel(cfgDir, name, answers) {
|
|
77
|
+
const entry = buildChannelEntry(name, answers);
|
|
78
|
+
if (Object.keys(entry.envVars).length) writeDotenvMerge(cfgDir, entry.envVars);
|
|
79
|
+
const cfg = readConfig();
|
|
80
|
+
cfg.channels = cfg.channels && typeof cfg.channels === 'object' ? cfg.channels : {};
|
|
81
|
+
cfg.channels[name] = { ...(cfg.channels[name] || {}), ...entry.channelConfig };
|
|
82
|
+
writeConfig(cfg);
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const mask = (v) => {
|
|
87
|
+
const s = String(v);
|
|
88
|
+
return s.length <= 4 ? '••••' : `${s.slice(0, 3)}…${s.slice(-2)}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Interactive channel step. `prompt(label)` resolves to a trimmed string;
|
|
92
|
+
// `write(text)` sinks UI output (defaults to process.stdout). Returns
|
|
93
|
+
// { skipped, channel?, needsPlugin? }. Never echoes a secret field value.
|
|
94
|
+
export async function runChannelStep({ cfgDir, prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
95
|
+
const { dim, ok, warn } = colors;
|
|
96
|
+
const list = CHANNEL_CATALOG.map((c) => `${c.name}${c.builtin ? '' : ' *'}`).join(' · ');
|
|
97
|
+
write(` ${dim('Where will you talk to the agent? Built-in: slack/telegram/matrix/http. (* = needs a plugin package.)')}\n`);
|
|
98
|
+
write(` ${dim(list)}\n\n`);
|
|
99
|
+
const pick = (await prompt(' channel (Enter to skip): ')).trim().toLowerCase();
|
|
100
|
+
if (!pick) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
101
|
+
const spec = channelByName(pick);
|
|
102
|
+
if (!spec) { write(` ${warn('skipped:')} unknown channel "${pick}"\n\n`); return { skipped: true }; }
|
|
103
|
+
|
|
104
|
+
const answers = {};
|
|
105
|
+
for (const f of spec.fields) {
|
|
106
|
+
const v = (await prompt(` ${spec.label} — ${f.prompt}${f.optional ? ' (optional)' : ''}: `)).trim();
|
|
107
|
+
if (v) answers[f.key] = v;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const entry = persistChannel(cfgDir, pick, answers);
|
|
111
|
+
const credKeys = Object.keys(entry.envVars);
|
|
112
|
+
write(` ${ok('✓ channel enabled:')} ${pick} ${credKeys.length ? dim(`creds: ${credKeys.join(', ')}`) : ''}\n`);
|
|
113
|
+
for (const f of spec.fields) {
|
|
114
|
+
if (f.secret && answers[f.key]) write(` ${dim(`${f.env} = ${mask(answers[f.key])} (stored in ${cfgDir}/.env, 0600)`)}\n`);
|
|
115
|
+
}
|
|
116
|
+
if (entry.needsPlugin) {
|
|
117
|
+
write(` ${warn('plugin required:')} ${entry.needsPlugin}\n`);
|
|
118
|
+
write(` ${dim(`install with: lazyclaw channels install ${entry.needsPlugin} (or npm install --prefix ${cfgDir} ${entry.needsPlugin})`)}\n`);
|
|
119
|
+
}
|
|
120
|
+
write('\n');
|
|
121
|
+
return { skipped: false, channel: pick, needsPlugin: entry.needsPlugin };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
124
|
+
return { skipped: true };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Outbound webhook step (moved verbatim-in-spirit from setup.mjs).
|
|
129
|
+
export async function runWebhookStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
130
|
+
const { dim, ok, warn } = colors;
|
|
131
|
+
write(` ${dim('Outbound webhook for `lazyclaw message send <name> <text>`. Slack / Discord Incoming Webhook URLs work as-is.')}\n\n`);
|
|
132
|
+
const hookName = (await prompt(' webhook name (Enter to skip): ')).trim();
|
|
133
|
+
if (!hookName) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
134
|
+
const hookUrl = (await prompt(' webhook URL: ')).trim();
|
|
135
|
+
if (!hookUrl) { write(` ${warn('skipped:')} URL required\n\n`); return { skipped: true }; }
|
|
136
|
+
try {
|
|
137
|
+
const fresh = readConfig();
|
|
138
|
+
messageAdd(fresh, hookName, hookUrl);
|
|
139
|
+
writeConfig(fresh);
|
|
140
|
+
write(` ${ok('✓ webhook saved:')} ${hookName}\n\n`);
|
|
141
|
+
return { skipped: false, name: hookName };
|
|
142
|
+
} catch (e) {
|
|
143
|
+
write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
144
|
+
return { skipped: true };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Context-window step (asked right after the model pick): how much past
|
|
149
|
+
// conversation to keep each turn — a sliding history budget, NOT the model's
|
|
150
|
+
// hard limit. Enter on both prompts keeps the defaults.
|
|
151
|
+
export async function runContextStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
152
|
+
const { dim, ok } = colors;
|
|
153
|
+
const cf = await import('../config_features.mjs');
|
|
154
|
+
const cur = cf.chatWindowGet(readConfig());
|
|
155
|
+
write(` ${dim(`Context window — how much past conversation to send each turn (sliding budget, not the model's hard limit). Now: ${cur.turns} turns · ${cur.tokens} tokens.`)}\n\n`);
|
|
156
|
+
const turnsRaw = (await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim();
|
|
157
|
+
const tokensRaw = (await prompt(` token budget (Enter = ${cur.tokens}): `)).trim();
|
|
158
|
+
if (!turnsRaw && !tokensRaw) { write(` ${dim('— kept defaults —')}\n\n`); return { skipped: true }; }
|
|
159
|
+
const turns = parseInt(turnsRaw, 10);
|
|
160
|
+
const tokens = parseInt(tokensRaw, 10);
|
|
161
|
+
const cfg = readConfig();
|
|
162
|
+
cf.chatWindowSet(cfg, {
|
|
163
|
+
...(Number.isFinite(turns) && turns > 0 ? { turns } : {}),
|
|
164
|
+
...(Number.isFinite(tokens) && tokens >= 256 ? { tokens } : {}),
|
|
165
|
+
});
|
|
166
|
+
writeConfig(cfg);
|
|
167
|
+
const w = cf.chatWindowGet(cfg);
|
|
168
|
+
write(` ${ok('✓ context window:')} ${w.turns} turns · ${w.tokens} tokens\n`);
|
|
169
|
+
write(` ${dim('change later: /context turns <N> | tokens <N>')}\n\n`);
|
|
170
|
+
return { skipped: false, ...w };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Optional orchestration step: enable the multi-agent planner+workers pipeline.
|
|
174
|
+
// Defaults the planner to the configured provider and workers to [planner];
|
|
175
|
+
// the user can layer more workers later via /orchestrator or the CLI.
|
|
176
|
+
export async function runOrchestratorStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
177
|
+
const { dim, ok } = colors;
|
|
178
|
+
const cf = await import('../config_features.mjs');
|
|
179
|
+
// Reuse the same searchable provider/model picker as the model step, so
|
|
180
|
+
// planner + workers are chosen from a list (arrow keys / filter), not typed.
|
|
181
|
+
const { _pickProviderInteractive } = await import('../tui/pickers.mjs');
|
|
182
|
+
const toSpec = (p) => (p && p.provider) ? (p.model ? `${p.provider}:${p.model}` : p.provider) : null;
|
|
183
|
+
write(` ${dim('Multi-agent: a planner splits your task into subtasks, workers run them in parallel, then a synthesis step merges the results. Skip for a single agent.')}\n\n`);
|
|
184
|
+
const yn = (await prompt(' enable orchestration? [y/N] ')).trim().toLowerCase();
|
|
185
|
+
if (yn !== 'y' && yn !== 'yes') { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
186
|
+
const cfg = readConfig();
|
|
187
|
+
const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
|
|
188
|
+
// Planner — pick from the provider/model list (Esc keeps the default).
|
|
189
|
+
write(` ${dim('Pick the PLANNER (decomposes the task):')}\n`);
|
|
190
|
+
const planner = toSpec(await _pickProviderInteractive()) || base;
|
|
191
|
+
// Workers — pick one or more from the same list.
|
|
192
|
+
const workers = [];
|
|
193
|
+
let more = true;
|
|
194
|
+
while (more) {
|
|
195
|
+
write(` ${dim(`Pick a WORKER (runs the subtasks)${workers.length ? ` — ${workers.length} added` : ''}:`)}\n`);
|
|
196
|
+
const spec = toSpec(await _pickProviderInteractive());
|
|
197
|
+
if (spec && !workers.includes(spec)) workers.push(spec);
|
|
198
|
+
more = ((await prompt(' add another worker? [y/N] ')).trim().toLowerCase().startsWith('y'));
|
|
199
|
+
}
|
|
200
|
+
if (!workers.length) workers.push(planner);
|
|
201
|
+
cf.orchestratorSet(cfg, { planner, workers });
|
|
202
|
+
cf.orchestratorEnable(cfg, true);
|
|
203
|
+
writeConfig(cfg);
|
|
204
|
+
write(` ${ok('✓ orchestration enabled:')} planner ${planner} · workers ${workers.join(', ')}\n`);
|
|
205
|
+
write(` ${dim('change later: /orchestrator or lazyclaw orchestrator status')}\n\n`);
|
|
206
|
+
return { skipped: false, planner, workers };
|
|
207
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Skill management commands (list/show/install/remove/search/curate/classify),
|
|
2
|
+
// extracted from cli.mjs in Phase D3. Self-contained over skills*.mjs modules.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { configPath } from '../lib/config.mjs';
|
|
6
|
+
|
|
7
|
+
export async function cmdSkills(sub, positional, flags = {}) {
|
|
8
|
+
const skillsMod = await import('../skills.mjs');
|
|
9
|
+
const cfgDir = path.dirname(configPath());
|
|
10
|
+
switch (sub) {
|
|
11
|
+
case undefined:
|
|
12
|
+
case 'list': {
|
|
13
|
+
// Same --filter / --limit semantic as v3.33's sessions list:
|
|
14
|
+
// case-insensitive name substring, then post-filter cap.
|
|
15
|
+
let items = skillsMod.listSkills(cfgDir);
|
|
16
|
+
if (flags.filter) {
|
|
17
|
+
const f = String(flags.filter).toLowerCase();
|
|
18
|
+
items = items.filter(s => s.name.toLowerCase().includes(f));
|
|
19
|
+
}
|
|
20
|
+
if (flags.limit !== undefined) {
|
|
21
|
+
const n = parseInt(flags.limit, 10);
|
|
22
|
+
if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
|
|
23
|
+
}
|
|
24
|
+
console.log(JSON.stringify(items.map(s => ({ name: s.name, bytes: s.bytes, summary: s.summary })), null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
case 'show': {
|
|
28
|
+
const name = positional[0];
|
|
29
|
+
if (!name) { console.error('Usage: lazyclaw skills show <name>'); process.exit(2); }
|
|
30
|
+
try { process.stdout.write(skillsMod.loadSkill(name, cfgDir)); }
|
|
31
|
+
catch (e) { console.error(e.message); process.exit(1); }
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
case 'install': {
|
|
35
|
+
// Four forms:
|
|
36
|
+
// 1. install user/repo[@ref][:subpath] — GitHub bundle
|
|
37
|
+
// 2. install <name> --from <path>
|
|
38
|
+
// 3. install <name> --from-url <https://...>
|
|
39
|
+
// 4. install <name> — body via stdin
|
|
40
|
+
// Detect form 1 via a slash in the first positional and the
|
|
41
|
+
// absence of any --from* flag (so a literal local skill name
|
|
42
|
+
// with `/` still routes to the explicit-flag branch — though
|
|
43
|
+
// skillPath() rejects slashes anyway).
|
|
44
|
+
const name = positional[0];
|
|
45
|
+
if (!name) { console.error('Usage: lazyclaw skills install <user/repo[@ref][:path]> | <name> [--from <path> | --from-url <https://...>]'); process.exit(2); }
|
|
46
|
+
if (name.includes('/') && !flags.from && !flags['from-url']) {
|
|
47
|
+
const inst = await import('../skills_install.mjs');
|
|
48
|
+
try {
|
|
49
|
+
const r = await inst.installFromGithub(name, cfgDir, {
|
|
50
|
+
prefix: flags.prefix || '',
|
|
51
|
+
force: !!flags.force,
|
|
52
|
+
maxBytes: flags['max-bytes'] !== undefined ? parseInt(flags['max-bytes'], 10) : undefined,
|
|
53
|
+
timeoutMs: flags['timeout-ms'] !== undefined ? parseInt(flags['timeout-ms'], 10) : undefined,
|
|
54
|
+
});
|
|
55
|
+
console.log(JSON.stringify({
|
|
56
|
+
ok: true,
|
|
57
|
+
spec: `${r.spec.owner}/${r.spec.repo}@${r.spec.ref}${r.spec.subpath ? ':' + r.spec.subpath : ''}`,
|
|
58
|
+
installed: r.installed,
|
|
59
|
+
skipped: r.skipped,
|
|
60
|
+
}, null, 2));
|
|
61
|
+
return;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(`error: ${e?.message || e}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
let content;
|
|
68
|
+
if (flags['from-url']) {
|
|
69
|
+
const url = String(flags['from-url']);
|
|
70
|
+
// Refuse http/file/data — only https. The skill content goes
|
|
71
|
+
// straight into the system prompt, so source authenticity matters.
|
|
72
|
+
if (!url.startsWith('https://')) {
|
|
73
|
+
console.error('skills install --from-url requires an https:// URL');
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
const fetchFn = globalThis.fetch;
|
|
77
|
+
if (!fetchFn) { console.error('fetch is not available in this Node runtime'); process.exit(1); }
|
|
78
|
+
// Configurable max size — protect against pathological responses
|
|
79
|
+
// that would balloon the prompt and the disk file. 1 MiB cap.
|
|
80
|
+
const MAX_BYTES = 1_048_576;
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetchFn(url, { redirect: 'follow' });
|
|
83
|
+
if (!res.ok) { console.error(`fetch ${url} → ${res.status}`); process.exit(1); }
|
|
84
|
+
// Stream the body so we can stop at the cap rather than loading
|
|
85
|
+
// an arbitrarily large response into memory.
|
|
86
|
+
const reader = res.body?.getReader?.();
|
|
87
|
+
if (!reader) { content = await res.text(); }
|
|
88
|
+
else {
|
|
89
|
+
const chunks = [];
|
|
90
|
+
let total = 0;
|
|
91
|
+
while (true) {
|
|
92
|
+
const { value, done } = await reader.read();
|
|
93
|
+
if (done) break;
|
|
94
|
+
total += value.length;
|
|
95
|
+
if (total > MAX_BYTES) {
|
|
96
|
+
console.error(`skills install: response exceeds ${MAX_BYTES} bytes; refusing`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
chunks.push(value);
|
|
100
|
+
}
|
|
101
|
+
content = new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks.map(c => Buffer.from(c))));
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(`skills install fetch failed: ${e?.message || e}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
} else if (flags.from) {
|
|
108
|
+
content = fs.readFileSync(flags.from, 'utf8');
|
|
109
|
+
} else {
|
|
110
|
+
content = await new Promise(resolve => {
|
|
111
|
+
let buf = '';
|
|
112
|
+
process.stdin.setEncoding('utf8');
|
|
113
|
+
process.stdin.on('data', d => { buf += d; });
|
|
114
|
+
process.stdin.on('end', () => resolve(buf));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const written = skillsMod.installSkill(name, content, cfgDir);
|
|
118
|
+
console.log(JSON.stringify({ ok: true, name, path: written, bytes: content.length }));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
case 'remove': {
|
|
122
|
+
const name = positional[0];
|
|
123
|
+
if (!name) { console.error('Usage: lazyclaw skills remove <name>'); process.exit(2); }
|
|
124
|
+
skillsMod.removeSkill(name, cfgDir);
|
|
125
|
+
console.log(JSON.stringify({ ok: true, removed: name }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
case 'search': {
|
|
129
|
+
// Mirror of `lazyclaw sessions search` — case-insensitive substring
|
|
130
|
+
// by default, --regex for pattern mode. Returns per-skill match
|
|
131
|
+
// count + first-excerpt window (40 chars before/after match).
|
|
132
|
+
// The skill body IS markdown so users typically search for terms
|
|
133
|
+
// mentioned in instructions or examples.
|
|
134
|
+
const query = positional[0];
|
|
135
|
+
if (!query) { console.error('Usage: lazyclaw skills search <query> [--regex]'); process.exit(2); }
|
|
136
|
+
const useRegex = !!flags.regex;
|
|
137
|
+
let matcher;
|
|
138
|
+
if (useRegex) {
|
|
139
|
+
try { matcher = new RegExp(query, 'i'); }
|
|
140
|
+
catch (e) { console.error(`invalid regex: ${e.message}`); process.exit(2); }
|
|
141
|
+
} else {
|
|
142
|
+
const q = query.toLowerCase();
|
|
143
|
+
matcher = { test: (s) => String(s).toLowerCase().includes(q) };
|
|
144
|
+
}
|
|
145
|
+
const items = skillsMod.listSkills(cfgDir);
|
|
146
|
+
const matches = [];
|
|
147
|
+
for (const s of items) {
|
|
148
|
+
let body;
|
|
149
|
+
try { body = skillsMod.loadSkill(s.name, cfgDir); }
|
|
150
|
+
catch { continue; } // file may have been removed mid-listing
|
|
151
|
+
// Count matches across the whole body, not per-line. For a
|
|
152
|
+
// skill body that's a few KB this is plenty fast and the count
|
|
153
|
+
// matches the user's intuition of "how many times does it
|
|
154
|
+
// mention X."
|
|
155
|
+
let matchCount = 0;
|
|
156
|
+
let firstExcerpt = null;
|
|
157
|
+
if (useRegex) {
|
|
158
|
+
// Re-anchor the regex with /gi so we can iterate; the original
|
|
159
|
+
// matcher was /i for boolean test() above. Rebuild here.
|
|
160
|
+
const gFlag = new RegExp(query, 'gi');
|
|
161
|
+
for (const m of body.matchAll(gFlag)) {
|
|
162
|
+
matchCount++;
|
|
163
|
+
if (firstExcerpt === null) {
|
|
164
|
+
const pos = m.index ?? 0;
|
|
165
|
+
const start = Math.max(0, pos - 40);
|
|
166
|
+
const end = Math.min(body.length, pos + m[0].length + 40);
|
|
167
|
+
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
const lower = body.toLowerCase();
|
|
172
|
+
const q = query.toLowerCase();
|
|
173
|
+
let pos = 0;
|
|
174
|
+
while (true) {
|
|
175
|
+
const i = lower.indexOf(q, pos);
|
|
176
|
+
if (i < 0) break;
|
|
177
|
+
matchCount++;
|
|
178
|
+
if (firstExcerpt === null) {
|
|
179
|
+
const start = Math.max(0, i - 40);
|
|
180
|
+
const end = Math.min(body.length, i + q.length + 40);
|
|
181
|
+
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
182
|
+
}
|
|
183
|
+
pos = i + q.length;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (matchCount > 0) {
|
|
187
|
+
matches.push({
|
|
188
|
+
name: s.name,
|
|
189
|
+
bytes: s.bytes,
|
|
190
|
+
matchCount,
|
|
191
|
+
excerpt: firstExcerpt,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
case 'curate': {
|
|
199
|
+
// Lifecycle sweep: agent-authored skills unused >90d move into
|
|
200
|
+
// skills/.archive/ (recoverable). Human-authored skills are never
|
|
201
|
+
// moved. The real clock is injected here; the module stays pure.
|
|
202
|
+
const curator = await import('../skills_curator.mjs');
|
|
203
|
+
const r = curator.curate(cfgDir, Date.now());
|
|
204
|
+
console.log(JSON.stringify(r, null, 2));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
case 'classify': {
|
|
208
|
+
const name = positional[0];
|
|
209
|
+
if (!name) { console.error('Usage: lazyclaw skills classify <name>'); process.exit(2); }
|
|
210
|
+
const curator = await import('../skills_curator.mjs');
|
|
211
|
+
console.log(JSON.stringify({ name, state: curator.classify(name, cfgDir, Date.now()), usage: curator.usageOf(name, cfgDir) }, null, 2));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
default:
|
|
215
|
+
console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]|curate|classify <name>>');
|
|
216
|
+
process.exit(2);
|
|
217
|
+
}
|
|
218
|
+
}
|