lazyclaw 6.5.0 → 6.7.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/agents.mjs +68 -1
- package/cli.mjs +5 -0
- package/commands/agents_registry.mjs +22 -0
- package/commands/login.mjs +108 -0
- package/daemon/route_table.mjs +1 -0
- package/daemon/routes/meta.mjs +29 -0
- package/lib/args.mjs +2 -2
- package/mas/index_db.mjs +32 -10
- package/package.json +1 -1
- package/providers/claude_keychain.mjs +40 -0
- package/providers/model_catalogue.mjs +5 -2
- package/web/dashboard.js +7 -1
package/agents.mjs
CHANGED
|
@@ -90,6 +90,16 @@ function defaultShape(name) {
|
|
|
90
90
|
tools: [...DEFAULT_TOOLS],
|
|
91
91
|
tags: [],
|
|
92
92
|
iconEmoji: '',
|
|
93
|
+
// Explicit Team Live sprite choice: an integer 1..20 picks one of the 20
|
|
94
|
+
// built-in pixel-art avatars (web/avatars/NN.png); null lets the dashboard
|
|
95
|
+
// keep inferring one from the agent's name/role/tags. (dashboard.js
|
|
96
|
+
// avatarIndexFor already honours rec.avatar — this is the registry side.)
|
|
97
|
+
avatar: null,
|
|
98
|
+
// Optional custom character image: a ready-to-use <img src> — either a
|
|
99
|
+
// remote http(s) URL or a daemon-served '/agent-avatars/<file>' path for a
|
|
100
|
+
// photo the user supplied (copied under <configDir>/agent-avatars/). null =
|
|
101
|
+
// none. Takes precedence over `avatar` and the keyword inference.
|
|
102
|
+
avatarImage: null,
|
|
93
103
|
// Optional parent agent (hierarchy). '' = top-level. A team's org tree is
|
|
94
104
|
// derived from members' manager links (see teams.teamTree). Validated to
|
|
95
105
|
// reference a registered agent and to never form a cycle.
|
|
@@ -124,6 +134,18 @@ function writeAtomic(filePath, obj) {
|
|
|
124
134
|
const VALID_MEMORY_WRITE = ['auto', 'manual', 'off'];
|
|
125
135
|
const VALID_SKILL_WRITE = ['auto', 'manual', 'off'];
|
|
126
136
|
|
|
137
|
+
// Normalise an explicit avatar choice. null/''/undefined → null (keep the
|
|
138
|
+
// dashboard's keyword inference). A value that parses to an integer 1..20 picks
|
|
139
|
+
// that built-in sprite. Anything else (0, 21, fractional, non-numeric) throws.
|
|
140
|
+
function validateAvatar(v) {
|
|
141
|
+
if (v === undefined || v === null || v === '') return null;
|
|
142
|
+
const n = typeof v === 'number' ? v : Number(String(v).trim());
|
|
143
|
+
if (!Number.isInteger(n) || n < 1 || n > 20) {
|
|
144
|
+
throw new AgentError('avatar must be an integer 1..20 (or null to clear)', 'AGENT_BAD_AVATAR');
|
|
145
|
+
}
|
|
146
|
+
return n;
|
|
147
|
+
}
|
|
148
|
+
|
|
127
149
|
// Validate a proposed `manager` (parent agent) for `name`: it must reference a
|
|
128
150
|
// registered agent, may not be the agent itself, and may not close a cycle
|
|
129
151
|
// (i.e. `name` must not already be an ancestor of the proposed manager).
|
|
@@ -151,7 +173,7 @@ function validateManager(name, manager, configDir) {
|
|
|
151
173
|
return mgr;
|
|
152
174
|
}
|
|
153
175
|
|
|
154
|
-
export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars, skillWrite, manager } = {}, configDir = defaultConfigDir()) {
|
|
176
|
+
export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', avatar, memoryWrite, memoryMaxChars, skillWrite, manager } = {}, configDir = defaultConfigDir()) {
|
|
155
177
|
ensureValidName(name);
|
|
156
178
|
const p = agentPath(name, configDir);
|
|
157
179
|
if (fs.existsSync(p)) {
|
|
@@ -175,6 +197,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
|
|
|
175
197
|
tools: toolsClean,
|
|
176
198
|
tags: Array.isArray(tags) ? tags : [],
|
|
177
199
|
iconEmoji: String(iconEmoji || ''),
|
|
200
|
+
avatar: validateAvatar(avatar),
|
|
178
201
|
memoryWrite: mw,
|
|
179
202
|
memoryMaxChars: Number.isFinite(+memoryMaxChars) && +memoryMaxChars > 0 ? +memoryMaxChars : 12 * 1024,
|
|
180
203
|
skillWrite: sw,
|
|
@@ -220,6 +243,12 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
|
|
|
220
243
|
if (patch.skillWrite !== undefined && !VALID_SKILL_WRITE.includes(patch.skillWrite)) {
|
|
221
244
|
throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
|
|
222
245
|
}
|
|
246
|
+
if (patch.avatar !== undefined) {
|
|
247
|
+
next.avatar = validateAvatar(patch.avatar);
|
|
248
|
+
}
|
|
249
|
+
if (patch.avatarImage !== undefined) {
|
|
250
|
+
next.avatarImage = (patch.avatarImage == null || patch.avatarImage === '') ? null : String(patch.avatarImage);
|
|
251
|
+
}
|
|
223
252
|
if (patch.manager !== undefined) {
|
|
224
253
|
next.manager = validateManager(name, patch.manager, configDir);
|
|
225
254
|
}
|
|
@@ -227,6 +256,44 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
|
|
|
227
256
|
return next;
|
|
228
257
|
}
|
|
229
258
|
|
|
259
|
+
// Supported custom-avatar image types → their served content-type. svg is
|
|
260
|
+
// deliberately excluded (inline-script XSS risk in an <img>/object context).
|
|
261
|
+
export const AVATAR_IMAGE_TYPES = {
|
|
262
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
263
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Point an agent at a custom character image. `src` is either a remote http(s)
|
|
267
|
+
// URL (stored verbatim) or a path to a local image file (copied into
|
|
268
|
+
// <configDir>/agent-avatars/<name><ext> and stored as a daemon-served
|
|
269
|
+
// '/agent-avatars/<name><ext>' path). Returns the patched record. The image
|
|
270
|
+
// takes precedence over the numeric sprite + keyword inference in the dashboard.
|
|
271
|
+
export function setAgentAvatarImage(name, src, configDir = defaultConfigDir()) {
|
|
272
|
+
if (!getAgent(name, configDir)) throw new AgentError(`no agent "${name}"`, 'AGENT_NO_AGENT');
|
|
273
|
+
const s = String(src ?? '').trim();
|
|
274
|
+
if (!s) throw new AgentError('avatar image source required (a file path or http(s) URL)', 'AGENT_BAD_AVATAR_IMAGE');
|
|
275
|
+
if (/^https?:\/\//i.test(s)) {
|
|
276
|
+
return patchAgent(name, { avatarImage: s }, configDir);
|
|
277
|
+
}
|
|
278
|
+
const ext = path.extname(s).toLowerCase();
|
|
279
|
+
if (!AVATAR_IMAGE_TYPES[ext]) {
|
|
280
|
+
throw new AgentError(`unsupported image type "${ext || '(none)'}" — use png/jpg/jpeg/gif/webp or an http(s) URL`, 'AGENT_BAD_AVATAR_IMAGE');
|
|
281
|
+
}
|
|
282
|
+
if (!fs.existsSync(s) || !fs.statSync(s).isFile()) {
|
|
283
|
+
throw new AgentError(`no such image file: ${s}`, 'AGENT_BAD_AVATAR_IMAGE');
|
|
284
|
+
}
|
|
285
|
+
const destDir = path.join(configDir, 'agent-avatars');
|
|
286
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
287
|
+
// Drop any previously stored image for this agent (it may have a different
|
|
288
|
+
// extension) so a re-point doesn't leave a stale file shadowing the new one.
|
|
289
|
+
for (const e of Object.keys(AVATAR_IMAGE_TYPES)) {
|
|
290
|
+
const old = path.join(destDir, `${name}${e}`);
|
|
291
|
+
if (fs.existsSync(old)) fs.unlinkSync(old);
|
|
292
|
+
}
|
|
293
|
+
fs.copyFileSync(s, path.join(destDir, `${name}${ext}`));
|
|
294
|
+
return patchAgent(name, { avatarImage: `/agent-avatars/${name}${ext}` }, configDir);
|
|
295
|
+
}
|
|
296
|
+
|
|
230
297
|
export function removeAgent(name, configDir = defaultConfigDir()) {
|
|
231
298
|
const p = agentPath(name, configDir);
|
|
232
299
|
if (!fs.existsSync(p)) {
|
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;
|
|
@@ -35,6 +35,7 @@ export async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
35
35
|
model: flags.model || '',
|
|
36
36
|
tools: tools === null ? undefined : tools,
|
|
37
37
|
tags: agentsMod.parseToolsFlag(flags.tags) || [],
|
|
38
|
+
avatar: flags.avatar,
|
|
38
39
|
skillWrite: flags['skill-write'],
|
|
39
40
|
manager: flags.manager,
|
|
40
41
|
}, cfgDir);
|
|
@@ -64,6 +65,7 @@ export async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
64
65
|
if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
|
|
65
66
|
if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
|
|
66
67
|
if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
|
|
68
|
+
if (flags.avatar !== undefined) patch.avatar = /^(none|clear|off)$/i.test(String(flags.avatar)) ? null : flags.avatar;
|
|
67
69
|
if (Object.keys(patch).length === 0) {
|
|
68
70
|
console.error('agent edit: no fields to update');
|
|
69
71
|
process.exit(2);
|
|
@@ -72,6 +74,26 @@ export async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
72
74
|
catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
|
|
73
75
|
return;
|
|
74
76
|
}
|
|
77
|
+
case 'set-avatar': {
|
|
78
|
+
const val = positional[1];
|
|
79
|
+
if (!name || val === undefined) {
|
|
80
|
+
console.error('Usage: lazyclaw agent set-avatar <name> <1-20 | none | <image-file> | http(s)://url>\n 1-20 pick a built-in pixel-art sprite\n none keep the role-inferred default\n file/url use a custom character photo');
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
const v = String(val).trim();
|
|
84
|
+
try {
|
|
85
|
+
if (/^(none|clear|off)$/i.test(v)) {
|
|
86
|
+
emitJson(agentsMod.patchAgent(name, { avatar: null, avatarImage: null }, cfgDir));
|
|
87
|
+
} else if (/^\d+$/.test(v)) {
|
|
88
|
+
// a built-in sprite index — clear any custom image so it shows through
|
|
89
|
+
emitJson(agentsMod.patchAgent(name, { avatar: v, avatarImage: null }, cfgDir));
|
|
90
|
+
} else {
|
|
91
|
+
// a local image file or a remote URL
|
|
92
|
+
emitJson(agentsMod.setAgentAvatarImage(name, val, cfgDir));
|
|
93
|
+
}
|
|
94
|
+
} catch (err) { console.error(`agent set-avatar: ${err?.message || err}`); process.exit(2); }
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
75
97
|
case 'remove':
|
|
76
98
|
case 'rm':
|
|
77
99
|
case 'delete': {
|
|
@@ -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/daemon/route_table.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export const ROUTES = [
|
|
|
28
28
|
{ m: (c) => c.route === 'GET /dashboard.css', h: meta.dashboardCss },
|
|
29
29
|
{ m: (c) => c.route === 'GET /dashboard.js', h: meta.dashboardJs },
|
|
30
30
|
{ m: (c) => c.req.method === 'GET' && /^\/avatars\/\d{2}\.png$/.test(c.path || ''), h: meta.avatar },
|
|
31
|
+
{ m: (c) => c.req.method === 'GET' && /^\/agent-avatars\/[A-Za-z0-9_-]+\.[a-z]+$/.test(c.path || ''), h: meta.agentAvatar },
|
|
31
32
|
{ m: (c) => c.route === 'GET /version', h: meta.version },
|
|
32
33
|
{ m: (c) => c.route === 'POST /exec/request', h: conversation.execRequest },
|
|
33
34
|
{ m: (c) => c.route === 'GET /health' || c.route === 'GET /healthz', h: meta.health },
|
package/daemon/routes/meta.mjs
CHANGED
|
@@ -65,6 +65,35 @@ export async function dashboardJs(c) {
|
|
|
65
65
|
return serveWebFile(c, 'dashboard.js', 'text/javascript; charset=utf-8');
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Serve a custom agent character image (web app: <img src="/agent-avatars/NN">).
|
|
69
|
+
// These are user-supplied photos copied under <configDir>/agent-avatars/ by
|
|
70
|
+
// `lazyclaw agent set-avatar`. Served from the config dir (NOT the package web/
|
|
71
|
+
// dir) since they're per-install user data. The filename is constrained to
|
|
72
|
+
// `<word>.<ext>` so no path traversal escapes the agent-avatars directory.
|
|
73
|
+
const _AGENT_AVATAR_CT = {
|
|
74
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
75
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
76
|
+
};
|
|
77
|
+
export async function agentAvatar(c) {
|
|
78
|
+
const { res } = c;
|
|
79
|
+
const m = /^\/agent-avatars\/([A-Za-z0-9_-]+\.[a-z]+)$/.exec(c.path || '');
|
|
80
|
+
const file = m && m[1];
|
|
81
|
+
const ct = file && _AGENT_AVATAR_CT[nodePath.extname(file).toLowerCase()];
|
|
82
|
+
const base = c.gwConfigDir;
|
|
83
|
+
if (!file || file.includes('..') || !ct || !base) {
|
|
84
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
85
|
+
return res.end('not found\n');
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const body = _readAssetCached(nodePath.join(base, 'agent-avatars', file));
|
|
89
|
+
res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-cache' });
|
|
90
|
+
return res.end(body);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
93
|
+
return res.end(`not found: ${file} (${e?.message || e})\n`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
export async function dashboard(c) {
|
|
69
98
|
const { res } = c;
|
|
70
99
|
// Serve the lazyclaw-only web dashboard (a single static
|
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',
|
|
@@ -277,6 +277,6 @@ _lazyclaw "$@"
|
|
|
277
277
|
// AGENT_REG_SUBS to decide whether a bare `agent <sub>` routes to the agent
|
|
278
278
|
// registry vs a one-shot agent run. TEAM_SUBS/TASK_SUBS mirror their command's
|
|
279
279
|
// subcommands for the same disambiguation pattern.
|
|
280
|
-
export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
|
|
280
|
+
export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth', 'set-avatar']);
|
|
281
281
|
export const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
|
|
282
282
|
export const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
|
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.7.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",
|
|
@@ -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/web/dashboard.js
CHANGED
|
@@ -1409,7 +1409,13 @@
|
|
|
1409
1409
|
}
|
|
1410
1410
|
return 1; // generic PM look
|
|
1411
1411
|
}
|
|
1412
|
-
|
|
1412
|
+
// A user-supplied custom image (set via `lazyclaw agent set-avatar`) wins
|
|
1413
|
+
// over the picked/inferred built-in sprite. rec.avatarImage is already a
|
|
1414
|
+
// ready-to-use src (a remote URL or a daemon-served /agent-avatars/ path).
|
|
1415
|
+
function avatarSrc(rec) {
|
|
1416
|
+
if (rec && rec.avatarImage) return rec.avatarImage;
|
|
1417
|
+
return `/avatars/${String(avatarIndexFor(rec)).padStart(2, '0')}.png`;
|
|
1418
|
+
}
|
|
1413
1419
|
// Build the { name, children[] } tree rooted at the lead (mirrors teamTree).
|
|
1414
1420
|
function buildTeamTree(team, byId) {
|
|
1415
1421
|
const lead = team.lead;
|