lazyclaw 3.88.0 → 3.99.3

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/cli.mjs CHANGED
@@ -28,6 +28,20 @@ function writeConfig(cfg) {
28
28
  fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
29
29
  }
30
30
 
31
+ // Synchronous, dependency-free resolver for the api-key the
32
+ // chat / agent flow sends. Mirrors config_features.resolveApiKey
33
+ // without forcing the dynamic import on every hot-path call.
34
+ // 1. cfg.authProfiles[provider] active label, if set
35
+ // 2. first profile in the array
36
+ // 3. legacy single `cfg["api-key"]` (pre-v3.93 configs)
37
+ function _resolveAuthKey(cfg, provider) {
38
+ const arr = (cfg.authProfiles || {})[provider] || [];
39
+ const active = (cfg.authActiveProfile || {})[provider];
40
+ const hit = arr.find((p) => p && p.label === active) || arr[0];
41
+ if (hit?.key) return hit.key;
42
+ return cfg['api-key'] || '';
43
+ }
44
+
31
45
  async function importWorkflow(file) {
32
46
  const abs = path.resolve(file);
33
47
  const url = pathToFileURL(abs).href;
@@ -678,12 +692,39 @@ async function cmdOnboard(flags) {
678
692
  if (!flags['non-interactive']) {
679
693
  // Interactive onboarding is a single guided prompt sequence — kept tiny.
680
694
  // For automation always use --non-interactive plus the value flags.
695
+ // Skip the prompts entirely when the user passed --pick (or no
696
+ // provider yet AND we're on a TTY) so they get the full picker.
697
+ const wantPicker = !!flags.pick;
698
+ if (wantPicker || (!flags.provider && process.stdin.isTTY)) {
699
+ const picked = await _pickProviderInteractive();
700
+ if (picked) {
701
+ flags.provider = flags.provider || picked.provider;
702
+ if (picked.model && !flags.model) flags.model = picked.model;
703
+ }
704
+ }
681
705
  const readline = await import('node:readline');
682
706
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
683
707
  const ask = q => new Promise(resolve => rl.question(q, resolve));
684
- flags.provider = flags.provider || (await ask('provider [mock|anthropic]: ')).trim();
685
- flags.model = flags.model || (await ask('model (or "anthropic/claude-opus-4-7"): ')).trim();
686
- flags['api-key'] = flags['api-key'] || (await ask('api-key (leave blank for mock): ')).trim();
708
+ if (!flags.provider) {
709
+ const provs = Object.keys(_registryMod.PROVIDERS).join('|');
710
+ const noKeyHint = '\x1b[38;5;208mclaude-cli\x1b[0m (subscription, no key) is the default';
711
+ process.stdout.write(`hint: ${noKeyHint}\n`);
712
+ flags.provider = (await ask(`provider [${provs}]: `)).trim() || 'claude-cli';
713
+ }
714
+ if (!flags.model) {
715
+ const meta = (_registryMod.PROVIDER_INFO || {})[flags.provider] || {};
716
+ const sample = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(any)';
717
+ const dflt = meta.defaultModel || '';
718
+ flags.model = (await ask(`model (e.g. ${sample}) [${dflt}]: `)).trim() || dflt;
719
+ }
720
+ // Only ask for api-key when the picked provider actually needs one.
721
+ // claude-cli / ollama / mock all skip this — that's the whole point
722
+ // of supporting them.
723
+ const meta = (_registryMod.PROVIDER_INFO || {})[flags.provider] || {};
724
+ if (meta.requiresApiKey && !flags['api-key']) {
725
+ const prefix = meta.keyPrefix ? ` (starts with "${meta.keyPrefix}")` : '';
726
+ flags['api-key'] = (await ask(`api-key${prefix}: `)).trim();
727
+ }
687
728
  rl.close();
688
729
  }
689
730
  const next = applyOnboardConfig(readConfig(), flags);
@@ -697,7 +738,11 @@ async function cmdDoctor() {
697
738
  const cfg = readConfig();
698
739
  const issues = [];
699
740
  if (!cfg.provider) issues.push('config.provider is missing — run `lazyclaw onboard`');
700
- if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
741
+ // Only flag a missing api-key when the picked provider actually
742
+ // requires one. claude-cli / ollama / mock all run keylessly, so the
743
+ // previous `provider !== 'mock'` check produced false positives.
744
+ const _meta = (_registryMod.PROVIDER_INFO || {})[cfg.provider] || {};
745
+ if (cfg.provider && _meta.requiresApiKey && !cfg['api-key']) {
701
746
  issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
702
747
  }
703
748
  if (cfg.provider && !PROVIDERS_HAS(_registryMod.PROVIDERS, cfg.provider)) {
@@ -830,6 +875,8 @@ const SUBCOMMANDS = [
830
875
  'daemon', 'version', 'completion', 'help',
831
876
  'export', 'import',
832
877
  'rates',
878
+ // OpenClaw-parity subsurfaces (v3.93–v3.98)
879
+ 'auth', 'pairing', 'nodes', 'message', 'workspace', 'browse', 'cron',
833
880
  ];
834
881
 
835
882
  const SUBCOMMAND_SUBS = {
@@ -839,6 +886,12 @@ const SUBCOMMAND_SUBS = {
839
886
  providers: ['list', 'info', 'test'],
840
887
  rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
841
888
  completion: ['bash', 'zsh'],
889
+ auth: ['list', 'add', 'remove', 'use', 'rotate'],
890
+ pairing: ['list', 'add', 'remove'],
891
+ nodes: ['list', 'register', 'remove'],
892
+ message: ['list', 'add', 'remove', 'send'],
893
+ workspace: ['list', 'init', 'show', 'remove', 'path'],
894
+ cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
842
895
  };
843
896
 
844
897
  function bashCompletion() {
@@ -1047,6 +1100,13 @@ const HELP_SUMMARIES = {
1047
1100
  export: 'Dump config + skills (+ optional sessions) as a JSON bundle',
1048
1101
  import: 'Apply a JSON bundle from stdin or --from <path>',
1049
1102
  rates: 'Manage cost rate-cards in config (rates list|set <provider/model>|delete|shape)',
1103
+ auth: 'Multiple keys per provider (auth list|add|remove|use|rotate <provider>)',
1104
+ pairing: 'Sender allowlist for the messaging surface (pairing list|add|remove <id>)',
1105
+ nodes: 'Companion device registration (nodes list|register|remove <id>)',
1106
+ message: 'Outbound webhook messaging (message list|add|remove|send <name>)',
1107
+ workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
1108
+ browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
1109
+ cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
1050
1110
  inspect: 'Print persisted workflow state without executing',
1051
1111
  clear: 'Delete a persisted workflow state file (idempotent)',
1052
1112
  validate: 'Static-check a workflow file: shape, deps, cycles, parallelism',
@@ -1063,13 +1123,13 @@ const HELP_DETAILS = {
1063
1123
  validate: 'Usage: lazyclaw validate <workflow.mjs>\n Static check: load + shape + dep + cycle + parallelism estimate.\n Exit 0 valid · 1 hard failure (issues populated) · 2 file/import error.',
1064
1124
  graph: 'Usage: lazyclaw graph <workflow.mjs> [--lr] [--state <session-id>] [--dir <state-dir>]\n Emit the workflow DAG as Mermaid syntax (graph TD by default; --lr for left-right).\n --state overlays a persisted run\'s status (success ✓ / running ⏳ / failed ✗ / pending) with classDef styling.\n Output is paste-ready for GitHub markdown / Notion / Obsidian.',
1065
1125
  config: 'Usage: lazyclaw config <get|set|list|delete|path|edit|validate> [key] [value]\n Local key-value config at $LAZYCLAW_CONFIG_DIR/config.json (default ~/.lazyclaw).\n `path` prints the file location; `edit` opens it in $EDITOR (or $LAZYCLAW_EDITOR / $VISUAL / vi) and validates JSON on save.\n `validate` checks the structural integrity of the whole config file (typed values, known providers, rate-card shape).',
1066
- chat: 'Usage: lazyclaw chat [--session <id>] [--skill name1,name2] [--pick]\n --session persists turns to <configDir>/sessions/<id>.jsonl across invocations.\n --skill composes named skills into a system message at the head of the conversation.\n --pick opens an interactive provider/model picker before the prompt (also auto-fires on first run).',
1067
- agent: 'Usage: lazyclaw agent <prompt|-> [--provider X] [--model Y] [--skill list] [--thinking N] [--show-thinking] [--usage] [--cost]\n One-shot non-interactive call. Pass "-" as the prompt to read from stdin.\n --usage prints normalized {inputTokens, outputTokens, ...} to stderr after the response.\n --cost adds a cost line on stderr when config.rates has a card for the active provider/model.',
1126
+ chat: 'Usage: lazyclaw chat [--session <id>] [--skill name1,name2] [--workspace <name>] [--pick] [--sandbox docker:<image>] [--sandbox-network <net>] [--sandbox-mount <m>] [--sandbox-env <e>]\n --session persists turns to <configDir>/sessions/<id>.jsonl across invocations.\n --skill composes named skills into a system message at the head of the conversation.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md from <configDir>/workspaces/<name>/ into the system prompt.\n --pick opens an interactive provider/model picker before the prompt (also auto-fires on first run).\n --sandbox routes the underlying claude CLI through `docker run --rm -i --network <net> -v cwd:cwd ...` (default --network=none).',
1127
+ agent: 'Usage: lazyclaw agent <prompt|-> [--provider X] [--model Y] [--skill list] [--workspace <name>] [--thinking N] [--show-thinking] [--usage] [--cost] [--sandbox docker:<image>]\n One-shot non-interactive call. Pass "-" as the prompt to read from stdin.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md into the system prompt (combines with --skill).\n --usage prints normalized {inputTokens, outputTokens, ...} to stderr after the response.\n --cost adds a cost line on stderr when config.rates has a card for the active provider/model.\n --sandbox docker:<image> wraps the subprocess provider (claude-cli) in a Docker container; --sandbox-network defaults to none.',
1068
1128
  doctor: 'Usage: lazyclaw doctor\n Validates configuration and registered providers. Exits 0 only when no issues.',
1069
1129
  status: 'Usage: lazyclaw status\n Provider, model, and masked API key. Never prints the raw key.',
1070
1130
  onboard: 'Usage: lazyclaw onboard [--non-interactive] [--provider X] [--model Y] [--api-key Z]\n --model accepts the unified "provider/model" string (e.g. anthropic/claude-opus-4-7).',
1071
1131
  sessions: 'Usage: lazyclaw sessions <list [--filter <substr>] [--limit <N>]|show <id>|clear <id>|export <id> [--format md|json|text]|search <query> [--regex]>\n list — recent sessions by mtime; --filter caps to ids containing substring (case-insensitive); --limit caps result count.\n export — render in chosen format (md default for human sharing, json for tooling, text for paste).\n search — case-insensitive substring (or --regex pattern) match across all session content; returns first excerpt + match count per matching session.',
1072
- skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n --from-url fetches over HTTPS only; 1 MiB body cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
1132
+ skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <user/repo[@ref][:path]> [--prefix <p>] [--force] | install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n install <user>/<repo>[@<ref>][:<subpath>] — fetch a GitHub tarball, install every .md under skills/ (or the explicit subpath, or repo root). Default ref is `main`.\n --prefix prepends a name prefix so a multi-skill repo doesn\'t collide with locally-managed skills. --force overwrites existing names.\n install <name> --from <path> | --from-url <https://...> single-file install. --from-url is HTTPS-only with a 1 MiB cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
1073
1133
  providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.',
1074
1134
  daemon: 'Usage: lazyclaw daemon [--port <N>] [--once] [--auth-token <token>] [--allow-origin <origin>] [--rate-limit <N>] [--response-cache] [--log <level>] [--shutdown-timeout-ms <N>] [--cost-cap-<currency> <N> ...] [--workflow-state-dir <dir>]\n Always binds 127.0.0.1. --port 0 picks a random port and prints the URL.\n --auth-token also reads $LAZYCLAW_AUTH_TOKEN; --allow-origin also reads $LAZYCLAW_ALLOW_ORIGINS.\n --rate-limit <N> caps each remote IP at N requests / 60 s.\n --response-cache enables process-scoped memoization; per-request opt-in via body.cache.\n --log <debug|info|warn|error> emits JSON-line access logs on stderr (also reads $LAZYCLAW_LOG_LEVEL).\n --shutdown-timeout-ms <N> caps graceful drain on SIGINT/SIGTERM (default 10000). Second signal forces immediate exit.\n --cost-cap-usd 100 (or any currency code in lowercase) rejects POST /agent + /chat with 402 once cumulative cost reaches the cap.\n --workflow-state-dir <dir> backs GET /workflows + GET /workflows/<id> (default .workflow-state, also reads $LAZYCLAW_WORKFLOW_STATE_DIR).',
1075
1135
  version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
@@ -1077,6 +1137,13 @@ const HELP_DETAILS = {
1077
1137
  export: 'Usage: lazyclaw export [--include-secrets] [--include-sessions] > bundle.json\n --include-secrets keeps the raw api-key in the bundle (default redacts it).\n --include-sessions adds full turn content (default keeps metadata only).',
1078
1138
  import: 'Usage: lazyclaw import [--from <path>] [--overwrite-skills] [--no-overwrite-config] [--import-sessions]\n Reads JSON from stdin (or --from <path>). Sessions are NEVER overwritten.\n Redacted api-keys (***REDACTED***) are dropped, never written.',
1079
1139
  rates: 'Usage: lazyclaw rates <list [--filter <substr>] [--limit <N>] | set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD] | delete <key> | shape | validate | copy <src> <dst> [--force]>\n Rates are per million tokens. costFromUsage uses cfg.rates to compute the cost block in /usage and body.cost.\n `list` accepts --filter (case-insensitive key substring) and --limit (post-filter cap), same shape sessions/skills/workflows lists use.\n `shape` prints the reference template (zero-filled) you can copy into config.\n `validate` checks the cfg.rates shape: required fields, non-negative numbers, known providers (warn-only).\n `copy` clones an existing card to a new key (use when a new model launches at the same price as an old one).',
1140
+ auth: 'Usage: lazyclaw auth <list <provider> | add <provider> <key> [--label <name>] | remove <provider> <label> | use <provider> <label> | rotate <provider>>\n Multiple keys per provider for rate-limit rotation. The active label is sent on every chat / agent call.\n `rotate` advances the cursor to the next label; pair with a 429 hook for auto-failover.',
1141
+ pairing: 'Usage: lazyclaw pairing <list | add <id> [--label <name>] | remove <id>>\n Sender allowlist for the messaging surface. Inbound senders not on this list are rejected.\n Sender ids are opaque per-channel: Slack member id, Discord user id, phone number for SMS, etc.',
1142
+ nodes: 'Usage: lazyclaw nodes <list | register <id> [--platform macos|ios|android|web|cli] [--label <name>] | remove <id>>\n Companion device registration table. CLI only — the actual mobile / menu-bar apps are out of scope here.\n Platform is free-form lower-case; future surfaces (iOS / Android nodes) authenticate against the daemon using these ids.',
1143
+ message: 'Usage: lazyclaw message <list | add <name> <webhook-url> [--kind slack|discord|generic] | remove <name> | send <name> <text>>\n Outbound webhook messaging — Slack / Discord Incoming Webhooks. Auto-detects kind from the URL pattern.\n send accepts a literal string, or `-` to read the body from stdin.',
1144
+ workspace: 'Usage: lazyclaw workspace <list | init <name> | show <name> [<file>] | remove <name> | path <name>>\n Workspace = a directory under <configDir>/workspaces/<name>/ containing AGENTS.md, SOUL.md, TOOLS.md.\n When `chat` or `agent` is invoked with --workspace <name>, the three files are stitched into a single system prompt at the head of the conversation. Missing files are skipped silently.\n init scaffolds the three files with short stubs you replace.\n show prints the composed prompt; show <name> AGENTS.md (etc) prints just one file.',
1145
+ browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
1146
+ cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
1080
1147
  };
1081
1148
 
1082
1149
  function cmdHelp(name) {
@@ -1140,11 +1207,27 @@ async function cmdAgent(prompt, flags) {
1140
1207
  // Defaults from config.skills (same shape) if --skill not passed.
1141
1208
  const skillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
1142
1209
  .split(',').map(s => s.trim()).filter(Boolean);
1143
- let systemPrompt = null;
1210
+ // --workspace <name> stitches AGENTS.md / SOUL.md / TOOLS.md from
1211
+ // <configDir>/workspaces/<name>/ at the head of the system prompt.
1212
+ // Workspace + skill compose: workspace block first, skill block
1213
+ // after — same order as `lazyclaw workspace show` so the user can
1214
+ // preview exactly what the LLM will see.
1215
+ const workspaceName = flags.workspace || cfg.workspace || '';
1216
+ const promptParts = [];
1217
+ if (workspaceName) {
1218
+ try {
1219
+ const ws = await import('./workspace.mjs');
1220
+ const wsPrompt = ws.composeWorkspacePrompt(path.dirname(configPath()), workspaceName);
1221
+ if (wsPrompt) promptParts.push(wsPrompt);
1222
+ } catch (e) { console.error(`workspace error: ${e.message}`); process.exit(2); }
1223
+ }
1144
1224
  if (skillNames.length > 0) {
1145
- try { systemPrompt = skillsMod.composeSystemPrompt(skillNames, path.dirname(configPath())); }
1146
- catch (e) { console.error(`skill error: ${e.message}`); process.exit(2); }
1225
+ try {
1226
+ const skillPrompt = skillsMod.composeSystemPrompt(skillNames, path.dirname(configPath()));
1227
+ if (skillPrompt) promptParts.push(skillPrompt);
1228
+ } catch (e) { console.error(`skill error: ${e.message}`); process.exit(2); }
1147
1229
  }
1230
+ const systemPrompt = promptParts.length ? promptParts.join('\n\n---\n\n') : null;
1148
1231
 
1149
1232
  let text = prompt;
1150
1233
  if (text === '-' || text === undefined) {
@@ -1182,10 +1265,24 @@ async function cmdAgent(prompt, flags) {
1182
1265
  const ratesMod = await import('./providers/rates.mjs');
1183
1266
  costFromUsage = ratesMod.costFromUsage;
1184
1267
  }
1268
+ // --sandbox docker:<image> routes the underlying subprocess
1269
+ // (currently only the claude-cli provider hits this branch)
1270
+ // through `docker run`. parseSandboxSpec returns null when the
1271
+ // flag is absent / "off" so the no-flag path is bit-identical.
1272
+ let sandboxSpec = null;
1273
+ if (flags.sandbox) {
1274
+ const sb = await import('./sandbox.mjs');
1275
+ try { sandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
1276
+ catch (e) { console.error(`error: ${e.message}`); process.exit(2); }
1277
+ if (sandboxSpec && provName !== 'claude-cli') {
1278
+ process.stderr.write(`warn: --sandbox only wraps subprocess providers; ${provName} ignores it\n`);
1279
+ }
1280
+ }
1185
1281
  try {
1186
1282
  for await (const chunk of prov.sendMessage(messages, {
1187
- apiKey: cfg['api-key'],
1283
+ apiKey: _resolveAuthKey(cfg, provName),
1188
1284
  model: flags.model || cfg.model,
1285
+ sandbox: sandboxSpec,
1189
1286
  thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
1190
1287
  onThinking: showThinking ? t => process.stderr.write(t) : undefined,
1191
1288
  onUsage: (showUsage || showCost) ? (u) => {
@@ -1215,7 +1312,12 @@ async function cmdAgent(prompt, flags) {
1215
1312
  // (replaces rl.line with the full command). Tab still goes through
1216
1313
  // readline's tab-completer for cycling.
1217
1314
  function _attachGhostAutocomplete(rl) {
1218
- if (!process.stdout.isTTY) return;
1315
+ // Returns a `dispose()` callback that detaches the keypress listener
1316
+ // and the rl 'line' listener installed below. Without disposal the
1317
+ // process never exits — Node keeps the event loop alive while
1318
+ // process.stdin has a 'keypress' listener attached. (This was the
1319
+ // root cause of the slow `/exit` users reported.)
1320
+ if (!process.stdout.isTTY) return () => {};
1219
1321
  const cmds = SLASH_COMMANDS.map((c) => c.cmd);
1220
1322
  let lastGhost = '';
1221
1323
  // Find the longest match for the current input. Returns '' when
@@ -1277,7 +1379,15 @@ function _attachGhostAutocomplete(rl) {
1277
1379
  process.stdin.on('keypress', onKeypress);
1278
1380
  // Clear ghost on each new prompt so a stale dim hint doesn't carry
1279
1381
  // over between turns.
1280
- rl.on('line', () => { lastGhost = ''; });
1382
+ const onLine = () => { lastGhost = ''; };
1383
+ rl.on('line', onLine);
1384
+ return () => {
1385
+ try { process.stdin.removeListener('keypress', onKeypress); } catch (_) {}
1386
+ try { rl.removeListener('line', onLine); } catch (_) {}
1387
+ // Wipe any leftover ghost on screen so the user's terminal doesn't
1388
+ // keep a dim suffix after we exit.
1389
+ try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
1390
+ };
1281
1391
  }
1282
1392
 
1283
1393
  // LazyClaw banner — printed once at the top of every interactive chat
@@ -1315,15 +1425,23 @@ function _printChatBanner(activeProvName, activeModel, version) {
1315
1425
  async function _pickProviderInteractive() {
1316
1426
  const providers = Object.keys(_registryMod.PROVIDERS);
1317
1427
  if (!providers.length) return { provider: 'mock', model: null };
1318
- // Build the picker list: one row per provider, models surfaced inline
1319
- // when the provider exposes them via a `models` array (anthropic/openai/
1320
- // gemini/ollama do; mock doesn't).
1428
+ const info = _registryMod.PROVIDER_INFO || {};
1429
+ // Build one row per (provider, model) pair using the static
1430
+ // PROVIDER_INFO.suggestedModels list (registry.mjs is the single
1431
+ // source of truth). When a provider has no models (mock), we emit
1432
+ // one row for the provider itself.
1321
1433
  const items = [];
1322
1434
  for (const name of providers) {
1323
- const p = _registryMod.PROVIDERS[name];
1324
- const ms = (p && Array.isArray(p.models) && p.models.length) ? p.models : [null];
1325
- for (const m of ms) {
1326
- items.push({ provider: name, model: m, label: m ? `${name} ${m}` : name });
1435
+ const meta = info[name] || {};
1436
+ const models = (Array.isArray(meta.suggestedModels) && meta.suggestedModels.length)
1437
+ ? meta.suggestedModels
1438
+ : [null];
1439
+ const keyTag = meta.requiresApiKey
1440
+ ? '\x1b[38;5;245m[api key]\x1b[0m'
1441
+ : (name === 'claude-cli' ? '\x1b[38;5;208m[subscription]\x1b[0m' : '\x1b[38;5;245m[no key]\x1b[0m');
1442
+ for (const m of models) {
1443
+ const label = m ? `${name.padEnd(11)} ${m}` : `${name.padEnd(11)} (no model)`;
1444
+ items.push({ provider: name, model: m, label, keyTag });
1327
1445
  }
1328
1446
  }
1329
1447
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
@@ -1339,21 +1457,35 @@ async function _pickProviderInteractive() {
1339
1457
  });
1340
1458
  return { provider: ans || providers[0], model: null };
1341
1459
  }
1460
+ // Default cursor: prefer claude-cli (subscription, no key) so first-
1461
+ // time users with Claude Code already installed don't have to scroll.
1462
+ let idx = items.findIndex(it => it.provider === 'claude-cli');
1463
+ if (idx < 0) idx = 0;
1464
+
1342
1465
  const readline = await import('node:readline');
1343
1466
  readline.emitKeypressEvents(process.stdin);
1344
1467
  if (process.stdin.setRawMode) process.stdin.setRawMode(true);
1345
- let idx = 0;
1468
+ // Long lists need scrolling so the picker fits the terminal height.
1469
+ // Reserve 6 rows for header + footer + breathing room.
1346
1470
  const draw = () => {
1347
- // Move to top of picker block, redraw rows, leave cursor at the bottom.
1348
- process.stdout.write('\x1b[?25l'); // hide cursor
1471
+ process.stdout.write('\x1b[?25l'); // hide cursor
1349
1472
  process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1350
1473
  process.stdout.write('\x1b[38;5;208mLazyClaw — pick a provider/model\x1b[0m\n');
1351
- process.stdout.write('\x1b[2m↑/↓ to move · Enter to confirm · q to quit\x1b[0m\n\n');
1352
- items.forEach((it, i) => {
1474
+ process.stdout.write('\x1b[2m↑/↓ to move · Enter to confirm · q to quit\x1b[0m\n');
1475
+ process.stdout.write('\x1b[2m[subscription] = uses `claude` login (no key) · [api key] = needs sk-... key · [no key] = local\x1b[0m\n\n');
1476
+ const rows = Math.max(6, (process.stdout.rows || 24) - 6);
1477
+ let from = Math.max(0, idx - Math.floor(rows / 2));
1478
+ if (from + rows > items.length) from = Math.max(0, items.length - rows);
1479
+ const to = Math.min(items.length, from + rows);
1480
+ for (let i = from; i < to; i++) {
1481
+ const it = items[i];
1353
1482
  const marker = i === idx ? '\x1b[38;5;208m❯\x1b[0m ' : ' ';
1354
1483
  const text = i === idx ? `\x1b[1m${it.label}\x1b[0m` : it.label;
1355
- process.stdout.write(`${marker}${text}\n`);
1356
- });
1484
+ process.stdout.write(`${marker}${text} ${it.keyTag}\n`);
1485
+ }
1486
+ if (to < items.length) {
1487
+ process.stdout.write(`\x1b[2m …(${items.length - to} more)\x1b[0m\n`);
1488
+ }
1357
1489
  };
1358
1490
  draw();
1359
1491
  return await new Promise((resolve) => {
@@ -1361,6 +1493,10 @@ async function _pickProviderInteractive() {
1361
1493
  if (!key) return;
1362
1494
  if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
1363
1495
  else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
1496
+ else if (key.name === 'pageup') { idx = Math.max(0, idx - 10); draw(); }
1497
+ else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 10); draw(); }
1498
+ else if (key.name === 'home') { idx = 0; draw(); }
1499
+ else if (key.name === 'end') { idx = items.length - 1; draw(); }
1364
1500
  else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
1365
1501
  else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
1366
1502
  else if (key.name === 'q' || key.name === 'escape') { cleanup(); resolve(items[idx]); }
@@ -1368,8 +1504,8 @@ async function _pickProviderInteractive() {
1368
1504
  const cleanup = () => {
1369
1505
  process.stdin.off('keypress', onKey);
1370
1506
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
1371
- process.stdout.write('\x1b[?25h'); // show cursor
1372
- process.stdout.write('\x1b[2J\x1b[H');
1507
+ process.stdout.write('\x1b[?25h'); // show cursor
1508
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1373
1509
  };
1374
1510
  process.stdin.on('keypress', onKey);
1375
1511
  });
@@ -1417,15 +1553,26 @@ async function cmdChat(flags = {}) {
1417
1553
  terminal: useTerminal,
1418
1554
  prompt: useTerminal ? '\x1b[38;5;208m›\x1b[0m ' : '',
1419
1555
  });
1556
+ let _disposeGhost = () => {};
1420
1557
  if (useTerminal) {
1421
1558
  // Cursor-style ghost autocomplete: when the buffer starts with `/`,
1422
1559
  // render the longest matching command after the cursor in dim grey.
1423
1560
  // Right-arrow at end-of-line accepts. Tab still cycles via the
1424
1561
  // existing handleSlash branch; this only adds the inline preview.
1425
- _attachGhostAutocomplete(rl);
1562
+ _disposeGhost = _attachGhostAutocomplete(rl) || (() => {});
1426
1563
  rl.prompt();
1427
1564
  }
1428
1565
 
1566
+ // --sandbox docker:<image> wraps subprocess-providers (claude-cli)
1567
+ // in a docker container. Parsed once up front so a slash-command
1568
+ // model switch doesn't have to re-parse every turn.
1569
+ let sandboxSpec = null;
1570
+ if (flags.sandbox) {
1571
+ const sb = await import('./sandbox.mjs');
1572
+ try { sandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
1573
+ catch (e) { console.error(`error: ${e.message}`); process.exit(2); }
1574
+ }
1575
+
1429
1576
  // Persistent session ID. When --session is set we hydrate prior turns from
1430
1577
  // <configDir>/sessions/<id>.jsonl and append every new turn back to it.
1431
1578
  // Without --session, chat is in-memory only (matches phase 4 behavior).
@@ -1442,18 +1589,33 @@ async function cmdChat(flags = {}) {
1442
1589
  // double-prepend skills that the prior invocation already added).
1443
1590
  const skillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
1444
1591
  .split(',').map(s => s.trim()).filter(Boolean);
1592
+ // --workspace <name> sits at the head of the system prompt, then
1593
+ // any --skill block. The two compose with the same `\n---\n`
1594
+ // separator the agent path uses, so `lazyclaw workspace show` is
1595
+ // a faithful preview.
1596
+ const workspaceName = flags.workspace || cfg.workspace || '';
1597
+ const sysParts = [];
1598
+ if (workspaceName && !messages.some(m => m.role === 'system')) {
1599
+ try {
1600
+ const ws = await import('./workspace.mjs');
1601
+ const wsPrompt = ws.composeWorkspacePrompt(cfgDir, workspaceName);
1602
+ if (wsPrompt) sysParts.push(wsPrompt);
1603
+ } catch (e) { console.error(`workspace error: ${e.message}`); process.exit(2); }
1604
+ }
1445
1605
  if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
1446
1606
  try {
1447
1607
  const sys = skillsMod.composeSystemPrompt(skillNames, cfgDir);
1448
- if (sys) {
1449
- messages.unshift({ role: 'system', content: sys });
1450
- if (sessionId) sessionsMod.appendTurn(sessionId, 'system', sys, cfgDir);
1451
- }
1608
+ if (sys) sysParts.push(sys);
1452
1609
  } catch (e) {
1453
1610
  console.error(`skill error: ${e.message}`);
1454
1611
  process.exit(2);
1455
1612
  }
1456
1613
  }
1614
+ if (sysParts.length && !messages.some(m => m.role === 'system')) {
1615
+ const merged = sysParts.join('\n\n---\n\n');
1616
+ messages.unshift({ role: 'system', content: merged });
1617
+ if (sessionId) sessionsMod.appendTurn(sessionId, 'system', merged, cfgDir);
1618
+ }
1457
1619
 
1458
1620
  let charsSent = messages.reduce((n, m) => n + (m.role === 'user' ? String(m.content || '').length : 0), 0);
1459
1621
  if (sessionId && messages.length > (skillNames.length > 0 ? 1 : 0)) {
@@ -1626,7 +1788,7 @@ async function cmdChat(flags = {}) {
1626
1788
  }
1627
1789
  };
1628
1790
 
1629
- for await (const line of rl) {
1791
+ try { for await (const line of rl) {
1630
1792
  const text = line.trim();
1631
1793
  if (!text) { if (useTerminal) rl.prompt(); continue; }
1632
1794
  if (text.startsWith('/')) {
@@ -1651,8 +1813,9 @@ async function cmdChat(flags = {}) {
1651
1813
  process.on('SIGINT', onSigint);
1652
1814
  try {
1653
1815
  for await (const chunk of prov.sendMessage(messages, {
1654
- apiKey: cfg['api-key'],
1816
+ apiKey: _resolveAuthKey(cfg, activeProvName),
1655
1817
  model: activeModel,
1818
+ sandbox: sandboxSpec,
1656
1819
  signal: turnAc.signal,
1657
1820
  onUsage: accumulateUsage,
1658
1821
  })) {
@@ -1674,6 +1837,21 @@ async function cmdChat(flags = {}) {
1674
1837
  process.off('SIGINT', onSigint);
1675
1838
  }
1676
1839
  if (useTerminal) rl.prompt();
1840
+ } } finally {
1841
+ // Clean shutdown — without this, /exit "worked" but the process
1842
+ // hung for ~3-5 s while Node waited for stdin's keypress listener
1843
+ // and raw mode to release. Tearing them down explicitly drops the
1844
+ // exit time to <100 ms.
1845
+ try { _disposeGhost(); } catch (_) {}
1846
+ try { rl.close(); } catch (_) {}
1847
+ if (useTerminal && process.stdin.isTTY && process.stdin.setRawMode) {
1848
+ try { process.stdin.setRawMode(false); } catch (_) {}
1849
+ }
1850
+ // process.stdin keeps the event loop alive in raw / readline mode.
1851
+ // Pause + unref releases the hold so the process can exit cleanly
1852
+ // from natural completion (no need for a hard process.exit).
1853
+ try { process.stdin.pause(); } catch (_) {}
1854
+ try { process.stdin.unref(); } catch (_) {}
1677
1855
  }
1678
1856
  }
1679
1857
 
@@ -1902,6 +2080,399 @@ async function cmdRates(sub, positional, flags = {}) {
1902
2080
  }
1903
2081
  }
1904
2082
 
2083
+ // Loads on first use to avoid paying the import cost when the user
2084
+ // only ran `lazyclaw chat` or similar; cli.mjs is already a 2700-line
2085
+ // hot path and we don't need every helper paged in.
2086
+ let _configFeatures = null;
2087
+ async function _ensureConfigFeatures() {
2088
+ if (!_configFeatures) _configFeatures = await import('./config_features.mjs');
2089
+ return _configFeatures;
2090
+ }
2091
+
2092
+ async function cmdAuth(sub, positional, flags = {}) {
2093
+ const m = await _ensureConfigFeatures();
2094
+ const cfg = readConfig();
2095
+ switch (sub) {
2096
+ case undefined:
2097
+ case 'list': {
2098
+ const provider = positional[0];
2099
+ if (!provider) {
2100
+ // No provider given → return the active-label map for every
2101
+ // provider that has at least one profile so the user can see
2102
+ // their full auth state at once.
2103
+ const out = {};
2104
+ for (const p of Object.keys(cfg.authProfiles || {})) {
2105
+ out[p] = {
2106
+ active: (cfg.authActiveProfile || {})[p] || null,
2107
+ profiles: m.authList(cfg, p),
2108
+ };
2109
+ }
2110
+ console.log(JSON.stringify(out, null, 2));
2111
+ return;
2112
+ }
2113
+ const profiles = m.authList(cfg, provider);
2114
+ console.log(JSON.stringify({
2115
+ provider,
2116
+ active: (cfg.authActiveProfile || {})[provider] || null,
2117
+ profiles,
2118
+ }, null, 2));
2119
+ return;
2120
+ }
2121
+ case 'add': {
2122
+ const [provider, key] = positional;
2123
+ if (!provider || !key) {
2124
+ console.error('Usage: lazyclaw auth add <provider> <key> [--label <name>]');
2125
+ process.exit(2);
2126
+ }
2127
+ try {
2128
+ const lbl = m.authAdd(cfg, provider, key, flags.label);
2129
+ writeConfig(cfg);
2130
+ console.log(JSON.stringify({ ok: true, provider, label: lbl }));
2131
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2132
+ return;
2133
+ }
2134
+ case 'remove': {
2135
+ const [provider, label] = positional;
2136
+ if (!provider || !label) {
2137
+ console.error('Usage: lazyclaw auth remove <provider> <label>');
2138
+ process.exit(2);
2139
+ }
2140
+ try { m.authRemove(cfg, provider, label); writeConfig(cfg); }
2141
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2142
+ console.log(JSON.stringify({ ok: true, provider, removed: label }));
2143
+ return;
2144
+ }
2145
+ case 'use': {
2146
+ const [provider, label] = positional;
2147
+ if (!provider || !label) {
2148
+ console.error('Usage: lazyclaw auth use <provider> <label>');
2149
+ process.exit(2);
2150
+ }
2151
+ try { m.authUse(cfg, provider, label); writeConfig(cfg); }
2152
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2153
+ console.log(JSON.stringify({ ok: true, provider, active: label }));
2154
+ return;
2155
+ }
2156
+ case 'rotate': {
2157
+ const provider = positional[0];
2158
+ if (!provider) {
2159
+ console.error('Usage: lazyclaw auth rotate <provider>');
2160
+ process.exit(2);
2161
+ }
2162
+ const next = m.authRotate(cfg, provider);
2163
+ if (!next) {
2164
+ console.error(`error: need at least 2 profiles to rotate (provider "${provider}")`);
2165
+ process.exit(1);
2166
+ }
2167
+ writeConfig(cfg);
2168
+ console.log(JSON.stringify({ ok: true, provider, active: next }));
2169
+ return;
2170
+ }
2171
+ default:
2172
+ console.error('Usage: lazyclaw auth <list|add|remove|use|rotate> ...');
2173
+ process.exit(2);
2174
+ }
2175
+ }
2176
+
2177
+ async function cmdPairing(sub, positional, flags = {}) {
2178
+ const m = await _ensureConfigFeatures();
2179
+ const cfg = readConfig();
2180
+ switch (sub) {
2181
+ case undefined:
2182
+ case 'list':
2183
+ console.log(JSON.stringify(m.pairingList(cfg), null, 2));
2184
+ return;
2185
+ case 'add': {
2186
+ const id = positional[0];
2187
+ if (!id) {
2188
+ console.error('Usage: lazyclaw pairing add <id> [--label <name>]');
2189
+ process.exit(2);
2190
+ }
2191
+ try { m.pairingAdd(cfg, id, flags.label); writeConfig(cfg); }
2192
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2193
+ console.log(JSON.stringify({ ok: true, id }));
2194
+ return;
2195
+ }
2196
+ case 'remove': {
2197
+ const id = positional[0];
2198
+ if (!id) {
2199
+ console.error('Usage: lazyclaw pairing remove <id>');
2200
+ process.exit(2);
2201
+ }
2202
+ try { m.pairingRemove(cfg, id); writeConfig(cfg); }
2203
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2204
+ console.log(JSON.stringify({ ok: true, removed: id }));
2205
+ return;
2206
+ }
2207
+ default:
2208
+ console.error('Usage: lazyclaw pairing <list|add|remove> ...');
2209
+ process.exit(2);
2210
+ }
2211
+ }
2212
+
2213
+ async function cmdNodes(sub, positional, flags = {}) {
2214
+ const m = await _ensureConfigFeatures();
2215
+ const cfg = readConfig();
2216
+ switch (sub) {
2217
+ case undefined:
2218
+ case 'list':
2219
+ console.log(JSON.stringify(m.nodesList(cfg), null, 2));
2220
+ return;
2221
+ case 'register': {
2222
+ const id = positional[0];
2223
+ if (!id) {
2224
+ console.error('Usage: lazyclaw nodes register <id> [--platform macos|ios|android|web|cli] [--label <name>]');
2225
+ process.exit(2);
2226
+ }
2227
+ try { m.nodesRegister(cfg, id, flags.platform || 'cli', flags.label || ''); writeConfig(cfg); }
2228
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2229
+ console.log(JSON.stringify({ ok: true, id, platform: flags.platform || 'cli' }));
2230
+ return;
2231
+ }
2232
+ case 'remove': {
2233
+ const id = positional[0];
2234
+ if (!id) {
2235
+ console.error('Usage: lazyclaw nodes remove <id>');
2236
+ process.exit(2);
2237
+ }
2238
+ try { m.nodesRemove(cfg, id); writeConfig(cfg); }
2239
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2240
+ console.log(JSON.stringify({ ok: true, removed: id }));
2241
+ return;
2242
+ }
2243
+ default:
2244
+ console.error('Usage: lazyclaw nodes <list|register|remove> ...');
2245
+ process.exit(2);
2246
+ }
2247
+ }
2248
+
2249
+ async function cmdMessage(sub, positional, flags = {}) {
2250
+ const m = await _ensureConfigFeatures();
2251
+ const cfg = readConfig();
2252
+ switch (sub) {
2253
+ case undefined:
2254
+ case 'list':
2255
+ console.log(JSON.stringify(m.messageList(cfg), null, 2));
2256
+ return;
2257
+ case 'add': {
2258
+ const [name, url] = positional;
2259
+ if (!name || !url) {
2260
+ console.error('Usage: lazyclaw message add <name> <webhook-url> [--kind slack|discord|generic]');
2261
+ process.exit(2);
2262
+ }
2263
+ try { m.messageAdd(cfg, name, url, flags.kind); writeConfig(cfg); }
2264
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2265
+ console.log(JSON.stringify({ ok: true, name }));
2266
+ return;
2267
+ }
2268
+ case 'remove': {
2269
+ const name = positional[0];
2270
+ if (!name) {
2271
+ console.error('Usage: lazyclaw message remove <name>');
2272
+ process.exit(2);
2273
+ }
2274
+ try { m.messageRemove(cfg, name); writeConfig(cfg); }
2275
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2276
+ console.log(JSON.stringify({ ok: true, removed: name }));
2277
+ return;
2278
+ }
2279
+ case 'send': {
2280
+ const [name, ...textParts] = positional;
2281
+ if (!name) {
2282
+ console.error('Usage: lazyclaw message send <name> <text|->');
2283
+ process.exit(2);
2284
+ }
2285
+ let text = textParts.join(' ');
2286
+ // `-` reads body from stdin so a long agent reply can be piped:
2287
+ // lazyclaw agent "summarize foo" | lazyclaw message send team -
2288
+ if (text === '-' || (!text && !process.stdin.isTTY)) {
2289
+ text = await new Promise((resolve) => {
2290
+ let buf = '';
2291
+ process.stdin.on('data', (c) => { buf += c; });
2292
+ process.stdin.on('end', () => resolve(buf.trim()));
2293
+ });
2294
+ }
2295
+ if (!text) {
2296
+ console.error('error: empty message body');
2297
+ process.exit(1);
2298
+ }
2299
+ try {
2300
+ const r = await m.messageSend(cfg, name, text);
2301
+ console.log(JSON.stringify(r));
2302
+ } catch (e) {
2303
+ console.error(`error: ${e.message}`); process.exit(1);
2304
+ }
2305
+ return;
2306
+ }
2307
+ default:
2308
+ console.error('Usage: lazyclaw message <list|add|remove|send> ...');
2309
+ process.exit(2);
2310
+ }
2311
+ }
2312
+
2313
+ async function cmdWorkspace(sub, positional, flags = {}) {
2314
+ const ws = await import('./workspace.mjs');
2315
+ const cfgDir = path.dirname(configPath());
2316
+ switch (sub) {
2317
+ case undefined:
2318
+ case 'list': {
2319
+ console.log(JSON.stringify(ws.listWorkspaces(cfgDir), null, 2));
2320
+ return;
2321
+ }
2322
+ case 'init': {
2323
+ const name = positional[0];
2324
+ if (!name) { console.error('Usage: lazyclaw workspace init <name>'); process.exit(2); }
2325
+ try {
2326
+ const dir = ws.initWorkspace(cfgDir, name);
2327
+ console.log(JSON.stringify({ ok: true, name, dir, files: ws.WORKSPACE_FILES }));
2328
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2329
+ return;
2330
+ }
2331
+ case 'show': {
2332
+ const [name, fileName] = positional;
2333
+ if (!name) { console.error('Usage: lazyclaw workspace show <name> [<file>]'); process.exit(2); }
2334
+ try {
2335
+ if (fileName) process.stdout.write(ws.readWorkspaceFile(cfgDir, name, fileName));
2336
+ else process.stdout.write(ws.composeWorkspacePrompt(cfgDir, name) + '\n');
2337
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2338
+ return;
2339
+ }
2340
+ case 'remove': {
2341
+ const name = positional[0];
2342
+ if (!name) { console.error('Usage: lazyclaw workspace remove <name>'); process.exit(2); }
2343
+ try { ws.removeWorkspace(cfgDir, name); }
2344
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2345
+ console.log(JSON.stringify({ ok: true, removed: name }));
2346
+ return;
2347
+ }
2348
+ case 'path': {
2349
+ const name = positional[0];
2350
+ if (!name) { console.log(ws.workspaceRoot(cfgDir)); return; }
2351
+ try { console.log(ws.workspaceDir(cfgDir, name)); }
2352
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2353
+ return;
2354
+ }
2355
+ default:
2356
+ console.error('Usage: lazyclaw workspace <list|init|show|remove|path> ...');
2357
+ process.exit(2);
2358
+ }
2359
+ }
2360
+
2361
+ async function cmdBrowse(url, flags = {}) {
2362
+ if (!url) { console.error('Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--meta]'); process.exit(2); }
2363
+ const { browse } = await import('./browse.mjs');
2364
+ const opts = {};
2365
+ if (flags['max-bytes'] !== undefined) opts.maxBytes = parseInt(flags['max-bytes'], 10);
2366
+ if (flags['timeout-ms'] !== undefined) opts.timeoutMs = parseInt(flags['timeout-ms'], 10);
2367
+ if (flags['user-agent']) opts.userAgent = flags['user-agent'];
2368
+ try {
2369
+ const r = await browse(url, opts);
2370
+ if (flags.meta) {
2371
+ process.stderr.write(JSON.stringify({
2372
+ url: r.url, title: r.title, bytes: r.bytes, truncated: r.truncated,
2373
+ }) + '\n');
2374
+ }
2375
+ process.stdout.write(r.markdown);
2376
+ } catch (e) {
2377
+ console.error(`error: ${e?.message || e}`);
2378
+ process.exit(1);
2379
+ }
2380
+ }
2381
+
2382
+ async function cmdCron(sub, positional, flags = {}) {
2383
+ const cron = await import('./cron.mjs');
2384
+ const cfg = readConfig();
2385
+ const backend = cron.pickBackend();
2386
+ switch (sub) {
2387
+ case undefined:
2388
+ case 'list': {
2389
+ const jobs = cron.listJobs(cfg);
2390
+ console.log(JSON.stringify({ backend, jobs }, null, 2));
2391
+ return;
2392
+ }
2393
+ case 'show': {
2394
+ const name = positional[0];
2395
+ if (!name) { console.error('Usage: lazyclaw cron show <name>'); process.exit(2); }
2396
+ const job = cron.getJob(cfg, name);
2397
+ if (!job) { console.error(`error: no job "${name}"`); process.exit(1); }
2398
+ console.log(JSON.stringify({ backend, name, ...job }, null, 2));
2399
+ return;
2400
+ }
2401
+ case 'add': {
2402
+ // Shape: lazyclaw cron add <name> "<cron-spec>" -- <cmd> [args...]
2403
+ // The `--` separator was already consumed by parseArgs, but
2404
+ // the spec is the second positional and the command is
2405
+ // everything after it. parseArgs preserves order, so:
2406
+ // positional[0] = name
2407
+ // positional[1] = "0 9 * * *"
2408
+ // positional[2..] = cmd argv
2409
+ const [name, schedule, ...cmd] = positional;
2410
+ if (!name || !schedule || !cmd.length) {
2411
+ console.error('Usage: lazyclaw cron add <name> "<cron-spec>" -- <cmd> ...');
2412
+ process.exit(2);
2413
+ }
2414
+ try {
2415
+ cron.upsertJob(cfg, name, schedule, cmd);
2416
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2417
+ writeConfig(cfg);
2418
+ // Install to system scheduler — failure here doesn't roll
2419
+ // back the config write because the job is "scheduled in
2420
+ // intent". `cron sync` reconciles.
2421
+ try {
2422
+ if (backend === 'launchd') cron.installLaunchdJob(name, schedule, cmd);
2423
+ else cron.installCrontabJob(name, schedule, cmd);
2424
+ } catch (e) {
2425
+ console.error(`warn: backend install failed: ${e.message} — config saved; run \`cron sync\` to retry`);
2426
+ process.exit(1);
2427
+ }
2428
+ console.log(JSON.stringify({ ok: true, backend, name, schedule, command: cmd }, null, 2));
2429
+ return;
2430
+ }
2431
+ case 'remove': {
2432
+ const name = positional[0];
2433
+ if (!name) { console.error('Usage: lazyclaw cron remove <name>'); process.exit(2); }
2434
+ try { cron.removeJob(cfg, name); } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2435
+ writeConfig(cfg);
2436
+ try {
2437
+ if (backend === 'launchd') cron.uninstallLaunchdJob(name);
2438
+ else cron.uninstallCrontabJob(name);
2439
+ } catch (e) {
2440
+ console.error(`warn: backend uninstall failed: ${e.message}`);
2441
+ }
2442
+ console.log(JSON.stringify({ ok: true, backend, removed: name }));
2443
+ return;
2444
+ }
2445
+ case 'sync': {
2446
+ // Re-install every job in cfg.cron — useful after a fresh
2447
+ // OS image where the launchd plists / crontab were wiped.
2448
+ const out = [];
2449
+ for (const [name, job] of Object.entries(cfg.cron || {})) {
2450
+ try {
2451
+ if (backend === 'launchd') cron.installLaunchdJob(name, job.schedule, job.command);
2452
+ else cron.installCrontabJob(name, job.schedule, job.command);
2453
+ out.push({ name, ok: true });
2454
+ } catch (e) {
2455
+ out.push({ name, ok: false, error: e.message });
2456
+ }
2457
+ }
2458
+ console.log(JSON.stringify({ backend, results: out }, null, 2));
2459
+ return;
2460
+ }
2461
+ case 'run': {
2462
+ const name = positional[0];
2463
+ if (!name) { console.error('Usage: lazyclaw cron run <name>'); process.exit(2); }
2464
+ try {
2465
+ const code = cron.runJob(cfg, name);
2466
+ process.exit(code || 0);
2467
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
2468
+ return;
2469
+ }
2470
+ default:
2471
+ console.error('Usage: lazyclaw cron <list|add|remove|show|sync|run> ...');
2472
+ process.exit(2);
2473
+ }
2474
+ }
2475
+
1905
2476
  async function cmdSkills(sub, positional, flags = {}) {
1906
2477
  const skillsMod = await import('./skills.mjs');
1907
2478
  const cfgDir = path.dirname(configPath());
@@ -1930,9 +2501,38 @@ async function cmdSkills(sub, positional, flags = {}) {
1930
2501
  return;
1931
2502
  }
1932
2503
  case 'install': {
1933
- // Three forms: --from <path>, --from-url <https://...>, or stdin.
2504
+ // Four forms:
2505
+ // 1. install user/repo[@ref][:subpath] — GitHub bundle
2506
+ // 2. install <name> --from <path>
2507
+ // 3. install <name> --from-url <https://...>
2508
+ // 4. install <name> — body via stdin
2509
+ // Detect form 1 via a slash in the first positional and the
2510
+ // absence of any --from* flag (so a literal local skill name
2511
+ // with `/` still routes to the explicit-flag branch — though
2512
+ // skillPath() rejects slashes anyway).
1934
2513
  const name = positional[0];
1935
- if (!name) { console.error('Usage: lazyclaw skills install <name> [--from <path> | --from-url <https://...>]'); process.exit(2); }
2514
+ if (!name) { console.error('Usage: lazyclaw skills install <user/repo[@ref][:path]> | <name> [--from <path> | --from-url <https://...>]'); process.exit(2); }
2515
+ if (name.includes('/') && !flags.from && !flags['from-url']) {
2516
+ const inst = await import('./skills_install.mjs');
2517
+ try {
2518
+ const r = await inst.installFromGithub(name, cfgDir, {
2519
+ prefix: flags.prefix || '',
2520
+ force: !!flags.force,
2521
+ maxBytes: flags['max-bytes'] !== undefined ? parseInt(flags['max-bytes'], 10) : undefined,
2522
+ timeoutMs: flags['timeout-ms'] !== undefined ? parseInt(flags['timeout-ms'], 10) : undefined,
2523
+ });
2524
+ console.log(JSON.stringify({
2525
+ ok: true,
2526
+ spec: `${r.spec.owner}/${r.spec.repo}@${r.spec.ref}${r.spec.subpath ? ':' + r.spec.subpath : ''}`,
2527
+ installed: r.installed,
2528
+ skipped: r.skipped,
2529
+ }, null, 2));
2530
+ return;
2531
+ } catch (e) {
2532
+ console.error(`error: ${e?.message || e}`);
2533
+ process.exit(1);
2534
+ }
2535
+ }
1936
2536
  let content;
1937
2537
  if (flags['from-url']) {
1938
2538
  const url = String(flags['from-url']);
@@ -2442,6 +3042,13 @@ function parseArgs(argv) {
2442
3042
  const out = { positional: [], flags: {} };
2443
3043
  for (let i = 0; i < argv.length; i++) {
2444
3044
  const a = argv[i];
3045
+ // POSIX `--`: everything after is positional verbatim. Used by
3046
+ // `cron add <name> "<spec>" -- <cmd> [args...]` so a recurring
3047
+ // command with --flag of its own doesn't get parsed as our flag.
3048
+ if (a === '--') {
3049
+ for (let j = i + 1; j < argv.length; j++) out.positional.push(argv[j]);
3050
+ break;
3051
+ }
2445
3052
  if (a.startsWith('--')) {
2446
3053
  const eq = a.indexOf('=');
2447
3054
  if (eq >= 0) {
@@ -2467,10 +3074,161 @@ function parseArgs(argv) {
2467
3074
  return out;
2468
3075
  }
2469
3076
 
3077
+ // Interactive launcher — fired when the user types `lazyclaw` with
3078
+ // no subcommand AND we're attached to a TTY. OpenClaw's launcher
3079
+ // pattern: ASCII banner + provider/model status + arrow-key menu of
3080
+ // every common action. Selecting a row drops the user into the
3081
+ // matching subcommand via process.argv mutation + main() re-entry,
3082
+ // so chat / agent / etc. behave bit-identically to typing them
3083
+ // directly. Non-TTY (piped, scripted) callers still see the
3084
+ // classic "Usage: …" line so automation isn't surprised.
3085
+ async function cmdLauncher() {
3086
+ await ensureRegistry();
3087
+ const cfg = readConfig();
3088
+ const provider = cfg.provider || '(unset — pick during onboard)';
3089
+ const model = cfg.model || '(default)';
3090
+ const items = [
3091
+ { id: 'chat', label: 'Chat', desc: 'interactive REPL with the configured provider', argv: ['chat'] },
3092
+ { id: 'agent', label: 'Agent', desc: 'one-shot prompt — read text and exit', argv: ['agent'], promptForBody: true },
3093
+ { id: 'onboard', label: 'Onboard', desc: 'pick provider / model / api-key', argv: ['onboard'] },
3094
+ { id: 'workspace', label: 'Workspace', desc: 'AGENTS.md / SOUL.md / TOOLS.md prompt bundles', argv: ['workspace', 'list'] },
3095
+ { id: 'browse', label: 'Browse', desc: 'fetch a URL → markdown', argv: ['browse'], promptForUrl: true },
3096
+ { id: 'skills', label: 'Skills', desc: 'installed skill bundles', argv: ['skills', 'list'] },
3097
+ { id: 'sessions', label: 'Sessions', desc: 'persisted chat sessions', argv: ['sessions', 'list'] },
3098
+ { id: 'providers', label: 'Providers', desc: 'registered providers + reachability', argv: ['providers', 'list'] },
3099
+ { id: 'cron', label: 'Cron', desc: 'recurring agent runs (launchd / crontab)', argv: ['cron', 'list'] },
3100
+ { id: 'doctor', label: 'Doctor', desc: 'diagnostic — config, providers, workflows', argv: ['doctor'] },
3101
+ { id: 'status', label: 'Status', desc: 'current provider / model / masked key', argv: ['status'] },
3102
+ { id: 'help', label: 'Help', desc: 'one-line summary of every subcommand', argv: ['help'] },
3103
+ { id: 'quit', label: 'Quit', desc: 'exit without doing anything', argv: null },
3104
+ ];
3105
+
3106
+ const readline = await import('node:readline');
3107
+ readline.emitKeypressEvents(process.stdin);
3108
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
3109
+ let idx = 0;
3110
+
3111
+ // Pretty header — same accent palette as _printChatBanner so
3112
+ // returning users recognise it.
3113
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
3114
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3115
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3116
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
3117
+ const warn = (s) => `\x1b[33m${s}\x1b[0m`;
3118
+
3119
+ const draw = () => {
3120
+ process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
3121
+ const banner = [
3122
+ accent(' ╭──────────────────────────────╮'),
3123
+ accent(' │ _ │'),
3124
+ accent(' │ | |__ _ _____ _ _ │'),
3125
+ accent(' │ | / _` |_ / || | \'_| │'),
3126
+ accent(' │ |_\\__,_/__\\_, |_| │'),
3127
+ accent(' │ LazyClaw |__/ ' + (readVersionFromRepo() || '').padEnd(10) + ' │'),
3128
+ accent(' ╰──────────────────────────────╯'),
3129
+ ];
3130
+ banner.forEach((l) => process.stdout.write(l + '\n'));
3131
+ process.stdout.write('\n');
3132
+ const provDisplay = provider === '(unset — pick during onboard)'
3133
+ ? warn(provider)
3134
+ : ok(provider);
3135
+ process.stdout.write(` ${dim('provider ·')} ${provDisplay}\n`);
3136
+ process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
3137
+ process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
3138
+ process.stdout.write('\n');
3139
+ process.stdout.write(` ${dim('↑/↓ to move · Enter to select · q or Esc to quit')}\n\n`);
3140
+
3141
+ // Trim list to terminal height so the menu still fits when
3142
+ // someone shrinks the window or runs in a small split pane.
3143
+ const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
3144
+ const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
3145
+ const toIdx = Math.min(items.length, fromIdx + rowsAvail);
3146
+ for (let i = fromIdx; i < toIdx; i++) {
3147
+ const it = items[i];
3148
+ const marker = i === idx ? accent('❯ ') : ' ';
3149
+ const lbl = i === idx ? bold(it.label.padEnd(11)) : it.label.padEnd(11);
3150
+ process.stdout.write(`${marker}${lbl} ${dim(it.desc)}\n`);
3151
+ }
3152
+ process.stdout.write('\n');
3153
+ };
3154
+
3155
+ // Tear down raw mode + listeners cleanly so the next subcommand
3156
+ // starts with a sane stdin (otherwise `chat` after launcher inherits
3157
+ // the launcher's raw mode and behaves weirdly).
3158
+ const teardown = (onKey) => {
3159
+ if (onKey) process.stdin.off('keypress', onKey);
3160
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
3161
+ process.stdout.write('\x1b[?25h'); // show cursor
3162
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen
3163
+ };
3164
+
3165
+ draw();
3166
+ const picked = await new Promise((resolve) => {
3167
+ const onKey = (_str, key) => {
3168
+ if (!key) return;
3169
+ if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
3170
+ else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
3171
+ else if (key.name === 'home') { idx = 0; draw(); }
3172
+ else if (key.name === 'end') { idx = items.length - 1; draw(); }
3173
+ else if (key.name === 'pageup') { idx = Math.max(0, idx - 5); draw(); }
3174
+ else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 5); draw(); }
3175
+ else if (key.name === 'return') { teardown(onKey); resolve(items[idx]); }
3176
+ else if (key.ctrl && key.name === 'c') { teardown(onKey); resolve({ id: 'quit', argv: null }); }
3177
+ else if (key.name === 'escape' || key.name === 'q') { teardown(onKey); resolve({ id: 'quit', argv: null }); }
3178
+ };
3179
+ process.stdin.on('keypress', onKey);
3180
+ });
3181
+
3182
+ if (!picked || !picked.argv) {
3183
+ process.exit(0);
3184
+ }
3185
+ // Two surfaces need a follow-up question before they can run:
3186
+ // - `agent`: needs a prompt body
3187
+ // - `browse`: needs a URL
3188
+ // Ask via a simple readline prompt so the launcher stays
3189
+ // self-contained instead of forwarding into a half-typed argv.
3190
+ if (picked.promptForBody) {
3191
+ const body = await _quickPrompt('prompt: ');
3192
+ if (!body) process.exit(0);
3193
+ picked.argv = ['agent', body];
3194
+ } else if (picked.promptForUrl) {
3195
+ const url = await _quickPrompt('url: ');
3196
+ if (!url) process.exit(0);
3197
+ picked.argv = ['browse', url];
3198
+ }
3199
+ // Replace argv and re-enter main(). The chosen subcommand sees
3200
+ // the same parser surface as if the user had typed it directly.
3201
+ process.argv = [process.argv[0], process.argv[1], ...picked.argv];
3202
+ await main();
3203
+ }
3204
+
3205
+ async function _quickPrompt(label) {
3206
+ const readline = await import('node:readline');
3207
+ process.stdout.write('\n');
3208
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3209
+ const ans = await new Promise((resolve) => rl.question(label, resolve));
3210
+ rl.close();
3211
+ return ans.trim();
3212
+ }
3213
+
2470
3214
  async function main() {
2471
3215
  const argv = process.argv.slice(2);
2472
3216
  const cmd = argv[0];
2473
3217
  const rest = parseArgs(argv.slice(1));
3218
+ // No subcommand at all: drop into the interactive launcher when we
3219
+ // can render one (TTY both ways), otherwise fall through to the
3220
+ // historical "Usage: ..." line so scripts / piped callers stay
3221
+ // predictable.
3222
+ if (cmd === undefined) {
3223
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3224
+ await cmdLauncher();
3225
+ return;
3226
+ }
3227
+ console.error('Usage: lazyclaw <' + SUBCOMMANDS.join('|') + '> ...');
3228
+ console.error('Run `lazyclaw help` for a one-line summary of each subcommand.');
3229
+ console.error('Tip: launch in an interactive terminal to get the arrow-key menu.');
3230
+ process.exit(2);
3231
+ }
2474
3232
  switch (cmd) {
2475
3233
  case 'run': {
2476
3234
  const [sessionId, file] = rest.positional;
@@ -2593,6 +3351,40 @@ async function main() {
2593
3351
  await cmdRates(sub, rest.positional.slice(1), rest.flags);
2594
3352
  break;
2595
3353
  }
3354
+ case 'auth': {
3355
+ const sub = rest.positional[0];
3356
+ await cmdAuth(sub, rest.positional.slice(1), rest.flags);
3357
+ break;
3358
+ }
3359
+ case 'pairing': {
3360
+ const sub = rest.positional[0];
3361
+ await cmdPairing(sub, rest.positional.slice(1), rest.flags);
3362
+ break;
3363
+ }
3364
+ case 'nodes': {
3365
+ const sub = rest.positional[0];
3366
+ await cmdNodes(sub, rest.positional.slice(1), rest.flags);
3367
+ break;
3368
+ }
3369
+ case 'message': {
3370
+ const sub = rest.positional[0];
3371
+ await cmdMessage(sub, rest.positional.slice(1), rest.flags);
3372
+ break;
3373
+ }
3374
+ case 'workspace': {
3375
+ const sub = rest.positional[0];
3376
+ await cmdWorkspace(sub, rest.positional.slice(1), rest.flags);
3377
+ break;
3378
+ }
3379
+ case 'browse': {
3380
+ await cmdBrowse(rest.positional[0], rest.flags);
3381
+ break;
3382
+ }
3383
+ case 'cron': {
3384
+ const sub = rest.positional[0];
3385
+ await cmdCron(sub, rest.positional.slice(1), rest.flags);
3386
+ break;
3387
+ }
2596
3388
  case 'daemon': {
2597
3389
  await cmdDaemon(rest.flags);
2598
3390
  break;