lazyclaw 6.3.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
@@ -7,6 +7,65 @@ import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
7
 
8
8
  const BUNDLE_VERSION = 1;
9
9
 
10
+ // Key names that carry secrets. A bundle on a teammate's laptop must never
11
+ // leak per-provider keys (cfg.authProfiles[<provider>][i].key, written by
12
+ // providers/auth_store.mjs) or any other secret-bearing config value, not
13
+ // just the legacy top-level cfg['api-key']. Match on the KEY name so we can
14
+ // redact custom/nested entries without knowing the config schema ahead of
15
+ // time, while leaving non-secret keys (baseUrl, model, label, …) untouched.
16
+ const SECRET_KEY_RE = /(?:api[-_]?key|secret|token|password|authorization|access[-_]?key)/i;
17
+
18
+ // Deep-clone cfg with every secret-bearing string value redacted. Pure — never
19
+ // mutates the input. Preserves structure/labels so the bundle stays
20
+ // inspectable (e.g. authProfiles keeps {label} but drops {key}).
21
+ function redactSecrets(value, keyName = '') {
22
+ if (Array.isArray(value)) return value.map((v) => redactSecrets(v));
23
+ if (value && typeof value === 'object') {
24
+ const out = {};
25
+ for (const [k, v] of Object.entries(value)) out[k] = redactSecrets(v, k);
26
+ return out;
27
+ }
28
+ if (typeof value === 'string' && SECRET_KEY_RE.test(keyName)) return '***REDACTED***';
29
+ return value;
30
+ }
31
+
32
+ // Reciprocal of the export redaction: deep-clone cfg with every
33
+ // '***REDACTED***' placeholder dropped, so importing a redacted bundle never
34
+ // persists the literal placeholder string into config.json. Skipping the key
35
+ // (rather than writing it) means an existing real value survives the default
36
+ // "bundle wins" merge, and an authProfiles entry keeps its {label} slot minus
37
+ // the secret. Symmetric with redactSecrets / redactAuthProfileKeys above.
38
+ const REDACTED_PLACEHOLDER = '***REDACTED***';
39
+ function stripRedacted(value) {
40
+ if (Array.isArray(value)) return value.map(stripRedacted);
41
+ if (value && typeof value === 'object') {
42
+ const out = {};
43
+ for (const [k, v] of Object.entries(value)) {
44
+ if (v === REDACTED_PLACEHOLDER) continue; // drop the redacted key entirely
45
+ out[k] = stripRedacted(v);
46
+ }
47
+ return out;
48
+ }
49
+ return value;
50
+ }
51
+
52
+ // Redact the per-provider api keys in authProfiles. The field is literally
53
+ // named `key` (not `apiKey`), so the generic key-name match above doesn't
54
+ // catch it; handle it structurally instead. Keep `label` so the auth profile
55
+ // shape stays inspectable. Operates on the already-cloned safeCfg.
56
+ function redactAuthProfileKeys(safeCfg) {
57
+ const profiles = safeCfg.authProfiles;
58
+ if (!profiles || typeof profiles !== 'object') return safeCfg;
59
+ for (const provider of Object.keys(profiles)) {
60
+ const arr = profiles[provider];
61
+ if (!Array.isArray(arr)) continue;
62
+ profiles[provider] = arr.map((p) =>
63
+ p && typeof p === 'object' && 'key' in p ? { ...p, key: '***REDACTED***' } : p,
64
+ );
65
+ }
66
+ return safeCfg;
67
+ }
68
+
10
69
  export async function cmdExport(flags) {
11
70
  // Portable bundle: config + every installed skill + (optionally) every
12
71
  // persisted session. Writes JSON to stdout so the caller pipes it
@@ -19,10 +78,9 @@ export async function cmdExport(flags) {
19
78
  const sessionsMod = await import('../sessions.mjs');
20
79
  const cfgDir = path.dirname(configPath());
21
80
  const cfg = readConfig();
22
- const safeCfg = { ...cfg };
23
- if (!flags['include-secrets']) {
24
- if (safeCfg['api-key']) safeCfg['api-key'] = '***REDACTED***';
25
- }
81
+ // redactSecrets deep-clones, so --include-secrets keeps the original cfg
82
+ // verbatim while the default path emits a fully redacted copy.
83
+ const safeCfg = flags['include-secrets'] ? cfg : redactAuthProfileKeys(redactSecrets(cfg));
26
84
  const skills = skillsMod.listSkills(cfgDir).map(s => ({
27
85
  name: s.name,
28
86
  content: skillsMod.loadSkill(s.name, cfgDir),
@@ -79,11 +137,13 @@ export async function cmdImport(flags) {
79
137
  // Config
80
138
  if (bundle.config && typeof bundle.config === 'object') {
81
139
  const existing = readConfig();
140
+ // Strip every redacted placeholder from the incoming config BEFORE the
141
+ // merge so the literal '***REDACTED***' is never persisted — at the
142
+ // top-level api-key, inside authProfiles[].key, or any nested secret.
143
+ const incoming = stripRedacted(bundle.config);
82
144
  const next = flags['no-overwrite-config']
83
- ? { ...bundle.config, ...existing } // existing wins
84
- : { ...existing, ...bundle.config }; // bundle wins (default)
85
- // Drop redacted secrets so we never write the placeholder string.
86
- if (next['api-key'] === '***REDACTED***') delete next['api-key'];
145
+ ? { ...incoming, ...existing } // existing wins
146
+ : { ...existing, ...incoming }; // bundle wins (default)
87
147
  writeConfig(next);
88
148
  stats.configKeys = Object.keys(bundle.config).length;
89
149
  }
@@ -15,7 +15,8 @@ import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
15
15
  import {
16
16
  _attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
17
17
  _pickModelInteractive, _pickProviderInteractive, _printChatBanner,
18
- _quickPrompt, _renderBanner, _renderV5Banner,
18
+ _quickPrompt, _quickPromptSecret, _renderBanner, _renderV5Banner,
19
+ _pickYesNo,
19
20
  } from '../tui/pickers.mjs';
20
21
  import { firstRunMode as _firstRunMode } from '../first_run.mjs';
21
22
  import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
@@ -24,6 +25,7 @@ import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } fr
24
25
  import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
25
26
  import { runChannelStep, runWebhookStep, runOrchestratorStep, runContextStep } from './setup_channels.mjs';
26
27
  import { splashPropsForSetup, renderSplashToString } from '../tui/splash_props.mjs';
28
+ import { HELP_SUMMARIES, HELP_DETAILS } from './help_text.mjs';
27
29
 
28
30
  function applyOnboardConfig(currentCfg, flags) {
29
31
  // Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
@@ -78,9 +80,13 @@ export async function cmdOnboard(flags) {
78
80
  const meta = (getRegistry().PROVIDER_INFO || {})[flags.provider] || {};
79
81
  if (meta.requiresApiKey && !flags['api-key']) {
80
82
  const prefix = meta.keyPrefix ? ` (starts with "${meta.keyPrefix}")` : '';
81
- flags['api-key'] = (await ask(`api-key${prefix}: `)).trim();
83
+ // Close the line-mode reader before the masked raw-mode read so they
84
+ // don't both consume stdin. The key is masked (•) — never echoed.
85
+ rl.close();
86
+ flags['api-key'] = await _quickPromptSecret(`api-key${prefix}: `);
87
+ } else {
88
+ rl.close();
82
89
  }
83
- rl.close();
84
90
  }
85
91
  const next = applyOnboardConfig(readConfig(), flags);
86
92
  if (!next.provider) { console.error('onboard: provider is required'); process.exit(2); }
@@ -92,79 +98,6 @@ export async function cmdOnboard(flags) {
92
98
 
93
99
 
94
100
 
95
- // One-line summaries used by `lazyclaw help`. Format keeps it scan-friendly
96
- // in a 80-column terminal: subcommand padded to 12 chars, then the summary.
97
- const HELP_SUMMARIES = {
98
- run: 'Execute a workflow file (run <session-id> <workflow.mjs>)',
99
- resume: 'Resume a workflow from its last persisted checkpoint',
100
- config: 'Manage local config (get|set|list|delete <key>)',
101
- chat: 'Interactive REPL with the configured provider',
102
- agent: 'One-shot prompt: streams a single response, exits',
103
- doctor: 'Print diagnostic JSON; exits non-zero on issues',
104
- status: 'Print current provider/model/masked key as JSON',
105
- onboard: 'Guided setup (use --non-interactive for scripts)',
106
- sessions: 'Persistent chat sessions (list|show|clear|export)',
107
- skills: 'Markdown skill bundles (list|show|install|remove)',
108
- providers: 'Inspect / register providers (list|info|test|add|remove|models)',
109
- daemon: 'Run the local HTTP gateway (--port, --auth-token, --allow-origin)',
110
- version: 'Print VERSION + node + platform as JSON',
111
- completion: 'Emit shell completion script (completion <bash|zsh>)',
112
- export: 'Dump config + skills (+ optional sessions) as a JSON bundle',
113
- import: 'Apply a JSON bundle from stdin or --from <path>',
114
- rates: 'Manage cost rate-cards in config (rates list|set <provider/model>|delete|shape)',
115
- auth: 'Multiple keys per provider (auth list|add|remove|use|rotate <provider>)',
116
- pairing: 'Sender allowlist for the messaging surface (pairing list|add|remove <id>)',
117
- nodes: 'Companion device registration (nodes list|register|remove <id>)',
118
- message: 'Outbound webhook messaging (message list|add|remove|send <name>)',
119
- workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
120
- browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
121
- cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
122
- setup: 'Hermes-style phased first-run wizard (provider + verify chat + channel + workspace + skill + webhook)',
123
- dashboard: 'Launch the lazyclaw-only web UI (lighter than the full lazyclaude dashboard)',
124
- inspect: 'Print persisted workflow state without executing',
125
- clear: 'Delete a persisted workflow state file (idempotent)',
126
- validate: 'Static-check a workflow file: shape, deps, cycles, parallelism',
127
- graph: 'Emit workflow DAG as Mermaid syntax (paste-ready for docs)',
128
- orchestrator: 'Multi-agent dispatch — planner decomposes, workers run, planner synthesises',
129
- };
130
-
131
- // Detailed usage per subcommand for `lazyclaw help <name>`. Kept as flat
132
- // strings so the help output is identical in every terminal.
133
- const HELP_DETAILS = {
134
- run: 'Usage: lazyclaw run <session-id> <workflow.mjs> [--parallel | --parallel-persistent] [--concurrency <N>]\n Default: runPersistent — sequential, persists state, resumable via `lazyclaw resume`.\n --parallel: runParallel — topological-level DAG, in-memory only, NOT resumable.\n --parallel-persistent: runPersistentDag — DAG + checkpoint + resume.\n --concurrency <N>: cap in-flight nodes within a level (DAG modes only). 0/missing → unbounded.\n Workflow file exports `nodes`; deps: string[] declares dependencies for both DAG modes.',
135
- resume: 'Usage: lazyclaw resume <session-id> <workflow.mjs> [--parallel-persistent] [--concurrency <N>]\n Re-enters a previously persisted run; succeeds nodes are skipped.\n Pass --parallel-persistent to resume a DAG run (must match the original run\'s mode).\n --concurrency <N>: cap in-flight nodes per level (DAG mode only).',
136
- inspect: 'Usage: lazyclaw inspect [<session-id>] [--dir <state-dir>] [--status done|resumable|failed|running] [--summary] [--filter <substr>] [--limit <N>] [--node <node-id>] [--slowest <N>] [--critical-path <workflow.mjs>] [--aggregate]\n With no session-id: list every persisted session in the state dir, sorted by recency.\n --aggregate (list mode): per-node stats across all sessions (count, success/failed/pending/running, min/max/avg/total duration).\n --status filters the listing to a single lifecycle bucket.\n --filter / --limit refine list-mode further (case-insensitive sessionId substring + post-filter cap).\n --summary trims per-node detail in single-session mode (matches list-mode shape).\n --node <id>: print just that node\'s state. Exit 0 success/pending/running, 1 failed, 2 no such node.\n --slowest <N>: top N nodes by durationMs (descending, ties broken by id).\n --critical-path <workflow.mjs>: longest-weighted-path analysis using each node\'s recorded durationMs (bottleneck finder).\n With a session-id (no per-node flag): print full state. Exit code: 0=resumable, 1=fully done, 2=no state, 3=terminal failure.',
137
- clear: 'Usage: lazyclaw clear <session-id> [--dir <state-dir>]\n Delete the state file for <session-id>. Idempotent — exits 0 whether the file existed or not.\n Refuses sessionIds that resolve outside <state-dir>. Mirrors DELETE /workflows/<id> on the daemon.',
138
- 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.',
139
- 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.',
140
- 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).',
141
- 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).',
142
- 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.',
143
- doctor: 'Usage: lazyclaw doctor\n Validates configuration and registered providers. Exits 0 only when no issues.',
144
- status: 'Usage: lazyclaw status\n Provider, model, and masked API key. Never prints the raw key.',
145
- 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).',
146
- 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.',
147
- 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.',
148
- providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T] | add <name> --base-url <url> [--api-key <k>] [--default-model <id>] [--no-probe] | remove <name> | models <name> [--filter <substr>]>\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.\n add — register a custom OpenAI-compatible endpoint (NIM / OpenRouter / Together / Groq / vLLM / LM Studio / …).\n Probes /v1/models on success unless --no-probe is set; persists to cfg.customProviders[].\n remove — drop a custom provider entry from cfg.customProviders[].\n models — fetch + print the live model catalogue from <provider>/v1/models (works for openai / ollama / custom).',
149
- 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).',
150
- version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
151
- completion: 'Usage: lazyclaw completion <bash|zsh>\n bash: eval "$(lazyclaw completion bash)"\n zsh: lazyclaw completion zsh > "${fpath[1]}/_lazyclaw"',
152
- 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).',
153
- 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.',
154
- 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).',
155
- sandbox: 'Usage: lazyclaw sandbox <list|test|add|use> [args]\n list show 6 backends (local, docker, ssh, singularity, modal, daytona)\n test <kind> run echo through the backend (or argv-shape check for remote)\n add <name> --kind <kind> [--image|--host|--user|--workspace|--app|--confiner ...]\n use <profile> set the profile as cfg.sandbox.default',
156
- 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.',
157
- 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.',
158
- 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.',
159
- 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.',
160
- 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.',
161
- 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.',
162
- 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).',
163
- setup: 'Usage: lazyclaw setup [--skip-test]\n Hermes-style phased first-run wizard — get one clean chat working first,\n then optionally add the rest. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick; ≥64k-context tip)\n 2. Verify the provider responds (1-token ping; --skip-test bypasses)\n 3. Optional channel / gateway (Slack / Telegram / Matrix / HTTP built in;\n Discord / Email / Signal / Voice / WhatsApp via plugin packages)\n 4. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 5. Optional skill bundle install from GitHub\n 6. Optional outbound webhook (Slack / Discord)\n 7. Optional multi-agent orchestration (planner + workers)\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
164
- dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n See web/dashboard.html for the current tab set (v5: Chat / Sessions / Workflows / Skills / Providers / Rates / Metrics / Doctor / Config / Status / Agents / Teams / Tasks / Trainer / Recall / Sandbox / Channels).\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
165
- orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
166
- };
167
-
168
101
  export function cmdHelp(name) {
169
102
  if (!name) {
170
103
  process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
@@ -206,7 +139,16 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
206
139
  const cfgDir = path.dirname(configPath());
207
140
  const colors = { accent, bold, dim, ok, warn };
208
141
 
142
+ // Per-step gating: `--only a,b` runs ONLY those; `--skip a,b` runs all but
143
+ // those. Steps: provider verify channel workspace skill webhook orchestrator.
144
+ // e.g. `lazyclaw setup --only channel` re-runs just the channel step.
145
+ const onlySet = flags.only ? new Set(String(flags.only).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean)) : null;
146
+ const skipSet = new Set(String(flags.skip || '').toLowerCase().split(',').map((s) => s.trim()).filter(Boolean));
147
+ const want = (step) => (onlySet ? onlySet.has(step) : !skipSet.has(step));
148
+ let cfgAfterOnboard = cfg;
149
+
209
150
  // ── Step 1/7: Provider + model (mandatory) ──────────────────
151
+ if (want('provider')) {
210
152
  process.stdout.write(` ${accent('Step 1/7 ·')} ${bold('Pick a provider + model')}\n`);
211
153
  process.stdout.write(` ${dim('Opens the arrow-key picker. Tip: pick a model with ≥64k context — small windows can\'t hold multi-step tool-calling state.')}\n\n`);
212
154
  await _quickPrompt(' ▶ press Enter to open the picker ');
@@ -225,7 +167,7 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
225
167
  // no provider set, bail out early — the rest of the wizard depends
226
168
  // on a provider being configured. `return` (not process.exit) so a
227
169
  // launcher caller can re-prompt or fall back gracefully.
228
- const cfgAfterOnboard = readConfig();
170
+ cfgAfterOnboard = readConfig();
229
171
  if (!cfgAfterOnboard.provider) {
230
172
  process.stdout.write(`\n ${warn('Setup not completed — provider was not configured.')}\n`);
231
173
  process.stdout.write(` ${dim('Run `lazyclaw setup` again when ready, or pick "Onboard" from the menu for a single-step picker.')}\n\n`);
@@ -236,12 +178,14 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
236
178
  // Context window — asked right after the model pick (optional; Enter keeps
237
179
  // defaults). Not a numbered step: it's part of the core model setup.
238
180
  await runContextStep({ prompt: _quickPrompt, colors });
181
+ }
239
182
 
240
183
  // ── Step 2/7: Verify one clean chat works ───────────────────
241
184
  // Hermes rule: get a clean reply before layering on channels/skills.
185
+ if (want('verify') && cfgAfterOnboard.provider) {
242
186
  process.stdout.write(` ${accent('Step 2/7 ·')} ${bold('Verify the provider responds')}\n`);
243
187
  process.stdout.write(` ${dim('Sends a 1-token "ping" via `lazyclaw providers test`. Confirm a clean reply before layering on channels/skills.')}\n\n`);
244
- const wantPing = !flags['skip-test'] && (await _quickPrompt(' test now? [Y/n] ')).trim().toLowerCase() !== 'n';
188
+ const wantPing = !flags['skip-test'] && await _pickYesNo('Test the provider now?', { subtitle: 'sends a 1-token ping to confirm a clean reply', yesLabel: 'Test now', noLabel: 'Skip', defaultYes: true });
245
189
  if (wantPing) {
246
190
  try {
247
191
  // No-exit probe (providers/probe.mjs) — the CLI `providers test` calls
@@ -258,15 +202,20 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
258
202
  } else {
259
203
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
260
204
  }
205
+ }
261
206
 
262
207
  // ── Step 3/7: Channel / gateway (optional) ──────────────────
208
+ if (want('channel')) {
263
209
  process.stdout.write(` ${accent('Step 3/7 ·')} ${bold('Where will you run it?')} ${dim('(optional)')}\n`);
264
210
  await runChannelStep({ cfgDir, prompt: _quickPrompt, colors });
211
+ }
265
212
 
266
213
  // ── Step 4/7: Optional workspace ────────────────────────────
214
+ if (want('workspace')) {
267
215
  process.stdout.write(` ${accent('Step 4/7 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
268
216
  process.stdout.write(` ${dim('A workspace is a folder of AGENTS.md / SOUL.md / TOOLS.md prompt files that auto-inject into chat / agent. Skip if you don\'t need project-specific personas yet.')}\n\n`);
269
- const wsName = (await _quickPrompt(' workspace name (Enter to skip): ')).trim();
217
+ const wantWs = await _pickYesNo('Initialise a workspace?', { yesLabel: 'Create one', noLabel: 'Skip', defaultYes: false });
218
+ const wsName = wantWs ? (await _quickPrompt(' workspace name: ')).trim() : '';
270
219
  if (wsName && /^[A-Za-z0-9_.-]+$/.test(wsName)) {
271
220
  try {
272
221
  const ws = await import('../workspace.mjs');
@@ -281,11 +230,14 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
281
230
  } else {
282
231
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
283
232
  }
233
+ }
284
234
 
285
235
  // ── Step 5/7: Optional skill bundle install ─────────────────
236
+ if (want('skill')) {
286
237
  process.stdout.write(` ${accent('Step 5/7 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
287
238
  process.stdout.write(` ${dim('Format: <user>/<repo>[@<ref>]. Skills are .md prompt fragments that compose into the system prompt via --skill.')}\n\n`);
288
- const skillSpec = (await _quickPrompt(' github spec (Enter to skip): ')).trim();
239
+ const wantSkill = await _pickYesNo('Install a skill bundle from GitHub?', { yesLabel: 'Install one', noLabel: 'Skip', defaultYes: false });
240
+ const skillSpec = wantSkill ? (await _quickPrompt(' github spec (<user>/<repo>[@<ref>]): ')).trim() : '';
289
241
  if (skillSpec) {
290
242
  try {
291
243
  const inst = await import('../skills_install.mjs');
@@ -302,14 +254,19 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
302
254
  } else {
303
255
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
304
256
  }
257
+ }
305
258
 
306
259
  // ── Step 6/7: Optional outbound webhook ─────────────────────
260
+ if (want('webhook')) {
307
261
  process.stdout.write(` ${accent('Step 6/7 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
308
262
  await runWebhookStep({ prompt: _quickPrompt, colors });
263
+ }
309
264
 
310
265
  // ── Step 7/7: Optional multi-agent orchestration ────────────
266
+ if (want('orchestrator')) {
311
267
  process.stdout.write(` ${accent('Step 7/7 ·')} ${bold('Enable multi-agent orchestration?')} ${dim('(optional)')}\n`);
312
268
  await runOrchestratorStep({ prompt: _quickPrompt, colors });
269
+ }
313
270
 
314
271
  // ── Wrap up ─────────────────────────────────────────────────
315
272
  process.stdout.write('\n');
@@ -319,6 +276,13 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
319
276
  process.stdout.write(` ${dim('•')} lazyclaw agent "..." ${dim('— one-shot prompt')}\n`);
320
277
  process.stdout.write(` ${dim('•')} lazyclaw doctor ${dim('— diagnostic JSON')}\n`);
321
278
  process.stdout.write(` ${dim('•')} lazyclaw setup ${dim('— re-run this wizard any time')}\n\n`);
279
+
280
+ // Release stdin so `lazyclaw setup` returns to the shell instead of hanging
281
+ // at "Setup complete". The interactive prompts (_arrowMenu / _quickPrompt)
282
+ // resume()+ref() stdin to read input; without an unref the ref'd handle keeps
283
+ // the event loop alive after the last step. When setup is invoked from the
284
+ // launcher, the next menu resume()+ref()s stdin again, so this is safe there.
285
+ try { if (process.stdin.unref) process.stdin.unref(); } catch { /* best-effort */ }
322
286
  }
323
287
 
324
288
  // First-run welcome panel + delegated onboard. Drawn once before the