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.
- package/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/dist/agents/claude.js +12 -0
- package/dist/agents/codex.js +12 -0
- package/dist/agents/cursor.js +12 -0
- package/dist/agents/hermes.js +12 -0
- package/dist/agents/opencode.js +12 -0
- package/dist/agents/registry.js +22 -0
- package/dist/agents/types.js +1 -0
- package/dist/cli.js +291 -0
- package/dist/commands/discover.js +76 -0
- package/dist/commands/doctor.js +84 -0
- package/dist/commands/init.js +47 -0
- package/dist/commands/install.js +74 -0
- package/dist/commands/list.js +74 -0
- package/dist/commands/optimize.js +152 -0
- package/dist/commands/plugin.js +232 -0
- package/dist/commands/remove.js +48 -0
- package/dist/commands/skillify.js +74 -0
- package/dist/commands/update.js +52 -0
- package/dist/commands/workspace.js +117 -0
- package/dist/create/match.js +23 -0
- package/dist/create/reflect.js +49 -0
- package/dist/create/skillify.js +117 -0
- package/dist/discover/collect.js +40 -0
- package/dist/discover/github.js +27 -0
- package/dist/discover/index.js +39 -0
- package/dist/discover/local.js +55 -0
- package/dist/discover/rank.js +63 -0
- package/dist/discover/types.js +1 -0
- package/dist/eval/runner.js +81 -0
- package/dist/eval/schema.js +78 -0
- package/dist/eval/scorers.js +19 -0
- package/dist/lock/global.js +53 -0
- package/dist/lock/project.js +67 -0
- package/dist/optimize/budget.js +22 -0
- package/dist/optimize/buffer.js +33 -0
- package/dist/optimize/diff.js +89 -0
- package/dist/optimize/loop.js +49 -0
- package/dist/plugin/guidance.js +30 -0
- package/dist/plugin/reflect.js +63 -0
- package/dist/plugin/sessions.js +58 -0
- package/dist/source/parser.js +63 -0
- package/dist/source/resolver.js +120 -0
- package/dist/state/store.js +120 -0
- package/dist/state/trust.js +31 -0
- package/dist/types.js +1 -0
- package/dist/util/collision.js +46 -0
- package/dist/util/exec.js +78 -0
- package/dist/util/frontmatter.js +72 -0
- package/dist/util/fs.js +77 -0
- package/dist/util/git.js +35 -0
- package/dist/util/log.js +33 -0
- package/dist/util/sanitize.js +36 -0
- package/dist/util/similarity.js +27 -0
- package/dist/util/versions.js +104 -0
- package/dist/workspace/channels.js +14 -0
- package/dist/workspace/collab.js +103 -0
- package/dist/workspace/registry.js +113 -0
- package/hooks/hooks.json +26 -0
- package/index/index.json +5 -0
- package/package.json +53 -0
package/dist/util/git.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function gitClone(url, dest, ref) {
|
|
8
|
+
const args = ['clone', '--depth', '1'];
|
|
9
|
+
if (ref)
|
|
10
|
+
args.push('--branch', ref);
|
|
11
|
+
args.push(url, dest);
|
|
12
|
+
await execFileAsync('git', args, {
|
|
13
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_LFS_SKIP_SMUDGE: '1' },
|
|
14
|
+
timeout: 60_000,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export async function gitGetHeadSha(dir) {
|
|
18
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: dir });
|
|
19
|
+
return stdout.trim();
|
|
20
|
+
}
|
|
21
|
+
export function makeTempDir(prefix) {
|
|
22
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `skillmax-${prefix}-`));
|
|
23
|
+
}
|
|
24
|
+
export function cleanTempDir(dir) {
|
|
25
|
+
const resolved = path.resolve(dir);
|
|
26
|
+
const tmpdir = path.resolve(os.tmpdir());
|
|
27
|
+
if (!resolved.startsWith(tmpdir + path.sep))
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
fs.rmSync(resolved, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// best-effort cleanup
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/util/log.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const RESET = '\x1b[0m';
|
|
2
|
+
const BOLD = '\x1b[1m';
|
|
3
|
+
const DIM = '\x1b[2m';
|
|
4
|
+
const GREEN = '\x1b[32m';
|
|
5
|
+
const YELLOW = '\x1b[33m';
|
|
6
|
+
const RED = '\x1b[31m';
|
|
7
|
+
const CYAN = '\x1b[36m';
|
|
8
|
+
export function info(msg) {
|
|
9
|
+
console.log(`${CYAN}i${RESET} ${msg}`);
|
|
10
|
+
}
|
|
11
|
+
export function success(msg) {
|
|
12
|
+
console.log(`${GREEN}✓${RESET} ${msg}`);
|
|
13
|
+
}
|
|
14
|
+
export function warn(msg) {
|
|
15
|
+
console.log(`${YELLOW}!${RESET} ${msg}`);
|
|
16
|
+
}
|
|
17
|
+
export function error(msg) {
|
|
18
|
+
console.error(`${RED}✗${RESET} ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
export function heading(msg) {
|
|
21
|
+
console.log(`\n${BOLD}${msg}${RESET}`);
|
|
22
|
+
}
|
|
23
|
+
export function dim(msg) {
|
|
24
|
+
console.log(`${DIM}${msg}${RESET}`);
|
|
25
|
+
}
|
|
26
|
+
export function table(rows) {
|
|
27
|
+
if (rows.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => (r[i] ?? '').length)));
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
const NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
3
|
+
const MAX_NAME_LEN = 64;
|
|
4
|
+
export function sanitizeName(raw) {
|
|
5
|
+
return raw
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
8
|
+
.replace(/^-+|-+$/g, '')
|
|
9
|
+
.substring(0, MAX_NAME_LEN) || 'unnamed-skill';
|
|
10
|
+
}
|
|
11
|
+
export function validateName(name) {
|
|
12
|
+
if (!name)
|
|
13
|
+
return 'name is required';
|
|
14
|
+
if (name.length > MAX_NAME_LEN)
|
|
15
|
+
return `name exceeds ${MAX_NAME_LEN} characters`;
|
|
16
|
+
if (!NAME_RE.test(name))
|
|
17
|
+
return 'name must be lowercase alphanumeric with single hyphens, starting with a letter';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export function isPathSafe(basePath, targetPath) {
|
|
21
|
+
const resolvedBase = path.resolve(basePath);
|
|
22
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
23
|
+
return resolvedTarget.startsWith(resolvedBase + path.sep) || resolvedTarget === resolvedBase;
|
|
24
|
+
}
|
|
25
|
+
export function sanitizeSubpath(subpath) {
|
|
26
|
+
const segments = subpath.split(/[/\\]/);
|
|
27
|
+
if (segments.some(s => s === '..'))
|
|
28
|
+
return null;
|
|
29
|
+
return segments.filter(s => s && s !== '.').join('/');
|
|
30
|
+
}
|
|
31
|
+
export function stripTerminalEscapes(str) {
|
|
32
|
+
return str
|
|
33
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') // CSI sequences
|
|
34
|
+
.replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
|
|
35
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // control chars (keep \t \n \r)
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared text similarity helpers used by discovery ranking, the prefer-update
|
|
3
|
+
* matcher, and in-session reflection. Single source of truth so tokenization
|
|
4
|
+
* stays consistent across all three (and future changes — stemming, stopwords —
|
|
5
|
+
* land in one place).
|
|
6
|
+
*/
|
|
7
|
+
/** Lowercase, split on non-alphanumeric runs, drop empties. */
|
|
8
|
+
export function tokenize(text) {
|
|
9
|
+
return text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.split(/[^a-z0-9]+/)
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
/** Token set of a string. */
|
|
15
|
+
export function tokenSet(text) {
|
|
16
|
+
return new Set(tokenize(text));
|
|
17
|
+
}
|
|
18
|
+
/** Jaccard similarity of two sets (0 when either is empty). */
|
|
19
|
+
export function jaccard(a, b) {
|
|
20
|
+
if (a.size === 0 || b.size === 0)
|
|
21
|
+
return 0;
|
|
22
|
+
let inter = 0;
|
|
23
|
+
for (const t of a)
|
|
24
|
+
if (b.has(t))
|
|
25
|
+
inter++;
|
|
26
|
+
return inter / (a.size + b.size - inter);
|
|
27
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { ensureDir, copyDir, removeDir } from './fs.js';
|
|
5
|
+
const VERSIONS_ROOT = path.join(os.homedir(), '.skillmax', 'versions');
|
|
6
|
+
/** Default number of prior versions retained per skill (review SG5: bound growth). */
|
|
7
|
+
export const MAX_RETAINED_VERSIONS = 5;
|
|
8
|
+
function skillVersionsDir(id) {
|
|
9
|
+
return path.join(VERSIONS_ROOT, id);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Atomically replace `target`'s contents with a copy of `source`.
|
|
13
|
+
*
|
|
14
|
+
* Crash-safety (review C4): `source` is first copied into a sibling staged dir on
|
|
15
|
+
* the SAME filesystem as `target`, so the two renames below are intra-filesystem
|
|
16
|
+
* and atomic (sidesteps the cross-device EXDEV concern, review F5). If the swap
|
|
17
|
+
* rename fails, the prior `target` is rolled back from its backup — a failed swap
|
|
18
|
+
* never destroys the previous version.
|
|
19
|
+
*/
|
|
20
|
+
export function atomicReplaceDir(target, source) {
|
|
21
|
+
if (!fs.existsSync(source)) {
|
|
22
|
+
throw new Error(`source directory not found: ${source}`);
|
|
23
|
+
}
|
|
24
|
+
// Refuse to replace a symlink: renaming the link (not its target) would leave
|
|
25
|
+
// the real upstream skill untouched and silently break the install topology
|
|
26
|
+
// (review: optimize/promote against a symlinked install dir). The caller must
|
|
27
|
+
// pass the resolved managed-copy directory.
|
|
28
|
+
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
29
|
+
throw new Error(`refusing to replace a symlink: ${target} (pass the resolved skill directory)`);
|
|
30
|
+
}
|
|
31
|
+
const parent = path.dirname(target);
|
|
32
|
+
ensureDir(parent);
|
|
33
|
+
const base = path.basename(target);
|
|
34
|
+
const staged = path.join(parent, `.${base}.staged-${process.pid}`);
|
|
35
|
+
const backup = path.join(parent, `.${base}.old-${process.pid}`);
|
|
36
|
+
removeDir(staged);
|
|
37
|
+
copyDir(source, staged);
|
|
38
|
+
removeDir(backup);
|
|
39
|
+
let backedUp = false;
|
|
40
|
+
if (fs.existsSync(target)) {
|
|
41
|
+
fs.renameSync(target, backup);
|
|
42
|
+
backedUp = true;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
fs.renameSync(staged, target);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (backedUp)
|
|
49
|
+
fs.renameSync(backup, target); // roll back
|
|
50
|
+
removeDir(staged);
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
if (backedUp)
|
|
54
|
+
removeDir(backup);
|
|
55
|
+
}
|
|
56
|
+
/** Copy a skill dir into the retained-versions store under <id>/<version>/. */
|
|
57
|
+
export function snapshot(id, version, srcDir) {
|
|
58
|
+
const dir = path.join(skillVersionsDir(id), version);
|
|
59
|
+
removeDir(dir);
|
|
60
|
+
copyDir(srcDir, dir);
|
|
61
|
+
pruneVersions(id);
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
64
|
+
/** Retained version names for a skill, newest first. */
|
|
65
|
+
export function listVersions(id) {
|
|
66
|
+
const dir = skillVersionsDir(id);
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return entries
|
|
75
|
+
.filter((e) => e.isDirectory())
|
|
76
|
+
.map((e) => ({ name: e.name, mtime: fs.statSync(path.join(dir, e.name)).mtimeMs }))
|
|
77
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
78
|
+
.map((e) => e.name);
|
|
79
|
+
}
|
|
80
|
+
/** Remove the oldest retained versions beyond MAX_RETAINED_VERSIONS. */
|
|
81
|
+
export function pruneVersions(id, keep = MAX_RETAINED_VERSIONS) {
|
|
82
|
+
const versions = listVersions(id); // newest first
|
|
83
|
+
for (const stale of versions.slice(keep)) {
|
|
84
|
+
removeDir(path.join(skillVersionsDir(id), stale));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Promote a candidate into the live location: retain the current live version,
|
|
89
|
+
* then atomically swap in the candidate. Reversible via `revert`.
|
|
90
|
+
*/
|
|
91
|
+
export function promote(params) {
|
|
92
|
+
if (fs.existsSync(params.liveDir)) {
|
|
93
|
+
snapshot(params.id, params.priorVersion, params.liveDir);
|
|
94
|
+
}
|
|
95
|
+
atomicReplaceDir(params.liveDir, params.candidateDir);
|
|
96
|
+
}
|
|
97
|
+
/** Restore a retained version into the live location atomically. */
|
|
98
|
+
export function revert(id, version, liveDir) {
|
|
99
|
+
const vdir = path.join(skillVersionsDir(id), version);
|
|
100
|
+
if (!fs.existsSync(vdir)) {
|
|
101
|
+
throw new Error(`version not retained: ${id}@${version}`);
|
|
102
|
+
}
|
|
103
|
+
atomicReplaceDir(liveDir, vdir);
|
|
104
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Release channels in promotion order: dev → beta → stable. */
|
|
2
|
+
export const CHANNELS = ['dev', 'beta', 'stable'];
|
|
3
|
+
export function isValidChannel(value) {
|
|
4
|
+
return CHANNELS.includes(value);
|
|
5
|
+
}
|
|
6
|
+
/** Ordinal position of a channel (dev=0, beta=1, stable=2). */
|
|
7
|
+
export function channelRank(channel) {
|
|
8
|
+
return CHANNELS.indexOf(channel);
|
|
9
|
+
}
|
|
10
|
+
/** The next channel up the promotion ladder, or null if already at stable. */
|
|
11
|
+
export function nextChannel(channel) {
|
|
12
|
+
const i = CHANNELS.indexOf(channel);
|
|
13
|
+
return i >= 0 && i < CHANNELS.length - 1 ? CHANNELS[i + 1] : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ensureDir, removeDir, copyDir, fileExists } from '../util/fs.js';
|
|
4
|
+
import { ensureValidName } from '../util/collision.js';
|
|
5
|
+
import { readRegistry, writeRegistry } from './registry.js';
|
|
6
|
+
/**
|
|
7
|
+
* Collaborative optimization + review/promote for the shared registry (U15).
|
|
8
|
+
*
|
|
9
|
+
* - Pooled evals are append-only JSONL (merge-friendly across contributors).
|
|
10
|
+
* - Promotion to a higher channel REQUIRES explicit review/approval (review S2):
|
|
11
|
+
* the gate refuses without `approve` + an `approver`, and records a receipt.
|
|
12
|
+
* - Divergent versions of the same skill in the target channel are surfaced as a
|
|
13
|
+
* CONFLICT, never silently merged (review R22/SG7: detection, not auto-resolve).
|
|
14
|
+
*/
|
|
15
|
+
function appendJsonl(file, record) {
|
|
16
|
+
ensureDir(path.dirname(file));
|
|
17
|
+
fs.appendFileSync(file, JSON.stringify(record) + '\n');
|
|
18
|
+
}
|
|
19
|
+
function readJsonl(file) {
|
|
20
|
+
try {
|
|
21
|
+
return fs
|
|
22
|
+
.readFileSync(file, 'utf-8')
|
|
23
|
+
.split('\n')
|
|
24
|
+
.filter((l) => l.trim())
|
|
25
|
+
.map((l) => JSON.parse(l));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function poolEval(registryDir, entry) {
|
|
32
|
+
appendJsonl(path.join(registryDir, 'evals', `${entry.skill}.jsonl`), entry);
|
|
33
|
+
}
|
|
34
|
+
export function pooledScores(registryDir, skill) {
|
|
35
|
+
return readJsonl(path.join(registryDir, 'evals', `${skill}.jsonl`));
|
|
36
|
+
}
|
|
37
|
+
export function reviewPromote(registryDir, params) {
|
|
38
|
+
if (!params.approve) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: `promotion to ${params.toChannel} requires review and approval (pass approve + an approver)`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (!params.approver) {
|
|
45
|
+
return { ok: false, reason: 'an approver is required for promotion' };
|
|
46
|
+
}
|
|
47
|
+
// Validate the skill name before it is joined into filesystem paths (review:
|
|
48
|
+
// path traversal via params.skill).
|
|
49
|
+
const nameCheck = ensureValidName(params.skill);
|
|
50
|
+
if (!nameCheck.ok) {
|
|
51
|
+
return { ok: false, reason: nameCheck.reason };
|
|
52
|
+
}
|
|
53
|
+
const idx = readRegistry(registryDir);
|
|
54
|
+
const candidates = idx.skills.filter((e) => e.name === params.skill);
|
|
55
|
+
if (candidates.length === 0) {
|
|
56
|
+
return { ok: false, reason: `"${params.skill}" is not in the registry` };
|
|
57
|
+
}
|
|
58
|
+
// Source = the entry being promoted INTO the target: the highest-versioned
|
|
59
|
+
// entry NOT already in the target channel (falls back to the target's own
|
|
60
|
+
// entry if it only exists there — a harmless no-op promote).
|
|
61
|
+
const promotable = candidates.filter((e) => e.channel !== params.toChannel);
|
|
62
|
+
const source = [...(promotable.length > 0 ? promotable : candidates)].sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))[0];
|
|
63
|
+
// Conflict detection: a different version already occupies the target channel.
|
|
64
|
+
const targetExisting = candidates.find((e) => e.channel === params.toChannel);
|
|
65
|
+
if (targetExisting && targetExisting.version !== source.version) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: `conflict: ${params.skill} is already ${targetExisting.version} in ${params.toChannel} (source ${source.version}); resolve manually`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Already in the target channel (only a target-channel entry exists): nothing
|
|
72
|
+
// to copy. Short-circuit BEFORE any removeDir/copyDir — otherwise src === dst
|
|
73
|
+
// and removeDir(dst) would delete the only copy (review: self-channel data loss).
|
|
74
|
+
if (source.channel === params.toChannel) {
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|
|
77
|
+
const srcDir = path.join(registryDir, 'skills', source.channel, params.skill);
|
|
78
|
+
if (!fileExists(path.join(srcDir, 'SKILL.md'))) {
|
|
79
|
+
return { ok: false, reason: `registry files missing for ${params.skill} in ${source.channel}` };
|
|
80
|
+
}
|
|
81
|
+
const dstDir = path.join(registryDir, 'skills', params.toChannel, params.skill);
|
|
82
|
+
removeDir(dstDir);
|
|
83
|
+
ensureDir(path.dirname(dstDir));
|
|
84
|
+
copyDir(srcDir, dstDir);
|
|
85
|
+
idx.skills = idx.skills.filter((e) => !(e.name === params.skill && e.channel === params.toChannel));
|
|
86
|
+
idx.skills.push({
|
|
87
|
+
name: params.skill,
|
|
88
|
+
channel: params.toChannel,
|
|
89
|
+
version: source.version,
|
|
90
|
+
publishedBy: source.publishedBy,
|
|
91
|
+
publishedAt: source.publishedAt,
|
|
92
|
+
});
|
|
93
|
+
writeRegistry(registryDir, idx);
|
|
94
|
+
// Append-only approval receipt (auditability; review S10 notes signing as a follow-up).
|
|
95
|
+
appendJsonl(path.join(registryDir, 'approvals.jsonl'), {
|
|
96
|
+
skill: params.skill,
|
|
97
|
+
toChannel: params.toChannel,
|
|
98
|
+
version: source.version,
|
|
99
|
+
approver: params.approver,
|
|
100
|
+
at: params.at,
|
|
101
|
+
});
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { ensureDir, copyDir, fileExists, removeDir } from '../util/fs.js';
|
|
5
|
+
import { readSkillMeta } from '../util/frontmatter.js';
|
|
6
|
+
import { ensureValidName, namespacedName } from '../util/collision.js';
|
|
7
|
+
import { isPathSafe } from '../util/sanitize.js';
|
|
8
|
+
import { isValidChannel } from './channels.js';
|
|
9
|
+
import { ensureState, loadState, saveState } from '../state/store.js';
|
|
10
|
+
const WORKSPACE_DIR = path.join(os.homedir(), '.skillmax', 'workspace');
|
|
11
|
+
function indexPath(registryDir) {
|
|
12
|
+
return path.join(registryDir, 'registry.json');
|
|
13
|
+
}
|
|
14
|
+
export function readRegistry(registryDir) {
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(fs.readFileSync(indexPath(registryDir), 'utf-8'));
|
|
17
|
+
if (!data || !Array.isArray(data.skills))
|
|
18
|
+
return { version: 1, skills: [] };
|
|
19
|
+
// registry.json is an UNTRUSTED shared file. Drop entries whose name or
|
|
20
|
+
// channel is invalid before any entry value is joined into a filesystem path
|
|
21
|
+
// (review: path traversal via crafted entry.name / entry.channel in sync()).
|
|
22
|
+
const skills = data.skills.filter((e) => e &&
|
|
23
|
+
typeof e.name === 'string' &&
|
|
24
|
+
ensureValidName(e.name).ok &&
|
|
25
|
+
typeof e.channel === 'string' &&
|
|
26
|
+
isValidChannel(e.channel));
|
|
27
|
+
return { version: 1, skills };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { version: 1, skills: [] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function writeRegistry(registryDir, idx) {
|
|
34
|
+
ensureDir(registryDir);
|
|
35
|
+
// Sort entries for merge-friendliness (review C5).
|
|
36
|
+
const sorted = [...idx.skills].sort((a, b) => a.name.localeCompare(b.name) || a.channel.localeCompare(b.channel));
|
|
37
|
+
const out = { version: 1, skills: sorted };
|
|
38
|
+
const p = indexPath(registryDir);
|
|
39
|
+
const tmp = p + '.tmp';
|
|
40
|
+
fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n');
|
|
41
|
+
fs.renameSync(tmp, p);
|
|
42
|
+
}
|
|
43
|
+
function skillStorePath(registryDir, channel, name) {
|
|
44
|
+
return path.join(registryDir, 'skills', channel, name);
|
|
45
|
+
}
|
|
46
|
+
/** Publish a local skill directory into the registry under a channel. */
|
|
47
|
+
export function publish(skillDir, registryDir, opts) {
|
|
48
|
+
const skillMd = path.join(skillDir, 'SKILL.md');
|
|
49
|
+
if (!fileExists(skillMd))
|
|
50
|
+
throw new Error(`no SKILL.md in ${skillDir}`);
|
|
51
|
+
const meta = readSkillMeta(fs.readFileSync(skillMd, 'utf-8'));
|
|
52
|
+
if (!meta)
|
|
53
|
+
throw new Error(`invalid SKILL.md in ${skillDir}`);
|
|
54
|
+
const nameCheck = ensureValidName(meta.name);
|
|
55
|
+
if (!nameCheck.ok)
|
|
56
|
+
throw new Error(nameCheck.reason);
|
|
57
|
+
const dest = skillStorePath(registryDir, opts.channel, meta.name);
|
|
58
|
+
removeDir(dest);
|
|
59
|
+
ensureDir(path.dirname(dest));
|
|
60
|
+
copyDir(skillDir, dest);
|
|
61
|
+
const entry = {
|
|
62
|
+
name: meta.name,
|
|
63
|
+
channel: opts.channel,
|
|
64
|
+
version: opts.version ?? (typeof meta.version === 'string' ? meta.version : '1.0.0'),
|
|
65
|
+
publishedBy: opts.publishedBy,
|
|
66
|
+
publishedAt: opts.at,
|
|
67
|
+
};
|
|
68
|
+
const idx = readRegistry(registryDir);
|
|
69
|
+
idx.skills = idx.skills.filter((e) => !(e.name === entry.name && e.channel === entry.channel));
|
|
70
|
+
idx.skills.push(entry);
|
|
71
|
+
writeRegistry(registryDir, idx);
|
|
72
|
+
return entry;
|
|
73
|
+
}
|
|
74
|
+
/** List registry entries, optionally filtered by channel. */
|
|
75
|
+
export function listRegistry(registryDir, channel) {
|
|
76
|
+
const entries = readRegistry(registryDir).skills;
|
|
77
|
+
return channel ? entries.filter((e) => e.channel === channel) : entries;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Materialize registry skills into the local managed workspace area and record
|
|
81
|
+
* their state (origin: workspace, trusted: false). Synced skills are keyed by an
|
|
82
|
+
* origin-namespaced id when they collide with an existing local skill, so the
|
|
83
|
+
* local skill's state and history are never overwritten (review A5/AE2).
|
|
84
|
+
*/
|
|
85
|
+
export function sync(registryDir, opts) {
|
|
86
|
+
const registryId = opts.registryId ?? path.basename(path.resolve(registryDir));
|
|
87
|
+
const entries = listRegistry(registryDir, opts.channel);
|
|
88
|
+
const synced = [];
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const src = skillStorePath(registryDir, entry.channel, entry.name);
|
|
91
|
+
if (!fileExists(path.join(src, 'SKILL.md')))
|
|
92
|
+
continue;
|
|
93
|
+
const existing = loadState(entry.name);
|
|
94
|
+
const collided = !!existing && existing.origin !== 'workspace';
|
|
95
|
+
const id = collided ? namespacedName(registryId, entry.name) : entry.name;
|
|
96
|
+
const dir = path.join(WORKSPACE_DIR, registryId, entry.name);
|
|
97
|
+
// Defense-in-depth: never remove/write outside the managed workspace area.
|
|
98
|
+
if (!isPathSafe(WORKSPACE_DIR, dir))
|
|
99
|
+
continue;
|
|
100
|
+
removeDir(dir);
|
|
101
|
+
ensureDir(path.dirname(dir));
|
|
102
|
+
copyDir(src, dir);
|
|
103
|
+
const state = ensureState({ name: entry.name, id, origin: 'workspace', version: entry.version, source: registryId }, opts.at);
|
|
104
|
+
state.origin = 'workspace';
|
|
105
|
+
state.channel = entry.channel;
|
|
106
|
+
state.version = entry.version;
|
|
107
|
+
state.source = registryId;
|
|
108
|
+
state.updatedAt = opts.at;
|
|
109
|
+
saveState(state);
|
|
110
|
+
synced.push({ name: entry.name, id, channel: entry.channel, dir, collided });
|
|
111
|
+
}
|
|
112
|
+
return synced;
|
|
113
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{ "type": "command", "command": "npx -y skillmaxxing plugin guidance" }
|
|
7
|
+
]
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"PostToolUse": [
|
|
11
|
+
{
|
|
12
|
+
"matcher": "*",
|
|
13
|
+
"hooks": [
|
|
14
|
+
{ "type": "command", "command": "npx -y skillmaxxing plugin on-tool" }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"Stop": [
|
|
19
|
+
{
|
|
20
|
+
"hooks": [
|
|
21
|
+
{ "type": "command", "command": "npx -y skillmaxxing plugin on-stop --agent claude --mode auto --threshold 10" }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
package/index/index.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"_note": "Starter seed for the curated skill index. Populate with verified public skills after auditing the ecosystem (plan open item; review P5f/A9). The discover command degrades to local + explicit repo sources when this is empty.",
|
|
4
|
+
"skills": []
|
|
5
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skillmaxxing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Self-evolving skills for your coding agent — auto-create and auto-improve skills as you work, no trigger needed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skillmaxxing": "./dist/cli.js",
|
|
8
|
+
"skill-maxing": "./dist/cli.js",
|
|
9
|
+
"skillmax": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"dev": "tsx src/cli.ts",
|
|
14
|
+
"check": "tsc --noEmit",
|
|
15
|
+
"test": "tsx --test test/**/*.test.ts",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"index",
|
|
21
|
+
"hooks",
|
|
22
|
+
".claude-plugin",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/Bennyoooo/skillmaxxing.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/Bennyoooo/skillmaxxing#readme",
|
|
31
|
+
"bugs": "https://github.com/Bennyoooo/skillmaxxing/issues",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"ai",
|
|
34
|
+
"agents",
|
|
35
|
+
"agent-skills",
|
|
36
|
+
"skills",
|
|
37
|
+
"skill-management",
|
|
38
|
+
"self-improving-agents"
|
|
39
|
+
],
|
|
40
|
+
"author": "Benny Jiang",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^24.0.0",
|
|
47
|
+
"tsx": "^4.20.0",
|
|
48
|
+
"typescript": "^5.8.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"yaml": "^2.9.0"
|
|
52
|
+
}
|
|
53
|
+
}
|