lazyclaw 3.88.0 → 3.99.4

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.
@@ -0,0 +1,241 @@
1
+ // Config-driven CLI surfaces — auth-profile rotation, pairing
2
+ // (sender allowlist), nodes (device registration), and outbound
3
+ // messaging webhooks. Each one is a thin record-keeper layered on
4
+ // top of the existing readConfig / writeConfig pair: the CLI never
5
+ // stores a separate file.
6
+ //
7
+ // All four were called out as OpenClaw parity gaps; we implement
8
+ // them as plain config keys here so the SAME `lazyclaw config get`
9
+ // flow keeps working and `lazyclaw export | jq` already covers
10
+ // backups without us writing a second exporter.
11
+
12
+ // Auth profiles ────────────────────────────────────────────────
13
+ //
14
+ // cfg.authProfiles[provider] = [{ key, label, addedAt }]
15
+ //
16
+ // Picked over a single `api-key` field so a user can keep multiple
17
+ // keys for the same provider (work / personal / spare) and rotate
18
+ // when one hits a rate limit. The rotation cursor lives in
19
+ // cfg.authActiveProfile[provider] = label so the choice persists
20
+ // across invocations.
21
+
22
+ export function authList(cfg, provider) {
23
+ const profiles = (cfg.authProfiles || {})[provider] || [];
24
+ return profiles.map((p) => ({
25
+ label: p.label,
26
+ addedAt: p.addedAt,
27
+ keyMasked: maskKey(p.key),
28
+ }));
29
+ }
30
+
31
+ export function authAdd(cfg, provider, key, label) {
32
+ if (!provider) throw new Error('provider is required');
33
+ if (!key) throw new Error('key is required');
34
+ cfg.authProfiles = cfg.authProfiles || {};
35
+ cfg.authProfiles[provider] = cfg.authProfiles[provider] || [];
36
+ const lbl = (label || `profile-${cfg.authProfiles[provider].length + 1}`).trim();
37
+ if (cfg.authProfiles[provider].some((p) => p.label === lbl)) {
38
+ throw new Error(`profile "${lbl}" already exists for ${provider}`);
39
+ }
40
+ cfg.authProfiles[provider].push({ key, label: lbl, addedAt: new Date().toISOString() });
41
+ // First-added profile becomes active so the user gets working
42
+ // auth rotation without a separate `auth use` step.
43
+ cfg.authActiveProfile = cfg.authActiveProfile || {};
44
+ if (!cfg.authActiveProfile[provider]) cfg.authActiveProfile[provider] = lbl;
45
+ return lbl;
46
+ }
47
+
48
+ export function authRemove(cfg, provider, label) {
49
+ const arr = (cfg.authProfiles || {})[provider] || [];
50
+ const idx = arr.findIndex((p) => p.label === label);
51
+ if (idx < 0) throw new Error(`no profile "${label}" for ${provider}`);
52
+ arr.splice(idx, 1);
53
+ if ((cfg.authActiveProfile || {})[provider] === label) {
54
+ cfg.authActiveProfile[provider] = arr[0]?.label || '';
55
+ }
56
+ }
57
+
58
+ export function authUse(cfg, provider, label) {
59
+ const arr = (cfg.authProfiles || {})[provider] || [];
60
+ if (!arr.some((p) => p.label === label)) {
61
+ throw new Error(`no profile "${label}" for ${provider}`);
62
+ }
63
+ cfg.authActiveProfile = cfg.authActiveProfile || {};
64
+ cfg.authActiveProfile[provider] = label;
65
+ }
66
+
67
+ export function authRotate(cfg, provider) {
68
+ const arr = (cfg.authProfiles || {})[provider] || [];
69
+ if (arr.length < 2) return null;
70
+ cfg.authActiveProfile = cfg.authActiveProfile || {};
71
+ const cur = cfg.authActiveProfile[provider];
72
+ const idx = arr.findIndex((p) => p.label === cur);
73
+ const next = arr[(idx + 1) % arr.length];
74
+ cfg.authActiveProfile[provider] = next.label;
75
+ return next.label;
76
+ }
77
+
78
+ // Resolves the api-key the chat / agent flow should send. Falls
79
+ // back to the legacy single `api-key` field so existing configs
80
+ // keep working without a migration.
81
+ export function resolveApiKey(cfg, provider) {
82
+ const arr = (cfg.authProfiles || {})[provider] || [];
83
+ const active = (cfg.authActiveProfile || {})[provider];
84
+ const hit = arr.find((p) => p.label === active) || arr[0];
85
+ if (hit?.key) return hit.key;
86
+ return cfg['api-key'] || '';
87
+ }
88
+
89
+ function maskKey(key) {
90
+ if (!key) return '';
91
+ const s = String(key);
92
+ if (s.length <= 8) return '****' + s.slice(-2);
93
+ return s.slice(0, 4) + '…' + s.slice(-4);
94
+ }
95
+
96
+ // Pairing (sender allowlist) ───────────────────────────────────
97
+ //
98
+ // cfg.pairing = [{ id, label, addedAt }]
99
+ //
100
+ // Sender ids are the opaque strings the messaging layer hands us
101
+ // (e.g. Slack member id, Discord user id, phone number for SMS
102
+ // bridges). Anything that isn't on the allowlist gets rejected by
103
+ // the inbound handler — same shape as openclaw `pairing approve`.
104
+
105
+ export function pairingList(cfg) {
106
+ return (cfg.pairing || []).slice();
107
+ }
108
+
109
+ export function pairingAdd(cfg, id, label) {
110
+ if (!id) throw new Error('id is required');
111
+ cfg.pairing = cfg.pairing || [];
112
+ if (cfg.pairing.some((p) => p.id === id)) {
113
+ throw new Error(`id "${id}" already paired`);
114
+ }
115
+ cfg.pairing.push({ id, label: label || '', addedAt: new Date().toISOString() });
116
+ }
117
+
118
+ export function pairingRemove(cfg, id) {
119
+ const arr = cfg.pairing || [];
120
+ const idx = arr.findIndex((p) => p.id === id);
121
+ if (idx < 0) throw new Error(`id "${id}" not found`);
122
+ arr.splice(idx, 1);
123
+ }
124
+
125
+ export function pairingHas(cfg, id) {
126
+ return (cfg.pairing || []).some((p) => p.id === id);
127
+ }
128
+
129
+ // Nodes (device registration) ──────────────────────────────────
130
+ //
131
+ // cfg.nodes = [{ id, platform, label, registeredAt }]
132
+ //
133
+ // CLI side of `openclaw nodes` — the actual mobile companion apps
134
+ // aren't in scope here, but the registration table lets a future
135
+ // app (or just `curl`) authenticate against `lazyclaw daemon`.
136
+ // Platform is free-form ('macos' / 'ios' / 'android' / 'web' /
137
+ // 'cli') so we don't constrain future surfaces.
138
+
139
+ export function nodesList(cfg) {
140
+ return (cfg.nodes || []).slice();
141
+ }
142
+
143
+ export function nodesRegister(cfg, id, platform = 'cli', label = '') {
144
+ if (!id) throw new Error('id is required');
145
+ cfg.nodes = cfg.nodes || [];
146
+ if (cfg.nodes.some((n) => n.id === id)) {
147
+ throw new Error(`node "${id}" already registered`);
148
+ }
149
+ cfg.nodes.push({
150
+ id,
151
+ platform: String(platform || 'cli').toLowerCase(),
152
+ label: label || '',
153
+ registeredAt: new Date().toISOString(),
154
+ });
155
+ }
156
+
157
+ export function nodesRemove(cfg, id) {
158
+ const arr = cfg.nodes || [];
159
+ const idx = arr.findIndex((n) => n.id === id);
160
+ if (idx < 0) throw new Error(`node "${id}" not found`);
161
+ arr.splice(idx, 1);
162
+ }
163
+
164
+ // Messaging — outbound webhooks ────────────────────────────────
165
+ //
166
+ // cfg.messaging.webhooks[name] = { kind: 'slack'|'discord', url }
167
+ //
168
+ // We deliberately store webhook URLs (not bot tokens) because that
169
+ // keeps the install footprint small — any user can paste a Slack
170
+ // "Incoming Webhook" URL and start sending without registering an
171
+ // app. Bot tokens can be added later as a separate `messaging.tokens`
172
+ // shape when we wire the bidirectional inbox.
173
+
174
+ const WEBHOOK_PATTERNS = {
175
+ slack: /^https?:\/\/hooks\.slack\.com\//i,
176
+ discord: /^https?:\/\/(?:discord(?:app)?\.com|canary\.discord\.com)\/api\/webhooks\//i,
177
+ };
178
+
179
+ function detectKind(url) {
180
+ for (const [kind, re] of Object.entries(WEBHOOK_PATTERNS)) {
181
+ if (re.test(url)) return kind;
182
+ }
183
+ return 'generic';
184
+ }
185
+
186
+ export function messageList(cfg) {
187
+ const map = (cfg.messaging || {}).webhooks || {};
188
+ return Object.entries(map).map(([name, v]) => ({
189
+ name,
190
+ kind: v.kind,
191
+ urlMasked: v.url ? v.url.slice(0, 32) + '…' + v.url.slice(-6) : '',
192
+ }));
193
+ }
194
+
195
+ export function messageAdd(cfg, name, url, kindOverride) {
196
+ if (!name) throw new Error('name is required');
197
+ if (!url) throw new Error('url is required');
198
+ cfg.messaging = cfg.messaging || {};
199
+ cfg.messaging.webhooks = cfg.messaging.webhooks || {};
200
+ if (cfg.messaging.webhooks[name]) {
201
+ throw new Error(`webhook "${name}" already exists`);
202
+ }
203
+ cfg.messaging.webhooks[name] = {
204
+ kind: kindOverride || detectKind(url),
205
+ url,
206
+ addedAt: new Date().toISOString(),
207
+ };
208
+ }
209
+
210
+ export function messageRemove(cfg, name) {
211
+ const map = (cfg.messaging || {}).webhooks || {};
212
+ if (!map[name]) throw new Error(`webhook "${name}" not found`);
213
+ delete map[name];
214
+ }
215
+
216
+ export async function messageSend(cfg, name, text, opts = {}) {
217
+ const map = (cfg.messaging || {}).webhooks || {};
218
+ const hook = map[name];
219
+ if (!hook) throw new Error(`webhook "${name}" not configured — add via \`lazyclaw message add\``);
220
+ const fetchFn = opts.fetch || globalThis.fetch;
221
+ if (!fetchFn) throw new Error('no fetch implementation');
222
+
223
+ // Slack and Discord both accept a JSON body but with different key
224
+ // shapes — Slack uses { text }, Discord uses { content }. The
225
+ // generic kind sends a plain JSON envelope so user-supplied
226
+ // endpoints can ingest whatever shape they like via { text }.
227
+ let body;
228
+ if (hook.kind === 'discord') body = JSON.stringify({ content: text });
229
+ else body = JSON.stringify({ text });
230
+
231
+ const res = await fetchFn(hook.url, {
232
+ method: 'POST',
233
+ headers: { 'content-type': 'application/json' },
234
+ body,
235
+ });
236
+ if (!res.ok) {
237
+ const errText = (await (res.text?.() || Promise.resolve(''))).slice(0, 300);
238
+ throw new Error(`webhook ${hook.kind} send failed: ${res.status} ${errText}`);
239
+ }
240
+ return { ok: true, kind: hook.kind, status: res.status };
241
+ }
package/cron.mjs ADDED
@@ -0,0 +1,359 @@
1
+ // Cron — recurring `lazyclaw agent` runs.
2
+ //
3
+ // `lazyclaw cron add daily-summary "0 9 * * *" -- agent "Summarise
4
+ // today's TODOs"` schedules the agent invocation every weekday at
5
+ // 9 AM. On macOS we install a launchd plist; on Linux / WSL we
6
+ // append a crontab entry. Both backends carry a per-job marker so
7
+ // `cron list` / `cron remove` round-trip cleanly.
8
+ //
9
+ // Why this is built into the CLI:
10
+ // - Most "make this scheduled" recipes for AI agents devolve into
11
+ // "add a crontab entry that pipes through a wrapper" — that's
12
+ // what we generate here, but with the right env vars to land in
13
+ // the user's lazyclaw config and a stable id for removal.
14
+ // - launchd plists on macOS don't honor `crontab -e`, so a
15
+ // single-platform implementation would feel broken to half the
16
+ // user base.
17
+ //
18
+ // The job spec lives in `cfg.cron[<name>]` so it survives
19
+ // uninstall/reinstall of the OS-level scheduler — `cron sync`
20
+ // reconciles. Schedule strings use 5-field cron syntax everywhere
21
+ // (the launchd backend internally translates to plist
22
+ // StartCalendarInterval entries).
23
+
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { spawn, spawnSync } from 'node:child_process';
28
+
29
+ const MARKER_PREFIX = '# lazyclaw-cron:';
30
+
31
+ class CronError extends Error {
32
+ constructor(message, code) { super(message); this.name = 'CronError'; this.code = code || 'CRON_ERR'; }
33
+ }
34
+
35
+ // 5-field cron spec parser — minimal but strict enough that
36
+ // "every Tuesday at 14:30" doesn't silently land at "every minute".
37
+ // Supports: number, *, range a-b, list a,b,c, step */n.
38
+ const FIELD_RANGES = [
39
+ { name: 'minute', min: 0, max: 59 },
40
+ { name: 'hour', min: 0, max: 23 },
41
+ { name: 'dom', min: 1, max: 31 },
42
+ { name: 'month', min: 1, max: 12 },
43
+ { name: 'dow', min: 0, max: 6 },
44
+ ];
45
+
46
+ export function parseCronSpec(spec) {
47
+ const tokens = String(spec || '').trim().split(/\s+/);
48
+ if (tokens.length !== 5) {
49
+ throw new CronError(`bad cron spec "${spec}" — need 5 fields, got ${tokens.length}`, 'CRON_BAD_SPEC');
50
+ }
51
+ const out = {};
52
+ for (let i = 0; i < 5; i++) {
53
+ const range = FIELD_RANGES[i];
54
+ out[range.name] = parseField(tokens[i], range);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function parseField(field, { name, min, max }) {
60
+ // Wildcard short-circuit.
61
+ if (field === '*') return { kind: 'any' };
62
+ // Step expressions: */N or RANGE/N.
63
+ const slash = field.indexOf('/');
64
+ if (slash >= 0) {
65
+ const head = field.slice(0, slash);
66
+ const step = Number(field.slice(slash + 1));
67
+ if (!Number.isFinite(step) || step < 1) {
68
+ throw new CronError(`bad step "${field}" in ${name}`, 'CRON_BAD_STEP');
69
+ }
70
+ if (head === '' || head === '*') return { kind: 'step', from: min, to: max, step };
71
+ const dash = head.indexOf('-');
72
+ if (dash < 0) throw new CronError(`bad step base "${head}" in ${name}`, 'CRON_BAD_STEP');
73
+ const a = Number(head.slice(0, dash)), b = Number(head.slice(dash + 1));
74
+ expectInRange(a, min, max, name); expectInRange(b, min, max, name);
75
+ return { kind: 'step', from: a, to: b, step };
76
+ }
77
+ // List a,b,c.
78
+ if (field.includes(',')) {
79
+ const items = field.split(',').map((p) => parseField(p, { name, min, max }));
80
+ return { kind: 'list', items };
81
+ }
82
+ // Range a-b.
83
+ const dash = field.indexOf('-');
84
+ if (dash >= 0) {
85
+ const a = Number(field.slice(0, dash)), b = Number(field.slice(dash + 1));
86
+ expectInRange(a, min, max, name); expectInRange(b, min, max, name);
87
+ return { kind: 'range', from: a, to: b };
88
+ }
89
+ // Plain number.
90
+ const n = Number(field);
91
+ expectInRange(n, min, max, name);
92
+ return { kind: 'value', value: n };
93
+ }
94
+
95
+ function expectInRange(n, min, max, fieldName) {
96
+ if (!Number.isFinite(n) || n < min || n > max) {
97
+ throw new CronError(`${fieldName} value ${n} out of range ${min}–${max}`, 'CRON_OUT_OF_RANGE');
98
+ }
99
+ }
100
+
101
+ // Expand a parsed field into the explicit list of values it
102
+ // matches. launchd needs explicit values, not patterns.
103
+ export function expandField(parsed, { min, max }) {
104
+ if (!parsed) return [];
105
+ switch (parsed.kind) {
106
+ case 'any': return null; // null === "every"
107
+ case 'value': return [parsed.value];
108
+ case 'range': return inclusive(parsed.from, parsed.to);
109
+ case 'step': {
110
+ const out = [];
111
+ for (let i = parsed.from; i <= parsed.to; i += parsed.step) out.push(i);
112
+ return out;
113
+ }
114
+ case 'list': {
115
+ const out = new Set();
116
+ for (const item of parsed.items) {
117
+ const xs = expandField(item, { min, max });
118
+ if (xs === null) return null;
119
+ xs.forEach((v) => out.add(v));
120
+ }
121
+ return [...out].sort((a, b) => a - b);
122
+ }
123
+ default: return [];
124
+ }
125
+ }
126
+
127
+ function inclusive(a, b) {
128
+ const [from, to] = a <= b ? [a, b] : [b, a];
129
+ return Array.from({ length: to - from + 1 }, (_, i) => from + i);
130
+ }
131
+
132
+ // ── id / shape ──────────────────────────────────────────────────
133
+
134
+ const NAME_RE = /^[A-Za-z0-9_.-]+$/;
135
+
136
+ export function ensureValidName(name) {
137
+ if (!name || !NAME_RE.test(name)) {
138
+ throw new CronError(`name "${name}" must match ${NAME_RE}`, 'CRON_BAD_NAME');
139
+ }
140
+ }
141
+
142
+ export function listJobs(cfg) {
143
+ return Object.entries(cfg.cron || {}).map(([name, j]) => ({
144
+ name,
145
+ schedule: j.schedule,
146
+ command: j.command,
147
+ addedAt: j.addedAt,
148
+ }));
149
+ }
150
+
151
+ export function getJob(cfg, name) {
152
+ return (cfg.cron || {})[name] || null;
153
+ }
154
+
155
+ export function upsertJob(cfg, name, schedule, command) {
156
+ ensureValidName(name);
157
+ parseCronSpec(schedule); // throws on bad spec
158
+ if (!command || (Array.isArray(command) && !command.length)) {
159
+ throw new CronError('command is required', 'CRON_NO_COMMAND');
160
+ }
161
+ cfg.cron = cfg.cron || {};
162
+ const existed = !!cfg.cron[name];
163
+ cfg.cron[name] = {
164
+ schedule,
165
+ command: Array.isArray(command) ? command : [String(command)],
166
+ addedAt: existed ? cfg.cron[name].addedAt : new Date().toISOString(),
167
+ updatedAt: new Date().toISOString(),
168
+ };
169
+ return existed ? 'updated' : 'created';
170
+ }
171
+
172
+ export function removeJob(cfg, name) {
173
+ if (!cfg.cron || !cfg.cron[name]) throw new CronError(`no job "${name}"`, 'CRON_NO_JOB');
174
+ delete cfg.cron[name];
175
+ }
176
+
177
+ // ── installer (system scheduler) ────────────────────────────────
178
+
179
+ export function pickBackend() {
180
+ if (process.platform === 'darwin') return 'launchd';
181
+ return 'crontab';
182
+ }
183
+
184
+ export function plistPath(name) {
185
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `com.lazyclaw.${name}.plist`);
186
+ }
187
+
188
+ export function buildPlist(name, schedule, command) {
189
+ const parsed = parseCronSpec(schedule);
190
+ const min = expandField(parsed.minute, { min: 0, max: 59 });
191
+ const hour = expandField(parsed.hour, { min: 0, max: 23 });
192
+ const dom = expandField(parsed.dom, { min: 1, max: 31 });
193
+ const month = expandField(parsed.month, { min: 1, max: 12 });
194
+ const dow = expandField(parsed.dow, { min: 0, max: 6 });
195
+ // launchd takes a single dict per fire-time. We expand the cron
196
+ // schedule into the cartesian product of (Minute × Hour × Day ×
197
+ // Month × Weekday); each null field means "every" so we encode
198
+ // nothing for it. For most schedules this is small (e.g. "every
199
+ // weekday 9 AM" = 5 entries).
200
+ const entries = cartesian([
201
+ minOrNull(min), minOrNull(hour), minOrNull(dom), minOrNull(month), minOrNull(dow),
202
+ ]);
203
+ const intervals = entries.map(([Minute, Hour, Day, Month, Weekday]) => {
204
+ const dict = {};
205
+ if (Minute !== null) dict.Minute = Minute;
206
+ if (Hour !== null) dict.Hour = Hour;
207
+ if (Day !== null) dict.Day = Day;
208
+ if (Month !== null) dict.Month = Month;
209
+ if (Weekday !== null) dict.Weekday = Weekday;
210
+ return dict;
211
+ });
212
+ const programArguments = command;
213
+ const stdoutPath = path.join(os.homedir(), '.lazyclaw', 'logs', `cron-${name}.out.log`);
214
+ const stderrPath = path.join(os.homedir(), '.lazyclaw', 'logs', `cron-${name}.err.log`);
215
+ return renderPlist({
216
+ label: `com.lazyclaw.${name}`,
217
+ programArguments,
218
+ intervals,
219
+ stdoutPath,
220
+ stderrPath,
221
+ });
222
+ }
223
+
224
+ function minOrNull(arr) {
225
+ return arr === null ? [null] : arr;
226
+ }
227
+
228
+ function cartesian(arrs) {
229
+ return arrs.reduce((acc, arr) => acc.flatMap((row) => arr.map((v) => row.concat([v]))), [[]]);
230
+ }
231
+
232
+ function renderPlist({ label, programArguments, intervals, stdoutPath, stderrPath }) {
233
+ const argLines = programArguments.map((a) => ` <string>${escapeXml(a)}</string>`).join('\n');
234
+ const intervalDicts = intervals.map((i) => {
235
+ const inner = Object.entries(i)
236
+ .map(([k, v]) => ` <key>${k}</key>\n <integer>${v}</integer>`).join('\n');
237
+ return ` <dict>\n${inner}\n </dict>`;
238
+ }).join('\n');
239
+ return `<?xml version="1.0" encoding="UTF-8"?>
240
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
241
+ <plist version="1.0">
242
+ <dict>
243
+ <key>Label</key>
244
+ <string>${escapeXml(label)}</string>
245
+ <key>ProgramArguments</key>
246
+ <array>
247
+ ${argLines}
248
+ </array>
249
+ <key>StartCalendarInterval</key>
250
+ <array>
251
+ ${intervalDicts}
252
+ </array>
253
+ <key>StandardOutPath</key>
254
+ <string>${escapeXml(stdoutPath)}</string>
255
+ <key>StandardErrorPath</key>
256
+ <string>${escapeXml(stderrPath)}</string>
257
+ <key>RunAtLoad</key>
258
+ <false/>
259
+ </dict>
260
+ </plist>
261
+ `;
262
+ }
263
+
264
+ function escapeXml(s) {
265
+ return String(s)
266
+ .replace(/&/g, '&amp;')
267
+ .replace(/</g, '&lt;')
268
+ .replace(/>/g, '&gt;')
269
+ .replace(/"/g, '&quot;')
270
+ .replace(/'/g, '&apos;');
271
+ }
272
+
273
+ // ── crontab backend (Linux / WSL) ───────────────────────────────
274
+
275
+ export function buildCrontabLine(name, schedule, command) {
276
+ const cmdStr = command.map(shellQuote).join(' ');
277
+ return `${schedule} ${cmdStr} ${MARKER_PREFIX}${name}`;
278
+ }
279
+
280
+ function shellQuote(arg) {
281
+ if (arg === '' || /[\s'"\\$`]/.test(arg)) return `'${String(arg).replace(/'/g, `'\\''`)}'`;
282
+ return String(arg);
283
+ }
284
+
285
+ // Reads current crontab; ignores "no crontab" exit codes.
286
+ function readCrontab() {
287
+ const r = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
288
+ if (r.status === 0) return r.stdout || '';
289
+ // exit 1 + stderr "no crontab" === empty crontab; treat as ''.
290
+ return '';
291
+ }
292
+
293
+ function writeCrontab(text) {
294
+ const r = spawnSync('crontab', ['-'], { input: text, encoding: 'utf8' });
295
+ if (r.status !== 0) {
296
+ throw new CronError(`crontab write failed: ${r.stderr || r.status}`, 'CRON_WRITE_FAIL');
297
+ }
298
+ }
299
+
300
+ export function installCrontabJob(name, schedule, command) {
301
+ const line = buildCrontabLine(name, schedule, command);
302
+ const cur = readCrontab();
303
+ // Drop any prior line for the same name so update == replace.
304
+ const filtered = cur.split('\n').filter((ln) => !ln.endsWith(`${MARKER_PREFIX}${name}`));
305
+ const next = [...filtered, line].filter(Boolean).join('\n') + '\n';
306
+ writeCrontab(next);
307
+ return line;
308
+ }
309
+
310
+ export function uninstallCrontabJob(name) {
311
+ const cur = readCrontab();
312
+ if (!cur) return false;
313
+ const next = cur.split('\n').filter((ln) => !ln.endsWith(`${MARKER_PREFIX}${name}`)).join('\n');
314
+ writeCrontab(next + (next.endsWith('\n') ? '' : '\n'));
315
+ return cur !== next + (next.endsWith('\n') ? '' : '\n');
316
+ }
317
+
318
+ // ── launchd backend (macOS) ─────────────────────────────────────
319
+
320
+ export function installLaunchdJob(name, schedule, command) {
321
+ const text = buildPlist(name, schedule, command);
322
+ const dst = plistPath(name);
323
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
324
+ fs.mkdirSync(path.join(os.homedir(), '.lazyclaw', 'logs'), { recursive: true });
325
+ fs.writeFileSync(dst, text);
326
+ // Try to load the agent so it takes effect now. If launchctl
327
+ // refuses (already loaded, no GUI session), surface the error
328
+ // but leave the plist on disk — `launchctl load` later will
329
+ // pick it up.
330
+ spawnSync('launchctl', ['unload', dst], { stdio: 'ignore' });
331
+ const r = spawnSync('launchctl', ['load', dst], { encoding: 'utf8' });
332
+ if (r.status !== 0) {
333
+ throw new CronError(`launchctl load failed: ${r.stderr || r.status}`, 'CRON_LAUNCHD_FAIL');
334
+ }
335
+ return dst;
336
+ }
337
+
338
+ export function uninstallLaunchdJob(name) {
339
+ const dst = plistPath(name);
340
+ if (fs.existsSync(dst)) spawnSync('launchctl', ['unload', dst], { stdio: 'ignore' });
341
+ if (fs.existsSync(dst)) fs.unlinkSync(dst);
342
+ }
343
+
344
+ // ── cron run (one-shot, fired by the system) ────────────────────
345
+
346
+ /**
347
+ * Synchronous run path that the OS scheduler invokes via the
348
+ * stored ProgramArguments. Reads the named job from the saved
349
+ * config, resolves the command to its argv, and exec()s it.
350
+ */
351
+ export function runJob(cfg, name) {
352
+ const job = getJob(cfg, name);
353
+ if (!job) throw new CronError(`no job "${name}"`, 'CRON_NO_JOB');
354
+ const [bin, ...args] = job.command;
355
+ const r = spawnSync(bin, args, { stdio: 'inherit' });
356
+ return r.status ?? 0;
357
+ }
358
+
359
+ export { CronError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.88.0",
3
+ "version": "3.99.4",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -40,6 +40,12 @@
40
40
  "ratelimit.mjs",
41
41
  "config-validate.mjs",
42
42
  "rates-validate.mjs",
43
+ "config_features.mjs",
44
+ "workspace.mjs",
45
+ "browse.mjs",
46
+ "sandbox.mjs",
47
+ "skills_install.mjs",
48
+ "cron.mjs",
43
49
  "providers/",
44
50
  "workflow/",
45
51
  "web/",