total-recall-brain 3.31.0 → 3.32.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/bin/total-recall.mjs +2 -2
- package/frontend/dist/assets/index-BUEZPLim.js +5742 -0
- package/frontend/dist/assets/index-FOJG4jQh.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/package.json +1 -1
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -10
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/command.mjs +269 -5
- package/src/cli/config.mjs +2 -1
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/deploy-ui.mjs +5 -4
- package/src/cli/deploy.mjs +3 -2
- package/src/cli/init.mjs +61 -6
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/reset-password.mjs +2 -1
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/cli/uninstall.mjs +2 -1
- 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-brain.mjs +2 -1
- package/src/core/schema.mjs +72 -13
- package/src/core/secrets-store.mjs +5 -4
- package/src/core/secure-file.mjs +71 -0
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/surface.mjs +52 -7
- package/src/core/webauthn-store.mjs +2 -1
- package/src/server/auth.mjs +3 -2
- package/src/server/index.mjs +2 -1
- package/src/server/routes/collab.mjs +7 -3
- package/src/server/routes/config.mjs +4 -3
- 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
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
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import yaml from 'yaml';
|
|
4
4
|
import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
|
|
5
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
5
6
|
|
|
6
7
|
// Pre-defined mapping of known settings
|
|
7
8
|
const KEY_MAP = {
|
|
@@ -224,7 +225,7 @@ export default async function configCommand(args) {
|
|
|
224
225
|
if (!fs.existsSync(configDir)) {
|
|
225
226
|
fs.mkdirSync(configDir, { recursive: true });
|
|
226
227
|
}
|
|
227
|
-
|
|
228
|
+
writeFileSecure(targetPath, yaml.stringify(targetObj), { encoding: 'utf8', mode: 0o600 });
|
|
228
229
|
|
|
229
230
|
console.log(`\n ✅ Success! Config key "${key}" ${layerLabel} set to: ${JSON.stringify(parsedValue)}`);
|
|
230
231
|
console.log(` 📍 Saved in: ${targetPath}\n`);
|
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',
|
package/src/cli/daemon.mjs
CHANGED
|
@@ -164,8 +164,8 @@ function directStart() {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
function directStop() {
|
|
168
|
-
const stopped = stopDaemon();
|
|
167
|
+
async function directStop() {
|
|
168
|
+
const stopped = await stopDaemon();
|
|
169
169
|
if (stopped) {
|
|
170
170
|
console.error(' ✅ Daemon stopped');
|
|
171
171
|
} else {
|
|
@@ -215,7 +215,7 @@ export default async function daemon(args) {
|
|
|
215
215
|
case 'stop':
|
|
216
216
|
if (useSystemd) systemdStop();
|
|
217
217
|
else if (useLaunchd) launchctlStop();
|
|
218
|
-
else directStop();
|
|
218
|
+
else await directStop();
|
|
219
219
|
break;
|
|
220
220
|
case 'status':
|
|
221
221
|
if (useSystemd) systemdStatus();
|
package/src/cli/deploy-ui.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import os from 'node:os';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import bcrypt from 'bcrypt';
|
|
21
21
|
import yaml from 'yaml';
|
|
22
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
22
23
|
import { BCRYPT_COST } from '../server/auth.mjs';
|
|
23
24
|
|
|
24
25
|
|
|
@@ -175,7 +176,7 @@ export function startDeployUI(port = 3001) {
|
|
|
175
176
|
if (opts.cloudRegion) current['cfg-cloud-region'] = opts.cloudRegion;
|
|
176
177
|
if (opts.installGlobal !== undefined) current['installGlobal'] = opts.installGlobal;
|
|
177
178
|
if (opts.projectPaths) current['projectPaths'] = opts.projectPaths;
|
|
178
|
-
|
|
179
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
179
180
|
|
|
180
181
|
} catch (e) {
|
|
181
182
|
console.error('Failed to auto-persist install options on disk:', e);
|
|
@@ -572,7 +573,7 @@ export function startDeployUI(port = 3001) {
|
|
|
572
573
|
}
|
|
573
574
|
|
|
574
575
|
const updated = { ...current, ...data };
|
|
575
|
-
|
|
576
|
+
writeFileSecure(configFile, JSON.stringify(updated, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
576
577
|
syncInstallOptionsFromDisk();
|
|
577
578
|
|
|
578
579
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1114,7 +1115,7 @@ export function startDeployUI(port = 3001) {
|
|
|
1114
1115
|
}
|
|
1115
1116
|
current['cfg-github-token'] = token;
|
|
1116
1117
|
current['backupRepo'] = backupUrl;
|
|
1117
|
-
|
|
1118
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1118
1119
|
|
|
1119
1120
|
log('✅ Encryption keys and tokens written.');
|
|
1120
1121
|
|
|
@@ -1735,7 +1736,7 @@ console.log("REMOTE_PASS_OK");
|
|
|
1735
1736
|
if (_installOptions.dashboardPassword) {
|
|
1736
1737
|
current['cfg-dashboard-password'] = _installOptions.dashboardPassword;
|
|
1737
1738
|
}
|
|
1738
|
-
|
|
1739
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1739
1740
|
} catch (e) {
|
|
1740
1741
|
console.error('Failed to auto-persist remote install options on disk:', e);
|
|
1741
1742
|
}
|
package/src/cli/deploy.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import yaml from 'yaml';
|
|
|
43
43
|
import bcrypt from 'bcrypt';
|
|
44
44
|
import crypto from 'node:crypto';
|
|
45
45
|
import { BCRYPT_COST } from '../server/auth.mjs';
|
|
46
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -407,7 +408,7 @@ function hardenSecurityConfig(dryRun, dashboardPassword = null) {
|
|
|
407
408
|
log(' 🔑 Dashboard credentials left unconfigured. You will set your admin password on first dashboard access.');
|
|
408
409
|
}
|
|
409
410
|
|
|
410
|
-
|
|
411
|
+
writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
411
412
|
logOk('Security config hardened: HTTPS required, Express bound to localhost, no public health, no legacy local PAT');
|
|
412
413
|
}
|
|
413
414
|
|
|
@@ -1095,7 +1096,7 @@ export default async function deploy(args) {
|
|
|
1095
1096
|
if (wizardOpts.dashboardPassword) {
|
|
1096
1097
|
current['cfg-dashboard-password'] = wizardOpts.dashboardPassword;
|
|
1097
1098
|
}
|
|
1098
|
-
|
|
1099
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1099
1100
|
} catch (e) {
|
|
1100
1101
|
console.error('Failed to auto-persist cloud install options on disk:', e);
|
|
1101
1102
|
}
|
package/src/cli/init.mjs
CHANGED
|
@@ -30,7 +30,10 @@ import path from 'node:path';
|
|
|
30
30
|
import os from 'node:os';
|
|
31
31
|
import { fileURLToPath } from 'node:url';
|
|
32
32
|
import { exec, spawn, spawnSync } from 'node:child_process';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
33
34
|
import { projectSkillsForScope, detectActiveSkillTargets } from './skill-projection.mjs';
|
|
35
|
+
import { ensureRepoExpert } from './repo-expert-generate.mjs';
|
|
36
|
+
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
34
37
|
|
|
35
38
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
39
|
const ROOT = path.join(__dirname, '..', '..');
|
|
@@ -261,6 +264,9 @@ export default async function init(args) {
|
|
|
261
264
|
}
|
|
262
265
|
|
|
263
266
|
for (const skill of skillsToSeed) {
|
|
267
|
+
// A repository expert contains project facts and must never be copied from
|
|
268
|
+
// the global brain or package scaffold into a different checkout.
|
|
269
|
+
if (isProject && skill === 'repo-expert') continue;
|
|
264
270
|
let skillSrc = path.join(scaffoldSkillsDir, skill);
|
|
265
271
|
|
|
266
272
|
// For ssss: prefer the npm-installed @ssss/cli package over scaffold
|
|
@@ -408,10 +414,10 @@ export default async function init(args) {
|
|
|
408
414
|
}
|
|
409
415
|
|
|
410
416
|
if (!config.dashboard.password_hash) {
|
|
411
|
-
|
|
417
|
+
writeFileSecure(securityPath, yaml.stringify(config), { encoding: "utf8", mode: 0o600 });
|
|
412
418
|
passwordMessage = "\n 🔑 Dashboard Access: Unconfigured\n (You will be prompted to set your password upon first browser access)";
|
|
413
419
|
} else {
|
|
414
|
-
|
|
420
|
+
writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
415
421
|
passwordMessage = '\n 🔑 Dashboard credentials preserved (custom password hash already configured)';
|
|
416
422
|
}
|
|
417
423
|
} catch (err) {
|
|
@@ -586,7 +592,7 @@ export default async function init(args) {
|
|
|
586
592
|
current['cfg-health-url'] = 'http://localhost:3000/health';
|
|
587
593
|
}
|
|
588
594
|
|
|
589
|
-
|
|
595
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
590
596
|
logOk('UI deploy configuration persisted to wizard-config.json!');
|
|
591
597
|
}
|
|
592
598
|
|
|
@@ -600,6 +606,17 @@ export default async function init(args) {
|
|
|
600
606
|
logStep('3.6/4', 'Ensuring openwiki is present');
|
|
601
607
|
await ensureOpenWiki(brainDir, isProject, opts.dryRun);
|
|
602
608
|
|
|
609
|
+
if (isProject) {
|
|
610
|
+
logStep('3.6/4', 'Ensuring repository-specific expert');
|
|
611
|
+
try {
|
|
612
|
+
const expert = ensureRepoExpert(cwd, { dryRun: opts.dryRun });
|
|
613
|
+
logOk(`repo-expert ${expert.action}: ${path.relative(cwd, expert.destFile)}`);
|
|
614
|
+
} catch (err) {
|
|
615
|
+
logWarn(`Project initialization stopped: ${err.message}`);
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
603
620
|
// ── Step 3.7: Project skills into the IDEs in use ──
|
|
604
621
|
// Scope matches the brain: `init --project` projects PROJECT skills into the
|
|
605
622
|
// repo's IDE skill dirs for IDEs actually used here (.claude/, .agents/, or
|
|
@@ -628,6 +645,7 @@ export default async function init(args) {
|
|
|
628
645
|
logOk(`${t.label}: ${n} skill(s) → ${path.relative(cwd, t.destDir) || t.destDir}/`);
|
|
629
646
|
}
|
|
630
647
|
log(` Discoverable skills: ${skills.map(s => s.name).join(', ')}`);
|
|
648
|
+
if (isProject) await connectDetectedClients(wired);
|
|
631
649
|
if (available.length > 0) {
|
|
632
650
|
log(` Also available (opt-in): ${available.map(t => t.clients.join('/')).join(', ')} — \`npx total-recall connect <ide>\``);
|
|
633
651
|
}
|
|
@@ -670,7 +688,7 @@ export default async function init(args) {
|
|
|
670
688
|
fs.mkdirSync(cfgDir, { recursive: true });
|
|
671
689
|
const brainCfg = { url: opts.brain };
|
|
672
690
|
if (opts.token) brainCfg.token = opts.token;
|
|
673
|
-
|
|
691
|
+
writeFileSecure(path.join(cfgDir, 'brain.json'), JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
|
|
674
692
|
logOk(`Registered brain at ${opts.brain}. Run \`npx total-recall sync\` to pull instructions.`);
|
|
675
693
|
} else {
|
|
676
694
|
// Zero-config bootstrap: auto-generate localhost brain.json and valid Developer PAT out-of-the-box
|
|
@@ -690,14 +708,22 @@ export default async function init(args) {
|
|
|
690
708
|
needsBootstrap = true;
|
|
691
709
|
}
|
|
692
710
|
|
|
711
|
+
if (isProject && !brainCfg.project_id) {
|
|
712
|
+
brainCfg.project_id = randomUUID();
|
|
713
|
+
needsBootstrap = true;
|
|
714
|
+
}
|
|
715
|
+
|
|
693
716
|
if (needsBootstrap) {
|
|
694
717
|
try {
|
|
695
718
|
const { issueKey } = await import('../server/keys.mjs');
|
|
696
719
|
const keyData = issueKey('Default Local Developer Key', { scopes: ['*'] });
|
|
697
720
|
brainCfg.url = brainCfg.url || 'http://localhost:3000';
|
|
698
721
|
brainCfg.token = brainCfg.token || keyData.token;
|
|
699
|
-
|
|
722
|
+
writeFileSecure(brainJsonPath, JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
|
|
700
723
|
logOk(`Bootstrap configuration successfully generated and pre-authorized at ${brainCfg.url}`);
|
|
724
|
+
if (brainCfg.project_id) {
|
|
725
|
+
logOk(`Assigned project UUID ${brainCfg.project_id} to project brain`);
|
|
726
|
+
}
|
|
701
727
|
} catch (err) {
|
|
702
728
|
logWarn(`Could not auto-generate bootstrap developer key: ${err.message}`);
|
|
703
729
|
}
|
|
@@ -786,7 +812,7 @@ export default async function init(args) {
|
|
|
786
812
|
current['cfg-dash-url'] = `${tunnelUrl}/`;
|
|
787
813
|
current['cfg-health-url'] = `${tunnelUrl}/health`;
|
|
788
814
|
|
|
789
|
-
|
|
815
|
+
writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
790
816
|
logOk('Registered Quick Tunnel URL in wizard-config.json!');
|
|
791
817
|
dashboardUrl = `${tunnelUrl}/`;
|
|
792
818
|
} else {
|
|
@@ -858,7 +884,14 @@ export default async function init(args) {
|
|
|
858
884
|
}
|
|
859
885
|
const projectName = path.basename(cwd);
|
|
860
886
|
const existingIdx = registry.findIndex(p => p.path === cwd);
|
|
887
|
+
// brainCfg is block-scoped to the bootstrap branch above; re-read the
|
|
888
|
+
// persisted brain.json so the project UUID reaches the registry.
|
|
889
|
+
let projectId;
|
|
890
|
+
try {
|
|
891
|
+
projectId = JSON.parse(fs.readFileSync(path.join(brainDir, 'config', 'brain.json'), 'utf8')).project_id;
|
|
892
|
+
} catch { projectId = undefined; }
|
|
861
893
|
const entry = {
|
|
894
|
+
project_id: projectId || undefined,
|
|
862
895
|
name: projectName,
|
|
863
896
|
path: cwd,
|
|
864
897
|
brainDir: brainDir,
|
|
@@ -920,3 +953,25 @@ export default async function init(args) {
|
|
|
920
953
|
|
|
921
954
|
process.exit(0);
|
|
922
955
|
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Register every IDE detected as in use as a connected client, so step 4's
|
|
959
|
+
* compile writes that IDE's instruction shim (CLAUDE.md, AGENTS.md, GEMINI.md…).
|
|
960
|
+
* Projecting skills alone left new repos without the global rules.
|
|
961
|
+
*/
|
|
962
|
+
export async function connectDetectedClients(wired, { connectFn } = {}) {
|
|
963
|
+
const { default: connect, CLIENTS } = await import('./connect.mjs');
|
|
964
|
+
const run = connectFn || connect;
|
|
965
|
+
const connected = [];
|
|
966
|
+
for (const target of wired) {
|
|
967
|
+
const client = (target.clients || []).find((c) => CLIENTS[c]);
|
|
968
|
+
if (!client || connected.includes(client)) continue;
|
|
969
|
+
try {
|
|
970
|
+
await run([client]);
|
|
971
|
+
connected.push(client);
|
|
972
|
+
} catch (err) {
|
|
973
|
+
logWarn(`Could not connect ${client}: ${err.message} — run \`npx total-recall connect ${client}\``);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return connected;
|
|
977
|
+
}
|