ai-optimize-master 0.0.0-stage → 0.3.2

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/lib/scan.mjs ADDED
@@ -0,0 +1,436 @@
1
+ // Read-only inventory of AI-tool assets: skill roots, skill duplication, broken
2
+ // local references, MCP configuration surfaces, and tool versions.
3
+ // Invariant (O1): this module never writes. It only reads files and runs the
4
+ // small set of version probes named in PROBES.
5
+ import { readdir, stat } from 'node:fs/promises'
6
+ import { join, basename } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { readTextIfExists, sha256Hex, expandHome } from './util.mjs'
9
+
10
+ // Version probes are an allowlist, not a shell: we only ever spawn these names.
11
+ // A binary that exists but fails to run must NOT be reported as usable (O3).
12
+ const PROBES = [
13
+ { id: 'node', command: 'node', args: ['--version'] },
14
+ { id: 'git', command: 'git', args: ['--version'] },
15
+ { id: 'pwsh', command: process.platform === 'win32' ? 'pwsh' : 'pwsh', args: ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.Major'] },
16
+ { id: 'claude', command: 'claude', args: ['--version'] },
17
+ { id: 'codex', command: 'codex', args: ['--version'] }
18
+ ]
19
+
20
+ // Known skill roots. `optional: true` means "use it if present, stay quiet if
21
+ // absent": a machine that has never installed Cursor should not get a finding for
22
+ // it. Roots supplied explicitly by the caller are always reported when unreadable.
23
+ export function defaultRoots(platform = process.platform) {
24
+ const home = homedir()
25
+ const dir = (...parts) => join(home, ...parts)
26
+ const roots = [
27
+ { id: 'dsh', label: 'DeepSeek Harness', dir: dir('.dsh', 'skills'), optional: true },
28
+ { id: 'agents', label: 'Agent Skills (cross-tool)', dir: dir('.agents', 'skills'), optional: true },
29
+ { id: 'agents-canonical', label: 'Agent Skills canonical source', dir: dir('.agents_skills'), optional: true },
30
+ { id: 'claude', label: 'Claude Code', dir: dir('.claude', 'skills'), optional: true },
31
+ { id: 'codex', label: 'Codex CLI', dir: dir('.codex', 'skills'), optional: true },
32
+ { id: 'openclaw', label: 'OpenClaw / ClawX', dir: dir('.openclaw', 'skills'), optional: true },
33
+ { id: 'cursor', label: 'Cursor', dir: dir('.cursor', 'skills'), optional: true },
34
+ { id: 'uking', label: 'U-King', dir: dir('.uking', 'skills'), optional: true },
35
+ // Other agents that follow the same <root>/<name>/SKILL.md convention.
36
+ // Paths are only used when they already exist; nothing is created.
37
+ { id: 'hermes', label: 'Hermes', dir: dir('.hermes', 'skills'), optional: true },
38
+ { id: 'opencode', label: 'OpenCode', dir: dir('.opencode', 'skills'), optional: true },
39
+ { id: 'pi', label: 'Pi', dir: dir('.pi', 'agent', 'skills'), optional: true },
40
+ { id: 'gemini', label: 'Gemini CLI', dir: dir('.gemini', 'skills'), optional: true },
41
+ { id: 'qwen', label: 'Qwen Code', dir: dir('.qwen', 'skills'), optional: true },
42
+ { id: 'trae', label: 'Trae', dir: dir('.trae', 'skills'), optional: true },
43
+ { id: 'windsurf', label: 'Windsurf', dir: dir('.windsurf', 'skills'), optional: true },
44
+ { id: 'qoder', label: 'Qoder', dir: dir('.qoder', 'skills'), optional: true },
45
+ { id: 'codebuddy', label: 'CodeBuddy', dir: dir('.codebuddy', 'skills'), optional: true },
46
+ { id: 'commandcode', label: 'CommandCode', dir: dir('.commandcode', 'skills'), optional: true },
47
+ { id: 'grok', label: 'Grok', dir: dir('.grok', 'skills'), optional: true },
48
+ { id: 'iflow', label: 'iFlow', dir: dir('.iflow', 'skills'), optional: true },
49
+ { id: 'lingma', label: 'Lingma', dir: dir('.lingma', 'skills'), optional: true },
50
+ { id: 'zcode', label: 'ZCode', dir: dir('.zcode', 'skills'), optional: true }
51
+ ]
52
+ // Extra roots (e.g. a shared Windows workstation tree) are explicit: an
53
+ // unreadable explicit root is reported, never silently ignored.
54
+ for (const extra of (process.env.DSH_OPTIMIZER_ROOTS ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean)) {
55
+ roots.push({ id: `extra:${basename(extra)}`, label: `Extra root ${extra}`, dir: expandHome(extra), optional: false })
56
+ }
57
+ return roots
58
+ }
59
+
60
+ export function defaultMcpConfigs(platform = process.platform) {
61
+ const home = homedir()
62
+ return [
63
+ { id: 'claude', label: 'Claude Code', file: join(home, '.claude.json') },
64
+ { id: 'codex', label: 'Codex CLI', file: join(home, '.codex', 'config.toml') },
65
+ { id: 'cursor', label: 'Cursor', file: join(home, '.cursor', 'mcp.json') }
66
+ ]
67
+ }
68
+
69
+ async function listSkillDirs(rootDir) {
70
+ let items
71
+ try {
72
+ items = await readdir(rootDir, { withFileTypes: true })
73
+ } catch (error) {
74
+ if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EPERM') return { skills: [], error: error.code }
75
+ throw error
76
+ }
77
+ const skills = []
78
+ for (const item of items) {
79
+ if (!item.isDirectory()) continue
80
+ const dir = join(rootDir, item.name)
81
+ const skillMd = join(dir, 'SKILL.md')
82
+ const text = await readTextIfExists(skillMd)
83
+ if (text === null) {
84
+ skills.push({ name: item.name, dir, hasSkillMd: false, skillMdHash: null, bytes: 0 })
85
+ continue
86
+ }
87
+ skills.push({ name: item.name, dir, hasSkillMd: true, skillMdHash: sha256Hex(text), bytes: Buffer.byteLength(text) })
88
+ }
89
+ return { skills }
90
+ }
91
+
92
+ // Relative local references such as `scripts/foo.mjs` or `references/bar.md`
93
+ // that the skill body tells an agent to open.
94
+ const REF_RE = /`((?:scripts|references|examples|assets|templates|docs)\/[^`\s)]+)`/g
95
+
96
+ async function checkReferences(dir, text) {
97
+ const broken = []
98
+ const seen = new Set()
99
+ for (const match of text.matchAll(REF_RE)) {
100
+ const ref = match[1]
101
+ if (seen.has(ref)) continue
102
+ seen.add(ref)
103
+ if (ref.includes('*') || ref.includes('<') || ref.endsWith('/')) continue
104
+ try {
105
+ await stat(join(dir, ref))
106
+ } catch {
107
+ broken.push(ref)
108
+ }
109
+ }
110
+ return broken
111
+ }
112
+
113
+ // A skill that hardcodes Windows-only absolute paths while being read on POSIX
114
+ // is a portability smell, never an automatic deletion (it may target a remote host).
115
+ function platformHints(text, platform) {
116
+ const hints = []
117
+ if (/[A-Za-z]:\\\\|\\bC:\\\\Users\\\\|\\bD:\\\\/.test(text)) hints.push('windows-absolute-paths')
118
+ if (platform !== 'win32' && hints.length) hints.push('read-on-non-windows')
119
+ return hints
120
+ }
121
+
122
+ async function probeTools(probes = PROBES) {
123
+ const { spawn } = await import('node:child_process')
124
+ const results = []
125
+ for (const probe of probes) {
126
+ const result = await new Promise(resolve => {
127
+ let settled = false
128
+ const done = value => { if (!settled) { settled = true; resolve(value) } }
129
+ try {
130
+ const child = spawn(probe.command, probe.args, { stdio: ['ignore', 'pipe', 'pipe'] })
131
+ let out = ''
132
+ let err = ''
133
+ child.stdout.on('data', d => { out += d })
134
+ child.stderr.on('data', d => { err += d })
135
+ child.on('error', error => done({ id: probe.id, found: false, ran: false, reason: error.code === 'ENOENT' ? 'not-on-path' : String(error.code ?? error.message) }))
136
+ child.on('close', code => {
137
+ const text = (out || err).trim()
138
+ if (code === 0 && text) done({ id: probe.id, found: true, ran: true, version: text.split(/\r?\n/)[0].slice(0, 80) })
139
+ else done({ id: probe.id, found: true, ran: false, reason: code === 0 ? 'empty-output' : `exit-${code}` })
140
+ })
141
+ setTimeout(() => { try { child.kill('SIGKILL') } catch {} ; done({ id: probe.id, found: true, ran: false, reason: 'timeout' }) }, 5000)
142
+ } catch (error) {
143
+ done({ id: probe.id, found: false, ran: false, reason: String(error.message) })
144
+ }
145
+ })
146
+ results.push(result)
147
+ }
148
+ return results
149
+ }
150
+
151
+ function parseMcpSurface(id, text) {
152
+ if (text === null) return { id, present: false, servers: [], parsable: false }
153
+ if (id === 'codex') {
154
+ // TOML: count [mcp_servers.<name>] tables without a TOML dependency.
155
+ const servers = [...text.matchAll(/^\[mcp_servers\.([^\]]+)\]/gm)].map(m => m[1].replace(/^["']|["']$/g, ''))
156
+ return { id, present: true, servers: [...new Set(servers)], parsable: true, format: 'toml' }
157
+ }
158
+ try {
159
+ const json = JSON.parse(text)
160
+ const servers = Object.keys(json?.mcpServers ?? {})
161
+ return { id, present: true, servers, parsable: true, format: 'json' }
162
+ } catch {
163
+ return { id, present: true, servers: [], parsable: false, format: 'json' }
164
+ }
165
+ }
166
+
167
+ async function pathStateOf(dir) {
168
+ try {
169
+ const info = await stat(dir)
170
+ return { exists: true, directory: info.isDirectory() }
171
+ } catch {
172
+ return { exists: false, directory: false }
173
+ }
174
+ }
175
+
176
+ export async function scan(options = {}) {
177
+ const platform = options.platform ?? process.platform
178
+ const roots = options.roots ?? defaultRoots(platform)
179
+ const mcpConfigs = options.mcpConfigs ?? defaultMcpConfigs(platform)
180
+ const includeProbes = options.probes !== false
181
+
182
+ const inventories = []
183
+ const bySkillName = new Map()
184
+
185
+ for (const root of roots) {
186
+ if (root.optional) {
187
+ // Absent optional roots are "not installed", not an error and not a finding.
188
+ const state = await pathStateOf(root.dir)
189
+ if (!state.exists) continue
190
+ }
191
+ const { skills, error } = await listSkillDirs(root.dir)
192
+ const rootResult = {
193
+ id: root.id,
194
+ label: root.label,
195
+ dir: root.dir,
196
+ applicable: error === undefined,
197
+ notApplicableReason: error ? `unreadable: ${error}` : null,
198
+ skillCount: skills.length,
199
+ skills: []
200
+ }
201
+ for (const skill of skills) {
202
+ const record = {
203
+ name: skill.name,
204
+ dir: skill.dir,
205
+ hasSkillMd: skill.hasSkillMd,
206
+ skillMdHash: skill.skillMdHash,
207
+ bytes: skill.bytes,
208
+ brokenReferences: [],
209
+ platformHints: []
210
+ }
211
+ if (skill.hasSkillMd) {
212
+ const text = await readTextIfExists(join(skill.dir, 'SKILL.md'))
213
+ record.brokenReferences = await checkReferences(skill.dir, text)
214
+ record.platformHints = platformHints(text, platform)
215
+ }
216
+ rootResult.skills.push(record)
217
+ const list = bySkillName.get(skill.name) ?? []
218
+ list.push({ root: root.id, rootLabel: root.label, ...record })
219
+ bySkillName.set(skill.name, list)
220
+ }
221
+ // Context weight is what the agent pays every session. The estimate is
222
+ // explicitly labelled: bytes/4 is a heuristic, never a measured token count (O9).
223
+ rootResult.contextBytes = rootResult.skills.reduce((n, s) => n + (s.bytes ?? 0), 0)
224
+ rootResult.contextEstimate = {
225
+ value: Math.round(rootResult.contextBytes / 4),
226
+ estimator: 'bytes/4',
227
+ note: 'Estimate, not a measured tokenizer count.'
228
+ }
229
+ inventories.push(rootResult)
230
+ }
231
+
232
+ // Duplicate analysis distinguishes "same name" from "same content".
233
+ const duplicates = []
234
+ for (const [name, copies] of bySkillName) {
235
+ if (copies.length < 2) continue
236
+ const hashes = new Set(copies.map(c => c.skillMdHash).filter(Boolean))
237
+ const identical = hashes.size === 1 && copies.every(c => c.hasSkillMd)
238
+ duplicates.push({
239
+ name,
240
+ copies: copies.map(c => ({ root: c.root, rootLabel: c.rootLabel, dir: c.dir, skillMdHash: c.skillMdHash })),
241
+ classification: identical ? 'identical-copies' : 'diverged-copies',
242
+ note: identical
243
+ ? 'Same SKILL.md content in several tool roots. Duplicate definition only; it does not prove any copy is unused.'
244
+ : 'Same skill name but different SKILL.md content. Divergence must be reviewed before any cleanup.'
245
+ })
246
+ }
247
+
248
+ const mcp = []
249
+ for (const config of mcpConfigs) {
250
+ const text = await readTextIfExists(config.file)
251
+ mcp.push({ ...parseMcpSurface(config.id, text), label: config.label, file: config.file })
252
+ }
253
+
254
+ const tools = includeProbes ? await probeTools(options.probesList ?? PROBES) : []
255
+
256
+ const findings = buildFindings({ inventories, duplicates, mcp, tools, platform })
257
+
258
+ return {
259
+ schema: 'dsh-optimizer/scan/v0.2',
260
+ platform,
261
+ scannedAt: new Date().toISOString(),
262
+ readOnly: true,
263
+ roots: inventories,
264
+ duplicates,
265
+ mcp,
266
+ tools,
267
+ findings
268
+ }
269
+ }
270
+
271
+ function finding(id, title, severity, platform, extra = {}) {
272
+ return {
273
+ id,
274
+ title,
275
+ severity,
276
+ platform,
277
+ applicable: extra.applicable ?? true,
278
+ notApplicableReason: extra.notApplicableReason ?? null,
279
+ verified: extra.verified ?? { method: 'read-file', evidence: '' },
280
+ current: extra.current ?? null,
281
+ recommended: extra.recommended ?? null,
282
+ remediation: extra.remediation ?? null
283
+ }
284
+ }
285
+
286
+ function buildFindings({ inventories, duplicates, mcp, tools, platform }) {
287
+ const findings = []
288
+
289
+ // Unreadable roots must say so; "could not read" is never "clean" (O2).
290
+ for (const root of inventories) {
291
+ if (!root.applicable) {
292
+ findings.push(finding(
293
+ `skill-root.unreadable:${root.id}`,
294
+ `Skill root ${root.label} cannot be read`,
295
+ 'info',
296
+ platform,
297
+ {
298
+ applicable: false,
299
+ notApplicableReason: root.notApplicableReason,
300
+ verified: { method: 'read-file', evidence: root.notApplicableReason },
301
+ current: root.dir
302
+ }
303
+ ))
304
+ }
305
+ }
306
+
307
+ const identical = duplicates.filter(d => d.classification === 'identical-copies')
308
+ if (identical.length) {
309
+ findings.push(finding(
310
+ 'skills.identical-duplicates',
311
+ `${identical.length} skill name(s) exist as byte-identical copies in several tool roots`,
312
+ 'info',
313
+ platform,
314
+ {
315
+ verified: { method: 'read-file', evidence: `${identical.length} groups share one SKILL.md hash` },
316
+ current: identical.map(d => ({ name: d.name, copies: d.copies.length })),
317
+ recommended: 'Review the owning tool for each copy before archiving. Identical content does not prove a copy is unused.',
318
+ remediation: null
319
+ }
320
+ ))
321
+ }
322
+
323
+ const diverged = duplicates.filter(d => d.classification === 'diverged-copies')
324
+ if (diverged.length) {
325
+ findings.push(finding(
326
+ 'skills.diverged-duplicates',
327
+ `${diverged.length} skill name(s) exist with different content across roots`,
328
+ 'medium',
329
+ platform,
330
+ {
331
+ verified: { method: 'read-file', evidence: `${diverged.length} groups have more than one SKILL.md hash` },
332
+ current: diverged.map(d => ({ name: d.name, hashes: new Set(d.copies.map(c => c.skillMdHash)).size })),
333
+ recommended: 'Decide a single source of truth per skill; do not archive until the divergent copy is reviewed.'
334
+ }
335
+ ))
336
+ }
337
+
338
+ const broken = []
339
+ for (const root of inventories) {
340
+ for (const skill of root.skills) {
341
+ if (skill.brokenReferences.length) broken.push({ root: root.id, name: skill.name, missing: skill.brokenReferences })
342
+ }
343
+ }
344
+ if (broken.length) {
345
+ findings.push(finding(
346
+ 'skills.broken-references',
347
+ `${broken.length} skill(s) reference local files that do not exist`,
348
+ 'medium',
349
+ platform,
350
+ {
351
+ verified: { method: 'read-file', evidence: `${broken.length} skill(s) with at least one missing relative reference` },
352
+ current: broken,
353
+ recommended: 'Repair the path or remove the dangling instruction. A skill that points at missing files misleads the agent.'
354
+ }
355
+ ))
356
+ }
357
+
358
+ const missingSkillMd = []
359
+ for (const root of inventories) {
360
+ for (const skill of root.skills) {
361
+ if (!skill.hasSkillMd) missingSkillMd.push({ root: root.id, name: skill.name, dir: skill.dir })
362
+ }
363
+ }
364
+ if (missingSkillMd.length) {
365
+ findings.push(finding(
366
+ 'skills.no-skill-md',
367
+ `${missingSkillMd.length} directory/directories under a skill root have no SKILL.md`,
368
+ 'info',
369
+ platform,
370
+ {
371
+ verified: { method: 'read-file', evidence: 'directory present, SKILL.md absent' },
372
+ current: missingSkillMd,
373
+ recommended: 'These are not discoverable as skills. Investigate before removing: another tool may use the directory.'
374
+ }
375
+ ))
376
+ }
377
+
378
+ const mcpTotal = mcp.reduce((n, m) => n + m.servers.length, 0)
379
+ findings.push(finding(
380
+ 'mcp.surface',
381
+ `MCP declarations found: ${mcpTotal} server entry/entries across ${mcp.filter(m => m.present).length} config file(s)`,
382
+ 'info',
383
+ platform,
384
+ {
385
+ verified: { method: 'read-file', evidence: 'parsed MCP config surfaces without executing any server' },
386
+ current: mcp.map(m => ({ id: m.id, present: m.present, parsable: m.parsable, servers: m.servers })),
387
+ recommended: 'MCP surfaces are read-only in this version. Starting a server or removing one is out of scope; a timeout never proves a server is dead.',
388
+ remediation: null
389
+ }
390
+ ))
391
+
392
+ for (const tool of tools) {
393
+ if (!tool.found) {
394
+ findings.push(finding(
395
+ `tool.missing:${tool.id}`,
396
+ `${tool.id} is not on PATH`,
397
+ 'info',
398
+ platform,
399
+ {
400
+ applicable: false,
401
+ notApplicableReason: tool.reason,
402
+ verified: { method: 'ran', evidence: `spawn failed: ${tool.reason}` },
403
+ current: null
404
+ }
405
+ ))
406
+ } else if (!tool.ran) {
407
+ // O3: presence on PATH is not proof that the tool works.
408
+ findings.push(finding(
409
+ `tool.unusable:${tool.id}`,
410
+ `${tool.id} exists on PATH but did not run successfully`,
411
+ 'medium',
412
+ platform,
413
+ {
414
+ verified: { method: 'ran', evidence: `spawned ${tool.id}; ${tool.reason}` },
415
+ current: { found: true, usable: false },
416
+ recommended: 'Investigate the failing executable. A file that exists is not an installed, working tool.'
417
+ }
418
+ ))
419
+ } else {
420
+ findings.push(finding(
421
+ `tool.version:${tool.id}`,
422
+ `${tool.id} reports a version`,
423
+ 'info',
424
+ platform,
425
+ {
426
+ verified: { method: 'ran', evidence: tool.version },
427
+ current: { found: true, usable: true, version: tool.version }
428
+ }
429
+ ))
430
+ }
431
+ }
432
+
433
+ return findings
434
+ }
435
+
436
+ export { PROBES }
package/lib/util.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // Shared primitives: canonical JSON, sha256, byte-safe file io, filesystem snapshots.
2
+ // Invariant: hashes over structured values MUST be key-order independent (see O10).
3
+ import { createHash } from 'node:crypto'
4
+ import { readFile, writeFile, readdir, stat, unlink, rename, mkdir, realpath } from 'node:fs/promises'
5
+ import { join, relative, dirname, resolve, sep } from 'node:path'
6
+ import { homedir } from 'node:os'
7
+
8
+ // Deterministic JSON: object keys sorted recursively, arrays keep order.
9
+ // Arrays are NOT sorted: step order and command arguments are semantic.
10
+ export function canonicalStringify(value) {
11
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
12
+ if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(',')}]`
13
+ const keys = Object.keys(value).sort()
14
+ return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalStringify(value[key])}`).join(',')}}`
15
+ }
16
+
17
+ // Structured values go through canonicalStringify so the hash never depends on key insertion order.
18
+ export function sha256Hex(value) {
19
+ const text = typeof value === 'string' ? value : canonicalStringify(value)
20
+ return createHash('sha256').update(text).digest('hex')
21
+ }
22
+
23
+ export function home() {
24
+ return process.env.DSH_OPTIMIZER_HOME || join(homedir(), '.dsh-optimizer')
25
+ }
26
+
27
+ export function expandHome(p) {
28
+ if (!p) return p
29
+ if (p === '~') return homedir()
30
+ if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2))
31
+ return p
32
+ }
33
+
34
+ export async function readBytesIfExists(file) {
35
+ try {
36
+ return await readFile(file)
37
+ } catch (error) {
38
+ if (error?.code === 'ENOENT') return null
39
+ throw error
40
+ }
41
+ }
42
+
43
+ export async function writeBytes(file, bytes) {
44
+ await mkdir(dirname(file), { recursive: true })
45
+ await writeFile(file, bytes)
46
+ }
47
+
48
+ export async function readTextIfExists(file) {
49
+ const bytes = await readBytesIfExists(file)
50
+ return bytes === null ? null : bytes.toString('utf8')
51
+ }
52
+
53
+ // Snapshot a tree as { relPath -> { size, mtimeMs, sha256 } }. Used by O1 (scan never writes).
54
+ export async function snapshotTree(root) {
55
+ const entries = new Map()
56
+ async function walk(dir) {
57
+ let items
58
+ try {
59
+ items = await readdir(dir, { withFileTypes: true })
60
+ } catch (error) {
61
+ if (error?.code === 'ENOENT') return
62
+ throw error
63
+ }
64
+ for (const item of items) {
65
+ const absolute = join(dir, item.name)
66
+ if (item.isDirectory()) {
67
+ await walk(absolute)
68
+ } else if (item.isFile()) {
69
+ const info = await stat(absolute)
70
+ const bytes = await readFile(absolute)
71
+ entries.set(relative(root, absolute).replaceAll('\\', '/'), {
72
+ size: info.size,
73
+ mtimeMs: info.mtimeMs,
74
+ sha256: createHash('sha256').update(bytes).digest('hex')
75
+ })
76
+ }
77
+ }
78
+ }
79
+ await walk(root)
80
+ return entries
81
+ }
82
+
83
+ export function snapshotEqual(a, b) {
84
+ if (a.size !== b.size) return false
85
+ for (const [key, value] of a) {
86
+ const other = b.get(key)
87
+ if (!other) return false
88
+ if (value.size !== other.size || value.mtimeMs !== other.mtimeMs || value.sha256 !== other.sha256) return false
89
+ }
90
+ for (const key of b.keys()) {
91
+ if (!a.has(key)) return false
92
+ }
93
+ return true
94
+ }
95
+
96
+ export async function sha256Of(file) {
97
+ const bytes = await readBytesIfExists(file)
98
+ if (bytes === null) return null
99
+ return createHash('sha256').update(bytes).digest('hex')
100
+ }
101
+
102
+ export async function removeIfExists(file) {
103
+ try {
104
+ await unlink(file)
105
+ } catch (error) {
106
+ if (error?.code !== 'ENOENT') throw error
107
+ }
108
+ }
109
+
110
+ export async function movePath(from, to) {
111
+ await mkdir(dirname(to), { recursive: true })
112
+ await rename(from, to)
113
+ }
114
+
115
+ // Guard: a write target must stay inside one of the declared roots.
116
+ export function isInside(root, target) {
117
+ const r = resolve(root)
118
+ const t = resolve(target)
119
+ return t === r || t.startsWith(r + sep)
120
+ }
121
+
122
+ export async function realpathOrNull(target) {
123
+ try { return await realpath(target) } catch { return null }
124
+ }
125
+
126
+ export function parseBool(value, fallback = false) {
127
+ if (value === undefined || value === null || value === '') return fallback
128
+ if (typeof value === 'boolean') return value
129
+ return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase())
130
+ }
package/mcp-server.mjs ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // MCP surface — READ-ONLY by design.
3
+ // A remote MCP caller may inspect and plan, but must not modify this machine:
4
+ // apply and undo are deliberately absent. See SECURITY.md.
5
+ import readline from 'node:readline'
6
+ import { scan } from './lib/scan.mjs'
7
+ import { buildPlan } from './lib/apply.mjs'
8
+
9
+ const tools = [
10
+ {
11
+ name: 'optimizer_scan',
12
+ description: 'Read-only inventory of AI tool skill roots, duplicates, broken references, MCP declarations, and tool versions. Never writes.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: { includeProbes: { type: 'boolean' } },
16
+ additionalProperties: false
17
+ }
18
+ },
19
+ {
20
+ name: 'optimizer_plan',
21
+ description: 'Build a reviewable, hash-bound plan from a scan. Never modifies anything. Note: the MCP surface cannot apply a plan; apply/undo exist only on the local CLI and the DSH plugin.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ select: { type: 'array', items: { type: 'string' } },
26
+ root: { type: 'array', items: { type: 'string' } }
27
+ },
28
+ additionalProperties: false
29
+ }
30
+ }
31
+ ]
32
+
33
+ async function call(name, args = {}) {
34
+ if (name === 'optimizer_scan') return scan({ probes: args.includeProbes ?? true })
35
+ if (name === 'optimizer_plan') {
36
+ const scanResult = await scan({ probes: false })
37
+ const selected = (args.select ?? []).map((skillName, index) => (args.root?.[index] ? { name: skillName, root: args.root[index] } : skillName))
38
+ return buildPlan(scanResult, { selections: selected })
39
+ }
40
+ throw Object.assign(new Error(`Unknown tool: ${name}`), { code: 'METHOD_NOT_FOUND' })
41
+ }
42
+
43
+ const send = value => process.stdout.write(`${JSON.stringify(value)}\n`)
44
+ const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
45
+
46
+ for await (const line of lines) {
47
+ if (!line.trim()) continue
48
+ let request
49
+ try { request = JSON.parse(line) } catch { continue }
50
+ if (request.id === undefined) continue
51
+ try {
52
+ if (request.method === 'initialize') {
53
+ send({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: request.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'ai-optimize-master', version: '0.3.2' } } })
54
+ } else if (request.method === 'tools/list') {
55
+ send({ jsonrpc: '2.0', id: request.id, result: { tools } })
56
+ } else if (request.method === 'tools/call') {
57
+ const value = await call(request.params?.name, request.params?.arguments ?? {})
58
+ send({ jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], isError: false } })
59
+ } else {
60
+ send({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: `Unknown method: ${request.method}` } })
61
+ }
62
+ } catch (error) {
63
+ send({ jsonrpc: '2.0', id: request.id, error: { code: -32000, message: error?.message ?? String(error) } })
64
+ }
65
+ }