lazyclaw 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +10 -21
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +254 -0
- package/commands/chat.mjs +1253 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +275 -0
- package/commands/gateway.mjs +194 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +490 -0
- package/commands/service.mjs +113 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +742 -0
- package/commands/setup_channels.mjs +207 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +42 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +435 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +165 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +51 -0
- package/first_run.mjs +24 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/registry_boot.mjs +55 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +15 -3
- package/providers/probe.mjs +28 -0
- package/secure_write.mjs +46 -0
- package/tui/editor.mjs +18 -2
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
// commands/setup.mjs — onboarding + setup + interactive launcher hub,
|
|
2
|
+
// extracted from cli.mjs (D7). Verbatim move of applyOnboardConfig,
|
|
3
|
+
// cmdOnboard, HELP_SUMMARIES, HELP_DETAILS, cmdHelp, cmdSetup,
|
|
4
|
+
// _runFirstTimeOnboard, _DispatchExit, _dispatchMenuChoice, cmdLauncher.
|
|
5
|
+
// Dynamic-import paths were rebased ./ -> ../ (so ./commands/X became
|
|
6
|
+
// ../commands/X, which resolves to the sibling), and the cmdChat calls
|
|
7
|
+
// lazy-import ./chat.mjs to break the setup <-> chat cycle.
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
configPath, readConfig, writeConfig,
|
|
11
|
+
_resolveAuthKey, _resolveBaseUrl, readVersionFromRepo,
|
|
12
|
+
} from '../lib/config.mjs';
|
|
13
|
+
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
14
|
+
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
15
|
+
import {
|
|
16
|
+
_attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
|
|
17
|
+
_pickModelInteractive, _pickProviderInteractive, _printChatBanner,
|
|
18
|
+
_quickPrompt, _renderBanner, _renderV5Banner,
|
|
19
|
+
} from '../tui/pickers.mjs';
|
|
20
|
+
import { firstRunMode as _firstRunMode } from '../first_run.mjs';
|
|
21
|
+
import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
|
|
22
|
+
import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
|
|
23
|
+
import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from '../tui/slash_dispatcher.mjs';
|
|
24
|
+
import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
|
|
25
|
+
import { runChannelStep, runWebhookStep, runOrchestratorStep, runContextStep } from './setup_channels.mjs';
|
|
26
|
+
import { splashPropsForSetup, renderSplashToString } from '../tui/splash_props.mjs';
|
|
27
|
+
|
|
28
|
+
function applyOnboardConfig(currentCfg, flags) {
|
|
29
|
+
// Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
|
|
30
|
+
// by splitting it, but explicit --provider always wins.
|
|
31
|
+
const { parseSlashProviderModel } = requireRegistry();
|
|
32
|
+
const next = { ...currentCfg };
|
|
33
|
+
if (flags.model) {
|
|
34
|
+
const parsed = parseSlashProviderModel(flags.model);
|
|
35
|
+
if (parsed.provider && !flags.provider) next.provider = parsed.provider;
|
|
36
|
+
next.model = parsed.model || flags.model;
|
|
37
|
+
}
|
|
38
|
+
if (flags.provider) next.provider = flags.provider;
|
|
39
|
+
if (flags['api-key']) next['api-key'] = flags['api-key'];
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Module is ESM but we want a synchronous-looking helper for the CLI flow.
|
|
44
|
+
// Cache the import on first use so we don't pay for it on every config call.
|
|
45
|
+
export async function cmdOnboard(flags) {
|
|
46
|
+
await ensureRegistry();
|
|
47
|
+
if (!flags['non-interactive']) {
|
|
48
|
+
// Interactive onboarding is a single guided prompt sequence — kept tiny.
|
|
49
|
+
// For automation always use --non-interactive plus the value flags.
|
|
50
|
+
// Skip the prompts entirely when the user passed --pick (or no
|
|
51
|
+
// provider yet AND we're on a TTY) so they get the full picker.
|
|
52
|
+
const wantPicker = !!flags.pick;
|
|
53
|
+
if (wantPicker || (!flags.provider && process.stdin.isTTY)) {
|
|
54
|
+
const picked = await _pickProviderInteractive();
|
|
55
|
+
if (picked) {
|
|
56
|
+
flags.provider = flags.provider || picked.provider;
|
|
57
|
+
if (picked.model && !flags.model) flags.model = picked.model;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const readline = await import('node:readline');
|
|
61
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
62
|
+
const ask = q => new Promise(resolve => rl.question(q, resolve));
|
|
63
|
+
if (!flags.provider) {
|
|
64
|
+
const provs = Object.keys(getRegistry().PROVIDERS).join('|');
|
|
65
|
+
const noKeyHint = '\x1b[38;2;217;179;90mclaude-cli\x1b[0m (subscription, no key) is the default';
|
|
66
|
+
process.stdout.write(`hint: ${noKeyHint}\n`);
|
|
67
|
+
flags.provider = (await ask(`provider [${provs}]: `)).trim() || 'claude-cli';
|
|
68
|
+
}
|
|
69
|
+
if (!flags.model) {
|
|
70
|
+
const meta = (getRegistry().PROVIDER_INFO || {})[flags.provider] || {};
|
|
71
|
+
const sample = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(any)';
|
|
72
|
+
const dflt = meta.defaultModel || '';
|
|
73
|
+
flags.model = (await ask(`model (e.g. ${sample}) [${dflt}]: `)).trim() || dflt;
|
|
74
|
+
}
|
|
75
|
+
// Only ask for api-key when the picked provider actually needs one.
|
|
76
|
+
// claude-cli / ollama / mock all skip this — that's the whole point
|
|
77
|
+
// of supporting them.
|
|
78
|
+
const meta = (getRegistry().PROVIDER_INFO || {})[flags.provider] || {};
|
|
79
|
+
if (meta.requiresApiKey && !flags['api-key']) {
|
|
80
|
+
const prefix = meta.keyPrefix ? ` (starts with "${meta.keyPrefix}")` : '';
|
|
81
|
+
flags['api-key'] = (await ask(`api-key${prefix}: `)).trim();
|
|
82
|
+
}
|
|
83
|
+
rl.close();
|
|
84
|
+
}
|
|
85
|
+
const next = applyOnboardConfig(readConfig(), flags);
|
|
86
|
+
if (!next.provider) { console.error('onboard: provider is required'); process.exit(2); }
|
|
87
|
+
writeConfig(next);
|
|
88
|
+
console.log(JSON.stringify({ ok: true, written: configPath(), provider: next.provider, model: next.model || null, hasApiKey: !!next['api-key'] }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
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
|
+
export function cmdHelp(name) {
|
|
169
|
+
if (!name) {
|
|
170
|
+
process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
|
|
171
|
+
process.stdout.write('Subcommands:\n');
|
|
172
|
+
for (const sub of SUBCOMMANDS) {
|
|
173
|
+
const summary = HELP_SUMMARIES[sub] || '';
|
|
174
|
+
process.stdout.write(` ${sub.padEnd(12)}${summary}\n`);
|
|
175
|
+
}
|
|
176
|
+
process.stdout.write('\nlazyclaw help <subcommand> detailed usage\n');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const detail = HELP_DETAILS[name];
|
|
180
|
+
if (!detail) {
|
|
181
|
+
process.stderr.write(`unknown subcommand: ${name}\n`);
|
|
182
|
+
process.stderr.write(`run \`lazyclaw help\` to see the list\n`);
|
|
183
|
+
process.exit(2);
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(detail + '\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
191
|
+
await ensureRegistry();
|
|
192
|
+
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
193
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
194
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
195
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
196
|
+
const warn = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
197
|
+
|
|
198
|
+
// Header.
|
|
199
|
+
if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H');
|
|
200
|
+
process.stdout.write(renderSplashToString(await splashPropsForSetup({ version: readVersionFromRepo() })) + '\n');
|
|
201
|
+
process.stdout.write('\n');
|
|
202
|
+
process.stdout.write(` ${bold('🔧 Setup wizard')}\n`);
|
|
203
|
+
process.stdout.write(` ${dim('Get one clean chat working first, then optionally add a channel, workspace, or skills. Press Enter to accept the default; type "skip" or "n" to bypass an optional step.')}\n\n`);
|
|
204
|
+
|
|
205
|
+
const cfg = readConfig();
|
|
206
|
+
const cfgDir = path.dirname(configPath());
|
|
207
|
+
const colors = { accent, bold, dim, ok, warn };
|
|
208
|
+
|
|
209
|
+
// ── Step 1/7: Provider + model (mandatory) ──────────────────
|
|
210
|
+
process.stdout.write(` ${accent('Step 1/7 ·')} ${bold('Pick a provider + model')}\n`);
|
|
211
|
+
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
|
+
await _quickPrompt(' ▶ press Enter to open the picker ');
|
|
213
|
+
try {
|
|
214
|
+
await cmdOnboard({ pick: true });
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// Don't kill the process — the setup wizard is often called
|
|
217
|
+
// from inside cmdLauncher's loop, and a process.exit there
|
|
218
|
+
// would close the launcher entirely (the surface bug the
|
|
219
|
+
// user reported as "Setup 누르고 엔터 누르니까 바로 꺼져").
|
|
220
|
+
// Surface the error and let the caller decide.
|
|
221
|
+
process.stderr.write(`onboard error: ${e?.message || e}\n`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Re-read config after onboard wrote it. If the user aborted with
|
|
225
|
+
// no provider set, bail out early — the rest of the wizard depends
|
|
226
|
+
// on a provider being configured. `return` (not process.exit) so a
|
|
227
|
+
// launcher caller can re-prompt or fall back gracefully.
|
|
228
|
+
const cfgAfterOnboard = readConfig();
|
|
229
|
+
if (!cfgAfterOnboard.provider) {
|
|
230
|
+
process.stdout.write(`\n ${warn('Setup not completed — provider was not configured.')}\n`);
|
|
231
|
+
process.stdout.write(` ${dim('Run `lazyclaw setup` again when ready, or pick "Onboard" from the menu for a single-step picker.')}\n\n`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
|
|
235
|
+
|
|
236
|
+
// Context window — asked right after the model pick (optional; Enter keeps
|
|
237
|
+
// defaults). Not a numbered step: it's part of the core model setup.
|
|
238
|
+
await runContextStep({ prompt: _quickPrompt, colors });
|
|
239
|
+
|
|
240
|
+
// ── Step 2/7: Verify one clean chat works ───────────────────
|
|
241
|
+
// Hermes rule: get a clean reply before layering on channels/skills.
|
|
242
|
+
process.stdout.write(` ${accent('Step 2/7 ·')} ${bold('Verify the provider responds')}\n`);
|
|
243
|
+
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';
|
|
245
|
+
if (wantPing) {
|
|
246
|
+
try {
|
|
247
|
+
// No-exit probe (providers/probe.mjs) — the CLI `providers test` calls
|
|
248
|
+
// process.exit, which would kill the rest of this wizard. Render one
|
|
249
|
+
// concise line instead of the full JSON dump and keep going.
|
|
250
|
+
const { probeProvider } = await import('../providers/probe.mjs');
|
|
251
|
+
const r = await probeProvider({ name: cfgAfterOnboard.provider, model: cfgAfterOnboard.model || undefined });
|
|
252
|
+
if (r.ok) process.stdout.write(` ${ok('✓ ' + (r.reply || 'ok'))} ${dim(`· ${r.model || cfgAfterOnboard.provider} · ${r.durationMs}ms`)}\n`);
|
|
253
|
+
else process.stdout.write(` ${warn('✗ ' + (r.error || 'no reply'))} ${dim(`· retry: lazyclaw providers test ${cfgAfterOnboard.provider}`)}\n`);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
process.stdout.write(` ${warn('test errored:')} ${e?.message || e}\n`);
|
|
256
|
+
}
|
|
257
|
+
process.stdout.write('\n');
|
|
258
|
+
} else {
|
|
259
|
+
process.stdout.write(` ${dim('— skipped —')}\n\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Step 3/7: Channel / gateway (optional) ──────────────────
|
|
263
|
+
process.stdout.write(` ${accent('Step 3/7 ·')} ${bold('Where will you run it?')} ${dim('(optional)')}\n`);
|
|
264
|
+
await runChannelStep({ cfgDir, prompt: _quickPrompt, colors });
|
|
265
|
+
|
|
266
|
+
// ── Step 4/7: Optional workspace ────────────────────────────
|
|
267
|
+
process.stdout.write(` ${accent('Step 4/7 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
|
|
268
|
+
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();
|
|
270
|
+
if (wsName && /^[A-Za-z0-9_.-]+$/.test(wsName)) {
|
|
271
|
+
try {
|
|
272
|
+
const ws = await import('../workspace.mjs');
|
|
273
|
+
const dir = ws.initWorkspace(cfgDir, wsName);
|
|
274
|
+
process.stdout.write(` ${ok('✓ workspace created:')} ${dir}\n`);
|
|
275
|
+
process.stdout.write(` ${dim('Edit AGENTS.md / SOUL.md / TOOLS.md any time. Use with: lazyclaw chat --workspace ' + wsName)}\n\n`);
|
|
276
|
+
} catch (e) {
|
|
277
|
+
process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
278
|
+
}
|
|
279
|
+
} else if (wsName) {
|
|
280
|
+
process.stdout.write(` ${warn('skipped:')} workspace name must match [A-Za-z0-9_.-]+\n\n`);
|
|
281
|
+
} else {
|
|
282
|
+
process.stdout.write(` ${dim('— skipped —')}\n\n`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Step 5/7: Optional skill bundle install ─────────────────
|
|
286
|
+
process.stdout.write(` ${accent('Step 5/7 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
|
|
287
|
+
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();
|
|
289
|
+
if (skillSpec) {
|
|
290
|
+
try {
|
|
291
|
+
const inst = await import('../skills_install.mjs');
|
|
292
|
+
const r = await inst.installFromGithub(skillSpec, cfgDir, { force: false });
|
|
293
|
+
process.stdout.write(` ${ok('✓ installed')} ${r.installed.length} ${dim('skill(s) from')} ${skillSpec}\n`);
|
|
294
|
+
r.installed.forEach((s) => process.stdout.write(` · ${s.name} ${dim(`(${s.bytes} bytes)`)}\n`));
|
|
295
|
+
if (r.skipped.length) {
|
|
296
|
+
process.stdout.write(` ${dim('skipped (already installed):')} ${r.skipped.map((s) => s.name).join(', ')}\n`);
|
|
297
|
+
}
|
|
298
|
+
process.stdout.write('\n');
|
|
299
|
+
} catch (e) {
|
|
300
|
+
process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
process.stdout.write(` ${dim('— skipped —')}\n\n`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Step 6/7: Optional outbound webhook ─────────────────────
|
|
307
|
+
process.stdout.write(` ${accent('Step 6/7 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
|
|
308
|
+
await runWebhookStep({ prompt: _quickPrompt, colors });
|
|
309
|
+
|
|
310
|
+
// ── Step 7/7: Optional multi-agent orchestration ────────────
|
|
311
|
+
process.stdout.write(` ${accent('Step 7/7 ·')} ${bold('Enable multi-agent orchestration?')} ${dim('(optional)')}\n`);
|
|
312
|
+
await runOrchestratorStep({ prompt: _quickPrompt, colors });
|
|
313
|
+
|
|
314
|
+
// ── Wrap up ─────────────────────────────────────────────────
|
|
315
|
+
process.stdout.write('\n');
|
|
316
|
+
process.stdout.write(` ${ok(bold('🎉 Setup complete.'))}\n`);
|
|
317
|
+
process.stdout.write(` ${dim('Run')} ${bold('lazyclaw')} ${dim('any time to open the menu, or jump in directly:')}\n`);
|
|
318
|
+
process.stdout.write(` ${dim('•')} lazyclaw chat ${dim('— REPL with the configured provider')}\n`);
|
|
319
|
+
process.stdout.write(` ${dim('•')} lazyclaw agent "..." ${dim('— one-shot prompt')}\n`);
|
|
320
|
+
process.stdout.write(` ${dim('•')} lazyclaw doctor ${dim('— diagnostic JSON')}\n`);
|
|
321
|
+
process.stdout.write(` ${dim('•')} lazyclaw setup ${dim('— re-run this wizard any time')}\n\n`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// First-run welcome panel + delegated onboard. Drawn once before the
|
|
325
|
+
// main launcher menu when the config has no provider yet. Walks the
|
|
326
|
+
// user through the same arrow-key picker that `lazyclaw onboard`
|
|
327
|
+
// uses; on success the launcher continues, on cancel the launcher
|
|
328
|
+
// exits politely instead of dropping into a menu where every option
|
|
329
|
+
// would error.
|
|
330
|
+
async function _runFirstTimeOnboard() {
|
|
331
|
+
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
332
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
333
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
334
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
335
|
+
process.stdout.write(renderSplashToString(await splashPropsForSetup({ version: readVersionFromRepo() })) + '\n');
|
|
336
|
+
process.stdout.write('\n');
|
|
337
|
+
process.stdout.write(` ${bold('👋 Welcome — first-time setup')}\n\n`);
|
|
338
|
+
process.stdout.write(` ${dim('No provider configured yet at')} ${configPath()}\n`);
|
|
339
|
+
process.stdout.write(` ${dim('Pick a provider + model below; LazyClaw stores it in ~/.lazyclaw/config.json.')}\n\n`);
|
|
340
|
+
process.stdout.write(` ${dim('Quick rule of thumb:')}\n`);
|
|
341
|
+
process.stdout.write(` ${dim(' · gemini / openai / anthropic — need an API key (sk-... / paste during setup)')}\n`);
|
|
342
|
+
process.stdout.write(` ${dim(' · claude-cli / ollama — keyless (use your existing Claude Code login or local Ollama)')}\n`);
|
|
343
|
+
process.stdout.write(` ${dim(' · mock — offline echo, only useful for testing')}\n\n`);
|
|
344
|
+
process.stdout.write(` ${dim('Press Enter to open the picker · Ctrl+C to abort.')}\n`);
|
|
345
|
+
await _quickPrompt(' ▶ ');
|
|
346
|
+
// Delegate to the real onboard flow with --pick so the picker UI
|
|
347
|
+
// fires regardless of how this entry point was reached. cmdOnboard
|
|
348
|
+
// owns config writing.
|
|
349
|
+
try {
|
|
350
|
+
await cmdOnboard({ pick: true });
|
|
351
|
+
} catch (e) {
|
|
352
|
+
process.stderr.write(`onboard error: ${e?.message || e}\n`);
|
|
353
|
+
}
|
|
354
|
+
process.stdout.write('\n');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Marker exception used by the launcher's process.exit guard. See
|
|
358
|
+
// _dispatchMenuChoice below for why intercepting process.exit is
|
|
359
|
+
// the cleanest way to keep the menu loop alive.
|
|
360
|
+
class _DispatchExit extends Error {
|
|
361
|
+
constructor(code) {
|
|
362
|
+
super(`subcommand requested exit ${code}`);
|
|
363
|
+
this.name = 'DispatchExit';
|
|
364
|
+
this.exitCode = Number.isFinite(code) ? code : 0;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Direct dispatch from a launcher pick. Replaces the previous
|
|
369
|
+
// `process.argv = [...]; await main()` round-trip so we can reuse
|
|
370
|
+
// the launcher across multiple iterations without compounding
|
|
371
|
+
// state.
|
|
372
|
+
//
|
|
373
|
+
// Subcommand functions across this CLI freely call `process.exit()`
|
|
374
|
+
// to signal their result — perfectly fine for one-shot CLI use,
|
|
375
|
+
// fatal to a launcher loop because the first exit kills the whole
|
|
376
|
+
// process before we can redraw the menu. Intercept process.exit for
|
|
377
|
+
// the duration of the dispatch and turn it into a thrown exception
|
|
378
|
+
// the loop can catch + log + continue from. This mirrors how Python
|
|
379
|
+
// CLI frameworks handle SystemExit when running inside a REPL.
|
|
380
|
+
async function _dispatchMenuChoice(argv) {
|
|
381
|
+
const sub = argv[0];
|
|
382
|
+
const rest = argv.slice(1);
|
|
383
|
+
const realExit = process.exit.bind(process);
|
|
384
|
+
process.exit = (code) => { throw new _DispatchExit(code); };
|
|
385
|
+
try {
|
|
386
|
+
switch (sub) {
|
|
387
|
+
case 'chat': return await (await import('./chat.mjs')).cmdChat({});
|
|
388
|
+
case 'agent': {
|
|
389
|
+
if (AGENT_REG_SUBS.has(rest[0])) return await (await import('../commands/agents.mjs')).cmdAgentRegistry(rest[0], rest.slice(1), {});
|
|
390
|
+
return await (await import('../commands/agents.mjs')).cmdAgent(rest[0] || '-', {});
|
|
391
|
+
}
|
|
392
|
+
case 'onboard': return await cmdOnboard({});
|
|
393
|
+
case 'setup': return await cmdSetup(undefined, rest, {});
|
|
394
|
+
case 'workspace': return await (await import('../commands/auth_nodes.mjs')).cmdWorkspace(rest[0], rest.slice(1), {});
|
|
395
|
+
case 'browse': return await (await import('../commands/misc.mjs')).cmdBrowse(rest[0], {});
|
|
396
|
+
case 'skills': return await (await import('../commands/skills.mjs')).cmdSkills(rest[0], rest.slice(1), {});
|
|
397
|
+
case 'sessions': return await (await import('../commands/sessions.mjs')).cmdSessions(rest[0], rest.slice(1), {});
|
|
398
|
+
case 'providers': return await (await import('../commands/providers.mjs')).cmdProviders(rest[0], rest.slice(1), {});
|
|
399
|
+
case 'cron': return await (await import('../commands/automation.mjs')).cmdCron(rest[0], rest.slice(1), {});
|
|
400
|
+
case 'loop': return await (await import('../commands/automation.mjs')).cmdLoop(rest[0] || '', {});
|
|
401
|
+
case 'loops': return await (await import('../commands/automation.mjs')).cmdLoops(rest[0], rest.slice(1), {});
|
|
402
|
+
case 'goal': return await (await import('../commands/automation.mjs')).cmdGoal(rest[0], rest.slice(1), {});
|
|
403
|
+
case 'memory': return await (await import('../commands/sessions.mjs')).cmdMemory(rest[0], rest.slice(1), {});
|
|
404
|
+
case 'slack': return await (await import('../commands/channels.mjs')).cmdSlack(rest[0], rest.slice(1), {});
|
|
405
|
+
case 'telegram': return await (await import('../commands/channels.mjs')).cmdTelegram(rest[0], rest.slice(1), {});
|
|
406
|
+
case 'matrix': return await (await import('../commands/channels.mjs')).cmdMatrix(rest[0], rest.slice(1), {});
|
|
407
|
+
case 'team': return await (await import('../commands/agents.mjs')).cmdTeam(rest[0], rest.slice(1), {});
|
|
408
|
+
case 'task': return await (await import('../commands/agents.mjs')).cmdTask(rest[0], rest.slice(1), {});
|
|
409
|
+
case 'auth': return await (await import('../commands/auth_nodes.mjs')).cmdAuth(rest[0], rest.slice(1), {});
|
|
410
|
+
case 'pairing': return await (await import('../commands/auth_nodes.mjs')).cmdPairing(rest[0], rest.slice(1), {});
|
|
411
|
+
case 'nodes': return await (await import('../commands/auth_nodes.mjs')).cmdNodes(rest[0], rest.slice(1), {});
|
|
412
|
+
case 'message': return await (await import('../commands/auth_nodes.mjs')).cmdMessage(rest[0], rest.slice(1), {});
|
|
413
|
+
case 'doctor': return await (await import('../commands/config.mjs')).cmdDoctor();
|
|
414
|
+
case 'status': return await (await import('../commands/config.mjs')).cmdStatus();
|
|
415
|
+
// v3.99.27 — fill the rest of the lazyclaw <subcommand> surface
|
|
416
|
+
// so the no-arg launcher mirrors every entry in SUBCOMMANDS.
|
|
417
|
+
case 'orchestrator': return await (await import('../commands/providers.mjs')).cmdOrchestrator(rest[0], rest.slice(1), {});
|
|
418
|
+
case 'rates': return await (await import('../commands/providers.mjs')).cmdRates(rest[0], rest.slice(1), {});
|
|
419
|
+
case 'config': {
|
|
420
|
+
// Mirror the main switch's tiny dispatcher.
|
|
421
|
+
const csub = rest[0];
|
|
422
|
+
if (csub === 'list' || csub === undefined) return (await import('../commands/config.mjs')).cmdConfigGet(undefined);
|
|
423
|
+
if (csub === 'get') return (await import('../commands/config.mjs')).cmdConfigGet(rest[1]);
|
|
424
|
+
if (csub === 'set') return (await import('../commands/config.mjs')).cmdConfigSet(rest[1], rest.slice(2).join(' '));
|
|
425
|
+
if (csub === 'path') { process.stdout.write(configPath() + '\n'); return; }
|
|
426
|
+
if (csub === 'edit') return await (await import('../commands/config.mjs')).cmdConfigEdit();
|
|
427
|
+
if (csub === 'validate') return await (await import('../commands/config.mjs')).cmdConfigValidate();
|
|
428
|
+
process.stderr.write('Usage: lazyclaw config <get|set|list|delete|path|edit|validate>\n');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
case 'inspect': return await (await import('../commands/workflow.mjs')).dispatch('inspect', { positional: [rest[0]], flags: {} });
|
|
432
|
+
case 'export': return await (await import('../commands/sessions.mjs')).cmdExport({});
|
|
433
|
+
case 'version': return await (await import('../commands/config.mjs')).cmdVersion();
|
|
434
|
+
// Phase G — persona compose subcommand (spec §9, decision C7).
|
|
435
|
+
case 'personality': return await (await import('../commands/config.mjs')).cmdPersonality(rest[0], rest[1], rest[2]);
|
|
436
|
+
// help <cmd> is the safe fallback for commands that need real
|
|
437
|
+
// arguments (run / resume / clear / validate / graph / daemon /
|
|
438
|
+
// import / completion). Print the usage so the user can re-launch
|
|
439
|
+
// with proper flags — the menu stays alive.
|
|
440
|
+
case 'help': return cmdHelp(rest[0]);
|
|
441
|
+
case 'dashboard': return await (await import('../commands/daemon.mjs')).cmdDashboard({});
|
|
442
|
+
default: throw new Error(`unknown menu choice: ${sub}`);
|
|
443
|
+
}
|
|
444
|
+
} catch (e) {
|
|
445
|
+
if (e instanceof _DispatchExit) {
|
|
446
|
+
// Subcommand wanted to exit. Surface a non-zero code so the
|
|
447
|
+
// user knows something flagged, but DON'T propagate — we want
|
|
448
|
+
// the launcher loop to continue.
|
|
449
|
+
if (e.exitCode !== 0) {
|
|
450
|
+
process.stderr.write(` \x1b[2m(subcommand returned exit code ${e.exitCode})\x1b[0m\n`);
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
throw e;
|
|
455
|
+
} finally {
|
|
456
|
+
process.exit = realExit;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function cmdLauncher() {
|
|
461
|
+
await ensureRegistry();
|
|
462
|
+
// Item table is fixed across iterations — only the dispatcher and
|
|
463
|
+
// the per-iteration draw redraw on each loop tick.
|
|
464
|
+
// Mirror every top-level `lazyclaw <subcommand>` here so the no-arg
|
|
465
|
+
// launcher is a complete discovery surface. Commands that need
|
|
466
|
+
// arguments (workflow runner, daemon, completion, import) route
|
|
467
|
+
// through `help <cmd>` so the menu pick prints copy-pasteable usage
|
|
468
|
+
// instead of erroring or blocking. Commands with a sensible default
|
|
469
|
+
// ('list' / 'status') get dispatched directly.
|
|
470
|
+
const items = [
|
|
471
|
+
// Core interaction
|
|
472
|
+
{ id: 'chat', label: 'Chat', desc: 'interactive REPL with the configured provider', argv: ['chat'] },
|
|
473
|
+
{ id: 'agent', label: 'Agent', desc: 'one-shot prompt — read text and exit', argv: ['agent'], promptForBody: true },
|
|
474
|
+
{ id: 'orchestrator', label: 'Orchestrator', desc: 'multi-agent dispatch — planner + workers', argv: ['orchestrator', 'status'] },
|
|
475
|
+
// UI & onboarding
|
|
476
|
+
{ id: 'dashboard', label: 'Dashboard', desc: 'open the lazyclaw web UI in your browser', argv: ['dashboard'] },
|
|
477
|
+
{ id: 'setup', label: 'Setup', desc: 'multi-step provider / workspace / skill wizard',argv: ['setup'] },
|
|
478
|
+
{ id: 'onboard', label: 'Onboard', desc: 'pick provider / model / api-key', argv: ['onboard'] },
|
|
479
|
+
// Auth & config
|
|
480
|
+
{ id: 'providers', label: 'Providers', desc: 'registered providers + reachability', argv: ['providers', 'list'] },
|
|
481
|
+
{ id: 'auth', label: 'Auth', desc: 'multi-key rotation per provider', argv: ['help', 'auth'] },
|
|
482
|
+
{ id: 'config', label: 'Config', desc: 'cfg.json get/set/list/delete/path/edit', argv: ['config', 'list'] },
|
|
483
|
+
{ id: 'rates', label: 'Rates', desc: 'per-model input/output pricing cards', argv: ['rates', 'list'] },
|
|
484
|
+
// Workspaces & assets
|
|
485
|
+
{ id: 'workspace', label: 'Workspace', desc: 'AGENTS.md / SOUL.md / TOOLS.md prompt bundles', argv: ['workspace', 'list'] },
|
|
486
|
+
{ id: 'skills', label: 'Skills', desc: 'installed skill bundles', argv: ['skills', 'list'] },
|
|
487
|
+
{ id: 'sessions', label: 'Sessions', desc: 'persisted chat sessions', argv: ['sessions', 'list'] },
|
|
488
|
+
// Outbound & schedule
|
|
489
|
+
{ id: 'browse', label: 'Browse', desc: 'fetch a URL → markdown', argv: ['browse'], promptForUrl: true },
|
|
490
|
+
{ id: 'message', label: 'Message', desc: 'outbound webhook (Slack / Discord / generic)', argv: ['message', 'list'] },
|
|
491
|
+
{ id: 'cron', label: 'Cron', desc: 'recurring agent runs (launchd / crontab)', argv: ['cron', 'list'] },
|
|
492
|
+
// Workflow runner (.mjs)
|
|
493
|
+
{ id: 'run', label: 'Run', desc: '.mjs workflow runner (needs session + file)', argv: ['help', 'run'] },
|
|
494
|
+
{ id: 'resume', label: 'Resume', desc: 're-enter a persisted workflow run', argv: ['help', 'resume'] },
|
|
495
|
+
{ id: 'inspect', label: 'Inspect', desc: 'list / drill into persisted workflow sessions', argv: ['inspect'] },
|
|
496
|
+
{ id: 'clear', label: 'Clear', desc: 'delete the state file for a session', argv: ['help', 'clear'] },
|
|
497
|
+
{ id: 'validate', label: 'Validate', desc: 'static-check a workflow.mjs (shape + deps)', argv: ['help', 'validate'] },
|
|
498
|
+
{ id: 'graph', label: 'Graph', desc: 'emit Mermaid graph TD / LR from a workflow', argv: ['help', 'graph'] },
|
|
499
|
+
// Devices & process
|
|
500
|
+
{ id: 'pairing', label: 'Pairing', desc: 'sender allowlist for the messaging surface', argv: ['pairing', 'list'] },
|
|
501
|
+
{ id: 'nodes', label: 'Nodes', desc: 'companion device registry', argv: ['nodes', 'list'] },
|
|
502
|
+
{ id: 'daemon', label: 'Daemon', desc: 'localhost HTTP daemon (blocking — see usage)', argv: ['help', 'daemon'] },
|
|
503
|
+
// Bundle
|
|
504
|
+
{ id: 'export', label: 'Export', desc: 'redacted config bundle → stdout', argv: ['export'] },
|
|
505
|
+
{ id: 'import', label: 'Import', desc: 'restore from a bundle on stdin', argv: ['help', 'import'] },
|
|
506
|
+
// Tools
|
|
507
|
+
{ id: 'completion', label: 'Completion', desc: 'shell completion (bash | zsh)', argv: ['help', 'completion'] },
|
|
508
|
+
{ id: 'version', label: 'Version', desc: 'lazyclaw version + Node + platform', argv: ['version'] },
|
|
509
|
+
// Diagnostics
|
|
510
|
+
{ id: 'doctor', label: 'Doctor', desc: 'diagnostic — config, providers, workflows', argv: ['doctor'] },
|
|
511
|
+
{ id: 'status', label: 'Status', desc: 'current provider / model / masked key', argv: ['status'] },
|
|
512
|
+
// Meta
|
|
513
|
+
{ id: 'help', label: 'Help', desc: 'one-line summary of every subcommand', argv: ['help'] },
|
|
514
|
+
{ id: 'quit', label: 'Quit', desc: 'exit lazyclaw', argv: null },
|
|
515
|
+
];
|
|
516
|
+
|
|
517
|
+
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
518
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
519
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
520
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
521
|
+
const warn = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
522
|
+
|
|
523
|
+
let idx = 0;
|
|
524
|
+
// Outer loop — each iteration is one menu render → pick →
|
|
525
|
+
// dispatch round. Subcommand return drops back here and the menu
|
|
526
|
+
// is redrawn. Quit / Esc / Ctrl-C breaks the loop and returns,
|
|
527
|
+
// which lets the calling main() exit naturally.
|
|
528
|
+
//
|
|
529
|
+
// try/finally below is load-bearing: the loop body keeps stdin
|
|
530
|
+
// ref'd so the picker's keypress events fire. If we just `return`
|
|
531
|
+
// on Quit, stdin stays ref'd and Node's event loop never empties
|
|
532
|
+
// → the `lazyclaw` process hangs forever after the user picked
|
|
533
|
+
// Quit. The finally explicitly pauses + unrefs stdin so the
|
|
534
|
+
// process exits cleanly the moment the user picks Quit.
|
|
535
|
+
try {
|
|
536
|
+
while (true) {
|
|
537
|
+
// First-run / config-missing guard: a fresh install has no
|
|
538
|
+
// `provider` set, so any menu pick that calls a provider would
|
|
539
|
+
// error halfway through. Funnel through cmdSetup before
|
|
540
|
+
// rendering the menu the first time around.
|
|
541
|
+
let cfg = readConfig();
|
|
542
|
+
if (!cfg.provider) {
|
|
543
|
+
try { await cmdSetup(undefined, [], {}); }
|
|
544
|
+
catch (e) {
|
|
545
|
+
process.stderr.write(`setup error: ${e?.message || e}\n`);
|
|
546
|
+
}
|
|
547
|
+
cfg = readConfig();
|
|
548
|
+
if (!cfg.provider) {
|
|
549
|
+
process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw setup` when ready, then try `lazyclaw` again.\n\n');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const provider = cfg.provider;
|
|
554
|
+
const model = cfg.model || '(default)';
|
|
555
|
+
|
|
556
|
+
// Re-establish stdin in raw / ref'd mode. A previous iteration
|
|
557
|
+
// (e.g. `chat`) deliberately paused + unref'd stdin in its
|
|
558
|
+
// exit-cleanup path so the process could end on /exit; now that
|
|
559
|
+
// we want to keep going, re-attach.
|
|
560
|
+
const readline = await import('node:readline');
|
|
561
|
+
readline.emitKeypressEvents(process.stdin);
|
|
562
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
563
|
+
process.stdin.resume();
|
|
564
|
+
process.stdin.ref();
|
|
565
|
+
|
|
566
|
+
const useLegacyBanner = !!process.env.LAZYCLAW_LEGACY_MENU;
|
|
567
|
+
const bannerRowsCached = useLegacyBanner
|
|
568
|
+
? _renderBanner(readVersionFromRepo())
|
|
569
|
+
: await _renderV5Banner(readVersionFromRepo());
|
|
570
|
+
const draw = () => {
|
|
571
|
+
process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
|
|
572
|
+
bannerRowsCached.forEach((l) => process.stdout.write(l + '\n'));
|
|
573
|
+
process.stdout.write('\n');
|
|
574
|
+
process.stdout.write(` ${dim('provider ·')} ${ok(provider)}\n`);
|
|
575
|
+
process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
|
|
576
|
+
process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
|
|
577
|
+
process.stdout.write('\n');
|
|
578
|
+
process.stdout.write(` ${dim('↑/↓ to move · Enter to select · / for slash command (e.g. /exit) · q or Esc to quit')}\n\n`);
|
|
579
|
+
const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
|
|
580
|
+
const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
|
|
581
|
+
const toIdx = Math.min(items.length, fromIdx + rowsAvail);
|
|
582
|
+
for (let i = fromIdx; i < toIdx; i++) {
|
|
583
|
+
const it = items[i];
|
|
584
|
+
const marker = i === idx ? accent('❯ ') : ' ';
|
|
585
|
+
const lbl = i === idx ? bold(it.label.padEnd(11)) : it.label.padEnd(11);
|
|
586
|
+
process.stdout.write(`${marker}${lbl} ${dim(it.desc)}\n`);
|
|
587
|
+
}
|
|
588
|
+
process.stdout.write('\n');
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// Slash-command mini prompt rendered just below the menu. Lets users
|
|
592
|
+
// type `/exit` / `/quit` / `/help` to leave (or get a list of slash
|
|
593
|
+
// commands) without hunting for the right special key. The menu is
|
|
594
|
+
// raw-mode and never sees a newline-terminated line, so we accumulate
|
|
595
|
+
// keystrokes locally instead of round-tripping through readline.
|
|
596
|
+
let slashBuffer = null; // null = menu mode; string = slash mode (always starts with '/')
|
|
597
|
+
let slashNotice = ''; // one-line hint shown after the buffer (e.g. "unknown command")
|
|
598
|
+
const LAUNCHER_SLASH_HELP = [
|
|
599
|
+
{ cmd: '/exit', help: 'leave lazyclaw' },
|
|
600
|
+
{ cmd: '/quit', help: 'alias for /exit' },
|
|
601
|
+
{ cmd: '/help', help: 'list slash commands' },
|
|
602
|
+
{ cmd: '/version', help: 'print version + node + platform' },
|
|
603
|
+
];
|
|
604
|
+
const drawWithSlash = () => {
|
|
605
|
+
draw();
|
|
606
|
+
process.stdout.write(` ${dim('slash ›')} ${slashBuffer}`);
|
|
607
|
+
if (slashNotice) process.stdout.write(` ${slashNotice}`);
|
|
608
|
+
process.stdout.write('\x1b[?25h'); // show cursor while typing
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
draw();
|
|
612
|
+
const picked = await new Promise((resolve) => {
|
|
613
|
+
const onKey = (str, key) => {
|
|
614
|
+
if (!key) return;
|
|
615
|
+
|
|
616
|
+
// ── Slash-command input mode ─────────────────────────────────
|
|
617
|
+
if (slashBuffer !== null) {
|
|
618
|
+
if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); return; }
|
|
619
|
+
if (key.name === 'escape') { slashBuffer = null; slashNotice = ''; draw(); return; }
|
|
620
|
+
if (key.name === 'return') {
|
|
621
|
+
const cmd = slashBuffer.trim().toLowerCase();
|
|
622
|
+
if (cmd === '/exit' || cmd === '/quit') { cleanup(); resolve({ id: 'quit', argv: null }); return; }
|
|
623
|
+
if (cmd === '/help') {
|
|
624
|
+
slashBuffer = '/';
|
|
625
|
+
slashNotice = dim(LAUNCHER_SLASH_HELP.map(c => `${c.cmd} (${c.help})`).join(' · '));
|
|
626
|
+
drawWithSlash();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (cmd === '/version') {
|
|
630
|
+
const v = readVersionFromRepo();
|
|
631
|
+
slashNotice = ok(`v${v} · node ${process.version} · ${process.platform}-${process.arch}`);
|
|
632
|
+
drawWithSlash();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Unknown command — keep the buffer so the user can edit it
|
|
636
|
+
// rather than retyping from scratch. Esc / Backspace bails.
|
|
637
|
+
slashNotice = warn(`unknown — try ${LAUNCHER_SLASH_HELP.map(c => c.cmd).join(' · ')}`);
|
|
638
|
+
drawWithSlash();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (key.name === 'backspace') {
|
|
642
|
+
slashNotice = '';
|
|
643
|
+
if (slashBuffer.length > 1) slashBuffer = slashBuffer.slice(0, -1);
|
|
644
|
+
else slashBuffer = null;
|
|
645
|
+
slashBuffer === null ? draw() : drawWithSlash();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// Append printable characters. Filter control / meta chords so
|
|
649
|
+
// Ctrl+L etc. don't pollute the buffer.
|
|
650
|
+
if (str && str.length === 1 && !key.ctrl && !key.meta && str >= ' ') {
|
|
651
|
+
slashBuffer += str;
|
|
652
|
+
slashNotice = '';
|
|
653
|
+
drawWithSlash();
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Menu navigation mode ─────────────────────────────────────
|
|
659
|
+
if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
|
|
660
|
+
else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
|
|
661
|
+
else if (key.name === 'home') { idx = 0; draw(); }
|
|
662
|
+
else if (key.name === 'end') { idx = items.length - 1; draw(); }
|
|
663
|
+
else if (key.name === 'pageup') { idx = Math.max(0, idx - 5); draw(); }
|
|
664
|
+
else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 5); draw(); }
|
|
665
|
+
else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
|
|
666
|
+
else if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); }
|
|
667
|
+
else if (key.name === 'escape' || key.name === 'q') { cleanup(); resolve({ id: 'quit', argv: null }); }
|
|
668
|
+
else if (str === '/') { slashBuffer = '/'; slashNotice = ''; drawWithSlash(); }
|
|
669
|
+
function cleanup() {
|
|
670
|
+
process.stdin.off('keypress', onKey);
|
|
671
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
672
|
+
process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
process.stdin.on('keypress', onKey);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
if (!picked || picked.id === 'quit' || !picked.argv) {
|
|
679
|
+
// v3.99.28 — break out of the while loop, fall through the
|
|
680
|
+
// finally (stdin cleanup), then hit the explicit process.exit(0)
|
|
681
|
+
// at the function tail. Previously this was `return`, which
|
|
682
|
+
// jumped over the explicit exit and left dangling timers /
|
|
683
|
+
// sockets (ollama probe, registry retry, etc.) keeping the
|
|
684
|
+
// event loop alive — visible to the user as "Quit didn't quit."
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Two menu items need a follow-up question before they can run:
|
|
689
|
+
// agent (prompt body), browse (URL). Ask once, then dispatch.
|
|
690
|
+
let argv = picked.argv;
|
|
691
|
+
if (picked.promptForBody) {
|
|
692
|
+
const body = await _quickPrompt('prompt: ');
|
|
693
|
+
if (!body) continue; // back to menu
|
|
694
|
+
argv = ['agent', body];
|
|
695
|
+
} else if (picked.promptForUrl) {
|
|
696
|
+
const url = await _quickPrompt('url: ');
|
|
697
|
+
if (!url) continue; // back to menu
|
|
698
|
+
argv = ['browse', url];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Dispatch. Errors don't terminate the launcher — they're
|
|
702
|
+
// surfaced as a stderr line and the menu redraws. Lets the
|
|
703
|
+
// user recover from a transient API hiccup without a relaunch.
|
|
704
|
+
try {
|
|
705
|
+
await _dispatchMenuChoice(argv);
|
|
706
|
+
} catch (e) {
|
|
707
|
+
process.stderr.write(`\n ${accent('✗')} ${e?.message || String(e)}\n`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Pause before re-drawing so the user can read the subcommand's
|
|
711
|
+
// output. `chat` is the special case: its REPL has already kept
|
|
712
|
+
// the user oriented for a long session, and they typed /exit
|
|
713
|
+
// explicitly, so jumping straight back to the menu reads as
|
|
714
|
+
// "ok, done with that conversation, back to the dashboard."
|
|
715
|
+
if (picked.id !== 'chat') {
|
|
716
|
+
process.stdout.write('\n');
|
|
717
|
+
await _quickPrompt(` ${dim('Press Enter to return to the menu… ')}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
} finally {
|
|
721
|
+
// Drop the stdin holds we kept open while the menu was active.
|
|
722
|
+
// Without this, the Node event loop never empties on Quit and
|
|
723
|
+
// the `lazyclaw` process hangs at the shell prompt. Mirrors the
|
|
724
|
+
// cleanup path cmdChat installed in v3.92 for the same reason.
|
|
725
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
726
|
+
try { process.stdin.setRawMode(false); } catch (_) {}
|
|
727
|
+
}
|
|
728
|
+
try { process.stdout.write('\x1b[?25h'); } catch (_) {} // restore cursor
|
|
729
|
+
try { process.stdin.pause(); } catch (_) {}
|
|
730
|
+
try { process.stdin.unref(); } catch (_) {}
|
|
731
|
+
}
|
|
732
|
+
// User reached the end of the launcher session — Quit / Esc / q /
|
|
733
|
+
// /exit / /quit / Ctrl-C, or a failed first-run setup. Skip the
|
|
734
|
+
// natural-exit wait and terminate now: a previously imported
|
|
735
|
+
// subcommand (ollama auto-start probe, registry caches, retry timers,
|
|
736
|
+
// etc.) may have registered an interval or socket that keeps the
|
|
737
|
+
// event loop alive for several seconds. Ctrl-C exits immediately;
|
|
738
|
+
// /exit and Quit should feel the same.
|
|
739
|
+
process.exit(0);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
|