total-recall-brain 3.31.2 → 3.32.1
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/bin/total-recall.mjs +20 -2
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
- package/src/cli/api.mjs +133 -0
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/brief.mjs +195 -0
- package/src/cli/command.mjs +269 -5
- package/src/cli/config.mjs +25 -7
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/group.mjs +151 -0
- package/src/cli/init.mjs +54 -0
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/project-id.mjs +43 -0
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/secret.mjs +36 -3
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/core/api-routes.mjs +99 -0
- package/src/core/app-deploy/adapter-contract.mjs +176 -0
- package/src/core/app-deploy/design-tokens.mjs +148 -108
- package/src/core/app-deploy/plan.mjs +2 -2
- package/src/core/app-deploy/resolve.mjs +30 -2
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
- package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
- package/src/core/app-deploy/runtime-cli.mjs +254 -0
- package/src/core/app-deploy/ui-elements.mjs +473 -0
- package/src/core/app-deploy/verify.mjs +18 -0
- package/src/core/command-surface.mjs +129 -0
- package/src/core/daemon-control.mjs +31 -12
- package/src/core/daemon-loop.mjs +14 -7
- package/src/core/plugin-loader.mjs +12 -12
- package/src/core/project-id.mjs +72 -0
- package/src/core/provider-catalog.mjs +1 -1
- package/src/core/schema.mjs +72 -13
- package/src/core/secrets-env-export.mjs +3 -4
- package/src/core/secrets-store.mjs +30 -29
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/source-adapters.mjs +2 -2
- package/src/core/surface.mjs +109 -16
- package/src/server/routes/help.mjs +1 -1
- package/src/server/routes/system.mjs +1 -1
- package/plugins/code-quality/README.md +0 -25
- package/plugins/code-quality/cli.mjs +0 -25
- package/plugins/code-quality/plugin.json +0 -17
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* total-recall brief
|
|
3
|
+
* Run first in every session (the global /start does): read-only brief of time, repo state, project id/groups, rules, commands, secrets health (counts only), tasks, trackers, and this repo's own start skill to follow next.
|
|
4
|
+
* Built-in (ships with the package); was a machine-local composable command.
|
|
5
|
+
*/
|
|
6
|
+
export default async function (args = []) {
|
|
7
|
+
// total-recall brief [--json] [--mesh]
|
|
8
|
+
// Session-start brief for the repo you are in: time, repo state, project identity and groups, rules, commands, secrets health (counts only), tasks, trackers, and this repo's own start skill.
|
|
9
|
+
const { spawn, spawnSync } = await import('node:child_process');
|
|
10
|
+
const fs = await import('node:fs');
|
|
11
|
+
const path = await import('node:path');
|
|
12
|
+
|
|
13
|
+
const argl = Array.isArray(args) ? args : [];
|
|
14
|
+
const asJson = argl.includes('--json');
|
|
15
|
+
const withMesh = argl.includes('--mesh');
|
|
16
|
+
if (argl.includes('--help') || argl.includes('-h')) {
|
|
17
|
+
console.log(`Usage: total-recall brief [--json] [--mesh]
|
|
18
|
+
|
|
19
|
+
Read-only brief for the start of a session in the current repo. Composes:
|
|
20
|
+
project-id, group, command list, secret catalog/rotation-due/shared/tracking-health
|
|
21
|
+
(counts only, never values), task list, mesh status (--mesh adds mesh ping), git,
|
|
22
|
+
the compiled rules line, docs/projects trackers, openwiki, and the repo's own
|
|
23
|
+
.agent/skills/start/SKILL.md, which you follow next when it exists.`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Run a Total Recall verb (async, bounded). Output is parsed for counts only.
|
|
28
|
+
const tr = (verbArgs, ms = 45000) => new Promise((resolve) => {
|
|
29
|
+
const child = spawn(process.execPath, [process.argv[1], ...verbArgs], { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
30
|
+
let out = ''; let err = '';
|
|
31
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
32
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
33
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve({ code: null, out, err, timedOut: true }); }, ms);
|
|
34
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err, timedOut: false }); });
|
|
35
|
+
});
|
|
36
|
+
const jsonLine = (text) => {
|
|
37
|
+
for (const line of text.split('\n').reverse()) {
|
|
38
|
+
const t = line.trim();
|
|
39
|
+
if ((t.startsWith('{') || t.startsWith('[')) && !t.includes('"subsystem"')) { try { return JSON.parse(t); } catch { /* next */ } }
|
|
40
|
+
}
|
|
41
|
+
try { return JSON.parse(text.slice(text.indexOf('{'))); } catch { return null; }
|
|
42
|
+
};
|
|
43
|
+
const num = (text, key) => { const m = text.match(new RegExp(`${key}=(\\d+)`)); return m ? Number(m[1]) : null; };
|
|
44
|
+
const git = (...a) => { const r = spawnSync('git', a, { cwd: process.cwd(), encoding: 'utf8' }); return r.status === 0 ? r.stdout.replace(/\r?\n$/, '') : null; };
|
|
45
|
+
|
|
46
|
+
const now = new Date();
|
|
47
|
+
const root = git('rev-parse', '--show-toplevel') || process.cwd();
|
|
48
|
+
const rel = (p) => path.relative(root, p) || '.';
|
|
49
|
+
|
|
50
|
+
const [pid, groupsRes, cmds, catalog, due, shared, tracking, tasks, secretList, mesh, ping] = await Promise.all([
|
|
51
|
+
tr(['project-id', '--json']),
|
|
52
|
+
tr(['group', 'list', '--json']),
|
|
53
|
+
tr(['command', 'list', '--json']),
|
|
54
|
+
tr(['secret', 'catalog']),
|
|
55
|
+
tr(['secret', 'rotation-due']),
|
|
56
|
+
tr(['secret', 'shared']),
|
|
57
|
+
tr(['secret', 'tracking-health']),
|
|
58
|
+
tr(['task', 'list']),
|
|
59
|
+
tr(['secret', 'list']),
|
|
60
|
+
tr(['mesh', 'status', '--json']),
|
|
61
|
+
withMesh ? tr(['mesh', 'ping', '--json'], 60000) : Promise.resolve(null),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// Project identity + the groups that contain it (recursively).
|
|
65
|
+
const project = jsonLine(pid.out);
|
|
66
|
+
const groups = [];
|
|
67
|
+
const groupList = jsonLine(groupsRes.out) || [];
|
|
68
|
+
if (project?.project_id && Array.isArray(groupList) && groupList.length) {
|
|
69
|
+
const members = await Promise.all(groupList.map((g) => tr(['group', 'members', g.id, '--recursive', '--json'])));
|
|
70
|
+
groupList.forEach((g, i) => {
|
|
71
|
+
const list = jsonLine(members[i].out) || [];
|
|
72
|
+
if (Array.isArray(list) && list.some((m) => m.id === project.project_id)) groups.push({ id: g.id, name: g.name });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Repo state.
|
|
77
|
+
const status = git('status', '--porcelain');
|
|
78
|
+
const repo = {
|
|
79
|
+
root,
|
|
80
|
+
branch: git('rev-parse', '--abbrev-ref', 'HEAD'),
|
|
81
|
+
dirty: status === null ? null : status.split('\n').filter(Boolean).length,
|
|
82
|
+
last_commit: git('log', '-1', '--format=%h %s (%cr)'),
|
|
83
|
+
upstream: git('rev-list', '--left-right', '--count', '@{upstream}...HEAD'),
|
|
84
|
+
};
|
|
85
|
+
if (repo.upstream) { const [behind, ahead] = repo.upstream.split(/\s+/).map(Number); repo.upstream = { ahead, behind, note: 'as of last fetch' }; }
|
|
86
|
+
|
|
87
|
+
// Compiled rules (whatever the IDE surface says is active).
|
|
88
|
+
let rules = null;
|
|
89
|
+
for (const f of ['CLAUDE.md', 'AGENTS.md', 'INSTRUCTIONS.md']) {
|
|
90
|
+
const p = path.join(root, f);
|
|
91
|
+
if (!fs.existsSync(p)) continue;
|
|
92
|
+
const m = fs.readFileSync(p, 'utf8').match(/Active Rules: (\d+) invariants, (\d+) preferences, (\d+) corrections/);
|
|
93
|
+
if (m) { rules = { file: f, invariants: +m[1], preferences: +m[2], corrections: +m[3], compiled: fs.statSync(p).mtime.toISOString() }; break; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Trackers, skills, openwiki, repo start skill.
|
|
97
|
+
const dirList = (p) => { try { return fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); } catch { return []; } };
|
|
98
|
+
const trackers = { in_progress: dirList(path.join(root, 'docs/projects/in-progress')), planned: dirList(path.join(root, 'docs/projects/planned')) };
|
|
99
|
+
const skillsDir = path.join(root, '.agent/skills');
|
|
100
|
+
const skills = dirList(skillsDir).filter((s) => fs.existsSync(path.join(skillsDir, s, 'SKILL.md')));
|
|
101
|
+
// A repo start skill is the repo's own; a deployed copy of the global one (repo_scoped: false) is not.
|
|
102
|
+
const startSkill = ['.agent/skills/start/SKILL.md', '.claude/skills/start/SKILL.md'].map((p) => path.join(root, p))
|
|
103
|
+
.find((p) => { try { return !/^repo_scoped:\s*false/m.test(fs.readFileSync(p, 'utf8').split('\n---')[0]); } catch { return false; } }) || null;
|
|
104
|
+
// OpenWiki: the repo's own wiki (openwiki/) first, then the brain copy.
|
|
105
|
+
const openwiki = ['openwiki', '.agent/skills/total-recall/openwiki', '.agent/openwiki'].map((d) => path.join(root, d)).filter((d) => fs.existsSync(d)).map((d) => {
|
|
106
|
+
const pages = fs.readdirSync(d).filter((f) => f.endsWith('.md'));
|
|
107
|
+
const entry = ['quickstart.md', 'README.md'].find((f) => pages.includes(f)) || pages[0] || null;
|
|
108
|
+
const newest = pages.reduce((t, f) => Math.max(t, fs.statSync(path.join(d, f)).mtimeMs), 0);
|
|
109
|
+
return { dir: rel(d), pages: pages.length, entry: entry ? rel(path.join(d, entry)) : null, updated: newest ? new Date(newest).toISOString() : null };
|
|
110
|
+
}).filter((w) => w.pages > 0);
|
|
111
|
+
|
|
112
|
+
// Secrets: counts and exit codes only — never values or masked fragments.
|
|
113
|
+
const secrets = {
|
|
114
|
+
keys: num(catalog.out, 'keys'),
|
|
115
|
+
providers: num(catalog.out, 'providers'),
|
|
116
|
+
rotation_due: /No secrets overdue/.test(due.out) ? 0 : (due.code === 0 ? num(catalog.out, 'rotate_due') : (num(due.out, 'due') ?? 'check')),
|
|
117
|
+
shared_values: shared.code === 0 ? 0 : (num(shared.out, 'error_groups') ?? num(shared.out, 'groups') ?? 'check'),
|
|
118
|
+
untracked: tracking.code === 0 ? 0 : (num(tracking.out, 'errors') ?? 'check'),
|
|
119
|
+
};
|
|
120
|
+
const cmdList = jsonLine(cmds.out) || [];
|
|
121
|
+
const taskLines = tasks.out.split('\n');
|
|
122
|
+
const taskCounts = { pending: taskLines.filter((l) => /^\s*\[pending\]/.test(l)).length, in_progress: taskLines.filter((l) => /^\s*\[in_progress\]/.test(l)).length };
|
|
123
|
+
const meshStatus = jsonLine(mesh.out);
|
|
124
|
+
const pingData = ping ? jsonLine(ping.out) : null;
|
|
125
|
+
|
|
126
|
+
// Domain: what this repo can reach and what runs on its own. Names only.
|
|
127
|
+
const os = await import('node:os');
|
|
128
|
+
const providers = [...new Set(secretList.out.split('\n').map((l) => l.match(/^\s*•\s+\S+\s+len=\s*\d+\s+(\S+)/)?.[1]).filter(Boolean))].sort();
|
|
129
|
+
const integrations = [];
|
|
130
|
+
for (const [scope, dir] of [['project', path.join(root, '.agent/skills/total-recall/integrations')], ['global', path.join(os.homedir(), '.agent/skills/total-recall/integrations')]]) {
|
|
131
|
+
try { for (const f of fs.readdirSync(dir)) if (f.endsWith('.md')) integrations.push(`${f.slice(0, -3)} (${scope})`); } catch { /* none */ }
|
|
132
|
+
}
|
|
133
|
+
const repoName = path.basename(root);
|
|
134
|
+
const automations = [];
|
|
135
|
+
try {
|
|
136
|
+
const la = path.join(os.homedir(), 'Library/LaunchAgents');
|
|
137
|
+
for (const f of fs.readdirSync(la)) {
|
|
138
|
+
if (!f.endsWith('.plist')) continue;
|
|
139
|
+
const body = fs.readFileSync(path.join(la, f), 'utf8');
|
|
140
|
+
if (body.includes(root + '/') || body.includes(root + '<') || f.includes(repoName)) automations.push(`launchd ${f.replace(/\.plist$/, '')}`);
|
|
141
|
+
}
|
|
142
|
+
} catch { /* not macOS */ }
|
|
143
|
+
const cron = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
144
|
+
if (cron.status === 0) cron.stdout.split('\n').filter((l) => l.trim() && !l.startsWith('#') && l.includes(root)).forEach((l) => automations.push(`cron ${l.trim().split(/\s+/).slice(0, 5).join(' ')}`));
|
|
145
|
+
let scripts = [];
|
|
146
|
+
try { scripts = Object.keys(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).scripts || {}); } catch { /* none */ }
|
|
147
|
+
const launchers = fs.readdirSync(root).filter((f) => { try { const st = fs.statSync(path.join(root, f)); return st.isFile() && (st.mode & 0o111) && !f.includes('.'); } catch { return false; } });
|
|
148
|
+
const domain = { providers, integrations, automations, launchers, npm_scripts: scripts };
|
|
149
|
+
|
|
150
|
+
const warnings = [];
|
|
151
|
+
if (!project?.project_id) warnings.push('No project brain here (total-recall init) — memory, secrets and rules are global-only.');
|
|
152
|
+
if (secrets.rotation_due && secrets.rotation_due !== 0) warnings.push(`Secrets overdue for rotation: ${secrets.rotation_due} (total-recall secret rotation-due).`);
|
|
153
|
+
if (secrets.shared_values && secrets.shared_values !== 0) warnings.push(`Credential values reused across keys/apps: ${secrets.shared_values} group(s) (total-recall secret shared).`);
|
|
154
|
+
if (secrets.untracked && secrets.untracked !== 0) warnings.push(`Secrets without usage/account tracking: ${secrets.untracked} (total-recall secret tracking-health).`);
|
|
155
|
+
if (rules && (now - new Date(rules.compiled)) > 7 * 86400000) warnings.push(`Instruction surfaces last compiled ${rules.compiled.slice(0, 10)} — run total-recall compile.`);
|
|
156
|
+
for (const w of openwiki) if (w.updated && (now - new Date(w.updated)) > 30 * 86400000) warnings.push(`OpenWiki ${w.dir} last updated ${w.updated.slice(0, 10)} — may be stale.`);
|
|
157
|
+
if (meshStatus && meshStatus.configured === false) warnings.push(`Mesh not configured: ${meshStatus.reason || 'unknown'}.`);
|
|
158
|
+
for (const [name, r] of Object.entries({ project_id: pid, catalog, tasks, mesh })) if (r?.timedOut) warnings.push(`${name} check timed out.`);
|
|
159
|
+
|
|
160
|
+
const brief = {
|
|
161
|
+
generated: { local: now.toString(), utc: now.toISOString(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
|
|
162
|
+
project: project ? { id: project.project_id, name: project.name, groups } : null,
|
|
163
|
+
repo, rules, skills, repo_start_skill: startSkill ? rel(startSkill) : null, openwiki, trackers,
|
|
164
|
+
domain,
|
|
165
|
+
commands: cmdList.map((c) => ({ name: c.name, scope: c.scope, risk: c.risk })),
|
|
166
|
+
secrets, tasks: taskCounts,
|
|
167
|
+
mesh: { configured: meshStatus?.configured ?? null, ping: pingData },
|
|
168
|
+
warnings,
|
|
169
|
+
next: startSkill
|
|
170
|
+
? [`Read and follow ${rel(startSkill)} — this repo's own start steps.`]
|
|
171
|
+
: [`This repo has no start skill of its own: read ${openwiki[0]?.entry || 'the openwiki'}${skills.includes('repo-expert') ? ' and .agent/skills/repo-expert/SKILL.md' : ''} before changing code.`],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (asJson) { console.log(JSON.stringify(brief, null, 2)); return; }
|
|
175
|
+
|
|
176
|
+
const L = [];
|
|
177
|
+
L.push(`# Brief — ${project?.name || path.basename(root)}`);
|
|
178
|
+
L.push(`Time: ${now.toLocaleString()} ${brief.generated.timezone} (UTC ${brief.generated.utc})`);
|
|
179
|
+
L.push(`Project id: ${project?.project_id || 'none'}${groups.length ? ` groups: ${groups.map((g) => g.name).join(', ')}` : ''}`);
|
|
180
|
+
L.push(`Repo: ${repo.branch || '?'} dirty files: ${repo.dirty ?? '?'} last: ${repo.last_commit || '?'}${repo.upstream ? ` ahead ${repo.upstream.ahead}/behind ${repo.upstream.behind} (last fetch)` : ''}`);
|
|
181
|
+
L.push(`Rules: ${rules ? `${rules.invariants} invariants, ${rules.preferences} preferences, ${rules.corrections} corrections (${rules.file}, compiled ${rules.compiled.slice(0, 16).replace('T', ' ')})` : 'no compiled surface'}`);
|
|
182
|
+
L.push(`Skills (${skills.length}): ${skills.join(', ') || 'none'}`);
|
|
183
|
+
L.push(`Commands (${cmdList.length}): ${cmdList.map((c) => c.name + (c.risk && !['read', 'write'].includes(c.risk) ? `[${c.risk}]` : '')).join(', ') || 'none'}`);
|
|
184
|
+
L.push(`Secrets: ${secrets.keys ?? '?'} keys / ${secrets.providers ?? '?'} providers; rotation due ${secrets.rotation_due}; shared ${secrets.shared_values}; untracked ${secrets.untracked}`);
|
|
185
|
+
L.push(`Providers with keys (${providers.length}): ${providers.join(', ') || 'none'}`);
|
|
186
|
+
L.push(`Integrations: ${integrations.join(', ') || 'none'} | automations: ${automations.join(', ') || 'none'}`);
|
|
187
|
+
L.push(`Entry points: launchers ${launchers.map((f) => './' + f).join(' ') || 'none'}; npm scripts ${scripts.length}${scripts.length ? ` (${scripts.slice(0, 12).join(', ')}${scripts.length > 12 ? ', …' : ''})` : ''}`);
|
|
188
|
+
L.push(`Tasks: ${taskCounts.pending} pending, ${taskCounts.in_progress} in progress`);
|
|
189
|
+
L.push(`Mesh: ${meshStatus?.configured ? 'configured' : 'not configured'}${pingData ? ` — ping: ${JSON.stringify(pingData).slice(0, 300)}` : ' (add --mesh to ping nodes)'}`);
|
|
190
|
+
L.push(`Trackers in progress: ${trackers.in_progress.join(', ') || 'none'}${trackers.planned.length ? ` | planned: ${trackers.planned.join(', ')}` : ''}`);
|
|
191
|
+
L.push(`OpenWiki: ${openwiki.length ? openwiki.map((w) => `${w.entry} (${w.pages} pages, updated ${w.updated.slice(0, 10)})`).join('; ') : 'none'}`);
|
|
192
|
+
if (warnings.length) { L.push('', 'Warnings:'); warnings.forEach((w) => L.push(`- ${w}`)); }
|
|
193
|
+
L.push('', 'Next:'); brief.next.forEach((n) => L.push(`- ${n}`));
|
|
194
|
+
console.log(L.join('\n'));
|
|
195
|
+
}
|
package/src/cli/command.mjs
CHANGED
|
@@ -1,17 +1,166 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
3
5
|
import { resolveBrainLayer } from '../core/config.mjs';
|
|
6
|
+
import { isSafeRelativePath, validatePluginManifest } from '../core/plugin-loader.mjs';
|
|
7
|
+
|
|
8
|
+
const COMMAND_NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
9
|
+
|
|
10
|
+
function safeCommandName(name) {
|
|
11
|
+
if (!COMMAND_NAME.test(name || '')) throw new Error('Command name must be lowercase kebab-case (1–64 characters)');
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Generate a composable command from one validated plugin manifest entry. */
|
|
16
|
+
export function generatePluginCommand(pluginDir, name, commandsDir) {
|
|
17
|
+
safeCommandName(name);
|
|
18
|
+
const root = fs.realpathSync(pluginDir);
|
|
19
|
+
const manifestPath = path.join(root, 'plugin.json');
|
|
20
|
+
if (!fs.statSync(manifestPath).isFile() || fs.lstatSync(manifestPath).isSymbolicLink()) {
|
|
21
|
+
throw new Error('Plugin manifest must be a regular file');
|
|
22
|
+
}
|
|
23
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
24
|
+
const validation = validatePluginManifest(manifest);
|
|
25
|
+
if (!validation.valid) throw new Error(`Invalid plugin manifest: ${validation.errors.join('; ')}`);
|
|
26
|
+
const spec = (manifest.commands || []).find((entry) => entry.name === name);
|
|
27
|
+
if (!spec) throw new Error(`Plugin '${manifest.id}' has no command '${name}'`);
|
|
28
|
+
if (!isSafeRelativePath(spec.handler) || spec.handler.includes('\\') || !spec.handler.endsWith('.mjs')) {
|
|
29
|
+
throw new Error(`Command '${name}' needs a safe .mjs handler path`);
|
|
30
|
+
}
|
|
31
|
+
const handlerPath = path.resolve(root, spec.handler);
|
|
32
|
+
const relative = path.relative(root, handlerPath);
|
|
33
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)
|
|
34
|
+
|| !fs.statSync(handlerPath).isFile() || fs.realpathSync(handlerPath) !== handlerPath) {
|
|
35
|
+
throw new Error(`Command '${name}' handler must be a regular file inside the plugin`);
|
|
36
|
+
}
|
|
37
|
+
fs.mkdirSync(commandsDir, { recursive: true });
|
|
38
|
+
const commandPath = path.join(commandsDir, `${name}.mjs`);
|
|
39
|
+
if (fs.existsSync(commandPath)) throw new Error(`Command '${name}' already exists`);
|
|
40
|
+
const handlerUrl = pathToFileURL(handlerPath).href;
|
|
41
|
+
const content = `// Generated from plugin ${manifest.id}; edit the plugin manifest/handler, then regenerate.
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import crypto from 'node:crypto';
|
|
45
|
+
import { spawn } from 'node:child_process';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
|
|
48
|
+
const name = ${JSON.stringify(name)};
|
|
49
|
+
const description = ${JSON.stringify(spec.description || '')};
|
|
50
|
+
const handlerUrl = ${JSON.stringify(handlerUrl)};
|
|
51
|
+
const backgroundAllowed = ${spec.background === true};
|
|
52
|
+
|
|
53
|
+
export async function run(argv = process.argv, direct = false) {
|
|
54
|
+
const args = argv.slice(direct ? 2 : 3);
|
|
55
|
+
const json = args.includes('--json');
|
|
56
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
57
|
+
console.log('Usage: total-recall ' + name + ' [args…] [--json]' + (backgroundAllowed ? ' [--background]' : '') + '\\n\\n' + description);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
if (args.includes('--background')) {
|
|
62
|
+
if (!backgroundAllowed) throw Object.assign(new Error('Background execution is not enabled for this command'), { exitCode: 2 });
|
|
63
|
+
const reports = path.join(path.dirname(fileURLToPath(import.meta.url)), 'reports');
|
|
64
|
+
fs.mkdirSync(reports, { recursive: true, mode: 0o700 });
|
|
65
|
+
const report = path.join(reports, name + '-' + crypto.randomUUID() + '.log');
|
|
66
|
+
const fd = fs.openSync(report, 'wx', 0o600);
|
|
67
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args.filter((a) => a !== '--background')], {
|
|
68
|
+
detached: true, stdio: ['ignore', fd, fd]
|
|
69
|
+
});
|
|
70
|
+
fs.closeSync(fd);
|
|
71
|
+
child.unref();
|
|
72
|
+
const status = { ok: true, pid: child.pid, report };
|
|
73
|
+
if (json) console.log(JSON.stringify(status));
|
|
74
|
+
else console.log('Started ' + name + ' in background (pid ' + child.pid + '); report: ' + report);
|
|
75
|
+
return status;
|
|
76
|
+
}
|
|
77
|
+
const handler = await import(handlerUrl);
|
|
78
|
+
const entry = handler.run || handler.default;
|
|
79
|
+
if (typeof entry !== 'function') throw new Error('Plugin command handler must export run() or default function');
|
|
80
|
+
const handlerArgs = args.filter((arg) => arg !== '--json');
|
|
81
|
+
const result = await entry(handler.run ? [process.execPath, 'total-recall', name, ...handlerArgs] : handlerArgs);
|
|
82
|
+
const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : 0;
|
|
83
|
+
if (json) console.log(JSON.stringify({ ok: exitCode === 0, exit_code: exitCode, result: result?.data ?? result ?? null }));
|
|
84
|
+
process.exitCode = exitCode;
|
|
85
|
+
return result;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
88
|
+
if (json) console.log(JSON.stringify({ ok: false, exit_code: exitCode, error: error.message }));
|
|
89
|
+
else console.error(error.message);
|
|
90
|
+
process.exitCode = exitCode;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
95
|
+
await run(process.argv, true);
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
fs.writeFileSync(commandPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
|
|
99
|
+
return commandPath;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generate `total-recall <name> config|<collection>|detect|init` for a layered
|
|
104
|
+
* skill deployed in the repo that owns `commandsDir` (<repo>/.agent/commands).
|
|
105
|
+
* The wrapper holds no config logic: it delegates to the running Total Recall
|
|
106
|
+
* CLI, so the layer contract in the skill core stays the only source of truth.
|
|
107
|
+
*/
|
|
108
|
+
export function generateSkillConfigCommand(skillId, name, commandsDir) {
|
|
109
|
+
safeCommandName(name);
|
|
110
|
+
safeCommandName(skillId);
|
|
111
|
+
const repoRoot = path.resolve(commandsDir, '..', '..');
|
|
112
|
+
const schemaPath = path.join(repoRoot, '.agent', 'skills', skillId, 'core', 'config.schema.json');
|
|
113
|
+
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
|
114
|
+
throw new Error(`Skill '${skillId}' in ${repoRoot} has no core/config.schema.json; deploy the layered skill first`);
|
|
115
|
+
}
|
|
116
|
+
fs.mkdirSync(commandsDir, { recursive: true });
|
|
117
|
+
const commandPath = path.join(commandsDir, `${name}.mjs`);
|
|
118
|
+
if (fs.existsSync(commandPath)) throw new Error(`Command '${name}' already exists`);
|
|
119
|
+
const fallbackModule = pathToFileURL(path.join(path.dirname(fileURLToPath(import.meta.url)), 'skill-config.mjs')).href;
|
|
120
|
+
const content = `// Generated repo-layer config command for skill ${skillId}; the contract is .agent/skills/${skillId}/core/config.schema.json.
|
|
121
|
+
import fs from 'node:fs';
|
|
122
|
+
import path from 'node:path';
|
|
123
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
124
|
+
|
|
125
|
+
const skillId = ${JSON.stringify(skillId)};
|
|
126
|
+
const name = ${JSON.stringify(name)};
|
|
127
|
+
const fallbackModule = ${JSON.stringify(fallbackModule)};
|
|
128
|
+
|
|
129
|
+
// Prefer the Total Recall install that is running this command, so upgrades apply.
|
|
130
|
+
function configModule() {
|
|
131
|
+
try {
|
|
132
|
+
const bin = fs.realpathSync(process.argv[1]);
|
|
133
|
+
const candidate = path.join(path.dirname(path.dirname(bin)), 'src', 'cli', 'skill-config.mjs');
|
|
134
|
+
if (path.basename(bin) === 'total-recall.mjs' && fs.existsSync(candidate)) return pathToFileURL(candidate).href;
|
|
135
|
+
} catch { /* fall back to the generating install */ }
|
|
136
|
+
return fallbackModule;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function run(argv = process.argv) {
|
|
140
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
141
|
+
const { runSkillConfig } = await import(configModule());
|
|
142
|
+
return runSkillConfig(skillId, argv.slice(3), { repoRoot, prefix: 'total-recall ' + name });
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
fs.writeFileSync(commandPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
|
|
146
|
+
return commandPath;
|
|
147
|
+
}
|
|
4
148
|
|
|
5
149
|
function printHelp() {
|
|
6
150
|
console.log(`
|
|
7
151
|
total-recall command — Manage custom composable CLI commands
|
|
8
152
|
|
|
9
153
|
Usage:
|
|
10
|
-
total-recall command create <name> "<code>" [--global]
|
|
154
|
+
total-recall command create <name> "<code>" [--global] [--description "<when to use>"] [--risk <class>]
|
|
155
|
+
Create a custom CLI command (listed in instruction surfaces)
|
|
156
|
+
total-recall command create <name> --from-plugin <dir> Generate from a plugin manifest
|
|
157
|
+
total-recall command create <name> --config-for-skill <id>
|
|
158
|
+
Generate repo-layer config verbs for a
|
|
159
|
+
layered skill deployed in this repo
|
|
11
160
|
total-recall command read <name> [--global] Read the code of a custom CLI command
|
|
12
161
|
total-recall command update <name> "<code>" [--global] Update an existing custom CLI command
|
|
13
162
|
total-recall command remove <name> [--global] Remove a custom CLI command
|
|
14
|
-
total-recall command list [--global]
|
|
163
|
+
total-recall command list [--global] [--json] List custom CLI commands
|
|
15
164
|
|
|
16
165
|
Examples:
|
|
17
166
|
npx total-recall command create hello "console.log('Hello from the brain!');"
|
|
@@ -49,9 +198,66 @@ export async function run(argv = []) {
|
|
|
49
198
|
return commandCmd(args);
|
|
50
199
|
}
|
|
51
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Built-in verbs resolve before custom commands (bin/total-recall.mjs), so a
|
|
203
|
+
* custom command with a built-in name could never run. Read the names from the
|
|
204
|
+
* dispatcher itself so this list cannot drift.
|
|
205
|
+
*/
|
|
206
|
+
export function builtinCommandNames() {
|
|
207
|
+
try {
|
|
208
|
+
const bin = fs.readFileSync(new URL('../../bin/total-recall.mjs', import.meta.url), 'utf8');
|
|
209
|
+
const block = bin.match(/^const COMMANDS = \{([\s\S]*?)^\};/m)?.[1] || '';
|
|
210
|
+
return new Set([...block.matchAll(/^\s+'?([a-z][a-z0-9-]*)'?\s*:/gm)].map((m) => m[1]));
|
|
211
|
+
} catch {
|
|
212
|
+
return new Set();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const RISKS = new Set(['read', 'write', 'money', 'dns-cert', 'production-deploy', 'secret-revoke']);
|
|
217
|
+
|
|
218
|
+
/** Pull `--flag <value>` pairs out of argv so positional parsing is unaffected. */
|
|
219
|
+
function takeOption(list, flag) {
|
|
220
|
+
const i = list.indexOf(flag);
|
|
221
|
+
if (i === -1) return { list, value: undefined };
|
|
222
|
+
return { list: [...list.slice(0, i), ...list.slice(i + 2)], value: list[i + 1] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function headerTags(description, risk) {
|
|
226
|
+
const one = (v) => String(v).replace(/[\r\n]+/g, ' ').trim();
|
|
227
|
+
return (description ? `// @description ${one(description)}\n` : '') + (risk ? `// @risk ${risk}\n` : '');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function existingTag(file, key) {
|
|
231
|
+
try { return fs.readFileSync(file, 'utf8').match(new RegExp(`^// @${key} (.+)$`, 'm'))?.[1] || undefined; } catch { return undefined; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Composable instructions: the surfaces list every command, so any change to
|
|
236
|
+
* the command set recompiles them (project layer, or every project for global).
|
|
237
|
+
*/
|
|
238
|
+
function recompileSurfaces(isGlobal) {
|
|
239
|
+
if (process.env.TR_COMMAND_NO_COMPILE === '1' || !process.argv[1]) return;
|
|
240
|
+
try {
|
|
241
|
+
const child = spawn(process.execPath, [process.argv[1], 'compile', isGlobal ? '--global' : '--project'], { detached: true, stdio: 'ignore' });
|
|
242
|
+
child.unref();
|
|
243
|
+
console.log(' ⏳ Instruction surfaces recompiling in the background (command list updated).');
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.warn(` ⚠️ Command saved, but surface recompile failed to start: ${error.message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
52
249
|
export default async function commandCmd(rawArgs = []) {
|
|
53
250
|
const isGlobal = rawArgs.includes('--global') || rawArgs.includes('-g');
|
|
54
|
-
const
|
|
251
|
+
const asJson = rawArgs.includes('--json');
|
|
252
|
+
let cleanArgs = rawArgs.filter(a => a !== '--global' && a !== '-g' && a !== '--json');
|
|
253
|
+
let description; let risk;
|
|
254
|
+
({ list: cleanArgs, value: description } = takeOption(cleanArgs, '--description'));
|
|
255
|
+
({ list: cleanArgs, value: risk } = takeOption(cleanArgs, '--risk'));
|
|
256
|
+
if (risk !== undefined && !RISKS.has(risk)) {
|
|
257
|
+
console.error(`Error: --risk must be one of ${[...RISKS].join(', ')}`);
|
|
258
|
+
process.exitCode = 2;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
55
261
|
|
|
56
262
|
const action = cleanArgs[0];
|
|
57
263
|
const name = cleanArgs[1];
|
|
@@ -67,9 +273,50 @@ export default async function commandCmd(rawArgs = []) {
|
|
|
67
273
|
process.exit(1);
|
|
68
274
|
}
|
|
69
275
|
|
|
276
|
+
if (name) {
|
|
277
|
+
try { safeCommandName(name); }
|
|
278
|
+
catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 2; return; }
|
|
279
|
+
}
|
|
280
|
+
|
|
70
281
|
const commandsDir = resolveTargetDir(isGlobal);
|
|
71
282
|
|
|
72
283
|
if (action === 'create') {
|
|
284
|
+
if (builtinCommandNames().has(name)) {
|
|
285
|
+
console.error(`Error: '${name}' is a built-in Total Recall command; a custom command with that name could never run. Choose another name.`);
|
|
286
|
+
process.exitCode = 2;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const otherDir = resolveTargetDir(!isGlobal);
|
|
290
|
+
if (path.resolve(otherDir) !== path.resolve(commandsDir) && fs.existsSync(path.join(otherDir, `${name}.mjs`))) {
|
|
291
|
+
console.warn(` ⚠️ A ${isGlobal ? 'project' : 'global'} command '${name}' already exists (${otherDir}); the project one runs first here.`);
|
|
292
|
+
}
|
|
293
|
+
const forSkill = cleanArgs.indexOf('--config-for-skill');
|
|
294
|
+
if (forSkill !== -1) {
|
|
295
|
+
try {
|
|
296
|
+
if (isGlobal) throw new Error('Repo-layer config commands belong to one repository; omit --global');
|
|
297
|
+
if (!cleanArgs[forSkill + 1]) throw new Error('Missing skill id after --config-for-skill');
|
|
298
|
+
const commandPath = generateSkillConfigCommand(cleanArgs[forSkill + 1], name, commandsDir);
|
|
299
|
+
console.log(`✔ Generated npx total-recall ${name} config|detect|init; saved to: ${commandPath}`);
|
|
300
|
+
recompileSurfaces(isGlobal);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error(`Error: ${error.message}`);
|
|
303
|
+
process.exitCode = 1;
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const fromPlugin = cleanArgs.indexOf('--from-plugin');
|
|
308
|
+
if (fromPlugin !== -1) {
|
|
309
|
+
try {
|
|
310
|
+
if (!cleanArgs[fromPlugin + 1]) throw new Error('Missing plugin directory after --from-plugin');
|
|
311
|
+
const commandPath = generatePluginCommand(cleanArgs[fromPlugin + 1], name, commandsDir);
|
|
312
|
+
console.log(`✔ Generated npx total-recall ${name} from plugin; saved to: ${commandPath}`);
|
|
313
|
+
recompileSurfaces(isGlobal);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error(`Error: ${error.message}`);
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
73
320
|
const code = cleanArgs[2];
|
|
74
321
|
if (!code) {
|
|
75
322
|
console.error(`Error: Missing code snippet for the command.`);
|
|
@@ -82,10 +329,15 @@ export default async function commandCmd(rawArgs = []) {
|
|
|
82
329
|
}
|
|
83
330
|
|
|
84
331
|
const commandPath = path.join(commandsDir, `${name}.mjs`);
|
|
332
|
+
if (fs.existsSync(commandPath)) {
|
|
333
|
+
console.error(`Error: Custom command '${name}' already exists.`);
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
85
337
|
|
|
86
338
|
// Wrap code in an exported run/default function for composable CLI execution
|
|
87
339
|
const fileContent = `// Auto-generated custom CLI command: ${name}
|
|
88
|
-
export async function run(argv = []) {
|
|
340
|
+
${headerTags(description, risk)}export async function run(argv = []) {
|
|
89
341
|
const args = Array.isArray(argv) ? argv.slice(3) : [];
|
|
90
342
|
${code}
|
|
91
343
|
}
|
|
@@ -97,6 +349,8 @@ export default async function (args = []) {
|
|
|
97
349
|
fs.writeFileSync(commandPath, fileContent, 'utf8');
|
|
98
350
|
console.log(`\x1b[32m✔ Successfully created custom command: \x1b[1mnpx total-recall ${name}\x1b[0m`);
|
|
99
351
|
console.log(` Saved to: ${commandPath}`);
|
|
352
|
+
if (!description) console.log(' Tip: add --description "<when to use it>" so the instruction surfaces say when to use this command.');
|
|
353
|
+
recompileSurfaces(isGlobal);
|
|
100
354
|
} else if (action === 'read') {
|
|
101
355
|
const commandPath = path.join(commandsDir, `${name}.mjs`);
|
|
102
356
|
if (fs.existsSync(commandPath)) {
|
|
@@ -119,8 +373,11 @@ export default async function (args = []) {
|
|
|
119
373
|
process.exit(1);
|
|
120
374
|
}
|
|
121
375
|
|
|
376
|
+
// Keep the declared description/risk unless the update replaces them.
|
|
377
|
+
const keptDescription = description ?? existingTag(commandPath, 'description');
|
|
378
|
+
const keptRisk = risk ?? existingTag(commandPath, 'risk');
|
|
122
379
|
const fileContent = `// Auto-generated custom CLI command: ${name}
|
|
123
|
-
export async function run(argv = []) {
|
|
380
|
+
${headerTags(keptDescription, keptRisk)}export async function run(argv = []) {
|
|
124
381
|
const args = Array.isArray(argv) ? argv.slice(3) : [];
|
|
125
382
|
${code}
|
|
126
383
|
}
|
|
@@ -132,6 +389,7 @@ export default async function (args = []) {
|
|
|
132
389
|
fs.writeFileSync(commandPath, fileContent, 'utf8');
|
|
133
390
|
console.log(`\x1b[32m✔ Successfully updated custom command: \x1b[1m${name}\x1b[0m`);
|
|
134
391
|
console.log(` Saved to: ${commandPath}`);
|
|
392
|
+
recompileSurfaces(isGlobal);
|
|
135
393
|
} else if (action === 'list') {
|
|
136
394
|
const targets = isGlobal
|
|
137
395
|
? [{ label: 'Global', dir: resolveTargetDir(true) }]
|
|
@@ -140,6 +398,11 @@ export default async function (args = []) {
|
|
|
140
398
|
{ label: 'Global', dir: resolveTargetDir(true) }
|
|
141
399
|
];
|
|
142
400
|
|
|
401
|
+
if (asJson) {
|
|
402
|
+
const { listSurfaceCommands } = await import('../core/command-surface.mjs');
|
|
403
|
+
console.log(JSON.stringify(listSurfaceCommands(targets.map((t) => ({ scope: t.label.toLowerCase(), dir: t.dir })))));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
143
406
|
let foundAny = false;
|
|
144
407
|
for (const t of targets) {
|
|
145
408
|
if (fs.existsSync(t.dir)) {
|
|
@@ -161,6 +424,7 @@ export default async function (args = []) {
|
|
|
161
424
|
if (fs.existsSync(commandPath)) {
|
|
162
425
|
fs.unlinkSync(commandPath);
|
|
163
426
|
console.log(`\x1b[32m✔ Successfully removed custom command: \x1b[1m${name}\x1b[0m`);
|
|
427
|
+
recompileSurfaces(isGlobal);
|
|
164
428
|
} else {
|
|
165
429
|
console.error(`Error: Custom command '${name}' does not exist.`);
|
|
166
430
|
process.exit(1);
|
package/src/cli/config.mjs
CHANGED
|
@@ -21,7 +21,12 @@ const KEY_MAP = {
|
|
|
21
21
|
|
|
22
22
|
'daily_cap_usd': { file: 'budget', path: 'budget.daily_cap_usd', type: 'number' },
|
|
23
23
|
'weekly_cap_usd': { file: 'budget', path: 'budget.weekly_cap_usd', type: 'number' },
|
|
24
|
-
'budget_enabled': { file: 'budget', path: 'budget.enabled', type: 'boolean' }
|
|
24
|
+
'budget_enabled': { file: 'budget', path: 'budget.enabled', type: 'boolean' },
|
|
25
|
+
|
|
26
|
+
// Size of each rules section in the compiled instruction files (characters).
|
|
27
|
+
'rules_invariants_budget_chars': { file: 'surface', path: 'rules.invariants_budget_chars', type: 'number' },
|
|
28
|
+
'rules_preferences_budget_chars': { file: 'surface', path: 'rules.preferences_budget_chars', type: 'number' },
|
|
29
|
+
'rules_corrections_budget_chars': { file: 'surface', path: 'rules.corrections_budget_chars', type: 'number' },
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
function resolveKeyInfo(key) {
|
|
@@ -33,7 +38,7 @@ function resolveKeyInfo(key) {
|
|
|
33
38
|
|
|
34
39
|
// Fallback / dynamic inference
|
|
35
40
|
const isBudget = key.startsWith('budget.') || key === 'budget';
|
|
36
|
-
const file = isBudget ? 'budget' : 'security';
|
|
41
|
+
const file = isBudget ? 'budget' : key.startsWith('rules.') ? 'surface' : 'security';
|
|
37
42
|
const targetPath = key;
|
|
38
43
|
|
|
39
44
|
let type = 'string';
|
|
@@ -131,6 +136,9 @@ export default async function configCommand(args) {
|
|
|
131
136
|
daily_cap_usd Cost-control daily cap (number)
|
|
132
137
|
weekly_cap_usd Cost-control weekly cap (number)
|
|
133
138
|
budget_enabled Enable/disable cost limit checks (true | false)
|
|
139
|
+
rules_invariants_budget_chars Characters for the invariants section of CLAUDE.md/AGENTS.md (number; default 9000)
|
|
140
|
+
rules_preferences_budget_chars Same for preferences (default 4000)
|
|
141
|
+
rules_corrections_budget_chars Same for corrections (default 8000)
|
|
134
142
|
allowed_origins CORS origins (e.g. "http://localhost:5173,http://localhost:3000")
|
|
135
143
|
|
|
136
144
|
Examples:
|
|
@@ -145,12 +153,21 @@ export default async function configCommand(args) {
|
|
|
145
153
|
const configDir = path.join(brainDir, 'config');
|
|
146
154
|
const securityPath = path.join(configDir, 'security.yml');
|
|
147
155
|
const budgetPath = path.join(configDir, 'budget.yml');
|
|
156
|
+
const surfacePath = path.join(configDir, 'surface.yml');
|
|
148
157
|
const layerLabel = layer === 'project' ? '[project]' : layer === 'global' ? '[global]' : '[auto]';
|
|
149
158
|
|
|
150
159
|
// Load configs safely
|
|
151
160
|
let securityConfig = {};
|
|
152
161
|
let budgetConfig = {};
|
|
162
|
+
let surfaceConfig = {};
|
|
163
|
+
const objFor = (file) => (file === 'budget' ? budgetConfig : file === 'surface' ? surfaceConfig : securityConfig);
|
|
164
|
+
const pathFor = (file) => (file === 'budget' ? budgetPath : file === 'surface' ? surfacePath : securityPath);
|
|
153
165
|
|
|
166
|
+
if (fs.existsSync(surfacePath)) {
|
|
167
|
+
try {
|
|
168
|
+
surfaceConfig = yaml.parse(fs.readFileSync(surfacePath, 'utf8')) || {};
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
154
171
|
if (fs.existsSync(securityPath)) {
|
|
155
172
|
try {
|
|
156
173
|
securityConfig = yaml.parse(fs.readFileSync(securityPath, 'utf8')) || {};
|
|
@@ -166,7 +183,7 @@ export default async function configCommand(args) {
|
|
|
166
183
|
const key = remainingArgs[1];
|
|
167
184
|
if (key) {
|
|
168
185
|
const info = resolveKeyInfo(key);
|
|
169
|
-
const targetObj = info.file
|
|
186
|
+
const targetObj = objFor(info.file);
|
|
170
187
|
const value = getNestedProp(targetObj, info.path);
|
|
171
188
|
|
|
172
189
|
if (args.includes('--json') || args.includes('-j')) {
|
|
@@ -178,7 +195,7 @@ export default async function configCommand(args) {
|
|
|
178
195
|
// List all known keys
|
|
179
196
|
const allSettings = {};
|
|
180
197
|
for (const [flatKey, info] of Object.entries(KEY_MAP)) {
|
|
181
|
-
const targetObj = info.file
|
|
198
|
+
const targetObj = objFor(info.file);
|
|
182
199
|
const val = getNestedProp(targetObj, info.path);
|
|
183
200
|
allSettings[flatKey] = val !== undefined ? val : null;
|
|
184
201
|
}
|
|
@@ -193,7 +210,8 @@ export default async function configCommand(args) {
|
|
|
193
210
|
}
|
|
194
211
|
console.log('\n 📍 Storage locations:');
|
|
195
212
|
console.log(` - Security: ${securityPath}`);
|
|
196
|
-
console.log(` - Budget: ${budgetPath}
|
|
213
|
+
console.log(` - Budget: ${budgetPath}`);
|
|
214
|
+
console.log(` - Surface: ${surfacePath}\n`);
|
|
197
215
|
}
|
|
198
216
|
}
|
|
199
217
|
return;
|
|
@@ -214,8 +232,8 @@ export default async function configCommand(args) {
|
|
|
214
232
|
}
|
|
215
233
|
|
|
216
234
|
const info = resolveKeyInfo(key);
|
|
217
|
-
const targetObj = info.file
|
|
218
|
-
const targetPath = info.file
|
|
235
|
+
const targetObj = objFor(info.file);
|
|
236
|
+
const targetPath = pathFor(info.file);
|
|
219
237
|
|
|
220
238
|
try {
|
|
221
239
|
const parsedValue = castAndValidate(rawValue, info.type, info.values);
|
package/src/cli/connect.mjs
CHANGED
|
@@ -10,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
// Resolve templates dir relative to this file (src/cli/ → ../../templates/)
|
|
11
11
|
const TEMPLATES_DIR = path.resolve(__dirname, '..', '..', 'templates');
|
|
12
12
|
|
|
13
|
-
const CLIENTS = {
|
|
13
|
+
export const CLIENTS = {
|
|
14
14
|
vscode: {
|
|
15
15
|
label: 'VS Code Copilot',
|
|
16
16
|
mode: 'file',
|