lazyclaw 6.3.1 → 6.4.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 (165) 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 +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +90 -33
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +196 -80
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -0,0 +1,150 @@
1
+ // commands/mcp.mjs — `lazyclaw mcp <list|add|remove|call>`.
2
+ //
3
+ // MCP servers are stored in cfg.mcp.servers[] (stdio transport only; an entry
4
+ // is { name, command, args[], env{}, allowGlob }). They are spawned at daemon
5
+ // boot by mcp/server_spawn.startConfigured. This command manages that list and
6
+ // lets you invoke a single tool from the CLI for a quick check.
7
+ //
8
+ // Verb dispatch + output convention mirror commands/agents.mjs cmdTeam:
9
+ // success → JSON on stdout, usage/validation errors → console.error + exit 2,
10
+ // operational failures → exit 1.
11
+
12
+ import { readConfig, writeConfig } from '../lib/config.mjs';
13
+
14
+ // The name becomes the tool namespace `mcp:<name>:<tool>`, so it must not carry
15
+ // a colon or whitespace — reuse the cron/job name grammar.
16
+ const NAME_RE = /^[A-Za-z0-9_.-]+$/;
17
+
18
+ const emitJson = (o) => process.stdout.write(JSON.stringify(o, null, 2) + '\n');
19
+
20
+ // `--args "-y pkg /tmp"` and `--env "K=V K2=V2"` arrive as one whitespace-
21
+ // joined string (parseArgs consumes a single argv token), so split on spaces.
22
+ function splitWords(raw) {
23
+ if (raw === undefined || raw === null || raw === true) return [];
24
+ return String(raw).split(/\s+/).filter(Boolean);
25
+ }
26
+ function parseEnv(raw) {
27
+ const env = {};
28
+ for (const pair of splitWords(raw)) {
29
+ const i = pair.indexOf('=');
30
+ if (i > 0) env[pair.slice(0, i)] = pair.slice(i + 1);
31
+ }
32
+ return env;
33
+ }
34
+
35
+ export async function cmdMcp(sub, positional = [], flags = {}) {
36
+ const cfg = readConfig();
37
+ const servers = (cfg.mcp && Array.isArray(cfg.mcp.servers)) ? cfg.mcp.servers : [];
38
+
39
+ switch (sub) {
40
+ case undefined:
41
+ case 'list': {
42
+ const configured = servers.map((s) => ({
43
+ name: s.name, command: s.command, args: s.args || [], allowGlob: s.allowGlob || '*',
44
+ }));
45
+ // listServers() reports servers connected in THIS process; the short-lived
46
+ // CLI never boots them, so this is normally empty — surfaced anyway so the
47
+ // same shape works when the command runs inside the daemon.
48
+ let connected = [];
49
+ try { connected = (await import('../mcp/client.mjs')).listServers(); }
50
+ catch { /* mcp client unavailable — report configured only */ }
51
+ emitJson({ ok: true, configured, connected });
52
+ return;
53
+ }
54
+
55
+ case 'add': {
56
+ const name = positional[0];
57
+ const command = flags.command;
58
+ if (!name || !command || command === true) {
59
+ console.error('Usage: lazyclaw mcp add <name> --command <cmd> [--args "<a b c>"] [--allow-glob <glob>] [--env "K=V ..."]');
60
+ process.exit(2);
61
+ }
62
+ if (!NAME_RE.test(name)) {
63
+ console.error(`mcp add: invalid name "${name}" — letters/digits/.-_ only (it becomes the tool namespace mcp:${name}:<tool>)`);
64
+ process.exit(2);
65
+ }
66
+ if (servers.find((s) => s.name === name)) {
67
+ console.error(`mcp add: server "${name}" already exists — remove it first or pick another name`);
68
+ process.exit(2);
69
+ }
70
+ const record = {
71
+ name,
72
+ command: String(command),
73
+ args: splitWords(flags.args),
74
+ allowGlob: (flags['allow-glob'] && flags['allow-glob'] !== true) ? String(flags['allow-glob']) : '*',
75
+ };
76
+ const env = parseEnv(flags.env);
77
+ if (Object.keys(env).length) record.env = env;
78
+ cfg.mcp = cfg.mcp || {};
79
+ cfg.mcp.servers = servers.concat([record]);
80
+ writeConfig(cfg);
81
+ emitJson({ ok: true, added: record });
82
+ return;
83
+ }
84
+
85
+ case 'remove':
86
+ case 'rm':
87
+ case 'delete': {
88
+ const name = positional[0];
89
+ if (!name) { console.error('Usage: lazyclaw mcp remove <name>'); process.exit(2); }
90
+ if (!servers.find((s) => s.name === name)) {
91
+ console.error(`mcp remove: no configured server "${name}"`);
92
+ process.exit(2);
93
+ }
94
+ cfg.mcp = cfg.mcp || {};
95
+ cfg.mcp.servers = servers.filter((s) => s.name !== name);
96
+ writeConfig(cfg);
97
+ emitJson({ ok: true, removed: name });
98
+ return;
99
+ }
100
+
101
+ case 'call': {
102
+ const server = positional[0];
103
+ const toolName = positional[1];
104
+ if (!server || !toolName) {
105
+ console.error('Usage: lazyclaw mcp call <server> <tool> [--args-json \'{"k":"v"}\']');
106
+ process.exit(2);
107
+ }
108
+ const sconf = servers.find((s) => s.name === server);
109
+ if (!sconf) {
110
+ console.error(`mcp call: no configured server "${server}" — add it with 'lazyclaw mcp add ${server} --command <cmd>'`);
111
+ process.exit(2);
112
+ }
113
+ let args = {};
114
+ if (flags['args-json'] !== undefined && flags['args-json'] !== true) {
115
+ try { args = JSON.parse(String(flags['args-json'])); }
116
+ catch (e) { console.error(`mcp call: --args-json is not valid JSON: ${e.message}`); process.exit(2); }
117
+ }
118
+ const mcpClient = await import('../mcp/client.mjs');
119
+ const registry = await import('../mas/tools/registry.mjs');
120
+ let started = false;
121
+ try {
122
+ // sensitive:true — the same approval-gate invariant the daemon boot
123
+ // enforces (server_spawn). A CLI call can never downgrade an MCP tool.
124
+ await mcpClient.startServer({
125
+ name: sconf.name, command: sconf.command, args: sconf.args || [],
126
+ env: sconf.env || {}, allowGlob: sconf.allowGlob || '*', sensitive: true,
127
+ });
128
+ started = true;
129
+ const full = `mcp:${server}:${toolName}`;
130
+ const tool = registry.lookup(full);
131
+ if (!tool) {
132
+ const available = registry.listNames().filter((n) => n.startsWith(`mcp:${server}:`));
133
+ console.error(`mcp call: tool "${full}" not found. Available: ${available.join(', ') || '(none)'}`);
134
+ process.exit(1);
135
+ }
136
+ emitJson(await tool.exec(args));
137
+ } catch (e) {
138
+ console.error(`mcp call: ${e?.message || e}`);
139
+ process.exit(1);
140
+ } finally {
141
+ if (started) { try { await mcpClient.stopServer(server); } catch { /* best-effort */ } }
142
+ }
143
+ return;
144
+ }
145
+
146
+ default:
147
+ console.error('Usage: lazyclaw mcp <list|add|remove|call> ...');
148
+ process.exit(2);
149
+ }
150
+ }
package/commands/misc.mjs CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import { configPath } from '../lib/config.mjs';
6
6
  import { resolveSandbox, listBackends } from '../sandbox/index.mjs';
7
+ import { pickAvailableConfiner } from '../sandbox/spawn.mjs';
7
8
 
8
9
  export async function cmdBrowse(url, flags = {}) {
9
10
  if (!url) { console.error('Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--meta]'); process.exit(2); }
@@ -33,6 +34,25 @@ export async function cmdSandbox(args, flags = {}) {
33
34
  return 0;
34
35
  }
35
36
 
37
+ if (sub === 'status') {
38
+ // Show the EFFECTIVE default-on confinement posture for this host so the
39
+ // operator can audit what sensitive tools run under.
40
+ const cfg = _sandboxLoadConfigOrEmpty();
41
+ const sb = cfg.sandbox || {};
42
+ const off = sb.confine === false || sb.default === 'off' || sb.default === 'none';
43
+ const confiner = (sb.local && sb.local.confiner && sb.local.confiner !== 'auto')
44
+ ? sb.local.confiner
45
+ : pickAvailableConfiner();
46
+ const allowNet = sb.allowNet !== false;
47
+ process.stdout.write(`confinement: ${off ? 'OFF' : 'ON'} (default-on; opt out with cfg.sandbox.confine=false)\n`);
48
+ process.stdout.write(`host confiner: ${confiner}\n`);
49
+ process.stdout.write(`policy: writes → workspace + temp only · secret dirs unreadable · network ${allowNet ? 'allowed' : 'denied'}\n`);
50
+ if (!off && confiner === 'none') {
51
+ process.stdout.write('note: no OS confiner available on this host — sensitive tools run UNCONFINED. Install bubblewrap or firejail on Linux.\n');
52
+ }
53
+ return 0;
54
+ }
55
+
36
56
  if (sub === 'test') {
37
57
  const name = args[1];
38
58
  if (!name) { process.stderr.write('usage: lazyclaw sandbox test <kind|profile>\n'); return 2; }
@@ -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';
@@ -78,9 +79,13 @@ export async function cmdOnboard(flags) {
78
79
  const meta = (getRegistry().PROVIDER_INFO || {})[flags.provider] || {};
79
80
  if (meta.requiresApiKey && !flags['api-key']) {
80
81
  const prefix = meta.keyPrefix ? ` (starts with "${meta.keyPrefix}")` : '';
81
- flags['api-key'] = (await ask(`api-key${prefix}: `)).trim();
82
+ // Close the line-mode reader before the masked raw-mode read so they
83
+ // don't both consume stdin. The key is masked (•) — never echoed.
84
+ rl.close();
85
+ flags['api-key'] = await _quickPromptSecret(`api-key${prefix}: `);
86
+ } else {
87
+ rl.close();
82
88
  }
83
- rl.close();
84
89
  }
85
90
  const next = applyOnboardConfig(readConfig(), flags);
86
91
  if (!next.provider) { console.error('onboard: provider is required'); process.exit(2); }
@@ -160,7 +165,7 @@ const HELP_DETAILS = {
160
165
  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
166
  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
167
  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.',
168
+ setup: 'Usage: lazyclaw setup [--skip-test] [--only <steps>] [--skip <steps>]\n --only/--skip take a comma list of: provider verify channel workspace skill webhook orchestrator (e.g. --only channel re-runs just that step).\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
169
  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
170
  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
171
  };
@@ -206,7 +211,16 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
206
211
  const cfgDir = path.dirname(configPath());
207
212
  const colors = { accent, bold, dim, ok, warn };
208
213
 
214
+ // Per-step gating: `--only a,b` runs ONLY those; `--skip a,b` runs all but
215
+ // those. Steps: provider verify channel workspace skill webhook orchestrator.
216
+ // e.g. `lazyclaw setup --only channel` re-runs just the channel step.
217
+ const onlySet = flags.only ? new Set(String(flags.only).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean)) : null;
218
+ const skipSet = new Set(String(flags.skip || '').toLowerCase().split(',').map((s) => s.trim()).filter(Boolean));
219
+ const want = (step) => (onlySet ? onlySet.has(step) : !skipSet.has(step));
220
+ let cfgAfterOnboard = cfg;
221
+
209
222
  // ── Step 1/7: Provider + model (mandatory) ──────────────────
223
+ if (want('provider')) {
210
224
  process.stdout.write(` ${accent('Step 1/7 ·')} ${bold('Pick a provider + model')}\n`);
211
225
  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
226
  await _quickPrompt(' ▶ press Enter to open the picker ');
@@ -225,7 +239,7 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
225
239
  // no provider set, bail out early — the rest of the wizard depends
226
240
  // on a provider being configured. `return` (not process.exit) so a
227
241
  // launcher caller can re-prompt or fall back gracefully.
228
- const cfgAfterOnboard = readConfig();
242
+ cfgAfterOnboard = readConfig();
229
243
  if (!cfgAfterOnboard.provider) {
230
244
  process.stdout.write(`\n ${warn('Setup not completed — provider was not configured.')}\n`);
231
245
  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 +250,14 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
236
250
  // Context window — asked right after the model pick (optional; Enter keeps
237
251
  // defaults). Not a numbered step: it's part of the core model setup.
238
252
  await runContextStep({ prompt: _quickPrompt, colors });
253
+ }
239
254
 
240
255
  // ── Step 2/7: Verify one clean chat works ───────────────────
241
256
  // Hermes rule: get a clean reply before layering on channels/skills.
257
+ if (want('verify') && cfgAfterOnboard.provider) {
242
258
  process.stdout.write(` ${accent('Step 2/7 ·')} ${bold('Verify the provider responds')}\n`);
243
259
  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';
260
+ 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
261
  if (wantPing) {
246
262
  try {
247
263
  // No-exit probe (providers/probe.mjs) — the CLI `providers test` calls
@@ -258,15 +274,20 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
258
274
  } else {
259
275
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
260
276
  }
277
+ }
261
278
 
262
279
  // ── Step 3/7: Channel / gateway (optional) ──────────────────
280
+ if (want('channel')) {
263
281
  process.stdout.write(` ${accent('Step 3/7 ·')} ${bold('Where will you run it?')} ${dim('(optional)')}\n`);
264
282
  await runChannelStep({ cfgDir, prompt: _quickPrompt, colors });
283
+ }
265
284
 
266
285
  // ── Step 4/7: Optional workspace ────────────────────────────
286
+ if (want('workspace')) {
267
287
  process.stdout.write(` ${accent('Step 4/7 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
268
288
  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();
289
+ const wantWs = await _pickYesNo('Initialise a workspace?', { yesLabel: 'Create one', noLabel: 'Skip', defaultYes: false });
290
+ const wsName = wantWs ? (await _quickPrompt(' workspace name: ')).trim() : '';
270
291
  if (wsName && /^[A-Za-z0-9_.-]+$/.test(wsName)) {
271
292
  try {
272
293
  const ws = await import('../workspace.mjs');
@@ -281,11 +302,14 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
281
302
  } else {
282
303
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
283
304
  }
305
+ }
284
306
 
285
307
  // ── Step 5/7: Optional skill bundle install ─────────────────
308
+ if (want('skill')) {
286
309
  process.stdout.write(` ${accent('Step 5/7 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
287
310
  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();
311
+ const wantSkill = await _pickYesNo('Install a skill bundle from GitHub?', { yesLabel: 'Install one', noLabel: 'Skip', defaultYes: false });
312
+ const skillSpec = wantSkill ? (await _quickPrompt(' github spec (<user>/<repo>[@<ref>]): ')).trim() : '';
289
313
  if (skillSpec) {
290
314
  try {
291
315
  const inst = await import('../skills_install.mjs');
@@ -302,14 +326,19 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
302
326
  } else {
303
327
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
304
328
  }
329
+ }
305
330
 
306
331
  // ── Step 6/7: Optional outbound webhook ─────────────────────
332
+ if (want('webhook')) {
307
333
  process.stdout.write(` ${accent('Step 6/7 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
308
334
  await runWebhookStep({ prompt: _quickPrompt, colors });
335
+ }
309
336
 
310
337
  // ── Step 7/7: Optional multi-agent orchestration ────────────
338
+ if (want('orchestrator')) {
311
339
  process.stdout.write(` ${accent('Step 7/7 ·')} ${bold('Enable multi-agent orchestration?')} ${dim('(optional)')}\n`);
312
340
  await runOrchestratorStep({ prompt: _quickPrompt, colors });
341
+ }
313
342
 
314
343
  // ── Wrap up ─────────────────────────────────────────────────
315
344
  process.stdout.write('\n');
@@ -319,6 +348,13 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
319
348
  process.stdout.write(` ${dim('•')} lazyclaw agent "..." ${dim('— one-shot prompt')}\n`);
320
349
  process.stdout.write(` ${dim('•')} lazyclaw doctor ${dim('— diagnostic JSON')}\n`);
321
350
  process.stdout.write(` ${dim('•')} lazyclaw setup ${dim('— re-run this wizard any time')}\n\n`);
351
+
352
+ // Release stdin so `lazyclaw setup` returns to the shell instead of hanging
353
+ // at "Setup complete". The interactive prompts (_arrowMenu / _quickPrompt)
354
+ // resume()+ref() stdin to read input; without an unref the ref'd handle keeps
355
+ // the event loop alive after the last step. When setup is invoked from the
356
+ // launcher, the next menu resume()+ref()s stdin again, so this is safe there.
357
+ try { if (process.stdin.unref) process.stdin.unref(); } catch { /* best-effort */ }
322
358
  }
323
359
 
324
360
  // First-run welcome panel + delegated onboard. Drawn once before the