skillmaxxing 0.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +11 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/LICENSE +21 -0
  4. package/README.md +152 -0
  5. package/dist/agents/claude.js +12 -0
  6. package/dist/agents/codex.js +12 -0
  7. package/dist/agents/cursor.js +12 -0
  8. package/dist/agents/hermes.js +12 -0
  9. package/dist/agents/opencode.js +12 -0
  10. package/dist/agents/registry.js +22 -0
  11. package/dist/agents/types.js +1 -0
  12. package/dist/cli.js +291 -0
  13. package/dist/commands/discover.js +76 -0
  14. package/dist/commands/doctor.js +84 -0
  15. package/dist/commands/init.js +47 -0
  16. package/dist/commands/install.js +74 -0
  17. package/dist/commands/list.js +74 -0
  18. package/dist/commands/optimize.js +152 -0
  19. package/dist/commands/plugin.js +232 -0
  20. package/dist/commands/remove.js +48 -0
  21. package/dist/commands/skillify.js +74 -0
  22. package/dist/commands/update.js +52 -0
  23. package/dist/commands/workspace.js +117 -0
  24. package/dist/create/match.js +23 -0
  25. package/dist/create/reflect.js +49 -0
  26. package/dist/create/skillify.js +117 -0
  27. package/dist/discover/collect.js +40 -0
  28. package/dist/discover/github.js +27 -0
  29. package/dist/discover/index.js +39 -0
  30. package/dist/discover/local.js +55 -0
  31. package/dist/discover/rank.js +63 -0
  32. package/dist/discover/types.js +1 -0
  33. package/dist/eval/runner.js +81 -0
  34. package/dist/eval/schema.js +78 -0
  35. package/dist/eval/scorers.js +19 -0
  36. package/dist/lock/global.js +53 -0
  37. package/dist/lock/project.js +67 -0
  38. package/dist/optimize/budget.js +22 -0
  39. package/dist/optimize/buffer.js +33 -0
  40. package/dist/optimize/diff.js +89 -0
  41. package/dist/optimize/loop.js +49 -0
  42. package/dist/plugin/guidance.js +30 -0
  43. package/dist/plugin/reflect.js +63 -0
  44. package/dist/plugin/sessions.js +58 -0
  45. package/dist/source/parser.js +63 -0
  46. package/dist/source/resolver.js +120 -0
  47. package/dist/state/store.js +120 -0
  48. package/dist/state/trust.js +31 -0
  49. package/dist/types.js +1 -0
  50. package/dist/util/collision.js +46 -0
  51. package/dist/util/exec.js +78 -0
  52. package/dist/util/frontmatter.js +72 -0
  53. package/dist/util/fs.js +77 -0
  54. package/dist/util/git.js +35 -0
  55. package/dist/util/log.js +33 -0
  56. package/dist/util/sanitize.js +36 -0
  57. package/dist/util/similarity.js +27 -0
  58. package/dist/util/versions.js +104 -0
  59. package/dist/workspace/channels.js +14 -0
  60. package/dist/workspace/collab.js +103 -0
  61. package/dist/workspace/registry.js +113 -0
  62. package/hooks/hooks.json +26 -0
  63. package/index/index.json +5 -0
  64. package/package.json +53 -0
@@ -0,0 +1,58 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { ensureDir } from '../util/fs.js';
5
+ /**
6
+ * Per-session tool-call counter that gates the auto-reflection loop. The plugin
7
+ * hooks increment a counter on every tool use (PostToolUse) and, on Stop, fire
8
+ * reflection only once enough substantive work has accrued — the Hermes
9
+ * "iteration-gated review" idea, adapted to coding-agent hooks. State lives
10
+ * outside any skill dir so it never affects skill content hashes.
11
+ */
12
+ const SESSIONS_DIR = path.join(os.homedir(), '.skillmax', 'sessions');
13
+ function sessionPath(id) {
14
+ const safe = id.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'session';
15
+ return path.join(SESSIONS_DIR, `${safe}.json`);
16
+ }
17
+ export function readSession(id) {
18
+ try {
19
+ const data = JSON.parse(fs.readFileSync(sessionPath(id), 'utf-8'));
20
+ if (typeof data?.tools === 'number') {
21
+ return { tools: data.tools, lastReflectTools: data.lastReflectTools ?? 0, reflectedAt: data.reflectedAt };
22
+ }
23
+ }
24
+ catch {
25
+ /* fall through to default */
26
+ }
27
+ return { tools: 0, lastReflectTools: 0 };
28
+ }
29
+ function writeSession(id, state) {
30
+ ensureDir(SESSIONS_DIR);
31
+ const target = sessionPath(id);
32
+ const tmp = target + '.tmp';
33
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
34
+ fs.renameSync(tmp, target);
35
+ }
36
+ /** Record one tool use; returns the new total. */
37
+ export function recordToolUse(id) {
38
+ const s = readSession(id);
39
+ s.tools += 1;
40
+ writeSession(id, s);
41
+ return s.tools;
42
+ }
43
+ /** Tool calls since the last reflection. */
44
+ export function toolsSinceReflect(id) {
45
+ const s = readSession(id);
46
+ return s.tools - s.lastReflectTools;
47
+ }
48
+ /** True when enough substantive work has accrued to warrant a reflection. */
49
+ export function shouldReflect(id, threshold) {
50
+ return toolsSinceReflect(id) >= threshold;
51
+ }
52
+ /** Mark that a reflection ran at the current tool count. */
53
+ export function markReflected(id, now) {
54
+ const s = readSession(id);
55
+ s.lastReflectTools = s.tools;
56
+ s.reflectedAt = now;
57
+ writeSession(id, s);
58
+ }
@@ -0,0 +1,63 @@
1
+ import * as path from 'node:path';
2
+ import { sanitizeSubpath } from '../util/sanitize.js';
3
+ const GITHUB_SHORTHAND = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?:\/(.+))?$/;
4
+ const GITHUB_URL = /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/;
5
+ const GIT_SSH = /^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/;
6
+ export function parseSource(input) {
7
+ const raw = input.trim();
8
+ if (raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('/') || raw.match(/^[A-Z]:\\/)) {
9
+ return { type: 'local', localPath: path.resolve(raw), raw };
10
+ }
11
+ const ghUrl = raw.match(GITHUB_URL);
12
+ if (ghUrl) {
13
+ const subpath = ghUrl[4] ? sanitizeSubpath(ghUrl[4]) : undefined;
14
+ if (ghUrl[4] && subpath === null) {
15
+ throw new Error(`Unsafe subpath in URL: ${raw}`);
16
+ }
17
+ return {
18
+ type: 'github',
19
+ owner: ghUrl[1],
20
+ repo: ghUrl[2],
21
+ ref: ghUrl[3],
22
+ subpath: subpath ?? undefined,
23
+ url: `https://github.com/${ghUrl[1]}/${ghUrl[2]}.git`,
24
+ raw,
25
+ };
26
+ }
27
+ const ssh = raw.match(GIT_SSH);
28
+ if (ssh) {
29
+ return {
30
+ type: 'git',
31
+ owner: ssh[2],
32
+ repo: ssh[3],
33
+ url: raw,
34
+ raw,
35
+ };
36
+ }
37
+ if (raw.startsWith('https://') || raw.startsWith('http://')) {
38
+ return { type: 'git', url: raw, raw };
39
+ }
40
+ const shorthand = raw.match(GITHUB_SHORTHAND);
41
+ if (shorthand) {
42
+ const subpath = shorthand[3] ? sanitizeSubpath(shorthand[3]) : undefined;
43
+ if (shorthand[3] && subpath === null) {
44
+ throw new Error(`Unsafe subpath: ${raw}`);
45
+ }
46
+ return {
47
+ type: 'github',
48
+ owner: shorthand[1],
49
+ repo: shorthand[2],
50
+ subpath: subpath ?? undefined,
51
+ url: `https://github.com/${shorthand[1]}/${shorthand[2]}.git`,
52
+ raw,
53
+ };
54
+ }
55
+ throw new Error(`Cannot parse source: '${raw}'. Expected: owner/repo, GitHub URL, git URL, or local path.`);
56
+ }
57
+ export function sourceLabel(source) {
58
+ if (source.type === 'local')
59
+ return source.localPath;
60
+ if (source.owner && source.repo)
61
+ return `${source.owner}/${source.repo}`;
62
+ return source.url ?? source.raw;
63
+ }
@@ -0,0 +1,120 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { gitClone, gitGetHeadSha, makeTempDir, cleanTempDir } from '../util/git.js';
4
+ import { readSkillMeta } from '../util/frontmatter.js';
5
+ /**
6
+ * Defense-in-depth guard for directory entries read from untrusted cloned repos
7
+ * (review S5): reject names that are path-traversal or separator-bearing before
8
+ * joining them into a filesystem path. readdir normally returns plain segments,
9
+ * but a maliciously crafted archive should never escape the scan root.
10
+ */
11
+ export function isSafeEntryName(name) {
12
+ return (name !== '' &&
13
+ name !== '.' &&
14
+ name !== '..' &&
15
+ !name.includes('/') &&
16
+ !name.includes('\\') &&
17
+ !name.includes('\0'));
18
+ }
19
+ export async function resolveSource(source) {
20
+ if (source.type === 'local') {
21
+ return resolveLocal(source.localPath);
22
+ }
23
+ return resolveRemote(source);
24
+ }
25
+ function resolveLocal(localPath) {
26
+ const resolved = path.resolve(localPath);
27
+ if (!fs.existsSync(resolved)) {
28
+ throw new Error(`Local path does not exist: ${resolved}`);
29
+ }
30
+ const skillMd = path.join(resolved, 'SKILL.md');
31
+ if (fs.existsSync(skillMd)) {
32
+ const content = fs.readFileSync(skillMd, 'utf-8');
33
+ const meta = readSkillMeta(content);
34
+ if (!meta)
35
+ throw new Error(`Invalid SKILL.md frontmatter at ${skillMd}`);
36
+ return [{ name: meta.name, meta, dir: resolved, isTemp: false }];
37
+ }
38
+ const skills = [];
39
+ for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {
40
+ if (!entry.isDirectory())
41
+ continue;
42
+ if (!isSafeEntryName(entry.name))
43
+ continue;
44
+ const sub = path.join(resolved, entry.name, 'SKILL.md');
45
+ if (!fs.existsSync(sub))
46
+ continue;
47
+ const content = fs.readFileSync(sub, 'utf-8');
48
+ const meta = readSkillMeta(content);
49
+ if (meta) {
50
+ skills.push({ name: meta.name, meta, dir: path.join(resolved, entry.name), isTemp: false });
51
+ }
52
+ }
53
+ if (skills.length === 0) {
54
+ throw new Error(`No SKILL.md found in ${resolved} or its subdirectories`);
55
+ }
56
+ return skills;
57
+ }
58
+ async function resolveRemote(source) {
59
+ const tmpDir = makeTempDir('clone');
60
+ try {
61
+ await gitClone(source.url, tmpDir, source.ref);
62
+ const sha = await gitGetHeadSha(tmpDir);
63
+ let searchDir = tmpDir;
64
+ if (source.subpath) {
65
+ searchDir = path.join(tmpDir, source.subpath);
66
+ if (!fs.existsSync(searchDir)) {
67
+ throw new Error(`Subpath '${source.subpath}' not found in ${source.url}`);
68
+ }
69
+ }
70
+ const skillMd = path.join(searchDir, 'SKILL.md');
71
+ if (fs.existsSync(skillMd)) {
72
+ const content = fs.readFileSync(skillMd, 'utf-8');
73
+ const meta = readSkillMeta(content);
74
+ if (!meta)
75
+ throw new Error(`Invalid SKILL.md frontmatter in ${source.url}`);
76
+ return [{ name: meta.name, meta, dir: searchDir, commitSha: sha, isTemp: true }];
77
+ }
78
+ const skills = [];
79
+ for (const entry of fs.readdirSync(searchDir, { withFileTypes: true })) {
80
+ if (!entry.isDirectory())
81
+ continue;
82
+ if (!isSafeEntryName(entry.name))
83
+ continue;
84
+ const sub = path.join(searchDir, entry.name, 'SKILL.md');
85
+ if (!fs.existsSync(sub))
86
+ continue;
87
+ const content = fs.readFileSync(sub, 'utf-8');
88
+ const meta = readSkillMeta(content);
89
+ if (meta) {
90
+ skills.push({ name: meta.name, meta, dir: path.join(searchDir, entry.name), commitSha: sha, isTemp: true });
91
+ }
92
+ }
93
+ if (skills.length === 0) {
94
+ throw new Error(`No SKILL.md found in ${source.url}`);
95
+ }
96
+ return skills;
97
+ }
98
+ catch (err) {
99
+ cleanTempDir(tmpDir);
100
+ throw err;
101
+ }
102
+ }
103
+ export function cleanupResolved(skills) {
104
+ const cleaned = new Set();
105
+ for (const skill of skills) {
106
+ if (!skill.isTemp)
107
+ continue;
108
+ let dir = skill.dir;
109
+ while (dir.includes('skillmax-clone-')) {
110
+ const parent = path.dirname(dir);
111
+ if (parent === dir)
112
+ break;
113
+ dir = parent;
114
+ }
115
+ if (!cleaned.has(dir)) {
116
+ cleaned.add(dir);
117
+ cleanTempDir(dir);
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,120 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { createHash } from 'node:crypto';
5
+ import { ensureDir } from '../util/fs.js';
6
+ const STATE_DIR = path.join(os.homedir(), '.skillmax', 'state');
7
+ /** Default cap on retained score-history entries (review SG5: bound unbounded growth). */
8
+ export const MAX_SCORE_HISTORY = 20;
9
+ /**
10
+ * Filesystem-safe state key. Pass an origin-namespaced identity (e.g. a
11
+ * workspace-synced skill's qualified name) so two same-named skills from
12
+ * different origins do not share one sidecar file — see plan U3 / review A5.
13
+ */
14
+ export function stateKey(identity) {
15
+ const safe = identity.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^[._]+/, '') || 'unnamed';
16
+ // Append a short hash of the RAW identity so two identities that sanitize to
17
+ // the same string (e.g. "team/a" and "team_a") never share one sidecar file —
18
+ // otherwise their trust flag and score history would silently merge (review A5).
19
+ const hash = createHash('sha256').update(identity).digest('hex').slice(0, 8);
20
+ return `${safe}-${hash}`;
21
+ }
22
+ function statePath(identity) {
23
+ return path.join(STATE_DIR, `${stateKey(identity)}.json`);
24
+ }
25
+ /** Read a skill's state sidecar, or null if absent/corrupt. Never throws. */
26
+ export function loadState(identity) {
27
+ try {
28
+ const raw = fs.readFileSync(statePath(identity), 'utf-8');
29
+ const data = JSON.parse(raw);
30
+ if (!data || typeof data.name !== 'string' || typeof data.id !== 'string') {
31
+ return null;
32
+ }
33
+ return data;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** Write a skill's state sidecar atomically, trimming score history. */
40
+ export function saveState(state) {
41
+ ensureDir(STATE_DIR);
42
+ const target = statePath(state.id);
43
+ const trimmed = {
44
+ ...state,
45
+ scoreHistory: state.scoreHistory.slice(-MAX_SCORE_HISTORY),
46
+ updatedAt: state.updatedAt,
47
+ };
48
+ const tmp = target + '.tmp';
49
+ fs.writeFileSync(tmp, JSON.stringify(trimmed, null, 2) + '\n');
50
+ fs.renameSync(tmp, target);
51
+ }
52
+ /** Load existing state for `id`, or create-and-persist a default (trusted:false). */
53
+ export function ensureState(init, now) {
54
+ const id = init.id ?? init.name;
55
+ const existing = loadState(id);
56
+ if (existing)
57
+ return existing;
58
+ const state = {
59
+ name: init.name,
60
+ id,
61
+ origin: init.origin,
62
+ trusted: false,
63
+ version: init.version ?? '1.0.0',
64
+ lifecycle: init.lifecycle ?? 'committed',
65
+ scoreHistory: [],
66
+ source: init.source,
67
+ createdAt: now,
68
+ updatedAt: now,
69
+ };
70
+ saveState(state);
71
+ return state;
72
+ }
73
+ /** Append a score entry (trimmed on save) and persist. No-op if state absent. */
74
+ export function recordScore(id, entry) {
75
+ const state = loadState(id);
76
+ if (!state)
77
+ return;
78
+ state.scoreHistory.push(entry);
79
+ state.updatedAt = entry.at;
80
+ saveState(state);
81
+ }
82
+ /** Transition lifecycle and persist. No-op if state absent. */
83
+ export function setLifecycle(id, lifecycle, now) {
84
+ const state = loadState(id);
85
+ if (!state)
86
+ return;
87
+ state.lifecycle = lifecycle;
88
+ state.updatedAt = now;
89
+ saveState(state);
90
+ }
91
+ /** List all persisted skill states (best-effort; skips corrupt files). */
92
+ export function listStates() {
93
+ let names;
94
+ try {
95
+ names = fs.readdirSync(STATE_DIR);
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ const out = [];
101
+ for (const file of names) {
102
+ if (!file.endsWith('.json'))
103
+ continue;
104
+ const id = file.slice(0, -'.json'.length);
105
+ const state = loadState(id);
106
+ if (state)
107
+ out.push(state);
108
+ }
109
+ return out;
110
+ }
111
+ /** Test/maintenance hook: remove a state sidecar. Returns true if removed. */
112
+ export function deleteState(id) {
113
+ try {
114
+ fs.rmSync(statePath(id));
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
@@ -0,0 +1,31 @@
1
+ import { loadState, saveState } from './store.js';
2
+ /**
3
+ * Trust model: agent-created and publicly-discovered skills default to
4
+ * `trusted: false` (set at state creation in store.ensureState). The execution
5
+ * sandbox refuses to auto-execute untrusted skills (see util/exec.ts). Trust is
6
+ * granted only by an explicit user action through `grantTrust`.
7
+ */
8
+ /** True only when a state record exists AND is explicitly trusted. */
9
+ export function isTrusted(id) {
10
+ return loadState(id)?.trusted ?? false;
11
+ }
12
+ /** Grant trust to a skill. No-op if no state record exists. Returns success. */
13
+ export function grantTrust(id, now) {
14
+ const state = loadState(id);
15
+ if (!state)
16
+ return false;
17
+ state.trusted = true;
18
+ state.updatedAt = now;
19
+ saveState(state);
20
+ return true;
21
+ }
22
+ /** Revoke trust from a skill. No-op if no state record exists. Returns success. */
23
+ export function revokeTrust(id, now) {
24
+ const state = loadState(id);
25
+ if (!state)
26
+ return false;
27
+ state.trusted = false;
28
+ state.updatedAt = now;
29
+ saveState(state);
30
+ return true;
31
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import * as fs from 'node:fs';
2
+ import { validateName, sanitizeName } from './sanitize.js';
3
+ /** Validate a skill name at a write boundary (review I6: validateName was never called). */
4
+ export function ensureValidName(name) {
5
+ const err = validateName(name);
6
+ if (err)
7
+ return { ok: false, reason: `invalid skill name "${name}": ${err}` };
8
+ return { ok: true };
9
+ }
10
+ /**
11
+ * Decide whether writing a skill named `name` into `destDir` is safe. Refuses to
12
+ * overwrite an existing skill unless `force` — the guarded replacement for the
13
+ * silent delete-then-write in fs.symlinkOrCopy on managed write paths (review C2).
14
+ */
15
+ export function checkWrite(destDir, name, opts = {}) {
16
+ const valid = ensureValidName(name);
17
+ if (!valid.ok)
18
+ return valid;
19
+ if (fs.existsSync(destDir) && !opts.force) {
20
+ return {
21
+ ok: false,
22
+ reason: `skill "${name}" already exists at ${destDir} (pass force to overwrite)`,
23
+ };
24
+ }
25
+ return { ok: true };
26
+ }
27
+ /**
28
+ * Case-insensitive collision check against existing names. Returns the colliding
29
+ * existing name, or null. Catches the `code-review` vs `Code-Review` footgun.
30
+ */
31
+ export function collidesWith(name, existing) {
32
+ const lower = name.toLowerCase();
33
+ for (const e of existing) {
34
+ if (e === name || e.toLowerCase() === lower)
35
+ return e;
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * Origin-namespaced name so a workspace sync never clobbers a local skill of the
41
+ * same name (review C2 / AE2). E.g. ("team-acme","code-review") →
42
+ * "team-acme-code-review" (sanitized to a valid skill name).
43
+ */
44
+ export function namespacedName(origin, name) {
45
+ return sanitizeName(`${origin}-${name}`);
46
+ }
@@ -0,0 +1,78 @@
1
+ import { execFile } from 'node:child_process';
2
+ import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
4
+ import { isTrusted } from '../state/trust.js';
5
+ /**
6
+ * Constrained subprocess runner for skill-authored scripts and eval rollouts.
7
+ *
8
+ * Honest scope (review S1/F3/KTD6): this is PROCESS-LEVEL hardening, NOT a
9
+ * security container. It does: no shell, a hard timeout, an env ALLOWLIST
10
+ * (default-deny — credentials like GITHUB_TOKEN never pass; review S8), a working
11
+ * directory, and an output-size cap. It does NOT: block network egress (not
12
+ * enforceable via env alone on macOS/Linux), prevent absolute-path writes, or
13
+ * cap memory/PIDs. Treat it as defense-in-depth. For untrusted skills, the trust
14
+ * gate below — not the subprocess limits — is the primary control.
15
+ */
16
+ /** Env vars passed through to sandboxed children. Default-deny everything else. */
17
+ const ALLOWED_ENV = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TEMP', 'TZ'];
18
+ export class SandboxRefusedError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'SandboxRefusedError';
22
+ }
23
+ }
24
+ function safeEnv(extra) {
25
+ const env = {};
26
+ for (const key of ALLOWED_ENV) {
27
+ const v = process.env[key];
28
+ if (v !== undefined)
29
+ env[key] = v;
30
+ }
31
+ // Best-effort network discouragement (NOT enforcement — see header note).
32
+ env.NO_PROXY = '*';
33
+ if (extra)
34
+ Object.assign(env, extra);
35
+ return env;
36
+ }
37
+ /**
38
+ * Run a command under the sandbox. Rejects (throws SandboxRefusedError) before
39
+ * spawning when the skill is untrusted and allowExec is not set — `trusted:false`
40
+ * skills never auto-execute (review C3/AE1).
41
+ */
42
+ export function runSandboxed(command, args, opts) {
43
+ if (!opts.allowExec) {
44
+ if (!opts.skillId || !isTrusted(opts.skillId)) {
45
+ const who = opts.skillId ? ` "${opts.skillId}"` : '';
46
+ return Promise.reject(new SandboxRefusedError(`refusing to execute untrusted skill${who}; grant trust or pass allowExec`));
47
+ }
48
+ }
49
+ const cwd = path.resolve(opts.cwd);
50
+ if (!fs.existsSync(cwd)) {
51
+ return Promise.reject(new SandboxRefusedError(`sandbox cwd does not exist: ${cwd}`));
52
+ }
53
+ const maxBuffer = opts.maxOutputBytes ?? 1024 * 1024;
54
+ return new Promise((resolve) => {
55
+ execFile(command, args, {
56
+ cwd,
57
+ env: safeEnv(opts.env),
58
+ timeout: opts.timeoutMs ?? 30_000,
59
+ maxBuffer,
60
+ shell: false,
61
+ windowsHide: true,
62
+ }, (err, stdout, stderr) => {
63
+ const e = err;
64
+ const timedOut = !!(e && e.killed && e.signal === 'SIGTERM');
65
+ const truncated = !!(e && e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER');
66
+ const code = typeof e?.code === 'number' ? e.code : e ? null : 0;
67
+ resolve({
68
+ ok: !err,
69
+ code,
70
+ signal: e?.signal ?? null,
71
+ stdout: stdout?.toString() ?? '',
72
+ stderr: stderr?.toString() ?? '',
73
+ timedOut,
74
+ truncated,
75
+ });
76
+ });
77
+ });
78
+ }
@@ -0,0 +1,72 @@
1
+ import * as fs from 'node:fs';
2
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
3
+ import { stripTerminalEscapes } from './sanitize.js';
4
+ export function parseFrontmatter(raw) {
5
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
6
+ if (!match)
7
+ return null;
8
+ let data;
9
+ try {
10
+ data = parseYaml(match[1]);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ if (!data || typeof data !== 'object')
16
+ return null;
17
+ const name = data.name;
18
+ const description = data.description;
19
+ if (typeof name !== 'string' || typeof description !== 'string')
20
+ return null;
21
+ const meta = {
22
+ ...data,
23
+ name: stripTerminalEscapes(name),
24
+ description: stripTerminalEscapes(description),
25
+ };
26
+ return { meta, content: match[2] };
27
+ }
28
+ export function readSkillMeta(skillMdContent) {
29
+ const result = parseFrontmatter(skillMdContent);
30
+ return result?.meta ?? null;
31
+ }
32
+ /**
33
+ * Recursively neutralize untrusted values before serialization: strip terminal
34
+ * escapes from strings and coerce functions to strings. The `yaml` package does
35
+ * not support executable tags (`!!js/function`) the way `gray-matter` does — the
36
+ * repo chose `yaml` deliberately to avoid eval-based RCE — but we sanitize
37
+ * defensively so a poisoned description can never round-trip into something a
38
+ * downstream parser treats as more than data (review S3).
39
+ */
40
+ function sanitizeYamlValue(value) {
41
+ if (typeof value === 'string')
42
+ return stripTerminalEscapes(value);
43
+ if (typeof value === 'function')
44
+ return String(value);
45
+ if (Array.isArray(value))
46
+ return value.map(sanitizeYamlValue);
47
+ if (value && typeof value === 'object') {
48
+ const out = {};
49
+ for (const [k, v] of Object.entries(value)) {
50
+ if (v === undefined)
51
+ continue;
52
+ out[k] = sanitizeYamlValue(v);
53
+ }
54
+ return out;
55
+ }
56
+ return value;
57
+ }
58
+ /**
59
+ * Serialize skill metadata + body back into SKILL.md text. Data-only YAML — no
60
+ * custom tags, no executable constructs. Pairs with parseFrontmatter so
61
+ * parse(serialize(x)) preserves the frontmatter block.
62
+ */
63
+ export function serializeFrontmatter(meta, body = '') {
64
+ const clean = sanitizeYamlValue(meta);
65
+ const yaml = stringifyYaml(clean, { lineWidth: 0 }).replace(/\n$/, '');
66
+ const trimmedBody = body.replace(/^\n+/, '');
67
+ return `---\n${yaml}\n---\n${trimmedBody}`;
68
+ }
69
+ /** Write a SKILL.md file from metadata + body using the safe serializer. */
70
+ export function writeSkillFile(skillMdPath, meta, body = '') {
71
+ fs.writeFileSync(skillMdPath, serializeFrontmatter(meta, body));
72
+ }
@@ -0,0 +1,77 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ export function ensureDir(dir) {
4
+ fs.mkdirSync(dir, { recursive: true });
5
+ }
6
+ export function symlinkOrCopy(src, dest, forceCopy = false) {
7
+ ensureDir(path.dirname(dest));
8
+ if (fs.existsSync(dest)) {
9
+ const stat = fs.lstatSync(dest);
10
+ if (stat.isSymbolicLink())
11
+ fs.unlinkSync(dest);
12
+ else if (stat.isDirectory())
13
+ fs.rmSync(dest, { recursive: true });
14
+ else
15
+ fs.unlinkSync(dest);
16
+ }
17
+ if (forceCopy) {
18
+ copyDir(src, dest);
19
+ return 'copy';
20
+ }
21
+ try {
22
+ const rel = path.relative(path.dirname(dest), src);
23
+ fs.symlinkSync(rel, dest);
24
+ return 'symlink';
25
+ }
26
+ catch {
27
+ copyDir(src, dest);
28
+ return 'copy';
29
+ }
30
+ }
31
+ export function copyDir(src, dest) {
32
+ ensureDir(dest);
33
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
34
+ const srcPath = path.join(src, entry.name);
35
+ const destPath = path.join(dest, entry.name);
36
+ if (entry.name === '.git')
37
+ continue;
38
+ if (entry.isDirectory()) {
39
+ copyDir(srcPath, destPath);
40
+ }
41
+ else {
42
+ fs.copyFileSync(srcPath, destPath);
43
+ }
44
+ }
45
+ }
46
+ export function removeDir(dir) {
47
+ try {
48
+ const stat = fs.lstatSync(dir);
49
+ if (stat.isSymbolicLink()) {
50
+ fs.unlinkSync(dir);
51
+ }
52
+ else {
53
+ fs.rmSync(dir, { recursive: true });
54
+ }
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ export function isSymlink(p) {
62
+ try {
63
+ return fs.lstatSync(p).isSymbolicLink();
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function fileExists(p) {
70
+ try {
71
+ fs.accessSync(p);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }