@theronap/agnoclast-mcp 0.9.96
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/README.md +47 -0
- package/bin/cortex-mcp.mjs +223 -0
- package/lib/capture.mjs +470 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/context_log.mjs +92 -0
- package/lib/diagnose.mjs +360 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/doctor.mjs +117 -0
- package/lib/edge_extract.mjs +156 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +130 -0
- package/lib/editors/claude.mjs +202 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/graphify_sync.mjs +134 -0
- package/lib/grep_cli.mjs +82 -0
- package/lib/hydrate.mjs +181 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/ingest_folder.mjs +170 -0
- package/lib/install.mjs +163 -0
- package/lib/login.mjs +148 -0
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/presence.mjs +226 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/red_link_triage.mjs +37 -0
- package/lib/redact.mjs +40 -0
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +153 -0
- package/lib/server.mjs +2986 -0
- package/lib/session_key.mjs +37 -0
- package/lib/setup.mjs +215 -0
- package/lib/skills.mjs +374 -0
- package/lib/statusline.mjs +67 -0
- package/lib/uninstall.mjs +237 -0
- package/lib/use_brain.mjs +82 -0
- package/lib/with_token.mjs +66 -0
- package/package.json +36 -0
- package/skills/author-docs/SKILL.md +74 -0
- package/skills/context/SKILL.md +25 -0
- package/skills/log/SKILL.md +114 -0
- package/skills/walkthrough/SKILL.md +189 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// PGL-21: derive the AI session's stable identity key. Pure — see session_key.test.mjs.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code sets CLAUDE_CODE_SESSION_ID for the lifetime of one logical conversation, surviving
|
|
4
|
+
// any number of MCP subprocess restarts (reconnects, tool-loading events) within it. Prefer it so a
|
|
5
|
+
// session-scoped set_active_brain (ADR-0020 Stage 2) keeps pointing at the right brain across a
|
|
6
|
+
// restart instead of silently falling back to the account pointer on a brand-new randomUUID() —
|
|
7
|
+
// reproduced live 2026-07-27: an explicit set_active_brain(scope:'session') stopped taking effect
|
|
8
|
+
// one restart later, with no visible cause, because the new process minted an unrelated session key
|
|
9
|
+
// with no session_write_pointers row of its own. Any other MCP host that doesn't set the var gets
|
|
10
|
+
// today's unchanged per-process-random behavior.
|
|
11
|
+
export function resolveSessionKey(env, randomUUID) {
|
|
12
|
+
return env.CLAUDE_CODE_SESSION_ID || randomUUID()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Which session id a `log_session` call should RECORD. Pure — see session_key.test.mjs.
|
|
16
|
+
//
|
|
17
|
+
// Distinct from resolveSessionKey above, and deliberately NOT built on it. That one answers "which
|
|
18
|
+
// session is this process" and may invent a random id, which is correct for a write pointer: any
|
|
19
|
+
// stable value works because it is only ever compared to itself.
|
|
20
|
+
//
|
|
21
|
+
// This answers "which session produced this record", and there an invented value is actively harmful.
|
|
22
|
+
// The id becomes the record's dedupe key (`claude-code:<id>`) and its payload.session_id, which is the
|
|
23
|
+
// ONLY join to the pages the session wrote (page_revisions.session_key). A random per-process uuid
|
|
24
|
+
// would look exactly like a real session id while pairing with no capture record and matching no
|
|
25
|
+
// revision — a confident join that is silently wrong. The timestamp fallback it would replace is at
|
|
26
|
+
// least honestly unattributable.
|
|
27
|
+
//
|
|
28
|
+
// So: caller's value, else the real conversation id, else NOTHING. Never a random.
|
|
29
|
+
//
|
|
30
|
+
// Measured against prod 2026-08-19: 103 of 136 skill records had no session id because the parameter
|
|
31
|
+
// is optional and callers omit it — 76% of every close-out permanently unjoinable to its own work.
|
|
32
|
+
export function resolveLogSessionId(explicit, env) {
|
|
33
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
|
|
34
|
+
const fromEnv = env?.CLAUDE_CODE_SESSION_ID
|
|
35
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { checkToken, resolveBase, readWiredToken, wiredDistTag } from './diagnose.mjs'
|
|
5
|
+
import { installSkills } from './skills.mjs'
|
|
6
|
+
// Pure config-merge functions live in the editor adapters; setup imports (and re-exports) THE SAME
|
|
7
|
+
// functions the `cortex install` path uses, so both write byte-identical config.
|
|
8
|
+
import { mergeClaudeMcp, mergeClaudeSettings } from './editors/claude.mjs'
|
|
9
|
+
import { mergeCodexToml, mergeCodexHooks } from './editors/codex.mjs'
|
|
10
|
+
|
|
11
|
+
// One-command employee onboarding. Wires both:
|
|
12
|
+
// 1. ~/.claude.json → the cortex MCP server (context-serving)
|
|
13
|
+
// 2. ~/.claude/settings.json → the capture Stop hook (activity ingest)
|
|
14
|
+
//
|
|
15
|
+
// Safe by construction: backs up each file before touching it, validates JSON,
|
|
16
|
+
// merges into existing structures (never clobbers other MCP servers / hooks),
|
|
17
|
+
// and is idempotent (re-running just updates the cortex entries in place).
|
|
18
|
+
|
|
19
|
+
const PKG = '@theronap/cortex-mcp'
|
|
20
|
+
|
|
21
|
+
// mergeCodexToml / mergeCodexHooks moved to ./editors/codex.mjs (imported above, re-exported below).
|
|
22
|
+
|
|
23
|
+
// PURE, exported for tests. The dist-tag decision is setup's own logic and it has a scar: a bare
|
|
24
|
+
// spec lets npx reuse a stale cached build, a frozen @x.y.z strands the machine forever (the
|
|
25
|
+
// 0.9.5→0.9.6 freeze that stranded a pilot install), and blindly writing @stable silently knocks a
|
|
26
|
+
// dogfooder off @latest. So: preserve an intentional @latest, otherwise @stable, never a version.
|
|
27
|
+
export function pickSpec(distTag, pkg = PKG) {
|
|
28
|
+
return distTag === 'latest' ? `${pkg}@latest` : `${pkg}@stable`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// PURE, exported for tests. Returns { token } or { error } — the caller owns process.exit, so the
|
|
32
|
+
// validation itself is reachable from a test runner.
|
|
33
|
+
export function parseSetupArgs(argv, pkg = PKG) {
|
|
34
|
+
const token = (argv ?? [])[0]
|
|
35
|
+
if (!token || token.startsWith('-')) {
|
|
36
|
+
return { error: `Usage: npx ${pkg} setup <CORTEX_TOKEN>\n\nGet your token from the Agnoclast console → Connect your AI.\n` }
|
|
37
|
+
}
|
|
38
|
+
return { token }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readJson(path) {
|
|
42
|
+
if (!existsSync(path)) return {}
|
|
43
|
+
const raw = readFileSync(path, 'utf8').trim()
|
|
44
|
+
if (!raw) return {}
|
|
45
|
+
return JSON.parse(raw) // throws on malformed — caller handles
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function backup(path) {
|
|
49
|
+
if (!existsSync(path)) return null
|
|
50
|
+
const bak = `${path}.cortex-bak`
|
|
51
|
+
copyFileSync(path, bak)
|
|
52
|
+
return bak
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensureDir(path) {
|
|
56
|
+
const dir = dirname(path)
|
|
57
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runSetup(argv, version) {
|
|
61
|
+
const parsed = parseSetupArgs(argv)
|
|
62
|
+
if (parsed.error) { process.stderr.write(parsed.error); process.exit(1) }
|
|
63
|
+
const token = parsed.token
|
|
64
|
+
// Wire a moving dist-tag — NOT a frozen version. A bare spec lets npx reuse a stale cached build; a
|
|
65
|
+
// frozen `@x.y.z` freezes the machine on that version forever (the 0.9.5→0.9.6 freeze that stranded a
|
|
66
|
+
// pilot install). A tag is re-resolved by npx against the registry, so machines pick up promoted
|
|
67
|
+
// releases on next launch without re-running setup. Promote a validated build with:
|
|
68
|
+
// npm dist-tag add @theronap/cortex-mcp@<version> stable
|
|
69
|
+
// PRESERVE an intentional @latest (dogfood) pin across re-runs (repair/setup) — otherwise this
|
|
70
|
+
// silently knocks a dogfooder back to @stable. Fresh installs and existing @stable get @stable.
|
|
71
|
+
const spec = pickSpec(wiredDistTag())
|
|
72
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
73
|
+
const home = homedir()
|
|
74
|
+
const claudeJson = join(home, '.claude.json')
|
|
75
|
+
const settingsJson = join(home, '.claude', 'settings.json')
|
|
76
|
+
|
|
77
|
+
const log = (m) => process.stdout.write(m + '\n')
|
|
78
|
+
log('')
|
|
79
|
+
log('Agnoclast setup — wiring your AI assistant…')
|
|
80
|
+
|
|
81
|
+
// ── 1. MCP server in ~/.claude.json ──────────────────────────────────────
|
|
82
|
+
try {
|
|
83
|
+
let cfg
|
|
84
|
+
try { cfg = readJson(claudeJson) } catch (e) {
|
|
85
|
+
process.stderr.write(`\n✗ ${claudeJson} is not valid JSON — fix or remove it, then re-run.\n`)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
const bak = backup(claudeJson)
|
|
89
|
+
cfg = mergeClaudeMcp(cfg, spec, token)
|
|
90
|
+
ensureDir(claudeJson)
|
|
91
|
+
writeFileSync(claudeJson, JSON.stringify(cfg, null, 2))
|
|
92
|
+
log(` ✓ MCP server → ${claudeJson}${bak ? ' (backup saved)' : ''}`)
|
|
93
|
+
} catch (e) {
|
|
94
|
+
process.stderr.write(` ✗ failed to update ${claudeJson}: ${e.message}\n`)
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── 1b. MCP server in Codex (~/.codex/config.toml), only if Codex is installed ──
|
|
99
|
+
// Codex gets the same Agnoclast context tools as Claude Code. Non-fatal: a Codex hiccup must
|
|
100
|
+
// never block the primary Claude wiring.
|
|
101
|
+
const codexDir = join(home, '.codex')
|
|
102
|
+
if (existsSync(codexDir)) {
|
|
103
|
+
try {
|
|
104
|
+
const codexToml = join(codexDir, 'config.toml')
|
|
105
|
+
const existing = existsSync(codexToml) ? readFileSync(codexToml, 'utf8') : ''
|
|
106
|
+
const bak = backup(codexToml)
|
|
107
|
+
writeFileSync(codexToml, mergeCodexToml(existing, spec, token))
|
|
108
|
+
log(` ✓ MCP server → ${codexToml}${bak ? ' (backup saved)' : ''}`)
|
|
109
|
+
} catch (e) {
|
|
110
|
+
log(` ⚠ Codex MCP wiring skipped: ${e.message} (Claude wiring unaffected)`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── 1c. Capture Stop hook in Codex (~/.codex/hooks.json) ──
|
|
114
|
+
// Without this, Codex sessions get context tools + skills but never capture — a silent gap
|
|
115
|
+
// (found 2026-07-02: a hand-set hook on Theron's machine was pinned to a stale 0.4.5, years
|
|
116
|
+
// behind `stable`, because nothing in setup/repair ever refreshed it). Same idempotent
|
|
117
|
+
// merge pattern as the Claude Stop hook below; non-fatal on failure.
|
|
118
|
+
try {
|
|
119
|
+
const codexHooks = join(codexDir, 'hooks.json')
|
|
120
|
+
let existingHooks
|
|
121
|
+
try { existingHooks = readJson(codexHooks) } catch { existingHooks = {} } // malformed → start fresh, don't block
|
|
122
|
+
const bak = backup(codexHooks)
|
|
123
|
+
// NO inline token (token-hygiene, 2026-07-02): `capture` resolves CORTEX_TOKEN itself via
|
|
124
|
+
// readWiredToken() from the config.toml this same setup run writes. An inlined secret in
|
|
125
|
+
// hooks.json shows up in every read/grep of the file — and from there in captured transcripts.
|
|
126
|
+
const captureCmd = `npx -y ${spec} capture`
|
|
127
|
+
ensureDir(codexHooks)
|
|
128
|
+
writeFileSync(codexHooks, JSON.stringify(mergeCodexHooks(existingHooks, captureCmd), null, 2))
|
|
129
|
+
log(` ✓ Capture hook → ${codexHooks}${bak ? ' (backup saved)' : ''}`)
|
|
130
|
+
log(' Codex will ask you to re-approve this hook once (it hashes hooks.json for tamper-detection).')
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log(` ⚠ Codex capture hook skipped: ${e.message} (Claude wiring unaffected)`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 2. Capture Stop hook in ~/.claude/settings.json ──────────────────────
|
|
137
|
+
try {
|
|
138
|
+
let s
|
|
139
|
+
try { s = readJson(settingsJson) } catch (e) {
|
|
140
|
+
process.stderr.write(`\n✗ ${settingsJson} is not valid JSON — fix or remove it, then re-run.\n`)
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
const bak = backup(settingsJson)
|
|
144
|
+
// Capture (Stop) + status/skills/snapshot (SessionStart) + hydrate (UserPromptSubmit), idempotent
|
|
145
|
+
// merge — the same pure function the `cortex install` Claude adapter uses. Also unwires the retired
|
|
146
|
+
// PreCompact reminder from seats that still carry it.
|
|
147
|
+
s = mergeClaudeSettings(s, spec)
|
|
148
|
+
|
|
149
|
+
ensureDir(settingsJson)
|
|
150
|
+
writeFileSync(settingsJson, JSON.stringify(s, null, 2))
|
|
151
|
+
log(` ✓ Capture + hydrate hooks + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
152
|
+
} catch (e) {
|
|
153
|
+
process.stderr.write(` ✗ failed to update ${settingsJson}: ${e.message}\n`)
|
|
154
|
+
process.exit(1)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 3. Managed skills — installed flat into every agent CLI present (Claude + Codex) ──
|
|
158
|
+
// Bundled first (sync, network-free), then the org-published set (fail-soft pull), so a fresh
|
|
159
|
+
// seat has its org's skills at first session, not second. CORTEX_TOKEN is in env for the pull
|
|
160
|
+
// only if the caller exported it; syncOrgSkills falls back to the token this setup just wired.
|
|
161
|
+
try {
|
|
162
|
+
installSkills({ quiet: false })
|
|
163
|
+
const { syncOrgSkills } = await import('./skills.mjs')
|
|
164
|
+
process.env.CORTEX_TOKEN = process.env.CORTEX_TOKEN || token
|
|
165
|
+
await syncOrgSkills({ quiet: false })
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
|
|
168
|
+
log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── 4. Self-verify — writing config proves "files written", NOT "connection works".
|
|
172
|
+
// Actually call the API so a bad/expired token is caught HERE, not 40 minutes into debugging.
|
|
173
|
+
log('')
|
|
174
|
+
log('Verifying your token against Agnoclast…')
|
|
175
|
+
const health = await checkToken(token, base)
|
|
176
|
+
if (health.ok) {
|
|
177
|
+
const n = health.projectCount
|
|
178
|
+
log(` ✓ Verified — your token works${typeof n === 'number' ? ` (you can see ${n} project${n === 1 ? '' : 's'})` : ''}.`)
|
|
179
|
+
} else {
|
|
180
|
+
log(' ⚠ Config written, but the live check did NOT pass:')
|
|
181
|
+
log(` ${health.diagnosis?.message ?? 'unknown error'}`)
|
|
182
|
+
log(' The files are in place; fix the above, then re-check with `doctor`.')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
log('')
|
|
186
|
+
log('⟳ IMPORTANT: fully quit and reopen Claude Code to load the Agnoclast server.')
|
|
187
|
+
log(' Then your AI sees your Agnoclast context and your sessions flow into the org.')
|
|
188
|
+
log(' Re-check anytime: npx -y @theronap/cortex-mcp doctor')
|
|
189
|
+
log(` Console: ${base}`)
|
|
190
|
+
log('')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// readWiredToken moved to diagnose.mjs (the shared leaf module) so `capture` can use the same
|
|
194
|
+
// resolver — hook commands no longer inline the token. Re-exported for compatibility.
|
|
195
|
+
export { readWiredToken }
|
|
196
|
+
|
|
197
|
+
// Re-export the pure merge functions from their adapter homes so existing importers of setup.mjs keep
|
|
198
|
+
// working and tests can assert setup uses the SAME function the install path does (byte-identity).
|
|
199
|
+
export { mergeClaudeMcp, mergeClaudeSettings, mergeCodexToml, mergeCodexHooks }
|
|
200
|
+
|
|
201
|
+
// `repair`: re-run the FULL setup at THIS version using the already-wired token. The one-command fix
|
|
202
|
+
// for a machine set up with an older version (e.g. when skills were installed to the old nested path,
|
|
203
|
+
// or the hooks are pinned to a stale version). No token argument needed.
|
|
204
|
+
export async function runRepair(version) {
|
|
205
|
+
const token = readWiredToken()
|
|
206
|
+
if (!token) {
|
|
207
|
+
process.stderr.write(
|
|
208
|
+
'No existing Agnoclast token found in ~/.claude.json or ~/.codex/config.toml.\n' +
|
|
209
|
+
'Run setup once with your token: npx -y @theronap/cortex-mcp setup <YOUR_TOKEN>\n',
|
|
210
|
+
)
|
|
211
|
+
process.exit(1)
|
|
212
|
+
}
|
|
213
|
+
process.stdout.write('Agnoclast repair — re-running setup with your existing token at this version…\n')
|
|
214
|
+
await runSetup([token], version)
|
|
215
|
+
}
|
package/lib/skills.mjs
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, rmSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
// Managed Agnoclast skills.
|
|
7
|
+
//
|
|
8
|
+
// Two sources, one install pipeline:
|
|
9
|
+
// 1. BUNDLED — shipped inside this package (cortex-log, cortex-context, cortex-author-docs).
|
|
10
|
+
// Inalterable: canonical content always wins; user edits are backed up + restored.
|
|
11
|
+
// 2. ORG-PUBLISHED — rows in the org's `org_skills` table (documentation-ingestion-spec.md
|
|
12
|
+
// Part B). An owner/manager/admin publishes a SKILL.md once (`skills push`); every seat's
|
|
13
|
+
// SessionStart `skills --repair` hook pulls + installs it here. Bundled names WIN collisions,
|
|
14
|
+
// so an org can never shadow a core skill. The pull is fail-soft (offline → last-good cache
|
|
15
|
+
// at ~/.cortex/org-skills-cache.json → skip), because SessionStart must never break.
|
|
16
|
+
//
|
|
17
|
+
// Installed into EVERY agent CLI present on the machine, at the flat layout each one discovers:
|
|
18
|
+
// ~/.claude/skills/<name>/SKILL.md (Claude Code)
|
|
19
|
+
// ~/.codex/skills/<name>/SKILL.md (OpenAI Codex CLI — only if ~/.codex exists)
|
|
20
|
+
//
|
|
21
|
+
// IMPORTANT: skills MUST be one level under <cli>/skills/ — both Claude Code and Codex discover
|
|
22
|
+
// skills as <cli>/skills/<name>/SKILL.md. An earlier layout nested them under skills/cortex/core/,
|
|
23
|
+
// which is installed but INVISIBLE to discovery (the skill never appears as a slash command).
|
|
24
|
+
//
|
|
25
|
+
// "Inalterable" in practice: on every install AND on every Claude session start (the SessionStart
|
|
26
|
+
// --repair hook), any managed skill whose on-disk content drifted is RESTORED — the user's version
|
|
27
|
+
// is backed up to SKILL.md.user-bak first, so nothing is lost, but the canonical skill always wins.
|
|
28
|
+
// Identical content is a no-op.
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
|
|
32
|
+
const ORG_CACHE = join(homedir(), '.cortex', 'org-skills-cache.json')
|
|
33
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ // mirrors the org_skills check; also blocks path tricks
|
|
34
|
+
|
|
35
|
+
// Agent CLIs we install skills into. Claude is primary; Codex is included whenever it's present.
|
|
36
|
+
// Both use the same flat <cli>/skills/<name>/SKILL.md discovery layout.
|
|
37
|
+
const CLIS = [
|
|
38
|
+
{ id: 'Claude Code', dir: join(homedir(), '.claude') },
|
|
39
|
+
{ id: 'Codex', dir: join(homedir(), '.codex') },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
// djb2 — tiny, dependency-free content fingerprint for the manifest (drift detection, not security).
|
|
43
|
+
function hash(s) {
|
|
44
|
+
let h = 5381
|
|
45
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
|
|
46
|
+
return h.toString(16)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureDir(path) {
|
|
50
|
+
if (!existsSync(path)) mkdirSync(path, { recursive: true })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The invocation name (slash command) comes from the SKILL.md `name:` frontmatter, not the folder.
|
|
54
|
+
// Use it as the install directory so the layout matches what the user types (/cortex-log).
|
|
55
|
+
function frontmatterName(source, fallback) {
|
|
56
|
+
const m = source.match(/^---[\s\S]*?\bname:\s*([^\n#]+)/)
|
|
57
|
+
return m ? m[1].trim() : fallback
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Discover bundled skills: each subdir of skills/ that contains a SKILL.md.
|
|
61
|
+
function bundledSkills() {
|
|
62
|
+
if (!existsSync(BUNDLED)) return []
|
|
63
|
+
return readdirSync(BUNDLED, { withFileTypes: true })
|
|
64
|
+
.filter((d) => d.isDirectory() && existsSync(join(BUNDLED, d.name, 'SKILL.md')))
|
|
65
|
+
.map((d) => {
|
|
66
|
+
const src = join(BUNDLED, d.name, 'SKILL.md')
|
|
67
|
+
const source = readFileSync(src, 'utf8')
|
|
68
|
+
return { name: frontmatterName(source, d.name), src, source }
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Write one skill file with the repair semantics: backup user drift, canonical wins, no-op on match.
|
|
73
|
+
function writeSkillFile(dest, source) {
|
|
74
|
+
if (!existsSync(dest)) {
|
|
75
|
+
ensureDir(dirname(dest))
|
|
76
|
+
writeFileSync(dest, source)
|
|
77
|
+
return 'installed'
|
|
78
|
+
}
|
|
79
|
+
const current = readFileSync(dest, 'utf8')
|
|
80
|
+
if (current === source) return 'unchanged'
|
|
81
|
+
try { copyFileSync(dest, `${dest}.user-bak`) } catch { /* best-effort backup */ }
|
|
82
|
+
writeFileSync(dest, source)
|
|
83
|
+
return 'repaired'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Install/repair a skill list into one CLI's skills root, plus remove org skills we previously
|
|
87
|
+
// installed that are no longer served. Merges the manifest rather than clobbering it, so bundled
|
|
88
|
+
// and org passes can run independently.
|
|
89
|
+
function installInto(skillsRoot, skills, { removeNames = [] } = {}) {
|
|
90
|
+
ensureDir(skillsRoot)
|
|
91
|
+
const result = { installed: [], repaired: [], unchanged: [], removed: [] }
|
|
92
|
+
|
|
93
|
+
for (const sk of skills) {
|
|
94
|
+
const dest = join(skillsRoot, sk.name, 'SKILL.md')
|
|
95
|
+
const status = writeSkillFile(dest, sk.source)
|
|
96
|
+
result[status].push(sk.name)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const name of removeNames) {
|
|
100
|
+
const destDir = join(skillsRoot, name)
|
|
101
|
+
const dest = join(destDir, 'SKILL.md')
|
|
102
|
+
if (!existsSync(dest)) continue
|
|
103
|
+
try {
|
|
104
|
+
copyFileSync(dest, `${dest}.removed-bak`)
|
|
105
|
+
rmSync(dest)
|
|
106
|
+
rmSync(`${dest}.removed-bak`)
|
|
107
|
+
rmSync(destDir, { recursive: true }) // dir now holds only leftovers we created
|
|
108
|
+
result.removed.push(name)
|
|
109
|
+
} catch { /* best-effort — a stubborn dir just stays */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Merge manifest: keep entries from the other pass, replace ours.
|
|
113
|
+
const manifestPath = join(skillsRoot, '.cortex-skills.json')
|
|
114
|
+
let manifest = { managed: [], updated_by: 'cortex-mcp', skills: {} }
|
|
115
|
+
try { manifest = { ...manifest, ...JSON.parse(readFileSync(manifestPath, 'utf8')) } } catch { /* fresh */ }
|
|
116
|
+
for (const name of removeNames) {
|
|
117
|
+
delete manifest.skills[name]
|
|
118
|
+
manifest.managed = (manifest.managed ?? []).filter((m) => m !== `${name}/SKILL.md`)
|
|
119
|
+
}
|
|
120
|
+
for (const sk of skills) {
|
|
121
|
+
if (!manifest.managed.includes(`${sk.name}/SKILL.md`)) manifest.managed.push(`${sk.name}/SKILL.md`)
|
|
122
|
+
manifest.skills[sk.name] = hash(sk.source)
|
|
123
|
+
}
|
|
124
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Install / repair the BUNDLED managed skills into every agent CLI present on the machine.
|
|
130
|
+
* Synchronous + network-free (setup calls this before any await).
|
|
131
|
+
* @param {{ quiet?: boolean }} opts quiet → only emit on actual change (for the SessionStart hook)
|
|
132
|
+
* @returns {{ installed: string[], repaired: string[], unchanged: string[], targets: string[] }}
|
|
133
|
+
*/
|
|
134
|
+
export function installSkills(opts = {}) {
|
|
135
|
+
const quiet = !!opts.quiet
|
|
136
|
+
const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
|
|
137
|
+
|
|
138
|
+
const skills = bundledSkills()
|
|
139
|
+
const summary = { installed: [], repaired: [], unchanged: [], targets: [] }
|
|
140
|
+
if (!skills.length) return summary // nothing bundled (shouldn't happen) — never error
|
|
141
|
+
|
|
142
|
+
const targets = CLIS.filter((c) => existsSync(c.dir))
|
|
143
|
+
if (!targets.length) return summary // no agent CLI on this machine
|
|
144
|
+
|
|
145
|
+
for (const cli of targets) {
|
|
146
|
+
const skillsRoot = join(cli.dir, 'skills')
|
|
147
|
+
const r = installInto(skillsRoot, skills)
|
|
148
|
+
summary.targets.push(cli.id)
|
|
149
|
+
summary.installed.push(...r.installed)
|
|
150
|
+
summary.repaired.push(...r.repaired)
|
|
151
|
+
summary.unchanged.push(...r.unchanged)
|
|
152
|
+
const changed = [...r.installed, ...r.repaired]
|
|
153
|
+
if (changed.length) log(` ✓ ${cli.id}: ${changed.join(', ')} → ${skillsRoot}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (quiet && (summary.installed.length || summary.repaired.length)) {
|
|
157
|
+
// SessionStart surfaces one line in Claude Code so a silent self-heal isn't invisible.
|
|
158
|
+
process.stdout.write(`Agnoclast: synced managed skill(s) into ${summary.targets.join(' + ')}.\n`)
|
|
159
|
+
}
|
|
160
|
+
return summary
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Decide what to install from a served org-skill list: drop invalid names, bundled collisions
|
|
164
|
+
// (bundled wins — core skills are inalterable) and cross-brain collisions. Pure, so it's
|
|
165
|
+
// unit-testable without a network.
|
|
166
|
+
export function planOrgInstall(served, bundledNames) {
|
|
167
|
+
const bundled = new Set(bundledNames)
|
|
168
|
+
const install = []
|
|
169
|
+
const skipped = []
|
|
170
|
+
|
|
171
|
+
// /api/skills now fans out across EVERY brain the caller belongs to (ADR-0022 acrossMyBrains), so
|
|
172
|
+
// for the first time two brains can serve the SAME skill name. Installing both would write one
|
|
173
|
+
// file twice and silently leave whichever landed last — an executable body from a brain the user
|
|
174
|
+
// never chose. There is no principled winner, so a cross-brain collision installs NEITHER and says
|
|
175
|
+
// so, matching how a bundled collision already resolves: when in doubt, do not install.
|
|
176
|
+
const byName = new Map()
|
|
177
|
+
for (const s of served ?? []) {
|
|
178
|
+
const n = (s?.name ?? '').trim()
|
|
179
|
+
if (!byName.has(n)) byName.set(n, [])
|
|
180
|
+
byName.get(n).push(s)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const s of served ?? []) {
|
|
184
|
+
const name = (s?.name ?? '').trim()
|
|
185
|
+
if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
|
|
186
|
+
if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
|
|
187
|
+
const dupes = byName.get(name) ?? []
|
|
188
|
+
if (dupes.length > 1) {
|
|
189
|
+
// Name the brains so the owner knows which to rename. `brain` is the tag acrossMyBrains adds.
|
|
190
|
+
const brains = [...new Set(dupes.map((d) => d?.brain).filter(Boolean))]
|
|
191
|
+
skipped.push({ name, why: `published by ${dupes.length} brains (${brains.join(', ') || 'unknown'}) — rename one; installing neither` })
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
install.push({ name, source: s.body_md })
|
|
195
|
+
}
|
|
196
|
+
return { install, skipped }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Pull the org's published skills and install them beside the bundled ones. FAIL-SOFT by design:
|
|
201
|
+
* no token → skip; fetch failure → last-good cache; nothing → skip. SessionStart must never break.
|
|
202
|
+
* Also removes org skills we previously installed that are no longer served (tracked via cache).
|
|
203
|
+
*/
|
|
204
|
+
export async function syncOrgSkills(opts = {}) {
|
|
205
|
+
const quiet = !!opts.quiet
|
|
206
|
+
const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
|
|
207
|
+
const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
|
|
208
|
+
|
|
209
|
+
const { resolveBase, resolveTokenSource, fetchCortex } = await import('./diagnose.mjs')
|
|
210
|
+
const token = resolveTokenSource().token
|
|
211
|
+
if (!token) { log(' · org skills: no token wired — skipped'); return summary }
|
|
212
|
+
|
|
213
|
+
// Previous org-installed names, so we can remove what the org deleted/disabled.
|
|
214
|
+
let cache = null
|
|
215
|
+
try { cache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* none */ }
|
|
216
|
+
|
|
217
|
+
let served = null
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/skills`, {
|
|
220
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
221
|
+
})
|
|
222
|
+
if (res.ok) {
|
|
223
|
+
served = (await res.json())?.skills ?? []
|
|
224
|
+
summary.source = 'server'
|
|
225
|
+
} else {
|
|
226
|
+
// ⚠ DO NOT MAKE THIS SILENT AGAIN. `if (res.ok)` alone is how a 100% failure hid for weeks:
|
|
227
|
+
// /api/skills answered 409 to every multi-brain caller, this fell through to the cache below,
|
|
228
|
+
// and the only log line ('unreachable and no cache') does NOT print when a cache exists. So
|
|
229
|
+
// org skills silently stopped updating and nothing anywhere said so. Fail-soft is right —
|
|
230
|
+
// SessionStart must not break — but fail-soft is not fail-quiet.
|
|
231
|
+
log(` · org skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
|
|
232
|
+
}
|
|
233
|
+
} catch { /* fall through to cache */ }
|
|
234
|
+
if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
|
|
235
|
+
if (!served) { log(' · org skills: unreachable and no cache — skipped'); return summary }
|
|
236
|
+
|
|
237
|
+
const { install, skipped } = planOrgInstall(served, bundledSkills().map((s) => s.name))
|
|
238
|
+
summary.skipped = skipped
|
|
239
|
+
const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
|
|
240
|
+
const currentNames = install.map((s) => s.name)
|
|
241
|
+
const removeNames = prevNames.filter((n) => !currentNames.includes(n))
|
|
242
|
+
|
|
243
|
+
for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
|
|
244
|
+
const r = installInto(join(cli.dir, 'skills'), install, { removeNames })
|
|
245
|
+
summary.installed.push(...r.installed)
|
|
246
|
+
summary.repaired.push(...r.repaired)
|
|
247
|
+
summary.removed.push(...r.removed)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Persist last-good only when the server actually answered (a cache-fed run keeps the old one).
|
|
251
|
+
if (summary.source === 'server') {
|
|
252
|
+
ensureDir(dirname(ORG_CACHE))
|
|
253
|
+
writeFileSync(ORG_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const changed = [...new Set([...summary.installed, ...summary.repaired])]
|
|
257
|
+
if (changed.length || summary.removed.length) {
|
|
258
|
+
const bits = []
|
|
259
|
+
if (changed.length) bits.push(`synced ${changed.join(', ')}`)
|
|
260
|
+
if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
|
|
261
|
+
process.stdout.write(`Agnoclast: org skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
|
|
262
|
+
} else if (!quiet) {
|
|
263
|
+
log(` ✓ org skills up to date (${currentNames.length} published${skipped.length ? `, ${skipped.length} skipped` : ''})`)
|
|
264
|
+
}
|
|
265
|
+
return summary
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
|
|
269
|
+
// body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
|
|
270
|
+
// the caller can choose "by what each one HOLDS, not by which name sounds related". This function
|
|
271
|
+
// existed as `body.error ?? HTTP ${status}`, which printed the bare code `brain_required` and threw
|
|
272
|
+
// all of that away — the user saw a two-word error with no notion of what a brain is, which ones they
|
|
273
|
+
// have, or what to type next. Pure + exported so the shape is unit-testable without a network.
|
|
274
|
+
export function renderPushError(body, status) {
|
|
275
|
+
const lines = [body?.message ?? body?.error ?? `HTTP ${status}`]
|
|
276
|
+
const brains = Array.isArray(body?.brains) ? body.brains : []
|
|
277
|
+
if (brains.length) {
|
|
278
|
+
lines.push('', ' Your brains:')
|
|
279
|
+
for (const b of brains) {
|
|
280
|
+
const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
|
|
281
|
+
const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
|
|
282
|
+
? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
|
|
283
|
+
lines.push(` ${b?.name ?? b?.orgId ?? '(unnamed)'}${pages}${titles}`)
|
|
284
|
+
}
|
|
285
|
+
lines.push('', ' Re-run with --brain "<name>".')
|
|
286
|
+
}
|
|
287
|
+
return lines.join('\n')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// `skills push <file> [--brain <name>]` / `skills push --disable <name> [--brain <name>]` /
|
|
291
|
+
// `skills push --enable <name> [--brain <name>]` — publish or toggle an org skill
|
|
292
|
+
// (owner/manager/admin; the server enforces the role).
|
|
293
|
+
//
|
|
294
|
+
// ⚠ --brain IS NOT OPTIONAL FOR A MULTI-BRAIN CALLER, and until 2026-08-08 there was no way to pass
|
|
295
|
+
// it. Publishing MODIFIES one brain, so the server correctly uses ADR-0022's `requireBrain` half and
|
|
296
|
+
// refuses to guess — but this command sent no `brain`, so every push and every --disable from a
|
|
297
|
+
// multi-brain account died on `brain_required` with no way forward. Confirmed against production:
|
|
298
|
+
// `skills push --disable cortex-author-docs` → `✗ brain_required`, full stop.
|
|
299
|
+
//
|
|
300
|
+
// This is the WRITE twin of the GET bug fixed in #467. The read was miscategorised and now fans out;
|
|
301
|
+
// the write was categorised correctly and simply had no input for the answer it demanded.
|
|
302
|
+
export async function runSkillsPush(argv) {
|
|
303
|
+
const { resolveBase, resolveTokenSource, fetchCortex } = await import('./diagnose.mjs')
|
|
304
|
+
const token = resolveTokenSource().token
|
|
305
|
+
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
306
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
307
|
+
|
|
308
|
+
const brainIdx = argv.findIndex((a) => a === '--brain')
|
|
309
|
+
const brain = brainIdx === -1 ? null : argv[brainIdx + 1]
|
|
310
|
+
if (brainIdx !== -1 && (!brain || brain.startsWith('-'))) {
|
|
311
|
+
process.stderr.write('Usage: skills push … --brain <name> (missing brain name)\n')
|
|
312
|
+
return 1
|
|
313
|
+
}
|
|
314
|
+
// Strip --brain AND its value before any positional parsing below. The value does not start with
|
|
315
|
+
// '-', so the `argv.find(a => !a.startsWith('-'))` file lookup would otherwise take it as the
|
|
316
|
+
// SKILL.md path and push the wrong thing.
|
|
317
|
+
const rest = brainIdx === -1 ? argv : argv.filter((_, i) => i !== brainIdx && i !== brainIdx + 1)
|
|
318
|
+
|
|
319
|
+
let payload
|
|
320
|
+
const toggleIdx = rest.findIndex((a) => a === '--disable' || a === '--enable')
|
|
321
|
+
if (toggleIdx !== -1) {
|
|
322
|
+
const name = rest[toggleIdx + 1]
|
|
323
|
+
if (!name) { process.stderr.write(`Usage: skills push ${rest[toggleIdx]} <name>\n`); return 1 }
|
|
324
|
+
payload = { name, enabled: rest[toggleIdx] === '--enable' }
|
|
325
|
+
} else {
|
|
326
|
+
const file = rest.find((a) => !a.startsWith('-'))
|
|
327
|
+
if (!file || !existsSync(file)) { process.stderr.write('Usage: skills push <SKILL.md> (file not found)\n'); return 1 }
|
|
328
|
+
const body_md = readFileSync(file, 'utf8')
|
|
329
|
+
const name = frontmatterName(body_md, '').toLowerCase()
|
|
330
|
+
if (!NAME_RE.test(name)) {
|
|
331
|
+
process.stderr.write(`SKILL.md needs frontmatter \`name:\` in lowercase kebab (got "${name || 'nothing'}").\n`)
|
|
332
|
+
return 1
|
|
333
|
+
}
|
|
334
|
+
payload = { name, body_md, enabled: true }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const qs = brain ? `?brain=${encodeURIComponent(brain)}` : ''
|
|
339
|
+
const res = await fetchCortex(`${base}/api/skills${qs}`, {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
342
|
+
body: JSON.stringify(payload),
|
|
343
|
+
})
|
|
344
|
+
const body = await res.json().catch(() => ({}))
|
|
345
|
+
if (!res.ok) { process.stderr.write(`✗ ${renderPushError(body, res.status)}\n`); return 1 }
|
|
346
|
+
process.stdout.write(
|
|
347
|
+
payload.body_md
|
|
348
|
+
? `✓ published "${payload.name}" to your org — every seat installs it on next session start.\n`
|
|
349
|
+
: `✓ "${payload.name}" ${payload.enabled ? 'enabled' : 'disabled'}.\n`,
|
|
350
|
+
)
|
|
351
|
+
return 0
|
|
352
|
+
} catch (e) {
|
|
353
|
+
process.stderr.write(`✗ network error: ${e.message}\n`)
|
|
354
|
+
return 1
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
|
|
359
|
+
// are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
|
|
360
|
+
export async function runSkills(argv = []) {
|
|
361
|
+
if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
|
|
362
|
+
|
|
363
|
+
const quiet = argv.includes('--quiet')
|
|
364
|
+
if (!quiet) process.stdout.write('\nAgnoclast skills — installing managed skills…\n')
|
|
365
|
+
const r = installSkills({ quiet })
|
|
366
|
+
if (!quiet) {
|
|
367
|
+
if (!r.targets.length) process.stdout.write(' ! No agent CLI found (~/.claude or ~/.codex). Nothing to install.\n')
|
|
368
|
+
else if (!r.installed.length && !r.repaired.length) {
|
|
369
|
+
process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (r.targets.length) await syncOrgSkills({ quiet })
|
|
373
|
+
return 0
|
|
374
|
+
}
|