aio-connect 1.1.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.
@@ -0,0 +1,41 @@
1
+ // rollback.js — `aio rollback`: surgically undo everything setup did (FR7)
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { stripBlock, removeMcpAdditions, backup } from './write.js';
5
+ import { BACKUP_DIR } from './paths.js';
6
+
7
+ export function runRollback() {
8
+ const home = os.homedir();
9
+ const targets = [
10
+ { label: 'opencode', file: path.join(home, '.config', 'opencode', 'AGENTS.md') },
11
+ { label: 'claude', file: path.join(home, '.claude', 'CLAUDE.md') },
12
+ { label: 'kimi', file: path.join(home, '.kimi-code', 'AGENTS.md') },
13
+ { label: 'jcode', file: path.join(home, '.jcode', 'AGENTS.md') },
14
+ { label: 'codex', file: path.join(home, '.codex', 'AGENTS.md') },
15
+ { label: 'gemini', file: path.join(home, '.gemini', 'GEMINI.md') },
16
+ { label: 'global', file: path.join(home, 'AGENTS.md') },
17
+ ];
18
+
19
+ console.log('aio rollback');
20
+ console.log('─'.repeat(76));
21
+
22
+ console.log('\nContext block');
23
+ let removed = 0;
24
+ for (const t of targets) {
25
+ const st = stripBlock(t.file);
26
+ if (st === 'removed') removed++;
27
+ console.log(` [${st === 'removed' ? 'x' : ' '}] ${t.label.padEnd(26)} ${st} — ${t.file}`);
28
+ }
29
+
30
+ console.log('\nMCP additions (only entries aio itself added)');
31
+ const mcp = removeMcpAdditions();
32
+ if (!mcp.length) console.log(' [x] none on record — nothing to remove');
33
+ for (const r of mcp) {
34
+ console.log(` [${r.status === 'removed' ? 'x' : ' '}] ${r.target.padEnd(26)} ${r.status}`);
35
+ }
36
+
37
+ console.log(`\nRemoved ${removed} context block(s).`);
38
+ console.log(`File backups (kept as safety net): ${BACKUP_DIR}`);
39
+ console.log('Local data (ai-tools.db, TOOLS-INDEX.md, TRACKING.md) was never touched.');
40
+ console.log('');
41
+ }
package/src/scan.js ADDED
@@ -0,0 +1,177 @@
1
+ // scan.js — read-only machine scan: agents, repos, tools, skills, MCP binary
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ // Agent spec: PATH binary OR instruction-parent dir present (codex/gemini may be
7
+ // configured without a shell wrapper).
8
+ const AGENTS = [
9
+ { name: 'opencode', dirHint: '.config/opencode' },
10
+ { name: 'claude', dirHint: '.claude' },
11
+ { name: 'kimi', dirHint: '.kimi-code' },
12
+ { name: 'jcode', dirHint: '.jcode' },
13
+ { name: 'freebuff', dirHint: null },
14
+ { name: 'hermes', dirHint: null },
15
+ { name: 'codex', dirHint: '.codex' },
16
+ { name: 'gemini', dirHint: '.gemini' },
17
+ ];
18
+ export const AGENT_NAMES = AGENTS.map((a) => a.name);
19
+
20
+ function pathDirs() {
21
+ return (process.env.PATH || '')
22
+ .split(path.delimiter)
23
+ .map((d) => d.trim().replace(/^"|"$/g, ''))
24
+ .filter(Boolean);
25
+ }
26
+
27
+ function findOnPath(name) {
28
+ const exts = ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '.PS1'];
29
+ for (const dir of pathDirs()) {
30
+ for (const ext of exts) {
31
+ const p = path.join(dir, name + ext);
32
+ try {
33
+ if (fs.existsSync(p)) return p;
34
+ } catch {
35
+ /* unreadable PATH entry — keep scanning */
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** Detect the known agent CLIs: on PATH, or by their instruction directory. */
43
+ export function detectAgents() {
44
+ const home = os.homedir();
45
+ return AGENTS.map(({ name, dirHint }) => {
46
+ const bin = findOnPath(name);
47
+ const configured = Boolean(dirHint && fs.existsSync(path.join(home, dirHint)));
48
+ return {
49
+ name,
50
+ found: Boolean(bin) || configured,
51
+ bin: bin || (configured ? `(configured: ~/${dirHint})` : null),
52
+ };
53
+ });
54
+ }
55
+
56
+ /** Detect an auxiliary binary (e.g. codebase-memory-mcp) on PATH. */
57
+ export function detectBinary(name) {
58
+ return findOnPath(name);
59
+ }
60
+
61
+ /** Scan a directory of cloned git repos; URL comes from each real .git/config. */
62
+ export function scanRepos(reposDir) {
63
+ if (!reposDir || !fs.existsSync(reposDir)) return [];
64
+ const out = [];
65
+ for (const ent of fs.readdirSync(reposDir, { withFileTypes: true })) {
66
+ if (!ent.isDirectory()) continue;
67
+ const full = path.join(reposDir, ent.name);
68
+ let url = '';
69
+ try {
70
+ const cfg = fs.readFileSync(path.join(full, '.git', 'config'), 'utf8');
71
+ const m = cfg.match(/url\s*=\s*(\S+)/);
72
+ if (m) url = m[1].replace(/\.git$/, '');
73
+ } catch {
74
+ /* not a git repo — listed as local-only */
75
+ }
76
+ out.push({ name: ent.name, url, path: full });
77
+ }
78
+ return out.sort((a, b) => a.name.localeCompare(b.name));
79
+ }
80
+
81
+ function stripMd(s) {
82
+ return s.replace(/[`*_]/g, '').trim();
83
+ }
84
+
85
+ /** Parse TOOLS-INDEX.md markdown tables → tool rows (fallback when no DB). */
86
+ export function parseToolsIndex(file) {
87
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
88
+ const out = [];
89
+ let section = '';
90
+ for (const line of lines) {
91
+ const h = line.match(/^##\s+(\d+[a-z]?)\.?\s+(.*)$/i);
92
+ if (h) {
93
+ section = h[2].replace(/\s*\(.*\)$/, '').trim();
94
+ continue;
95
+ }
96
+ if (!line.trim().startsWith('|')) continue;
97
+ const raw = line.split('|');
98
+ const cells = raw.slice(1, raw.length - 1).map((c) => c.trim());
99
+ if (cells.length < 2) continue;
100
+ if (/^:?-{3,}:?$/.test(cells[0])) continue; // separator row
101
+ const c0 = stripMd(cells[0]);
102
+ if (/^(command|tool|catatan|layanan|akses|fungsi|item|skill|name)$/i.test(c0)) continue; // header
103
+ out.push({
104
+ name: c0,
105
+ category: section,
106
+ access: stripMd(cells[0]),
107
+ version: '',
108
+ url: '',
109
+ description: cells.slice(1).map(stripMd).filter(Boolean).join(' · '),
110
+ status: '',
111
+ source: 'TOOLS-INDEX.md',
112
+ });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Tool catalog: prefer ai-tools.db (SQLite via node:sqlite, optional),
119
+ * fall back to TOOLS-INDEX.md parse, else empty (graceful on other machines).
120
+ */
121
+ export async function loadTools(dataDir) {
122
+ if (!dataDir) return [];
123
+ const dbPath = path.join(dataDir, 'ai-tools.db');
124
+ if (fs.existsSync(dbPath)) {
125
+ try {
126
+ const { DatabaseSync } = await import('node:sqlite');
127
+ const db = new DatabaseSync(dbPath, { readOnly: true });
128
+ const rows = db
129
+ .prepare(
130
+ 'SELECT name, category, access, version, url, description, status FROM tools ORDER BY id'
131
+ )
132
+ .all();
133
+ db.close();
134
+ if (rows.length) return rows.map((r) => ({ ...r, source: 'ai-tools.db' }));
135
+ } catch (e) {
136
+ console.error(`[aio] note: could not read ai-tools.db (${e.message}) — falling back`);
137
+ }
138
+ }
139
+ const mdPath = path.join(dataDir, 'TOOLS-INDEX.md');
140
+ if (fs.existsSync(mdPath)) {
141
+ try {
142
+ return parseToolsIndex(mdPath);
143
+ } catch (e) {
144
+ console.error(`[aio] note: could not parse TOOLS-INDEX.md (${e.message})`);
145
+ }
146
+ }
147
+ return [];
148
+ }
149
+
150
+ /** Count/list global agent skill directories (follows symlinked skills). */
151
+ export function scanSkills() {
152
+ const home = os.homedir();
153
+ const locations = [path.join(home, '.agents', 'skills'), path.join(home, '.claude', 'skills')];
154
+ return locations.map((loc) => {
155
+ let names = [];
156
+ try {
157
+ names = fs
158
+ .readdirSync(loc, { withFileTypes: true })
159
+ .filter((d) => {
160
+ if (d.isDirectory()) return true;
161
+ if (d.isSymbolicLink()) {
162
+ try {
163
+ return fs.statSync(path.join(loc, d.name)).isDirectory();
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+ return false;
169
+ })
170
+ .map((d) => d.name)
171
+ .sort();
172
+ } catch {
173
+ /* location absent */
174
+ }
175
+ return { location: loc, count: names.length, names };
176
+ });
177
+ }
package/src/setup.js ADDED
@@ -0,0 +1,119 @@
1
+ // setup.js — the `aio` default command: scan → generate → inject → ensure → report
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { resolveDataDir, resolveReposDir, writeState, BACKUP_DIR, STATE_DIR } from './paths.js';
6
+ import { detectAgents, detectBinary, scanRepos, loadTools, scanSkills } from './scan.js';
7
+ import { buildBlock, injectBlock, genContext, ensureMcp, fixPaths, backup } from './write.js';
8
+ import { getVersion } from './banner.js';
9
+
10
+ function targetFiles(home) {
11
+ return [
12
+ { label: 'opencode', file: path.join(home, '.config', 'opencode', 'AGENTS.md') },
13
+ { label: 'claude', file: path.join(home, '.claude', 'CLAUDE.md') },
14
+ { label: 'kimi', file: path.join(home, '.kimi-code', 'AGENTS.md') },
15
+ { label: 'jcode', file: path.join(home, '.jcode', 'AGENTS.md') },
16
+ { label: 'codex', file: path.join(home, '.codex', 'AGENTS.md') },
17
+ { label: 'gemini', file: path.join(home, '.gemini', 'GEMINI.md') },
18
+ { label: 'global', file: path.join(home, 'AGENTS.md') },
19
+ ];
20
+ }
21
+
22
+ function row(tag, name, detail) {
23
+ console.log(` [${tag}] ${name.padEnd(26)} ${detail}`);
24
+ }
25
+
26
+ export async function runSetup(opts = {}) {
27
+ const version = getVersion();
28
+ const home = os.homedir();
29
+
30
+ if (Number(process.versions.node.split('.')[0]) < 22) {
31
+ console.error(`[aio] warning: Node ${process.versions.node} detected — Node >= 22 recommended`);
32
+ }
33
+
34
+ const dataDir = resolveDataDir(opts.home);
35
+ const reposDir = resolveReposDir(opts.repos);
36
+ if (!dataDir) {
37
+ console.log('[aio] note: no local data dir found (ai-tools.db / TOOLS-INDEX.md).');
38
+ console.log('[aio] Install location registry first, or run with --home <dir>.');
39
+ }
40
+ if (!reposDir) {
41
+ console.log('[aio] note: repos dir not found. Pass --repos <dir> or set AIO_REPOS_DIR.');
42
+ }
43
+
44
+ const agents = detectAgents();
45
+ const repos = scanRepos(reposDir);
46
+ const tools = await loadTools(dataDir);
47
+ const skills = scanSkills();
48
+ const mcpBin = detectBinary('codebase-memory-mcp');
49
+
50
+ const manifestPath = dataDir
51
+ ? path.join(dataDir, 'aio-context.md')
52
+ : path.join(STATE_DIR, 'aio-context.md');
53
+ genContext({ version, dataDir, reposDir, repos, tools, skills, agents }, manifestPath);
54
+
55
+ const body = buildBlock({ version, manifestPath, dataDir });
56
+ const targets = targetFiles(home);
57
+ const blockResults = targets.map((t) => {
58
+ if (!fs.existsSync(path.dirname(t.file))) {
59
+ return { ...t, status: 'skipped (agent not installed)' };
60
+ }
61
+ return { ...t, ...injectBlock(t.file, body, `inject-${t.label}`) };
62
+ });
63
+
64
+ const mcpResults = mcpBin
65
+ ? ensureMcp(mcpBin)
66
+ : [{ target: 'codebase-memory-mcp', status: 'binary not on PATH — skipped' }];
67
+
68
+ const pathResults = fixPaths();
69
+
70
+ writeState({
71
+ version,
72
+ dataDir,
73
+ reposDir,
74
+ manifestPath,
75
+ lastRun: new Date().toISOString(),
76
+ });
77
+
78
+ /* ---- report ---- */
79
+ console.log('');
80
+ console.log(`aio setup v${version}`);
81
+ console.log('─'.repeat(76));
82
+
83
+ console.log('\nAgents');
84
+ for (const a of agents) {
85
+ const cover = {
86
+ opencode: '→ .config/opencode/AGENTS.md',
87
+ claude: '→ .claude/CLAUDE.md',
88
+ kimi: '→ .kimi-code/AGENTS.md',
89
+ jcode: '→ .jcode/AGENTS.md',
90
+ codex: '→ .codex/AGENTS.md',
91
+ gemini: '→ .gemini/GEMINI.md',
92
+ hermes: '→ ~/AGENTS.md',
93
+ freebuff: '→ ~/AGENTS.md (fallback)',
94
+ }[a.name] || '';
95
+ row(a.found ? 'x' : ' ', a.name, `${a.found ? 'detected' : 'not installed'} ${cover}`);
96
+ }
97
+
98
+ console.log('\nContext block (auto-use rules + usage disclosure)');
99
+ for (const r of blockResults) row(r.status.includes('skip') ? ' ' : 'x', path.basename(r.file), `${r.status} — ${r.file}`);
100
+
101
+ console.log('\nMCP (codebase-memory-mcp)');
102
+ if (!mcpResults.length) console.log(' [ ] none — no config files found');
103
+ for (const r of mcpResults) row(r.status.includes('skip') ? ' ' : 'x', r.target, r.status);
104
+
105
+ console.log('\nStale path repair (folder rename)');
106
+ if (!pathResults.length) console.log(' [x] clean — no broken "AI tutorial" references');
107
+ for (const r of pathResults) row('x', path.basename(r.target), r.status);
108
+
109
+ const skillTotal = skills.reduce((n, s) => n + s.count, 0);
110
+ console.log('\nManifest');
111
+ row('x', 'aio-context.md', `REPOS ${repos.length} · TOOLS ${tools.length} · SKILLS ${skillTotal}`);
112
+ console.log(` ${manifestPath}`);
113
+ console.log('');
114
+ console.log(`Backups: ${BACKUP_DIR}`);
115
+ console.log('Done. Open any agent directly (opencode / claude / kimi / ...) — it now knows');
116
+ console.log('your repos, tools & skills. No slash-commands needed. Usage is disclosed as:');
117
+ console.log(' [aio] Using <name> (<type>) — <function>');
118
+ console.log('');
119
+ }
package/src/update.js ADDED
@@ -0,0 +1,26 @@
1
+ // update.js — `aio update`: self-update from the public GitHub repo, then re-run setup
2
+ import os from 'node:os';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ export function runUpdate({ binPath }) {
6
+ const pkg = 'aio-connect';
7
+
8
+ console.log(`[aio] Updating ${pkg} from npm ...`);
9
+ const install = spawnSync('npm', ['install', '-g', pkg], {
10
+ shell: true,
11
+ stdio: 'inherit',
12
+ cwd: os.homedir(),
13
+ });
14
+ if (install.error) {
15
+ console.error(`[aio] update failed: ${install.error.message}`);
16
+ process.exit(1);
17
+ }
18
+ if (install.status !== 0) {
19
+ console.error(`[aio] update failed: npm install exited with code ${install.status}`);
20
+ process.exit(install.status ?? 1);
21
+ }
22
+
23
+ console.log('[aio] Package updated — re-running setup with the new version ...');
24
+ const fresh = spawnSync(process.execPath, [binPath], { stdio: 'inherit' });
25
+ process.exit(fresh.status ?? 0);
26
+ }
package/src/write.js ADDED
@@ -0,0 +1,298 @@
1
+ // write.js — backups, block injection, context manifest, MCP ensure, path repair
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { BACKUP_DIR, STATE_DIR } from './paths.js';
6
+
7
+ export const BLOCK_START = '<!-- aio:auto-config:v1:start -->';
8
+ export const BLOCK_END = '<!-- aio:auto-config:v1:end -->';
9
+ const BLOCK_RE = /<!-- aio:auto-config:v1:start -->[\s\S]*?<!-- aio:auto-config:v1:end -->/;
10
+
11
+ export function backup(file, label) {
12
+ if (!fs.existsSync(file)) return null;
13
+ fs.mkdirSync(BACKUP_DIR, { recursive: true });
14
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
15
+ const safe = String(label).replace(/[^a-z09_-]/gi, '_');
16
+ const dest = path.join(BACKUP_DIR, `${safe}__${ts}__${path.basename(file)}`);
17
+ fs.copyFileSync(file, dest);
18
+ return dest;
19
+ }
20
+
21
+ /** Build the auto-use rule block (FR2 + FR6: usage disclosure format). */
22
+ export function buildBlock({ version, manifestPath, dataDir }) {
23
+ const lines = [
24
+ `### AIO AUTO-CONTEXT (generated by aio v${version} — do not edit; re-run \`aio\` to refresh)`,
25
+ '',
26
+ `- Context manifest (auto-generated): \`${manifestPath}\``,
27
+ ];
28
+ if (dataDir) {
29
+ if (fs.existsSync(path.join(dataDir, 'TOOLS-INDEX.md'))) {
30
+ lines.push(`- Tools registry: \`${path.join(dataDir, 'TOOLS-INDEX.md')}\``);
31
+ }
32
+ if (fs.existsSync(path.join(dataDir, 'ai-tools.db'))) {
33
+ lines.push(`- Local tools DB (SQLite): \`${path.join(dataDir, 'ai-tools.db')}\``);
34
+ }
35
+ }
36
+ lines.push(
37
+ '',
38
+ 'Auto-use rules (plain-language prompts only — NO slash-commands required):',
39
+ '1. USAGE DISCLOSURE (MANDATORY — never skip): the moment you read, list, cite, or act on ANY entry from the manifest above, the FIRST line of that part of your reply must be exactly:',
40
+ ' [aio] Using <name> (<type>) — <function>',
41
+ ' where <type> = repo | cli | service | skill, and <function> = the manifest entry\'s one-line description.',
42
+ ' Example: [aio] Using Stirling-PDF (repo) — HTML/CSS/JS to PDF converter',
43
+ ' Emit the line BEFORE the related answer text, in every response that touches the manifest — not optional, not a recap, no rewording.',
44
+ '2. When a prompt involves a local repository, tool, service, or skill, read the manifest above and route yourself to the matching entry on your own — never ask the user to type `/command` for routing.',
45
+ '3. Prefer manifest entries (already installed locally) over proposing new installs.',
46
+ '4. If the user mentions a repo/tool that is not in the manifest, tell them to run `aio` again to rescan.',
47
+ '5. Reply in the user’s language.'
48
+ );
49
+ return lines.join('\n');
50
+ }
51
+
52
+ /** Idempotent marker injection: first run appends, later runs replace in place. */
53
+ export function injectBlock(file, body, label) {
54
+ const existed = fs.existsSync(file);
55
+ const current = existed ? fs.readFileSync(file, 'utf8') : '';
56
+ const had = BLOCK_RE.test(current);
57
+ const block = `${BLOCK_START}\n${body}\n${BLOCK_END}`;
58
+ const next = had
59
+ ? current.replace(BLOCK_RE, block)
60
+ : (current ? current.replace(/\s*$/, '\n\n') : '') + block + '\n';
61
+ if (next === current) return { status: 'unchanged', file };
62
+ if (existed) backup(file, label);
63
+ fs.mkdirSync(path.dirname(file), { recursive: true });
64
+ fs.writeFileSync(file, next);
65
+ return { status: had ? 'updated' : 'injected', file };
66
+ }
67
+
68
+ /** Surgically remove the block (rollback, FR7). Returns 'removed' | 'absent'. */
69
+ export function stripBlock(file) {
70
+ if (!fs.existsSync(file)) return 'absent';
71
+ const current = fs.readFileSync(file, 'utf8');
72
+ if (!BLOCK_RE.test(current)) return 'absent';
73
+ backup(file, 'rollback');
74
+ const next = current.replace(BLOCK_RE, '').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
75
+ fs.writeFileSync(file, next);
76
+ return 'removed';
77
+ }
78
+
79
+ /** Generate the context manifest (FR3): REPOS + TOOLS + SKILLS. */
80
+ export function genContext({ version, dataDir, reposDir, repos, tools, skills, agents }, outPath) {
81
+ const now = new Date().toISOString();
82
+ const L = [
83
+ `<!-- AUTO-GENERATED by aio v${version} on ${now} — do not edit; run \`aio\` to refresh -->`,
84
+ '',
85
+ '# AIO Context Manifest',
86
+ '',
87
+ `> Repos dir: \`${reposDir || '(not configured — pass --repos <dir> or set AIO_REPOS_DIR)'}\``,
88
+ dataDir ? `> Data dir: \`${dataDir}\`` : '> Data dir: (none — tools registry / DB not found)',
89
+ '',
90
+ `## REPOS (${repos.length})`,
91
+ '',
92
+ repos.length
93
+ ? ['| # | Repository | URL | Local path |', '|---|---|---|---|']
94
+ .concat(repos.map((r, i) => `| ${i + 1} | ${r.name} | ${r.url || '— (local only)'} | \`${r.path}\` |`))
95
+ .join('\n')
96
+ : '_No cloned repositories found._',
97
+ '',
98
+ `## TOOLS (${tools.length})`,
99
+ '',
100
+ tools.length
101
+ ? ['| Tool | What it does | Access / command | Version | Link |', '|---|---|---|---|---|']
102
+ .concat(
103
+ tools.map(
104
+ (t) =>
105
+ `| ${t.name} | ${t.description || '—'} | \`${(t.access || '').replace(/\|/g, '\\|')}\` | ${t.version || '—'} | ${t.url || '—'} |`
106
+ )
107
+ )
108
+ .join('\n')
109
+ : '_No tools catalog found (ai-tools.db / TOOLS-INDEX.md)._',
110
+ '',
111
+ `## SKILLS (${skills.reduce((n, s) => n + s.count, 0)})`,
112
+ '',
113
+ ];
114
+ for (const loc of skills) {
115
+ L.push(`- \`${loc.location}\` — ${loc.count} skills`);
116
+ if (loc.count) L.push(` - ${loc.names.join(', ')}`);
117
+ }
118
+ L.push('', '## AGENTS DETECTED', '');
119
+ for (const a of agents) L.push(`- ${a.name}: ${a.found ? `found (\`${a.bin}\`)` : 'not installed'}`);
120
+ L.push('');
121
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
122
+ fs.writeFileSync(outPath, L.join('\n'));
123
+ return outPath;
124
+ }
125
+
126
+ /* ---------------- MCP ensure (FR1) ---------------- */
127
+
128
+ const LEDGER = path.join(STATE_DIR, 'mcp-ledger.json');
129
+ const MCP_KEY = 'codebase-memory-mcp';
130
+
131
+ function ledgerRead() {
132
+ try {
133
+ return JSON.parse(fs.readFileSync(LEDGER, 'utf8'));
134
+ } catch {
135
+ return [];
136
+ }
137
+ }
138
+ function ledgerAdd(file, key) {
139
+ const l = ledgerRead();
140
+ if (!l.some((e) => e.file === file && e.key === key)) {
141
+ l.push({ file, key });
142
+ fs.mkdirSync(STATE_DIR, { recursive: true });
143
+ fs.writeFileSync(LEDGER, JSON.stringify(l, null, 2));
144
+ }
145
+ }
146
+ export function ledgerList() {
147
+ return ledgerRead();
148
+ }
149
+
150
+ export function ensureJsonEntry(file, key, entry, label, rootKey, create = false) {
151
+ let obj = {};
152
+ if (fs.existsSync(file)) {
153
+ try {
154
+ obj = JSON.parse(fs.readFileSync(file, 'utf8'));
155
+ } catch (e) {
156
+ return { target: path.basename(file), status: `parse error: ${e.message}` };
157
+ }
158
+ } else if (create && fs.existsSync(path.dirname(file))) {
159
+ obj = {}; // agent config dir present → bootstrap the file (gemini settings.json)
160
+ } else {
161
+ return { target: path.basename(file), status: 'file not found — skipped' };
162
+ }
163
+ const holder = rootKey ? (obj[rootKey] ||= {}) : obj;
164
+ if (holder[key]) return { target: path.basename(file), status: 'present' };
165
+ backup(file, label);
166
+ holder[key] = entry;
167
+ const text = JSON.stringify(obj, null, 2) + '\n';
168
+ JSON.parse(text); // verify before write
169
+ fs.writeFileSync(file, text);
170
+ ledgerAdd(file, key);
171
+ return { target: path.basename(file), status: 'added' };
172
+ }
173
+
174
+ /** codex config.toml: append [mcp_servers.<key>] only when absent. */
175
+ export function ensureTomlEntry(file, key, entry, label) {
176
+ if (!fs.existsSync(file)) return { target: path.basename(file), status: 'file not found — skipped' };
177
+ const txt = fs.readFileSync(file, 'utf8');
178
+ const header = `[mcp_servers.${key}]`;
179
+ if (txt.includes(header) || txt.includes(`[mcp_servers."${key}"]`)) {
180
+ return { target: path.basename(file), status: 'present' };
181
+ }
182
+ backup(file, label);
183
+ const body = Object.entries(entry)
184
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
185
+ .join('\n');
186
+ fs.writeFileSync(file, txt.replace(/\s*$/, '') + `\n\n${header}\n${body}\n`);
187
+ ledgerAdd(file, key);
188
+ return { target: path.basename(file), status: 'added' };
189
+ }
190
+
191
+ /** opencode.jsonc is JSONC (comments must survive) → textual insert. */
192
+ function ensureOpencodeJsonc(key, entry) {
193
+ const file = path.join(os.homedir(), '.config', 'opencode', 'opencode.jsonc');
194
+ if (!fs.existsSync(file)) return { target: 'opencode.jsonc', status: 'file not found — skipped' };
195
+ const txt = fs.readFileSync(file, 'utf8');
196
+ if (txt.includes(`"${key}"`)) return { target: 'opencode.jsonc', status: 'present' };
197
+ const marker = '"mcp": {';
198
+ const idx = txt.indexOf(marker);
199
+ if (idx === -1) return { target: 'opencode.jsonc', status: 'no "mcp" block — skipped' };
200
+ backup(file, 'opencode-jsonc');
201
+ const after = txt.slice(idx + marker.length);
202
+ const empty = /^\s*\}/.test(after);
203
+ const insert = `${marker}\n ${JSON.stringify(key)}: ${JSON.stringify(entry)}${empty ? '' : ','}`;
204
+ const next = txt.slice(0, idx) + insert + after;
205
+ fs.writeFileSync(file, next);
206
+ ledgerAdd(file, key);
207
+ return { target: 'opencode.jsonc', status: 'added' };
208
+ }
209
+
210
+ /** Ensure codebase-memory-mcp exists in opencode / claude / kimi / jcode / codex / gemini configs. */
211
+ export function ensureMcp(binary) {
212
+ const entry = { command: binary };
213
+ const home = os.homedir();
214
+ const results = [];
215
+ results.push(ensureOpencodeJsonc(MCP_KEY, { type: 'local', command: binary }));
216
+ results.push(
217
+ ensureJsonEntry(path.join(home, '.claude.json'), MCP_KEY, entry, 'claude-json', 'mcpServers')
218
+ );
219
+ results.push(
220
+ ensureJsonEntry(path.join(home, '.kimi-code', 'mcp.json'), MCP_KEY, entry, 'kimi-mcp', 'mcpServers')
221
+ );
222
+ results.push(
223
+ ensureJsonEntry(path.join(home, '.jcode', 'mcp.json'), MCP_KEY, entry, 'jcode-mcp', 'servers')
224
+ );
225
+ results.push(
226
+ ensureJsonEntry(path.join(home, '.gemini', 'settings.json'), MCP_KEY, entry, 'gemini-settings', 'mcpServers', true)
227
+ );
228
+ results.push(ensureTomlEntry(path.join(home, '.codex', 'config.toml'), MCP_KEY, entry, 'codex-toml'));
229
+ return results;
230
+ }
231
+
232
+ /** Reverse exactly what ensureMcp added (recorded in the ledger). */
233
+ export function removeMcpAdditions() {
234
+ const out = [];
235
+ for (const { file, key } of ledgerRead()) {
236
+ if (!fs.existsSync(file)) {
237
+ out.push({ target: path.basename(file), status: 'missing' });
238
+ continue;
239
+ }
240
+ const txt = fs.readFileSync(file, 'utf8');
241
+ if (!txt.includes(key)) {
242
+ out.push({ target: path.basename(file), status: 'already gone' });
243
+ continue;
244
+ }
245
+ backup(file, 'rollback-mcp');
246
+ if (file.endsWith('.toml')) {
247
+ const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
248
+ const re = new RegExp(`\\n?\\[mcp_servers\\."?${esc}"?\\][^\\[]*`, 'g');
249
+ fs.writeFileSync(file, txt.replace(re, ''));
250
+ } else if (file.endsWith('.jsonc')) {
251
+ const re = new RegExp(`${JSON.stringify(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*\\{[^{}]*\\},?\\s*`, 'g');
252
+ fs.writeFileSync(file, txt.replace(re, ''));
253
+ } else {
254
+ const obj = JSON.parse(txt);
255
+ for (const rk of ['mcpServers', 'servers']) {
256
+ if (obj[rk] && typeof obj[rk] === 'object' && key in obj[rk]) {
257
+ delete obj[rk][key];
258
+ break;
259
+ }
260
+ }
261
+ if (key in obj) delete obj[key];
262
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
263
+ }
264
+ out.push({ target: path.basename(file), status: 'removed' });
265
+ }
266
+ fs.writeFileSync(LEDGER, '[]');
267
+ return out;
268
+ }
269
+
270
+ /* ---------------- stale path repair ---------------- */
271
+
272
+ const STALE_FROM = 'AI tutorial';
273
+ const STALE_TO = 'all-in-one-repo';
274
+
275
+ /** Repair registry pointers broken by the folder rename. Returns per-file results. */
276
+ export function fixPaths() {
277
+ const home = os.homedir();
278
+ const targets = [
279
+ path.join(home, 'AGENTS.md'),
280
+ path.join(home, '.claude', 'CLAUDE.md'),
281
+ path.join(home, '.config', 'opencode', 'AGENTS.md'),
282
+ path.join(home, '.kimi-code', 'AGENTS.md'),
283
+ path.join(home, '.jcode', 'AGENTS.md'),
284
+ path.join(home, '.codex', 'AGENTS.md'),
285
+ path.join(home, '.gemini', 'GEMINI.md'),
286
+ ];
287
+ const results = [];
288
+ for (const file of targets) {
289
+ if (!fs.existsSync(file)) continue;
290
+ const txt = fs.readFileSync(file, 'utf8');
291
+ if (!txt.includes(STALE_FROM)) continue;
292
+ const count = txt.split(STALE_FROM).length - 1;
293
+ backup(file, 'pathfix');
294
+ fs.writeFileSync(file, txt.split(STALE_FROM).join(STALE_TO));
295
+ results.push({ target: file, status: `repaired ${count} path reference(s)` });
296
+ }
297
+ return results;
298
+ }