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