lazyclaw 6.6.0 → 6.8.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/cli.mjs +5 -0
- package/commands/chat.mjs +3 -5
- package/commands/login.mjs +108 -0
- package/commands/setup.mjs +5 -7
- package/commands/setup_permission.mjs +31 -0
- package/lib/args.mjs +1 -1
- package/lib/nl_config_command.mjs +103 -0
- package/lib/permission_mode.mjs +36 -0
- package/mas/index_db.mjs +32 -10
- package/package.json +1 -1
- package/providers/claude_cli.mjs +5 -1
- package/providers/claude_cli_session.mjs +1 -0
- package/providers/claude_keychain.mjs +40 -0
- package/providers/model_catalogue.mjs +5 -2
- package/tui/run_turn.mjs +25 -0
package/cli.mjs
CHANGED
|
@@ -403,6 +403,11 @@ async function main() {
|
|
|
403
403
|
await (await import('./commands/config.mjs')).cmdDoctor();
|
|
404
404
|
break;
|
|
405
405
|
}
|
|
406
|
+
case 'login': {
|
|
407
|
+
const code = await (await import('./commands/login.mjs')).cmdLogin(rest.positional, rest.flags);
|
|
408
|
+
if (code) process.exitCode = code;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
406
411
|
case 'status': {
|
|
407
412
|
await (await import('./commands/config.mjs')).cmdStatus();
|
|
408
413
|
break;
|
package/commands/chat.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../lib/config.mjs';
|
|
12
12
|
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
13
13
|
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
14
|
+
import { LAZYCLAW_META_GUARD } from '../lib/nl_config_command.mjs';
|
|
14
15
|
import {
|
|
15
16
|
_attachGhostAutocomplete, _fetchModelsForProvider,
|
|
16
17
|
_pickProviderInteractive, _printChatBanner,
|
|
@@ -176,7 +177,7 @@ export async function cmdChat(flags = {}) {
|
|
|
176
177
|
} catch (err) { console.error(`skill error: ${err.message}`); process.exit(2); }
|
|
177
178
|
}
|
|
178
179
|
if (_inkSysParts.length && !_inkMessages.some((m) => m.role === 'system')) {
|
|
179
|
-
const merged = _inkSysParts.join('\n\n---\n\n');
|
|
180
|
+
const merged = [..._inkSysParts, LAZYCLAW_META_GUARD].join('\n\n---\n\n');
|
|
180
181
|
_inkMessages.unshift({ role: 'system', content: merged });
|
|
181
182
|
if (_inkSessionId) sessionsMod.appendTurn(_inkSessionId, 'system', merged, _inkCfgDir);
|
|
182
183
|
}
|
|
@@ -455,7 +456,7 @@ export async function cmdChat(flags = {}) {
|
|
|
455
456
|
}
|
|
456
457
|
}
|
|
457
458
|
if (sysParts.length && !messages.some(m => m.role === 'system')) {
|
|
458
|
-
const merged = sysParts.join('\n\n---\n\n');
|
|
459
|
+
const merged = [...sysParts, LAZYCLAW_META_GUARD].join('\n\n---\n\n');
|
|
459
460
|
messages.unshift({ role: 'system', content: merged });
|
|
460
461
|
if (sessionId) sessionsMod.appendTurn(sessionId, 'system', merged, cfgDir);
|
|
461
462
|
}
|
|
@@ -651,11 +652,8 @@ export async function cmdChat(flags = {}) {
|
|
|
651
652
|
// listener from a previous run doesn't crash the launch with EADDRINUSE.
|
|
652
653
|
// Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
|
|
653
654
|
|
|
654
|
-
|
|
655
|
-
|
|
656
655
|
// sandbox subcommands — list/test/add/use (Phase D).
|
|
657
656
|
|
|
658
|
-
|
|
659
657
|
// Interactive launcher — fired when the user types `lazyclaw` with
|
|
660
658
|
// no subcommand AND we're attached to a TTY. OpenClaw's launcher
|
|
661
659
|
// pattern: ASCII banner + provider/model status + arrow-key menu of
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// commands/login.mjs — `lazyclaw login [claude]`.
|
|
2
|
+
//
|
|
3
|
+
// lazyclaw is keyless: chatting via the claude-cli provider spawns the `claude`
|
|
4
|
+
// binary, which carries its own login. But the model-LISTING path makes a
|
|
5
|
+
// direct api.anthropic.com call that needs a bearer, and on macOS the `claude`
|
|
6
|
+
// login lives in the Keychain (no credential file), so listing failed with
|
|
7
|
+
// "set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN". This command resolves the
|
|
8
|
+
// credential across env / file / Keychain and, when none is present, mints a
|
|
9
|
+
// long-lived token via `claude setup-token`. The minted token is stored in
|
|
10
|
+
// <config>/.env (0600, gitignored) — the same env path model listing reads.
|
|
11
|
+
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
14
|
+
import { _claudeCodeOAuthToken } from '../providers/model_catalogue.mjs';
|
|
15
|
+
import { readClaudeKeychainToken } from '../providers/claude_keychain.mjs';
|
|
16
|
+
import { writeDotenvMerge } from '../dotenv_min.mjs';
|
|
17
|
+
import { configPath } from '../lib/config.mjs';
|
|
18
|
+
|
|
19
|
+
// Where the claude-cli bearer comes from, in priority order. Pure + injectable.
|
|
20
|
+
export function resolveClaudeAuth({ env = process.env, home, readFileSync, keychainReader } = {}) {
|
|
21
|
+
if (env.CLAUDE_CODE_OAUTH_TOKEN) return { authenticated: true, source: 'env' };
|
|
22
|
+
if (env.ANTHROPIC_API_KEY) return { authenticated: true, source: 'apiKey' };
|
|
23
|
+
// Credential file (Linux / non-keychain) — keychain disabled here so we can
|
|
24
|
+
// distinguish the two sources for the status message.
|
|
25
|
+
const fileTok = _claudeCodeOAuthToken({ home, readFileSync, keychainReader: () => null });
|
|
26
|
+
if (fileTok) return { authenticated: true, source: 'file' };
|
|
27
|
+
const kcTok = (keychainReader || readClaudeKeychainToken)();
|
|
28
|
+
if (kcTok) return { authenticated: true, source: 'keychain' };
|
|
29
|
+
return { authenticated: false, source: 'none' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _hasClaudeBinary() {
|
|
33
|
+
try { execFileSync('claude', ['--version'], { stdio: 'ignore', timeout: 4000 }); return true; }
|
|
34
|
+
catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _runSetupToken() {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
try {
|
|
40
|
+
const p = spawn('claude', ['setup-token'], { stdio: 'inherit' });
|
|
41
|
+
p.on('exit', (code) => resolve(code ?? 1));
|
|
42
|
+
p.on('error', () => resolve(1));
|
|
43
|
+
} catch { resolve(1); }
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SOURCE_LABEL = {
|
|
48
|
+
env: 'CLAUDE_CODE_OAUTH_TOKEN env var',
|
|
49
|
+
apiKey: 'ANTHROPIC_API_KEY env var',
|
|
50
|
+
file: 'claude credential file (~/.claude)',
|
|
51
|
+
keychain: 'macOS Keychain (your `claude login`)',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export async function cmdLogin(positional = [], flags = {}, deps = {}) {
|
|
55
|
+
const log = deps.log || ((s) => process.stdout.write(s + '\n'));
|
|
56
|
+
const err = deps.err || ((s) => process.stderr.write(s + '\n'));
|
|
57
|
+
const provider = String(positional[0] || 'claude').toLowerCase();
|
|
58
|
+
if (provider !== 'claude' && provider !== 'claude-cli') {
|
|
59
|
+
err(`login: only 'claude' is supported right now (got "${provider}"). Other providers use their own CLI login.`);
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
const cfgDir = deps.cfgDir || path.dirname(configPath());
|
|
63
|
+
const resolve = deps.resolve || resolveClaudeAuth;
|
|
64
|
+
const writeEnv = deps.writeEnv || ((vars) => writeDotenvMerge(cfgDir, vars));
|
|
65
|
+
|
|
66
|
+
// Save a token the user already minted (e.g. via `claude setup-token`).
|
|
67
|
+
if (flags.token) {
|
|
68
|
+
const tok = String(flags.token).trim();
|
|
69
|
+
if (!tok) { err('login: --token was empty'); return 2; }
|
|
70
|
+
writeEnv({ CLAUDE_CODE_OAUTH_TOKEN: tok });
|
|
71
|
+
log('✓ saved CLAUDE_CODE_OAUTH_TOKEN to <config>/.env (0600). claude-cli model listing + recall will use it.');
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const status = resolve();
|
|
76
|
+
if (status.authenticated) {
|
|
77
|
+
log(`✓ claude-cli is already authenticated — via ${SOURCE_LABEL[status.source] || status.source}.`);
|
|
78
|
+
log(' Model listing, recall, and the keyless trainer will work. Nothing to do.');
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (flags.check) {
|
|
83
|
+
err('✗ no claude-cli credential found (no env token, ~/.claude credential file, or macOS Keychain login).');
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hasClaude = deps.hasClaudeBinary ? deps.hasClaudeBinary() : _hasClaudeBinary();
|
|
88
|
+
if (!hasClaude) {
|
|
89
|
+
err('No claude-cli credential found, and no `claude` binary on PATH.');
|
|
90
|
+
log('Install the Claude CLI and log in:');
|
|
91
|
+
log(' npm i -g @anthropic-ai/claude-code');
|
|
92
|
+
log(' claude login');
|
|
93
|
+
log('Then re-run `lazyclaw login`. (Headless/CI: set CLAUDE_CODE_OAUTH_TOKEN, or `lazyclaw login --token <token>`.)');
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
log('No usable credential found. Launching `claude setup-token` to mint a long-lived token…');
|
|
98
|
+
log('(A browser opens for OAuth; the token is printed when it finishes.)\n');
|
|
99
|
+
const code = deps.runSetupToken ? await deps.runSetupToken() : await _runSetupToken();
|
|
100
|
+
if (code !== 0) {
|
|
101
|
+
err('\n`claude setup-token` did not complete. Alternatively run `claude login`, then re-run `lazyclaw login`.');
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
log('\nDone. Copy the token printed above, then save it with EITHER:');
|
|
105
|
+
log(' lazyclaw login --token <paste-token> # writes <config>/.env (recommended)');
|
|
106
|
+
log(' export CLAUDE_CODE_OAUTH_TOKEN=<paste-token>');
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
package/commands/setup.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from '../lib/config.mjs';
|
|
13
13
|
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
14
14
|
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
15
|
+
import { runPermissionStep } from './setup_permission.mjs';
|
|
15
16
|
import {
|
|
16
17
|
_attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
|
|
17
18
|
_pickModelInteractive, _pickProviderInteractive, _printChatBanner,
|
|
@@ -94,10 +95,6 @@ export async function cmdOnboard(flags) {
|
|
|
94
95
|
console.log(JSON.stringify({ ok: true, written: configPath(), provider: next.provider, model: next.model || null, hasApiKey: !!next['api-key'] }));
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
98
|
export function cmdHelp(name) {
|
|
102
99
|
if (!name) {
|
|
103
100
|
process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
|
|
@@ -118,8 +115,6 @@ export function cmdHelp(name) {
|
|
|
118
115
|
process.stdout.write(detail + '\n');
|
|
119
116
|
}
|
|
120
117
|
|
|
121
|
-
|
|
122
|
-
|
|
123
118
|
export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
124
119
|
await ensureRegistry();
|
|
125
120
|
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
@@ -178,6 +173,10 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
|
178
173
|
// Context window — asked right after the model pick (optional; Enter keeps
|
|
179
174
|
// defaults). Not a numbered step: it's part of the core model setup.
|
|
180
175
|
await runContextStep({ prompt: _quickPrompt, colors });
|
|
176
|
+
|
|
177
|
+
// Tool-permission mode (cfg.chat.permissionMode) — part of the core chat
|
|
178
|
+
// setup. Extracted to ./setup_permission.mjs (file-size gate).
|
|
179
|
+
await runPermissionStep({ prompt: _quickPrompt, colors, cfg: cfgAfterOnboard });
|
|
181
180
|
}
|
|
182
181
|
|
|
183
182
|
// ── Step 2/7: Verify one clean chat works ───────────────────
|
|
@@ -703,4 +702,3 @@ export async function cmdLauncher() {
|
|
|
703
702
|
process.exit(0);
|
|
704
703
|
}
|
|
705
704
|
|
|
706
|
-
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// commands/setup_permission.mjs — the setup-wizard "tool permissions" step,
|
|
2
|
+
// extracted from commands/setup.mjs to stay under the file-size gate (D8) and
|
|
3
|
+
// to match the other modular steps (runContextStep / runChannelStep / …).
|
|
4
|
+
//
|
|
5
|
+
// Asks how the claude-cli agent should handle tool permissions and stores the
|
|
6
|
+
// choice at cfg.chat.permissionMode. lib/permission_mode.resolvePermissionMode
|
|
7
|
+
// reads it for every claude spawn; unset defaults to bypass (no nagging).
|
|
8
|
+
|
|
9
|
+
import { readConfig, writeConfig } from '../lib/config.mjs';
|
|
10
|
+
import { parsePermissionChoice } from '../lib/permission_mode.mjs';
|
|
11
|
+
|
|
12
|
+
export async function runPermissionStep({ prompt, colors, cfg } = {}) {
|
|
13
|
+
const accent = (colors && colors.accent) || ((s) => s);
|
|
14
|
+
const dim = (colors && colors.dim) || ((s) => s);
|
|
15
|
+
const ok = (colors && colors.ok) || ((s) => s);
|
|
16
|
+
const provider = String((cfg || readConfig()).provider || '');
|
|
17
|
+
// Only relevant to the claude-cli agent path (the one that spawns `claude`).
|
|
18
|
+
if (!/claude/.test(provider)) return;
|
|
19
|
+
|
|
20
|
+
process.stdout.write(` ${dim('Tool permissions — when the agent edits files or runs commands:')}\n`);
|
|
21
|
+
process.stdout.write(` ${dim(' bypass = never ask (fast) · ask = prompt each time · acceptEdits = auto-accept edits only · plan = read-only')}\n`);
|
|
22
|
+
const mode = parsePermissionChoice(await prompt(` ${accent('permission')} [bypass/ask/acceptEdits/plan] ${dim('(Enter = bypass)')}: `));
|
|
23
|
+
if (mode) {
|
|
24
|
+
const c = readConfig();
|
|
25
|
+
c.chat = { ...(c.chat || {}), permissionMode: mode };
|
|
26
|
+
writeConfig(c);
|
|
27
|
+
process.stdout.write(` ${ok('✓ permission mode:')} ${mode}\n\n`);
|
|
28
|
+
} else {
|
|
29
|
+
process.stdout.write(` ${dim('(unrecognised — kept the current setting)')}\n\n`);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/args.mjs
CHANGED
|
@@ -10,7 +10,7 @@ export const SUBCOMMANDS = [
|
|
|
10
10
|
'run', 'resume', 'inspect', 'clear', 'validate', 'graph',
|
|
11
11
|
'workflow',
|
|
12
12
|
'config', 'chat', 'agent',
|
|
13
|
-
'doctor', 'status', 'onboard',
|
|
13
|
+
'doctor', 'status', 'onboard', 'login',
|
|
14
14
|
'sessions', 'skills', 'providers',
|
|
15
15
|
'daemon', 'version', 'completion', 'help',
|
|
16
16
|
'export', 'import',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// lib/nl_config_command.mjs — turn a few high-signal natural-language requests
|
|
2
|
+
// into REAL lazyclaw config changes, so the chat model can't just reply
|
|
3
|
+
// "done" without anything happening (the user's bug: orchestrator on/off and
|
|
4
|
+
// planner/worker model swaps typed in chat were hallucinated as success).
|
|
5
|
+
//
|
|
6
|
+
// Deliberately CONSERVATIVE — it must never hijack a genuine chat turn. It only
|
|
7
|
+
// fires on short, imperative messages that clearly name a setting + an action,
|
|
8
|
+
// and bails on anything that looks like a question. Everything it doesn't catch
|
|
9
|
+
// falls through to the model (where the system-prompt honesty guard tells it to
|
|
10
|
+
// point at the real command instead of claiming success).
|
|
11
|
+
|
|
12
|
+
import { resolveModelAlias } from '../providers/claude_cli.mjs';
|
|
13
|
+
import { orchestratorGet, orchestratorSet, orchestratorEnable } from '../config_features.mjs';
|
|
14
|
+
|
|
15
|
+
// Appended to the chat system prompt (when one exists) so the model stops
|
|
16
|
+
// claiming it changed a setting it can't reach. The common orchestrator on/off
|
|
17
|
+
// and planner/worker swaps ARE applied for real by detectConfigCommand above;
|
|
18
|
+
// everything else must be redirected to a command, not faked.
|
|
19
|
+
export const LAZYCLAW_META_GUARD =
|
|
20
|
+
'You are running inside the lazyclaw CLI. You CANNOT change lazyclaw\'s own configuration ' +
|
|
21
|
+
'(provider, orchestrator planner/workers, models, agents, teams) from this chat — you have no tool for it. ' +
|
|
22
|
+
'If the user asks you to change a setting, do NOT claim you did it. Instead give the exact command: ' +
|
|
23
|
+
'`/orchestrator off | planner <provider:model> | worker add <provider:model>`, `lazyclaw agent add …`, ' +
|
|
24
|
+
'`lazyclaw team …`, or `lazyclaw config set <key> <value>`. ' +
|
|
25
|
+
'(Plain-language orchestrator on/off and planner/worker model changes are applied automatically — others are not.)';
|
|
26
|
+
|
|
27
|
+
const ORCH = /오케스트라|오케스트레이터|오케스트레이션|orchestrat/i;
|
|
28
|
+
const OFF = /\boff\b|\bdisable\b|끄|꺼|비활성/i;
|
|
29
|
+
const ON = /\bon\b|\benable\b|켜|활성/i;
|
|
30
|
+
const KO_MODEL = [['소넷', 'sonnet'], ['쏘넷', 'sonnet'], ['하이쿠', 'haiku'], ['오퍼스', 'opus'], ['오푸스', 'opus']];
|
|
31
|
+
const MODEL_TOKEN = /\b(opus|sonnet|haiku|gpt-?[0-9][\w.-]*|gemini-?[0-9][\w.-]*|o[0-9]|claude-[a-z0-9-]+)\b/i;
|
|
32
|
+
|
|
33
|
+
// Questions / explanations must pass straight through to the model.
|
|
34
|
+
function looksLikeQuestion(t) {
|
|
35
|
+
return /[??]/.test(t) ||
|
|
36
|
+
/(법|방법|어떻게|어케|뭐|무엇|설명|알려|차이|왜|how\b|what\b|why\b|explain|difference|\bvs\b)/i.test(t);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractModel(t) {
|
|
40
|
+
for (const [ko, en] of KO_MODEL) if (t.includes(ko)) return en;
|
|
41
|
+
const m = MODEL_TOKEN.exec(t);
|
|
42
|
+
if (!m) return null;
|
|
43
|
+
const tok = m[1].toLowerCase();
|
|
44
|
+
return resolveModelAlias(tok) || tok;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Returns an intent object or null. Intents:
|
|
48
|
+
// { kind: 'orchestrator', enable: boolean }
|
|
49
|
+
// { kind: 'planner'|'worker', model: string }
|
|
50
|
+
export function detectConfigCommand(text) {
|
|
51
|
+
const t = String(text || '').trim();
|
|
52
|
+
if (!t || t.length > 80) return null; // long → a real task, not a command
|
|
53
|
+
if (looksLikeQuestion(t)) return null;
|
|
54
|
+
|
|
55
|
+
if (ORCH.test(t)) {
|
|
56
|
+
if (OFF.test(t)) return { kind: 'orchestrator', enable: false }; // 비활성 → off (checked first)
|
|
57
|
+
if (ON.test(t)) return { kind: 'orchestrator', enable: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const wantsPlanner = /플래너|planner/i.test(t);
|
|
61
|
+
const wantsWorker = /워커|worker/i.test(t);
|
|
62
|
+
if (wantsPlanner && wantsWorker) return null; // ambiguous — do them in separate messages
|
|
63
|
+
if (wantsPlanner || wantsWorker) {
|
|
64
|
+
const model = extractModel(t);
|
|
65
|
+
if (model) return { kind: wantsPlanner ? 'planner' : 'worker', model };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const provOf = (spec) => {
|
|
71
|
+
const s = String(spec || '');
|
|
72
|
+
return s.includes(':') ? s.split(':')[0] : (s || 'claude-cli');
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Apply a detected intent for real. deps: { readConfig, writeConfig, ctxCfg? }.
|
|
76
|
+
// Returns a short confirmation string to show the user.
|
|
77
|
+
export function applyConfigCommand(intent, deps) {
|
|
78
|
+
const cfg = deps.readConfig();
|
|
79
|
+
const syncCtx = () => {
|
|
80
|
+
if (deps.ctxCfg) { deps.ctxCfg.provider = cfg.provider; deps.ctxCfg.orchestrator = cfg.orchestrator; }
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (intent.kind === 'orchestrator') {
|
|
84
|
+
orchestratorEnable(cfg, intent.enable);
|
|
85
|
+
deps.writeConfig(cfg); syncCtx();
|
|
86
|
+
return intent.enable
|
|
87
|
+
? '✓ Orchestrator ON — chats route through planner + workers. Set models with `/orchestrator planner <provider:model>` and `/orchestrator worker add <provider:model>`.'
|
|
88
|
+
: `✓ Orchestrator off — chats route to \`${cfg.provider}\`.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const o = orchestratorGet(cfg);
|
|
92
|
+
if (intent.kind === 'planner') {
|
|
93
|
+
const spec = `${provOf(o.planner)}:${intent.model}`;
|
|
94
|
+
orchestratorSet(cfg, { planner: spec });
|
|
95
|
+
deps.writeConfig(cfg); syncCtx();
|
|
96
|
+
return `✓ Planner → \`${spec}\`.`;
|
|
97
|
+
}
|
|
98
|
+
// worker — set the workers list to the chosen model (preserves the provider).
|
|
99
|
+
const spec = `${provOf((o.workers && o.workers[0]) || o.planner)}:${intent.model}`;
|
|
100
|
+
orchestratorSet(cfg, { workers: [spec] });
|
|
101
|
+
deps.writeConfig(cfg); syncCtx();
|
|
102
|
+
return `✓ Workers → [\`${spec}\`].`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// lib/permission_mode.mjs — the claude-cli permission mode lazyclaw passes to
|
|
2
|
+
// the spawned `claude` (`--permission-mode <mode>`).
|
|
3
|
+
//
|
|
4
|
+
// lazyclaw is an autonomous-agent CLI: the agentic / team path already runs the
|
|
5
|
+
// agent with bypassPermissions so it doesn't stop to ask before every tool. The
|
|
6
|
+
// interactive chat path historically passed no flag, so claude fell back to its
|
|
7
|
+
// own "default" mode and prompted on each tool — annoying when you just want it
|
|
8
|
+
// to run. This centralises the choice: `cfg.chat.permissionMode` (asked at
|
|
9
|
+
// setup) drives every claude spawn; unset defaults to bypassPermissions so the
|
|
10
|
+
// out-of-the-box experience doesn't nag. Cautious users pick 'default' (prompt
|
|
11
|
+
// each time), 'acceptEdits' (auto-accept edits, prompt for the rest), or 'plan'.
|
|
12
|
+
|
|
13
|
+
export const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_PERMISSION_MODE = 'bypassPermissions';
|
|
16
|
+
|
|
17
|
+
// Resolve the effective mode from config. An unset or unrecognised value falls
|
|
18
|
+
// back to the default rather than handing claude an invalid --permission-mode.
|
|
19
|
+
export function resolvePermissionMode(cfg) {
|
|
20
|
+
const m = cfg && cfg.chat && cfg.chat.permissionMode;
|
|
21
|
+
return PERMISSION_MODES.includes(m) ? m : DEFAULT_PERMISSION_MODE;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Map a setup-wizard answer to a permission mode. Empty (Enter) → the default
|
|
25
|
+
// (bypass). Returns null for an unrecognised answer so the caller can keep the
|
|
26
|
+
// current value rather than guess.
|
|
27
|
+
const CHOICE_MAP = {
|
|
28
|
+
'': DEFAULT_PERMISSION_MODE, bypass: 'bypassPermissions', b: 'bypassPermissions',
|
|
29
|
+
ask: 'default', default: 'default', d: 'default', prompt: 'default',
|
|
30
|
+
acceptedits: 'acceptEdits', accept: 'acceptEdits', edits: 'acceptEdits', e: 'acceptEdits',
|
|
31
|
+
plan: 'plan', p: 'plan',
|
|
32
|
+
};
|
|
33
|
+
export function parsePermissionChoice(answer) {
|
|
34
|
+
const k = String(answer ?? '').trim().toLowerCase();
|
|
35
|
+
return Object.prototype.hasOwnProperty.call(CHOICE_MAP, k) ? CHOICE_MAP[k] : null;
|
|
36
|
+
}
|
package/mas/index_db.mjs
CHANGED
|
@@ -42,6 +42,33 @@ function _logIndexFailure(configDir, scope, err) {
|
|
|
42
42
|
} catch { /* swallow — surface only via console.warn below */ }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// The native better-sqlite3 addon fails to load when node_modules was built
|
|
46
|
+
// against a different Node.js ABI than the one running lazyclaw (a Node switch
|
|
47
|
+
// via nvm/brew, or copied node_modules). Every index op then throws the same
|
|
48
|
+
// thing — so instead of dumping the raw stack on each write, recognise it and
|
|
49
|
+
// print ONE actionable hint, then stay quiet. Chat is unaffected; only recall /
|
|
50
|
+
// skill search degrade until the addon is rebuilt.
|
|
51
|
+
let _nativeHintShown = false;
|
|
52
|
+
export function _resetNativeHint() { _nativeHintShown = false; } // test seam
|
|
53
|
+
export function _isNativeAbiError(e) {
|
|
54
|
+
return /NODE_MODULE_VERSION|was compiled against a different Node|better_sqlite3\.node|invalid ELF header|dlopen\(/i
|
|
55
|
+
.test(String(e?.message || e || ''));
|
|
56
|
+
}
|
|
57
|
+
export function _warnIndexFailure(label, e) {
|
|
58
|
+
if (_isNativeAbiError(e)) {
|
|
59
|
+
if (_nativeHintShown) return;
|
|
60
|
+
_nativeHintShown = true;
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.warn(
|
|
63
|
+
'[index_db] recall index disabled — better-sqlite3 was built for a different Node.js version.\n' +
|
|
64
|
+
' Re-enable it once with: npm rebuild better-sqlite3 (in the lazyclaw install dir),\n' +
|
|
65
|
+
' or reinstall deps with the Node you run lazyclaw with. Chat is unaffected.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// eslint-disable-next-line no-console
|
|
69
|
+
console.warn(`[index_db] ${label}:`, e.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
function dbPath(configDir) {
|
|
46
73
|
return path.join(configDir, 'index.db');
|
|
47
74
|
}
|
|
@@ -180,8 +207,7 @@ export function indexSessionTurn(row, configDir = defaultConfigDir()) {
|
|
|
180
207
|
);
|
|
181
208
|
} catch (e) {
|
|
182
209
|
_logIndexFailure(configDir, 'sessions', e);
|
|
183
|
-
|
|
184
|
-
console.warn('[index_db] indexSessionTurn failed:', e.message);
|
|
210
|
+
_warnIndexFailure('indexSessionTurn failed', e);
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
213
|
|
|
@@ -196,8 +222,7 @@ export function indexSkill(row, configDir = defaultConfigDir()) {
|
|
|
196
222
|
);
|
|
197
223
|
} catch (e) {
|
|
198
224
|
_logIndexFailure(configDir, 'skills', e);
|
|
199
|
-
|
|
200
|
-
console.warn('[index_db] indexSkill failed:', e.message);
|
|
225
|
+
_warnIndexFailure('indexSkill failed', e);
|
|
201
226
|
}
|
|
202
227
|
}
|
|
203
228
|
|
|
@@ -212,8 +237,7 @@ export function deleteSkill(skillName, configDir = defaultConfigDir()) {
|
|
|
212
237
|
s.deleteSkill.run(String(skillName || ''));
|
|
213
238
|
} catch (e) {
|
|
214
239
|
_logIndexFailure(configDir, 'skills', e);
|
|
215
|
-
|
|
216
|
-
console.warn('[index_db] deleteSkill failed:', e.message);
|
|
240
|
+
_warnIndexFailure('deleteSkill failed', e);
|
|
217
241
|
}
|
|
218
242
|
}
|
|
219
243
|
|
|
@@ -228,8 +252,7 @@ export function indexTrajectory(row, configDir = defaultConfigDir()) {
|
|
|
228
252
|
);
|
|
229
253
|
} catch (e) {
|
|
230
254
|
_logIndexFailure(configDir, 'trajectories', e);
|
|
231
|
-
|
|
232
|
-
console.warn('[index_db] indexTrajectory failed:', e.message);
|
|
255
|
+
_warnIndexFailure('indexTrajectory failed', e);
|
|
233
256
|
}
|
|
234
257
|
}
|
|
235
258
|
|
|
@@ -243,8 +266,7 @@ export function indexMemory(row, configDir = defaultConfigDir()) {
|
|
|
243
266
|
);
|
|
244
267
|
} catch (e) {
|
|
245
268
|
_logIndexFailure(configDir, 'memories', e);
|
|
246
|
-
|
|
247
|
-
console.warn('[index_db] indexMemory failed:', e.message);
|
|
269
|
+
_warnIndexFailure('indexMemory failed', e);
|
|
248
270
|
}
|
|
249
271
|
}
|
|
250
272
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.0",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/providers/claude_cli.mjs
CHANGED
|
@@ -142,6 +142,10 @@ export function buildArgs(prompt, opts = {}) {
|
|
|
142
142
|
const maxTurns = opts.maxTurns == null ? 1 : opts.maxTurns;
|
|
143
143
|
if (maxTurns > 0) args.push('--max-turns', String(maxTurns));
|
|
144
144
|
args.push('--tools', opts.tools == null ? '' : String(opts.tools));
|
|
145
|
+
// Permission mode (e.g. bypassPermissions) so the agent doesn't stop to ask
|
|
146
|
+
// before every tool. Only emitted when set — the caller centralises the
|
|
147
|
+
// default via lib/permission_mode.resolvePermissionMode.
|
|
148
|
+
if (opts.permissionMode) args.push('--permission-mode', String(opts.permissionMode));
|
|
145
149
|
const modelAlias = resolveModelAlias(opts.model);
|
|
146
150
|
if (modelAlias) args.push('--model', modelAlias);
|
|
147
151
|
return args;
|
|
@@ -174,7 +178,7 @@ export const claudeCliProvider = {
|
|
|
174
178
|
const { getSession } = await import('./claude_cli_session.mjs');
|
|
175
179
|
const session = getSession(opts.sessionKey, {
|
|
176
180
|
bin, model: opts.model, cwd: opts.cwd, lean: opts.lean,
|
|
177
|
-
maxTurns: opts.maxTurns, tools: opts.tools,
|
|
181
|
+
maxTurns: opts.maxTurns, tools: opts.tools, permissionMode: opts.permissionMode,
|
|
178
182
|
system: opts.sessionSystem || opts.system || messages.find((m) => m.role === 'system')?.content,
|
|
179
183
|
});
|
|
180
184
|
let yielded = false;
|
|
@@ -52,6 +52,7 @@ class ClaudeSession {
|
|
|
52
52
|
const mt = o.maxTurns == null ? 1 : o.maxTurns;
|
|
53
53
|
if (mt > 0) args.push('--max-turns', String(mt));
|
|
54
54
|
args.push('--tools', o.tools == null ? '' : String(o.tools));
|
|
55
|
+
if (o.permissionMode) args.push('--permission-mode', String(o.permissionMode));
|
|
55
56
|
const model = resolveModelAlias(o.model);
|
|
56
57
|
if (model) args.push('--model', model);
|
|
57
58
|
if (o.system && String(o.system).trim()) args.push('--append-system-prompt', String(o.system));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// claude_keychain.mjs — read the Claude Code OAuth token from the macOS login
|
|
2
|
+
// Keychain.
|
|
3
|
+
//
|
|
4
|
+
// `claude login` on macOS stores its credential in the OS Keychain (there is no
|
|
5
|
+
// ~/.claude/.credentials.json file like on Linux), so lazyclaw's keyless paths
|
|
6
|
+
// (model listing, trainer detection) couldn't see an existing, working login
|
|
7
|
+
// and fell back to an "authenticate first" error. This reads that Keychain item
|
|
8
|
+
// — the same JSON blob the Linux file holds — and returns its accessToken.
|
|
9
|
+
//
|
|
10
|
+
// Read-only and macOS-only. The token is only ever sent to api.anthropic.com,
|
|
11
|
+
// never logged. The first read may surface a one-time Keychain access prompt;
|
|
12
|
+
// granting it ("Always Allow") makes subsequent reads silent.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
// macOS stores the Claude Code credential under this generic-password service.
|
|
17
|
+
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
18
|
+
|
|
19
|
+
// Pull the accessToken out of the credential blob (same shape as the Linux
|
|
20
|
+
// ~/.claude/.credentials.json file). Returns a non-empty string or null.
|
|
21
|
+
export function _extractAccessToken(raw) {
|
|
22
|
+
if (!raw || !String(raw).trim()) return null;
|
|
23
|
+
let j;
|
|
24
|
+
try { j = JSON.parse(raw); } catch { return null; }
|
|
25
|
+
const o = (j && j.claudeAiOauth) || j || {};
|
|
26
|
+
const tok = o.accessToken || o.access_token || (j && j.accessToken);
|
|
27
|
+
return (typeof tok === 'string' && tok) ? tok : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Read the Claude Code OAuth access token from the macOS Keychain. Returns the
|
|
31
|
+
// token string, or null on non-macOS, a missing item, denied access, or an
|
|
32
|
+
// unparseable blob. `exec`/`platform` are injectable for tests.
|
|
33
|
+
export function readClaudeKeychainToken({ platform = process.platform, exec } = {}) {
|
|
34
|
+
if (platform !== 'darwin') return null;
|
|
35
|
+
const run = exec || ((args) => execFileSync('security', args, { encoding: 'utf8', timeout: 8000 }));
|
|
36
|
+
let raw;
|
|
37
|
+
try { raw = run(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']); }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
return _extractAccessToken(raw);
|
|
40
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
+
import { readClaudeKeychainToken } from './claude_keychain.mjs';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Whether a provider exposes a model catalogue we can live-fetch. True for
|
|
@@ -146,7 +147,7 @@ export function modelCatalogueFor({ cfg, registryMod, resolveAuthKey, providerId
|
|
|
146
147
|
// (no file), so this returns null there — the caller falls through to its
|
|
147
148
|
// honest "set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN" error. Read-only;
|
|
148
149
|
// the token is only ever sent to api.anthropic.com, never logged.
|
|
149
|
-
export function _claudeCodeOAuthToken({ home, readFileSync } = {}) {
|
|
150
|
+
export function _claudeCodeOAuthToken({ home, readFileSync, keychainReader } = {}) {
|
|
150
151
|
const h = home || os.homedir();
|
|
151
152
|
const read = readFileSync || fs.readFileSync;
|
|
152
153
|
for (const rel of ['.claude/.credentials.json', '.config/claude/.credentials.json']) {
|
|
@@ -156,7 +157,9 @@ export function _claudeCodeOAuthToken({ home, readFileSync } = {}) {
|
|
|
156
157
|
if (typeof tok === 'string' && tok) return tok;
|
|
157
158
|
} catch { /* missing / unreadable / not JSON — try the next location */ }
|
|
158
159
|
}
|
|
159
|
-
|
|
160
|
+
// macOS keeps the login in the Keychain (no file) — read it there.
|
|
161
|
+
const fromKeychain = (keychainReader || readClaudeKeychainToken)();
|
|
162
|
+
return fromKeychain || null;
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
// A plain API key stored by `codex login --api-key` in ~/.codex/auth.json.
|
package/tui/run_turn.mjs
CHANGED
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
import { Chalk } from 'chalk';
|
|
29
29
|
import { chatAgenticGet, chatPlanModeGet, effectiveChatTools } from '../config_features.mjs';
|
|
30
30
|
import { defaultSandboxSpec } from '../sandbox/index.mjs';
|
|
31
|
+
import { resolvePermissionMode } from '../lib/permission_mode.mjs';
|
|
32
|
+
import { detectConfigCommand, applyConfigCommand } from '../lib/nl_config_command.mjs';
|
|
33
|
+
import { readConfig as _readCfg, writeConfig as _writeCfg } from '../lib/config.mjs';
|
|
31
34
|
|
|
32
35
|
// Force ANSI on these turn-status markers regardless of stdout TTY detection:
|
|
33
36
|
// the Ink path routes them through React state (Ink preserves embedded SGR
|
|
@@ -206,6 +209,25 @@ async function _runAgenticTurn({ ctx, messages, sysMsg, activeProvName, activeMo
|
|
|
206
209
|
export function makeRunTurn({ ctx, writeFn }) {
|
|
207
210
|
return async function runTurn(text, signal) {
|
|
208
211
|
if (signal?.aborted) return;
|
|
212
|
+
|
|
213
|
+
// Natural-language config commands (orchestrator on/off, planner/worker
|
|
214
|
+
// model) are applied for REAL here — otherwise the model just replies
|
|
215
|
+
// "done" while nothing changes (the reported bug). Conservative matcher;
|
|
216
|
+
// anything it doesn't catch falls through to the model below.
|
|
217
|
+
const _cmd = detectConfigCommand(text);
|
|
218
|
+
if (_cmd) {
|
|
219
|
+
const _msgs = ctx.getMessages();
|
|
220
|
+
_msgs.push({ role: 'user', content: text });
|
|
221
|
+
ctx.persistTurn('user', text);
|
|
222
|
+
let reply;
|
|
223
|
+
try { reply = applyConfigCommand(_cmd, { readConfig: _readCfg, writeConfig: _writeCfg, ctxCfg: ctx.cfg }); }
|
|
224
|
+
catch (e) { reply = `⚠ couldn't apply that change: ${e?.message || e}`; }
|
|
225
|
+
try { writeFn(reply + '\n'); } catch { /* sink best-effort */ }
|
|
226
|
+
_msgs.push({ role: 'assistant', content: reply });
|
|
227
|
+
ctx.persistTurn('assistant', reply);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
209
231
|
const messages = ctx.getMessages();
|
|
210
232
|
messages.push({ role: 'user', content: text });
|
|
211
233
|
try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
|
|
@@ -296,6 +318,9 @@ export function makeRunTurn({ ctx, writeFn }) {
|
|
|
296
318
|
// a one-shot spawn on any session failure, and the session respawns if
|
|
297
319
|
// the system prompt changes (plan-mode toggle) so it can't go stale.
|
|
298
320
|
persistent: ctx.cfg?.chat?.persistentSession !== false,
|
|
321
|
+
// claude-cli permission mode (cfg.chat.permissionMode, asked at setup;
|
|
322
|
+
// defaults to bypassPermissions). claude-cli reads it; others ignore it.
|
|
323
|
+
permissionMode: resolvePermissionMode(ctx.cfg),
|
|
299
324
|
sessionKey: (ctx.getSessionId && ctx.getSessionId()) || ctx.syntheticChatSessionId,
|
|
300
325
|
...(sysMsg ? { sessionSystem: sysMsg.content } : {}),
|
|
301
326
|
...(maxTokens !== undefined ? { maxTokens } : {}),
|