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
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();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* total-recall group
|
|
3
|
+
* Manage groups of projects and groups of groups (create, add, remove, list, show, tree, members --recursive, delete).
|
|
4
|
+
* Built-in (ships with the package); was a machine-local composable command.
|
|
5
|
+
*/
|
|
6
|
+
export default async function (args = []) {
|
|
7
|
+
// total-recall group <create|add|remove|list|tree|members|delete|show> …
|
|
8
|
+
// Groups of projects and groups of groups. Each group is an SSSS document
|
|
9
|
+
// (type: project_group) in the global brain: ~/.agent/skills/total-recall/groups/<uuid>.md
|
|
10
|
+
// Members are projects (random-UUID project ids) or other groups. Nesting is
|
|
11
|
+
// cycle-checked; a project or group may belong to several groups.
|
|
12
|
+
const fs = await import('node:fs');
|
|
13
|
+
const path = await import('node:path');
|
|
14
|
+
const os = await import('node:os');
|
|
15
|
+
const { randomUUID } = await import('node:crypto');
|
|
16
|
+
const { spawnSync } = await import('node:child_process');
|
|
17
|
+
const { createRequire } = await import('node:module');
|
|
18
|
+
const YAML = (await import('yaml')).default;
|
|
19
|
+
|
|
20
|
+
const argl = Array.isArray(args) ? args : [];
|
|
21
|
+
const asJson = argl.includes('--json');
|
|
22
|
+
const recursive = argl.includes('--recursive') || argl.includes('-r');
|
|
23
|
+
const pos = argl.filter((a) => !a.startsWith('-'));
|
|
24
|
+
const [sub, ...rest] = pos;
|
|
25
|
+
const DIR = path.join(os.homedir(), '.agent', 'skills', 'total-recall', 'groups');
|
|
26
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
27
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
const die = (m) => { process.exitCode = 1; if (asJson) console.log(JSON.stringify({ ok: false, error: m })); else console.error('✖ ' + m); };
|
|
29
|
+
const help = () => console.log(`Usage: total-recall group <command> [--json]
|
|
30
|
+
|
|
31
|
+
create <name> [--description "<text>"] New group (random UUID id)
|
|
32
|
+
add <group> <member…> Member = repo path (→ its project id), project UUID, or group name/UUID
|
|
33
|
+
remove <group> <member…> Remove members
|
|
34
|
+
list All groups
|
|
35
|
+
show <group> One group with direct members
|
|
36
|
+
tree [<group>] Nested view (all roots if omitted)
|
|
37
|
+
members <group> [--recursive] Direct members, or every project reached through nested groups
|
|
38
|
+
delete <group> Delete a group (refused while other groups contain it)
|
|
39
|
+
|
|
40
|
+
Groups are SSSS project_group documents in ${DIR}. Nesting is cycle-checked.`);
|
|
41
|
+
|
|
42
|
+
const load = () => fs.readdirSync(DIR).filter((f) => f.endsWith('.md')).map((f) => {
|
|
43
|
+
const raw = fs.readFileSync(path.join(DIR, f), 'utf8');
|
|
44
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
45
|
+
return m ? YAML.parse(m[1]) : null;
|
|
46
|
+
}).filter(Boolean);
|
|
47
|
+
const save = (g) => {
|
|
48
|
+
g.timestamp = new Date().toISOString();
|
|
49
|
+
const body = `---\n${YAML.stringify(g).trim()}\n---\n\n# ${g.title}\n\n${g.description || 'Project group.'}\n`;
|
|
50
|
+
const file = path.join(DIR, `${g.id}.md`); const tmp = `${file}.tmp-${process.pid}`;
|
|
51
|
+
fs.writeFileSync(tmp, body, { mode: 0o644 }); fs.renameSync(tmp, file);
|
|
52
|
+
};
|
|
53
|
+
const groups = load();
|
|
54
|
+
const byId = new Map(groups.map((g) => [g.id, g]));
|
|
55
|
+
const find = (ref) => groups.find((g) => g.id === ref) || groups.filter((g) => g.title === ref).at(0) || null;
|
|
56
|
+
const findAll = (ref) => groups.filter((g) => g.id === ref || g.title === ref);
|
|
57
|
+
const one = (ref) => { const hits = findAll(ref); if (hits.length > 1) { die(`"${ref}" matches ${hits.length} groups — use the id`); return null; } if (!hits.length) { die(`no group "${ref}"`); return null; } return hits[0]; };
|
|
58
|
+
|
|
59
|
+
const projectFromPath = (p) => {
|
|
60
|
+
const abs = path.resolve(p);
|
|
61
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return null;
|
|
62
|
+
const r = spawnSync(process.execPath, [process.argv[1], 'project-id', '--json'], { cwd: abs, encoding: 'utf8' });
|
|
63
|
+
const line = (r.stdout || '').split('\n').find((l) => l.startsWith('{"project_id"'));
|
|
64
|
+
if (r.status !== 0 || !line) return null;
|
|
65
|
+
const j = JSON.parse(line);
|
|
66
|
+
return { type: 'project', id: j.project_id, name: j.name, path: path.dirname(path.dirname(path.dirname(path.dirname(path.dirname(j.config))))) };
|
|
67
|
+
};
|
|
68
|
+
const resolveMember = (ref) => {
|
|
69
|
+
const g = find(ref); if (g) return { type: 'group', id: g.id, name: g.title };
|
|
70
|
+
if (UUID.test(ref)) {
|
|
71
|
+
// a bare UUID that is not a group: a project id (name/path unknown until added by path)
|
|
72
|
+
for (const x of groups) for (const m of x.members || []) if (m.type === 'project' && m.id === ref) return { ...m };
|
|
73
|
+
return { type: 'project', id: ref };
|
|
74
|
+
}
|
|
75
|
+
return projectFromPath(ref);
|
|
76
|
+
};
|
|
77
|
+
// does `from` (a group id) reach `target` (a group id) through nesting?
|
|
78
|
+
const reaches = (from, target, seen = new Set()) => {
|
|
79
|
+
if (from === target) return true; if (seen.has(from)) return false; seen.add(from);
|
|
80
|
+
return (byId.get(from)?.members || []).some((m) => m.type === 'group' && reaches(m.id, target, seen));
|
|
81
|
+
};
|
|
82
|
+
const flatten = (g, seen = new Set()) => {
|
|
83
|
+
const out = new Map(); if (seen.has(g.id)) return out; seen.add(g.id);
|
|
84
|
+
for (const m of g.members || []) {
|
|
85
|
+
if (m.type === 'project') out.set(m.id, m);
|
|
86
|
+
else if (byId.has(m.id)) for (const [k, v] of flatten(byId.get(m.id), seen)) out.set(k, v);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
const label = (m) => m.type === 'group' ? `▸ ${byId.get(m.id)?.title || m.name || m.id} (${m.id})` : `• ${m.name || '(project)'} ${m.id}${m.path ? ' ' + m.path : ''}`;
|
|
91
|
+
const printTree = (g, depth = 0, seen = new Set()) => {
|
|
92
|
+
console.log(`${' '.repeat(depth)}▸ ${g.title} (${g.id})`);
|
|
93
|
+
if (seen.has(g.id)) return; seen.add(g.id);
|
|
94
|
+
for (const m of g.members || []) {
|
|
95
|
+
if (m.type === 'group' && byId.has(m.id)) printTree(byId.get(m.id), depth + 1, seen);
|
|
96
|
+
else console.log(`${' '.repeat(depth + 1)}${label(m)}`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (!sub || sub === 'help' || argl.includes('--help') || argl.includes('-h')) { help(); return; }
|
|
101
|
+
|
|
102
|
+
if (sub === 'create') {
|
|
103
|
+
const name = rest.join(' ').trim(); if (!name) return die('group create <name>');
|
|
104
|
+
const di = argl.indexOf('--description');
|
|
105
|
+
const g = { type: 'project_group', id: randomUUID(), title: name, description: di >= 0 ? argl[di + 1] : '', members: [] };
|
|
106
|
+
save(g); return asJson ? console.log(JSON.stringify({ ok: true, group: g })) : console.log(`✔ group "${name}" ${g.id}`);
|
|
107
|
+
}
|
|
108
|
+
if (sub === 'add' || sub === 'remove') {
|
|
109
|
+
const g = one(rest[0]); if (!g) return; const refs = rest.slice(1); if (!refs.length) return die(`group ${sub} <group> <member…>`);
|
|
110
|
+
const results = [];
|
|
111
|
+
for (const ref of refs) {
|
|
112
|
+
const m = resolveMember(ref);
|
|
113
|
+
if (!m) { results.push({ ref, ok: false, error: 'not a group, project UUID, or repo path with a project brain' }); continue; }
|
|
114
|
+
if (sub === 'add') {
|
|
115
|
+
if (m.type === 'group' && reaches(m.id, g.id)) { results.push({ ref, ok: false, error: `cycle: "${m.name}" already contains "${g.title}"` }); continue; }
|
|
116
|
+
if ((g.members || []).some((x) => x.id === m.id)) { results.push({ ref, ok: true, note: 'already a member' }); continue; }
|
|
117
|
+
g.members = [...(g.members || []), m]; results.push({ ref, ok: true, added: m });
|
|
118
|
+
} else {
|
|
119
|
+
const before = (g.members || []).length; g.members = (g.members || []).filter((x) => x.id !== m.id);
|
|
120
|
+
results.push({ ref, ok: before !== g.members.length, removed: before !== g.members.length });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
save(g);
|
|
124
|
+
if (results.some((r) => !r.ok)) process.exitCode = 1;
|
|
125
|
+
return asJson ? console.log(JSON.stringify({ ok: !process.exitCode, group: g.id, results })) : results.forEach((r) => console.log(`${r.ok ? '✔' : '✖'} ${r.ref}${r.error ? ' — ' + r.error : r.note ? ' — ' + r.note : ''}`));
|
|
126
|
+
}
|
|
127
|
+
if (sub === 'list') {
|
|
128
|
+
const rows = groups.map((g) => ({ id: g.id, name: g.title, members: (g.members || []).length, projects: flatten(g).size }));
|
|
129
|
+
return asJson ? console.log(JSON.stringify(rows)) : (rows.length ? rows.forEach((r) => console.log(`▸ ${r.name} ${r.id} (${r.members} direct, ${r.projects} projects)`)) : console.log('No groups yet — total-recall group create <name>'));
|
|
130
|
+
}
|
|
131
|
+
if (sub === 'show') { const g = one(rest[0]); if (!g) return; return asJson ? console.log(JSON.stringify(g)) : (console.log(`▸ ${g.title} ${g.id}`), (g.members || []).forEach((m) => console.log(' ' + label(m)))); }
|
|
132
|
+
if (sub === 'tree') {
|
|
133
|
+
if (rest[0]) { const g = one(rest[0]); if (!g) return; return asJson ? console.log(JSON.stringify(g)) : printTree(g); }
|
|
134
|
+
const nested = new Set(groups.flatMap((g) => (g.members || []).filter((m) => m.type === 'group').map((m) => m.id)));
|
|
135
|
+
const roots = groups.filter((g) => !nested.has(g.id));
|
|
136
|
+
return asJson ? console.log(JSON.stringify(roots.map((g) => g.id))) : roots.forEach((g) => printTree(g));
|
|
137
|
+
}
|
|
138
|
+
if (sub === 'members') {
|
|
139
|
+
const g = one(rest[0]); if (!g) return;
|
|
140
|
+
const list = recursive ? [...flatten(g).values()] : (g.members || []);
|
|
141
|
+
return asJson ? console.log(JSON.stringify(list)) : list.forEach((m) => console.log(label(m)));
|
|
142
|
+
}
|
|
143
|
+
if (sub === 'delete') {
|
|
144
|
+
const g = one(rest[0]); if (!g) return;
|
|
145
|
+
const parents = groups.filter((x) => (x.members || []).some((m) => m.type === 'group' && m.id === g.id));
|
|
146
|
+
if (parents.length) return die(`"${g.title}" is inside ${parents.map((p) => p.title).join(', ')} — remove it there first`);
|
|
147
|
+
fs.unlinkSync(path.join(DIR, `${g.id}.md`));
|
|
148
|
+
return asJson ? console.log(JSON.stringify({ ok: true, deleted: g.id })) : console.log(`✔ deleted "${g.title}"`);
|
|
149
|
+
}
|
|
150
|
+
die(`unknown subcommand "${sub}" — total-recall group --help`);
|
|
151
|
+
}
|
package/src/cli/init.mjs
CHANGED
|
@@ -30,7 +30,9 @@ 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';
|
|
34
36
|
import { writeFileSecure } from '../core/secure-file.mjs';
|
|
35
37
|
|
|
36
38
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -262,6 +264,9 @@ export default async function init(args) {
|
|
|
262
264
|
}
|
|
263
265
|
|
|
264
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;
|
|
265
270
|
let skillSrc = path.join(scaffoldSkillsDir, skill);
|
|
266
271
|
|
|
267
272
|
// For ssss: prefer the npm-installed @ssss/cli package over scaffold
|
|
@@ -601,6 +606,17 @@ export default async function init(args) {
|
|
|
601
606
|
logStep('3.6/4', 'Ensuring openwiki is present');
|
|
602
607
|
await ensureOpenWiki(brainDir, isProject, opts.dryRun);
|
|
603
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
|
+
|
|
604
620
|
// ── Step 3.7: Project skills into the IDEs in use ──
|
|
605
621
|
// Scope matches the brain: `init --project` projects PROJECT skills into the
|
|
606
622
|
// repo's IDE skill dirs for IDEs actually used here (.claude/, .agents/, or
|
|
@@ -629,6 +645,7 @@ export default async function init(args) {
|
|
|
629
645
|
logOk(`${t.label}: ${n} skill(s) → ${path.relative(cwd, t.destDir) || t.destDir}/`);
|
|
630
646
|
}
|
|
631
647
|
log(` Discoverable skills: ${skills.map(s => s.name).join(', ')}`);
|
|
648
|
+
if (isProject) await connectDetectedClients(wired);
|
|
632
649
|
if (available.length > 0) {
|
|
633
650
|
log(` Also available (opt-in): ${available.map(t => t.clients.join('/')).join(', ')} — \`npx total-recall connect <ide>\``);
|
|
634
651
|
}
|
|
@@ -691,6 +708,11 @@ export default async function init(args) {
|
|
|
691
708
|
needsBootstrap = true;
|
|
692
709
|
}
|
|
693
710
|
|
|
711
|
+
if (isProject && !brainCfg.project_id) {
|
|
712
|
+
brainCfg.project_id = randomUUID();
|
|
713
|
+
needsBootstrap = true;
|
|
714
|
+
}
|
|
715
|
+
|
|
694
716
|
if (needsBootstrap) {
|
|
695
717
|
try {
|
|
696
718
|
const { issueKey } = await import('../server/keys.mjs');
|
|
@@ -699,6 +721,9 @@ export default async function init(args) {
|
|
|
699
721
|
brainCfg.token = brainCfg.token || keyData.token;
|
|
700
722
|
writeFileSecure(brainJsonPath, JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
|
|
701
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
|
+
}
|
|
702
727
|
} catch (err) {
|
|
703
728
|
logWarn(`Could not auto-generate bootstrap developer key: ${err.message}`);
|
|
704
729
|
}
|
|
@@ -859,7 +884,14 @@ export default async function init(args) {
|
|
|
859
884
|
}
|
|
860
885
|
const projectName = path.basename(cwd);
|
|
861
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; }
|
|
862
893
|
const entry = {
|
|
894
|
+
project_id: projectId || undefined,
|
|
863
895
|
name: projectName,
|
|
864
896
|
path: cwd,
|
|
865
897
|
brainDir: brainDir,
|
|
@@ -921,3 +953,25 @@ export default async function init(args) {
|
|
|
921
953
|
|
|
922
954
|
process.exit(0);
|
|
923
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
|
+
}
|
|
@@ -116,10 +116,10 @@ export async function createPlugin(args = []) {
|
|
|
116
116
|
const pluginDesc = description || skillFrontmatter.description || `${pluginName} extension for Total Recall AI OS`;
|
|
117
117
|
|
|
118
118
|
const manifest = {
|
|
119
|
-
$schema: "https://github.com/
|
|
119
|
+
$schema: "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
|
|
120
120
|
id,
|
|
121
121
|
name: pluginName,
|
|
122
|
-
version: "1.0
|
|
122
|
+
version: "0.1.0",
|
|
123
123
|
description: pluginDesc,
|
|
124
124
|
license: "MIT"
|
|
125
125
|
};
|
|
@@ -145,8 +145,7 @@ export async function createPlugin(args = []) {
|
|
|
145
145
|
command: id,
|
|
146
146
|
handler: "./cli.mjs",
|
|
147
147
|
subcommands: [
|
|
148
|
-
{ name: "status", description: `Show ${pluginName}
|
|
149
|
-
{ name: "run", description: `Execute ${pluginName} primary task` }
|
|
148
|
+
{ name: "status", description: `Show which ${pluginName} subcommands are implemented` }
|
|
150
149
|
]
|
|
151
150
|
};
|
|
152
151
|
|
|
@@ -155,16 +154,27 @@ export async function createPlugin(args = []) {
|
|
|
155
154
|
* ${pluginName} CLI Handler
|
|
156
155
|
* Executed via: npx total-recall ${id} <subcommand>
|
|
157
156
|
*/
|
|
157
|
+
// Scaffold: report honestly what exists. Add each real subcommand to
|
|
158
|
+
// IMPLEMENTED and to plugin.json "cli.subcommands" as it is built.
|
|
159
|
+
const IMPLEMENTED = {};
|
|
160
|
+
|
|
158
161
|
export async function run(argv = []) {
|
|
159
162
|
const args = Array.isArray(argv) ? argv.slice(3) : [];
|
|
160
163
|
const sub = args[0] || "status";
|
|
161
|
-
|
|
164
|
+
const json = args.includes("--json");
|
|
165
|
+
|
|
162
166
|
if (sub === "status") {
|
|
163
|
-
|
|
167
|
+
const status = { plugin: ${JSON.stringify(id)}, implemented: Object.keys(IMPLEMENTED) };
|
|
168
|
+
if (json) console.log(JSON.stringify(status));
|
|
169
|
+
else console.log(status.implemented.length
|
|
170
|
+
? \`${pluginName}: implemented subcommands: \${status.implemented.join(", ")}\`
|
|
171
|
+
: \`${pluginName}: scaffold only — no subcommands implemented yet.\`);
|
|
164
172
|
return;
|
|
165
173
|
}
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
if (IMPLEMENTED[sub]) return IMPLEMENTED[sub](args.slice(1));
|
|
175
|
+
|
|
176
|
+
console.error(\`${pluginName}: "\${sub}" is not implemented.\`);
|
|
177
|
+
process.exitCode = 2;
|
|
168
178
|
}
|
|
169
179
|
export default run;
|
|
170
180
|
`;
|
|
@@ -204,7 +214,7 @@ export default run;
|
|
|
204
214
|
const skillContent = `---
|
|
205
215
|
name: ${id}
|
|
206
216
|
description: "${pluginDesc}"
|
|
207
|
-
version: 1.0
|
|
217
|
+
version: 0.1.0
|
|
208
218
|
---
|
|
209
219
|
|
|
210
220
|
# ${pluginName}
|
|
@@ -284,7 +294,7 @@ export default generateContext;
|
|
|
284
294
|
"## Features",
|
|
285
295
|
"",
|
|
286
296
|
`- **Identifier**: \`${id}\``,
|
|
287
|
-
"- **Version**: 1.0
|
|
297
|
+
"- **Version**: 0.1.0"
|
|
288
298
|
];
|
|
289
299
|
if (useCases.length) lines.push(`- **Use cases**: ${useCases.join(", ")}`);
|
|
290
300
|
if (category) lines.push(`- **SSSS Category**: \`${category}\``);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* total-recall project-id [--json] | --all
|
|
3
|
+
*
|
|
4
|
+
* Prints this project's stable random-UUID id, creating it on first use.
|
|
5
|
+
* --all gives every registered project an id and reconciles the registry.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { detectProjectBrain } from './agent-dir.mjs';
|
|
9
|
+
import { getGlobalBrainDir } from './agent-dir.mjs';
|
|
10
|
+
import { ensureProjectId, repairProjectIds } from '../core/project-id.mjs';
|
|
11
|
+
|
|
12
|
+
export default async function projectIdCli(argv = []) {
|
|
13
|
+
const args = argv.filter((a) => a !== 'project-id');
|
|
14
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
15
|
+
console.log(
|
|
16
|
+
'Usage: total-recall project-id [--json]\n' +
|
|
17
|
+
' total-recall project-id --all [--json]\n\n' +
|
|
18
|
+
"Prints this repo's random-UUID project id (created on first use, stored in\n" +
|
|
19
|
+
'config/brain.json). --all ensures every registered project has one and syncs the registry.',
|
|
20
|
+
);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const json = args.includes('--json');
|
|
24
|
+
if (args.includes('--all')) {
|
|
25
|
+
const r = repairProjectIds(getGlobalBrainDir());
|
|
26
|
+
if (json) console.log(JSON.stringify(r));
|
|
27
|
+
else {
|
|
28
|
+
console.log(` ${r.total} project(s): ${r.created} id(s) created, ${r.synced} registry row(s) synced, ${r.missing_brain} without a brain on disk`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const project = detectProjectBrain(process.cwd());
|
|
33
|
+
if (!project) {
|
|
34
|
+
console.error(`No project brain found above ${process.cwd()}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const { project_id, created } = ensureProjectId(project.brainDir);
|
|
38
|
+
if (json) {
|
|
39
|
+
console.log(JSON.stringify({ project_id, name: path.basename(project.projectRoot).trim(), created, path: project.projectRoot }));
|
|
40
|
+
} else {
|
|
41
|
+
console.log(project_id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -13,6 +13,11 @@ import fs from 'node:fs';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { execSync } from 'node:child_process';
|
|
15
15
|
|
|
16
|
+
const SKIP_SCAN_DIRS = new Set([
|
|
17
|
+
'.agent', '.agents', '.claude', '.git', '.venv', 'node_modules', 'dist',
|
|
18
|
+
'build', 'coverage', '__pycache__', 'vault', 'logs', 'vendor',
|
|
19
|
+
]);
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* Scan a repo and return structured architecture data.
|
|
18
23
|
*/
|
|
@@ -83,7 +88,7 @@ export function scanRepo(repoRoot) {
|
|
|
83
88
|
try {
|
|
84
89
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
85
90
|
for (const entry of entries) {
|
|
86
|
-
if (entry.name.startsWith('.') ||
|
|
91
|
+
if (entry.name.startsWith('.') || SKIP_SCAN_DIRS.has(entry.name)) continue;
|
|
87
92
|
const full = path.join(dir, entry.name);
|
|
88
93
|
if (entry.isDirectory()) {
|
|
89
94
|
walkForExtensions(full, depth + 1);
|
|
@@ -119,8 +124,7 @@ export function scanRepo(repoRoot) {
|
|
|
119
124
|
try {
|
|
120
125
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
121
126
|
for (const entry of entries) {
|
|
122
|
-
if (entry.name.startsWith('.')
|
|
123
|
-
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build' || entry.name === '.git') continue;
|
|
127
|
+
if (entry.name.startsWith('.') || SKIP_SCAN_DIRS.has(entry.name)) continue;
|
|
124
128
|
const full = path.join(dir, entry.name);
|
|
125
129
|
if (entry.isDirectory()) {
|
|
126
130
|
const childCount = countChildren(full);
|
|
@@ -330,6 +334,7 @@ export function generateSkillMd(scan, repoRoot) {
|
|
|
330
334
|
lines.push(' structure, and runtime topology. MANDATORY: You MUST read the full SKILL.md');
|
|
331
335
|
lines.push(' file before executing.');
|
|
332
336
|
lines.push('repo_scoped: true');
|
|
337
|
+
lines.push(`repository_id: ${JSON.stringify(scan.name || path.basename(path.resolve(repoRoot)))}`);
|
|
333
338
|
lines.push(`generated_at: ${new Date().toISOString()}`);
|
|
334
339
|
// Record the repo by name, never by absolute path. SKILL.md is committed, so
|
|
335
340
|
// an absolute root bakes the generating machine's home directory into the
|
|
@@ -520,6 +525,35 @@ export function generateRepoExpert(repoRoot, opts = {}) {
|
|
|
520
525
|
};
|
|
521
526
|
}
|
|
522
527
|
|
|
528
|
+
/** Preserve a local expert and reject one that identifies another repository. */
|
|
529
|
+
export function ensureRepoExpert(repoRoot, opts = {}) {
|
|
530
|
+
const root = path.resolve(repoRoot);
|
|
531
|
+
const destFile = path.join(root, '.agent', 'skills', 'repo-expert', 'SKILL.md');
|
|
532
|
+
let repositoryId = path.basename(root);
|
|
533
|
+
try {
|
|
534
|
+
repositoryId = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name || repositoryId;
|
|
535
|
+
} catch { /* non-Node repositories use the checkout name */ }
|
|
536
|
+
|
|
537
|
+
if (fs.existsSync(destFile)) {
|
|
538
|
+
const content = fs.readFileSync(destFile, 'utf8');
|
|
539
|
+
const header = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] || '';
|
|
540
|
+
const scoped = /^repo_scoped:\s*true\s*$/m.test(header);
|
|
541
|
+
const id = header.match(/^repository_id:\s*(.+)\s*$/m)?.[1]?.trim();
|
|
542
|
+
const generatedFrom = header.match(/^generated_from:\s*(.+)\s*$/m)?.[1]?.trim();
|
|
543
|
+
const parsedId = id?.replace(/^["']|["']$/g, '');
|
|
544
|
+
const parsedFrom = generatedFrom?.replace(/^["']|["']$/g, '');
|
|
545
|
+
if (!scoped || (parsedId ? parsedId !== repositoryId :
|
|
546
|
+
!parsedFrom || parsedFrom !== path.basename(root))) {
|
|
547
|
+
throw new Error(`Existing repo-expert does not match ${repositoryId}: ${destFile}`);
|
|
548
|
+
}
|
|
549
|
+
return { action: 'existing', destFile, repositoryId };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (opts.dryRun) return { action: 'would-generate', destFile, repositoryId };
|
|
553
|
+
generateRepoExpert(root);
|
|
554
|
+
return { action: 'generated', destFile, repositoryId };
|
|
555
|
+
}
|
|
556
|
+
|
|
523
557
|
/**
|
|
524
558
|
* CLI handler for `npx total-recall skill generate-expert`
|
|
525
559
|
*/
|
package/src/cli/secret.mjs
CHANGED
|
@@ -57,8 +57,9 @@ import {
|
|
|
57
57
|
deployKeyToRemotes,
|
|
58
58
|
} from '../core/secrets-remote-deploy.mjs';
|
|
59
59
|
import { listProviders, getProvider } from '../core/provider-catalog.mjs';
|
|
60
|
-
import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
|
|
60
|
+
import { resolveBrainDir, parseLayerFlag, detectProjectBrain } from './agent-dir.mjs';
|
|
61
61
|
import fs from 'node:fs';
|
|
62
|
+
import { ensureProjectId } from '../core/project-id.mjs';
|
|
62
63
|
import path from 'node:path';
|
|
63
64
|
import { execFileSync } from 'node:child_process';
|
|
64
65
|
import os from 'node:os';
|
|
@@ -233,6 +234,7 @@ function printHelp() {
|
|
|
233
234
|
--docs <url> API docs URL
|
|
234
235
|
--rotate-days <n> Auto-rotation interval
|
|
235
236
|
--auto-rotate Enable auto-rotate flag
|
|
237
|
+
--tags <a,b,c> Optional tags saying what the key is and what uses it
|
|
236
238
|
--notes <text>
|
|
237
239
|
--label <text>
|
|
238
240
|
--project <path>
|
|
@@ -304,6 +306,7 @@ function parseArgs(args) {
|
|
|
304
306
|
rotate_days: null,
|
|
305
307
|
auto_rotate: false,
|
|
306
308
|
notes: null,
|
|
309
|
+
tags: null,
|
|
307
310
|
label: null,
|
|
308
311
|
project: null,
|
|
309
312
|
headscale_url: null,
|
|
@@ -390,6 +393,9 @@ function parseArgs(args) {
|
|
|
390
393
|
case '--provider':
|
|
391
394
|
out.provider = args[++i];
|
|
392
395
|
break;
|
|
396
|
+
case '--tags':
|
|
397
|
+
out.tags = args[++i];
|
|
398
|
+
break;
|
|
393
399
|
case '--scope':
|
|
394
400
|
out.scope = args[++i];
|
|
395
401
|
break;
|
|
@@ -573,6 +579,16 @@ function parseArgs(args) {
|
|
|
573
579
|
return out;
|
|
574
580
|
}
|
|
575
581
|
|
|
582
|
+
/** Id of the project the command runs in (created on first use), or null outside a project. */
|
|
583
|
+
function currentProjectId() {
|
|
584
|
+
try {
|
|
585
|
+
const project = detectProjectBrain(process.cwd());
|
|
586
|
+
return project ? ensureProjectId(project.brainDir).project_id : null;
|
|
587
|
+
} catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
576
592
|
export default async function secretCli(argv) {
|
|
577
593
|
const { layer, remainingArgs } = parseLayerFlag(argv);
|
|
578
594
|
const opts = parseArgs(remainingArgs);
|
|
@@ -708,6 +724,8 @@ export default async function secretCli(argv) {
|
|
|
708
724
|
process.exit(1);
|
|
709
725
|
}
|
|
710
726
|
await setSecret(brainDir, opts.key, opts.value, {
|
|
727
|
+
tags: opts.tags != null ? opts.tags : undefined,
|
|
728
|
+
project_id: currentProjectId(),
|
|
711
729
|
provider: opts.provider || inferProvider(opts.key),
|
|
712
730
|
scope: opts.scope,
|
|
713
731
|
repos: opts.repos.length ? opts.repos : undefined,
|
|
@@ -955,6 +973,7 @@ export default async function secretCli(argv) {
|
|
|
955
973
|
if (opts.rotate_days != null) patch.rotate_every_days = opts.rotate_days;
|
|
956
974
|
if (opts.auto_rotate) patch.auto_rotate = true;
|
|
957
975
|
if (opts.notes) patch.notes = opts.notes;
|
|
976
|
+
if (opts.tags != null) patch.tags = opts.tags;
|
|
958
977
|
if (opts.label) patch.label = opts.label;
|
|
959
978
|
if (opts.project) patch.project_path = opts.project;
|
|
960
979
|
if (opts.provider) patch.provider = opts.provider;
|
|
@@ -1008,7 +1027,21 @@ export default async function secretCli(argv) {
|
|
|
1008
1027
|
console.error('Usage: total-recall secret get <key>');
|
|
1009
1028
|
process.exit(1);
|
|
1010
1029
|
}
|
|
1011
|
-
const
|
|
1030
|
+
const project_id = currentProjectId();
|
|
1031
|
+
let r = await getSecret(brainDir, opts.key, { project_id });
|
|
1032
|
+
if (!r.found && layer === 'auto') {
|
|
1033
|
+
// Credentials live in the global store (SSOT). From inside a project
|
|
1034
|
+
// the auto layer resolves to the project store, so fall back to the
|
|
1035
|
+
// global one, but only for keys bound to this repo or unbound.
|
|
1036
|
+
const { globalBrainDir } = await import('../core/config.mjs');
|
|
1037
|
+
const { secretMatchesTarget } = await import('../core/secrets-env-export.mjs');
|
|
1038
|
+
if (globalBrainDir && globalBrainDir !== brainDir) {
|
|
1039
|
+
const meta = (await listSecretsMeta(globalBrainDir)).find((k) => k.key === opts.key);
|
|
1040
|
+
if (meta && secretMatchesTarget(meta, { projectPath: process.cwd(), includeGlobal: true })) {
|
|
1041
|
+
r = await getSecret(globalBrainDir, opts.key, { project_id });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1012
1045
|
if (!r.found) {
|
|
1013
1046
|
console.error(` Secret not found: ${opts.key}`);
|
|
1014
1047
|
process.exit(1);
|
|
@@ -1313,7 +1346,7 @@ export default async function secretCli(argv) {
|
|
|
1313
1346
|
}
|
|
1314
1347
|
console.log(`\n Secret audit (last ${events.length})\n`);
|
|
1315
1348
|
for (const e of events) {
|
|
1316
|
-
console.log(` ${e.ts} ${e.action.padEnd(8)} ${e.key} ${e.actor || ''}`);
|
|
1349
|
+
console.log(` ${e.ts} ${e.action.padEnd(8)} ${e.key} ${e.actor || ''}${e.project_id ? ` project=${e.project_id}` : ''}`);
|
|
1317
1350
|
}
|
|
1318
1351
|
console.log('');
|
|
1319
1352
|
return;
|