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,48 @@
1
+ import * as path from 'node:path';
2
+ import { ALL_AGENTS, getAgentOrThrow } from '../agents/registry.js';
3
+ import { removeDir, fileExists } from '../util/fs.js';
4
+ import { removeGlobalLockEntry } from '../lock/global.js';
5
+ import { removeProjectLockEntry } from '../lock/project.js';
6
+ import * as log from '../util/log.js';
7
+ export async function remove(args) {
8
+ if (args.names.length === 0) {
9
+ log.error('Specify at least one skill name to remove.');
10
+ return;
11
+ }
12
+ const agents = args.agent
13
+ ? [getAgentOrThrow(args.agent)]
14
+ : ALL_AGENTS;
15
+ const projectDir = process.cwd();
16
+ let removed = 0;
17
+ for (const name of args.names) {
18
+ let found = false;
19
+ for (const agent of agents) {
20
+ const dirs = [];
21
+ if (!args.scope || args.scope === 'global') {
22
+ dirs.push({ path: path.join(agent.globalSkillsDir, name), scope: 'global' });
23
+ }
24
+ if (!args.scope || args.scope === 'project') {
25
+ dirs.push({ path: path.join(projectDir, agent.projectSkillsDir, name), scope: 'project' });
26
+ }
27
+ for (const d of dirs) {
28
+ if (fileExists(d.path) && removeDir(d.path)) {
29
+ log.success(`Removed ${name} from ${agent.displayName} (${d.scope})`);
30
+ found = true;
31
+ }
32
+ }
33
+ }
34
+ if (found) {
35
+ removed++;
36
+ if (!args.scope || args.scope === 'global')
37
+ removeGlobalLockEntry(name);
38
+ if (!args.scope || args.scope === 'project')
39
+ removeProjectLockEntry(projectDir, name);
40
+ }
41
+ else {
42
+ log.warn(`Skill '${name}' not found in any agent.`);
43
+ }
44
+ }
45
+ if (removed > 0) {
46
+ log.success(`Removed ${removed} skill(s)`);
47
+ }
48
+ }
@@ -0,0 +1,74 @@
1
+ import * as fs from 'node:fs';
2
+ import { stageDraft, commitDraft, listDrafts, discardDraft, } from '../create/skillify.js';
3
+ import { findUpdateTarget } from '../create/match.js';
4
+ import { scanLocalSkills } from '../discover/local.js';
5
+ import { loadCuratedIndex } from '../discover/index.js';
6
+ import * as log from '../util/log.js';
7
+ function readDraft(p) {
8
+ const d = JSON.parse(fs.readFileSync(p, 'utf-8'));
9
+ if (!d ||
10
+ typeof d.name !== 'string' ||
11
+ typeof d.description !== 'string' ||
12
+ typeof d.body !== 'string') {
13
+ throw new Error('draft must include string name, description, and body');
14
+ }
15
+ return d;
16
+ }
17
+ export async function skillify(args) {
18
+ if (args.listDrafts) {
19
+ const drafts = listDrafts();
20
+ if (drafts.length === 0)
21
+ log.info('No staged drafts.');
22
+ else {
23
+ log.heading('Staged drafts');
24
+ for (const d of drafts)
25
+ log.info(` ${d}`);
26
+ }
27
+ return;
28
+ }
29
+ if (args.discard) {
30
+ discardDraft(args.discard);
31
+ log.success(`Discarded draft "${args.discard}".`);
32
+ return;
33
+ }
34
+ if (args.commit) {
35
+ await commitDraft(args.commit, {
36
+ scope: args.scope,
37
+ agents: args.agents,
38
+ copy: args.copy,
39
+ force: args.force,
40
+ });
41
+ log.success(`Committed "${args.commit}" (trusted: false).`);
42
+ return;
43
+ }
44
+ if (!args.draftPath) {
45
+ log.error('Usage: skill-maxing skillify --draft <draft.json> | --commit <name> | --list-drafts');
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const draft = readDraft(args.draftPath);
50
+ if (!args.forceNew) {
51
+ const existing = [...scanLocalSkills(), ...loadCuratedIndex()];
52
+ const m = findUpdateTarget(draft.name, draft.description, existing);
53
+ if (m.target) {
54
+ log.warn(`Prefer-update: ${m.reason}.`);
55
+ log.info(`Update/optimize "${m.target.name}" instead, or pass --new to create anyway.`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ }
60
+ const res = await stageDraft(draft, { allowExec: args.allowExec });
61
+ if (!res.ok) {
62
+ log.error(`Staging failed: ${res.detail}`);
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ log.success(`Staged "${draft.name}" at ${res.dir}`);
67
+ if (res.smokePassed === true)
68
+ log.success('Smoke test passed.');
69
+ else if (res.smokePassed === false)
70
+ log.warn('Smoke test failed.');
71
+ else if (res.detail)
72
+ log.info(res.detail);
73
+ log.info(`Review it, then commit: skill-maxing skillify --commit ${draft.name}`);
74
+ }
@@ -0,0 +1,52 @@
1
+ import { readGlobalLock } from '../lock/global.js';
2
+ import { readProjectLock } from '../lock/project.js';
3
+ import { install } from './install.js';
4
+ import * as log from '../util/log.js';
5
+ export async function update(args) {
6
+ const projectDir = process.cwd();
7
+ if (!args.scope || args.scope === 'global') {
8
+ const lock = readGlobalLock();
9
+ const entries = Object.entries(lock.skills);
10
+ const filtered = args.names
11
+ ? entries.filter(([name]) => args.names.includes(name))
12
+ : entries;
13
+ if (filtered.length === 0 && (!args.scope || args.scope === 'global')) {
14
+ log.info('No global skills to update.');
15
+ }
16
+ for (const [name, entry] of filtered) {
17
+ log.info(`Updating ${name} from ${entry.source}...`);
18
+ try {
19
+ await install({
20
+ source: entry.source,
21
+ agents: entry.agents,
22
+ scope: 'global',
23
+ });
24
+ }
25
+ catch (err) {
26
+ log.error(`Failed to update ${name}: ${err instanceof Error ? err.message : String(err)}`);
27
+ }
28
+ }
29
+ }
30
+ if (!args.scope || args.scope === 'project') {
31
+ const lock = readProjectLock(projectDir);
32
+ const entries = Object.entries(lock.skills);
33
+ const filtered = args.names
34
+ ? entries.filter(([name]) => args.names.includes(name))
35
+ : entries;
36
+ if (filtered.length === 0 && args.scope === 'project') {
37
+ log.info('No project skills to update.');
38
+ }
39
+ for (const [name, entry] of filtered) {
40
+ log.info(`Updating ${name} from ${entry.source}...`);
41
+ try {
42
+ await install({
43
+ source: entry.source,
44
+ scope: 'project',
45
+ });
46
+ }
47
+ catch (err) {
48
+ log.error(`Failed to update ${name}: ${err instanceof Error ? err.message : String(err)}`);
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,117 @@
1
+ import { publish, sync, listRegistry } from '../workspace/registry.js';
2
+ import { reviewPromote, poolEval } from '../workspace/collab.js';
3
+ import { isValidChannel } from '../workspace/channels.js';
4
+ import { stripTerminalEscapes } from '../util/sanitize.js';
5
+ import * as log from '../util/log.js';
6
+ /** Untrusted registry fields are sanitized before display (review: ANSI injection). */
7
+ const clean = stripTerminalEscapes;
8
+ function channelOf(value, fallback) {
9
+ if (value === undefined)
10
+ return fallback;
11
+ if (!isValidChannel(value))
12
+ throw new Error(`invalid channel "${value}" (dev|beta|stable)`);
13
+ return value;
14
+ }
15
+ export async function workspace(args) {
16
+ if (!args.registryDir) {
17
+ log.error('Usage: skill-maxing workspace <action> --registry <dir> [options]');
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const now = new Date().toISOString();
22
+ switch (args.action) {
23
+ case 'publish': {
24
+ if (!args.skillDir) {
25
+ log.error('Usage: workspace publish --registry <dir> --skill-dir <dir> --channel dev');
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ const entry = publish(args.skillDir, args.registryDir, {
30
+ channel: channelOf(args.channel, 'dev'),
31
+ publishedBy: args.by ?? 'unknown',
32
+ at: now,
33
+ });
34
+ log.success(`Published ${entry.name}@${entry.version} to ${entry.channel}.`);
35
+ log.info('Commit and push the registry repo to share with your team.');
36
+ return;
37
+ }
38
+ case 'sync': {
39
+ const synced = sync(args.registryDir, { channel: channelOf(args.channel), at: now });
40
+ if (args.json) {
41
+ console.log(JSON.stringify(synced, null, 2));
42
+ return;
43
+ }
44
+ if (synced.length === 0) {
45
+ log.warn('Nothing to sync.');
46
+ return;
47
+ }
48
+ log.heading(`Synced ${synced.length} skill(s)`);
49
+ for (const s of synced) {
50
+ log.info(` ${clean(s.name)} [${clean(s.channel)}]${s.collided ? ` (namespaced as ${clean(s.id)} -- local skill preserved)` : ''}`);
51
+ }
52
+ log.info('Synced skills are trusted:false and live under ~/.skillmax/workspace; install or optimize from there.');
53
+ return;
54
+ }
55
+ case 'list': {
56
+ const entries = listRegistry(args.registryDir, channelOf(args.channel));
57
+ if (args.json) {
58
+ console.log(JSON.stringify(entries, null, 2));
59
+ return;
60
+ }
61
+ if (entries.length === 0) {
62
+ log.info('Registry is empty.');
63
+ return;
64
+ }
65
+ log.table([
66
+ ['name', 'channel', 'version', 'by'],
67
+ ...entries.map((e) => [clean(e.name), clean(e.channel), clean(e.version), clean(e.publishedBy)]),
68
+ ]);
69
+ return;
70
+ }
71
+ case 'pool': {
72
+ if (!args.skillName || args.score === undefined) {
73
+ log.error('Usage: workspace pool --registry <dir> --skill <name> --score <0..1> [--by <who>]');
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+ poolEval(args.registryDir, {
78
+ skill: args.skillName,
79
+ score: args.score,
80
+ by: args.by ?? 'unknown',
81
+ at: now,
82
+ });
83
+ log.success(`Pooled eval result for ${args.skillName} (${args.score}).`);
84
+ return;
85
+ }
86
+ case 'promote': {
87
+ if (!args.skillName) {
88
+ log.error('Usage: workspace promote --registry <dir> --skill <name> --channel <beta|stable> --approve --approver <who>');
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ const target = channelOf(args.channel);
93
+ if (!target) {
94
+ log.error('promote requires --channel <beta|stable>');
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ const res = reviewPromote(args.registryDir, {
99
+ skill: args.skillName,
100
+ toChannel: target,
101
+ approve: args.approve ?? false,
102
+ approver: args.approver,
103
+ at: now,
104
+ });
105
+ if (!res.ok) {
106
+ log.error(res.reason);
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ log.success(`Promoted ${args.skillName} to ${target} (approved by ${args.approver}).`);
111
+ return;
112
+ }
113
+ default:
114
+ log.error(`Unknown workspace action: ${args.action}`);
115
+ process.exitCode = 1;
116
+ }
117
+ }
@@ -0,0 +1,23 @@
1
+ import { tokenSet, jaccard } from '../util/similarity.js';
2
+ export const DEFAULT_SIMILARITY_THRESHOLD = 0.6;
3
+ export function findUpdateTarget(name, description, existing, threshold = DEFAULT_SIMILARITY_THRESHOLD) {
4
+ const lower = name.toLowerCase();
5
+ const exact = existing.find((e) => e.name.toLowerCase() === lower);
6
+ if (exact)
7
+ return { target: exact, reason: `a skill named "${exact.name}" already exists`, similarity: 1 };
8
+ const descTokens = tokenSet(description);
9
+ let best = null;
10
+ for (const e of existing) {
11
+ const sim = jaccard(descTokens, tokenSet(e.description));
12
+ if (!best || sim > best.sim)
13
+ best = { c: e, sim };
14
+ }
15
+ if (best && best.sim >= threshold) {
16
+ return {
17
+ target: best.c,
18
+ reason: `"${best.c.name}" is ${(best.sim * 100).toFixed(0)}% similar — consider updating/optimizing it`,
19
+ similarity: best.sim,
20
+ };
21
+ }
22
+ return { target: null, reason: 'no sufficiently similar existing skill', similarity: best?.sim ?? 0 };
23
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Deterministic helpers for the in-session reflection loop (Hermes-style). The
3
+ * CLI/library does NOT decide whether to crystallize a skill — that judgment is
4
+ * the host agent's, gated by the user. These helpers only surface the signal:
5
+ * "this workflow shape has repeated", so the agent can propose (sparingly — review
6
+ * P7f) turning it into a skill via the prefer-update-over-create path.
7
+ */
8
+ import { jaccard } from '../util/similarity.js';
9
+ function normalizeStep(s) {
10
+ return s.trim().toLowerCase().replace(/\s+/g, ' ');
11
+ }
12
+ /** A stable signature for a workflow's ordered steps. */
13
+ export function workflowSignature(steps) {
14
+ return steps.map(normalizeStep).join(' > ');
15
+ }
16
+ function stepSet(steps) {
17
+ return new Set(steps.map(normalizeStep));
18
+ }
19
+ /** Jaccard similarity of two workflows' step sets (order-insensitive). */
20
+ export function similarity(a, b) {
21
+ return jaccard(stepSet(a.steps), stepSet(b.steps));
22
+ }
23
+ /**
24
+ * Cluster records by approximate similarity and return clusters that recur at
25
+ * least `minRepeat` times — the "this is reusable" signal. Deterministic:
26
+ * processes records in order, greedily assigning each to the first cluster it
27
+ * matches above `threshold`.
28
+ */
29
+ export function repeatedWorkflows(records, opts = {}) {
30
+ const minRepeat = opts.minRepeat ?? 2;
31
+ const threshold = opts.threshold ?? 0.7;
32
+ const clusters = [];
33
+ for (let i = 0; i < records.length; i++) {
34
+ const rec = records[i];
35
+ const existing = clusters.find((c) => similarity(c.representative, rec) >= threshold);
36
+ if (existing) {
37
+ existing.count++;
38
+ existing.indices.push(i);
39
+ }
40
+ else {
41
+ clusters.push({ representative: rec, count: 1, indices: [i] });
42
+ }
43
+ }
44
+ return clusters.filter((c) => c.count >= minRepeat);
45
+ }
46
+ /** True when any workflow shape has repeated enough to be worth crystallizing. */
47
+ export function hasRepeatedWorkflow(records, opts) {
48
+ return repeatedWorkflows(records, opts).length > 0;
49
+ }
@@ -0,0 +1,117 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { stringify as stringifyYaml } from 'yaml';
5
+ import { validateManifest } from '../eval/schema.js';
6
+ import { writeSkillFile } from '../util/frontmatter.js';
7
+ import { ensureValidName } from '../util/collision.js';
8
+ import { sanitizeSubpath } from '../util/sanitize.js';
9
+ import { ensureDir, removeDir, fileExists } from '../util/fs.js';
10
+ import { runSandboxed } from '../util/exec.js';
11
+ import { ensureState, setLifecycle } from '../state/store.js';
12
+ import { install } from '../commands/install.js';
13
+ const DRAFTS_DIR = path.join(os.homedir(), '.skillmax', 'drafts');
14
+ export function draftDir(name) {
15
+ return path.join(DRAFTS_DIR, name);
16
+ }
17
+ /**
18
+ * Stage a draft to a persistent draft dir (resumable across sessions — review I5)
19
+ * and optionally run its smoke test in the sandbox. Smoke tests require explicit
20
+ * allowExec: a freshly-synthesized skill is `trusted:false`, so running its code
21
+ * is a deliberate, user-authorized step (review A7), not an automatic one.
22
+ */
23
+ export async function stageDraft(draft, opts = {}) {
24
+ const nameCheck = ensureValidName(draft.name);
25
+ if (!nameCheck.ok)
26
+ return { ok: false, dir: '', smokePassed: null, detail: nameCheck.reason };
27
+ if (draft.eval) {
28
+ const err = validateManifest(draft.eval);
29
+ if (err)
30
+ return { ok: false, dir: '', smokePassed: null, detail: `eval scaffold invalid: ${err}` };
31
+ }
32
+ const dir = draftDir(draft.name);
33
+ removeDir(dir);
34
+ ensureDir(dir);
35
+ writeSkillFile(path.join(dir, 'SKILL.md'), {
36
+ name: draft.name,
37
+ description: draft.description,
38
+ version: '1.0.0',
39
+ ...(draft.tools ? { tools: draft.tools } : {}),
40
+ ...(draft.triggers ? { triggers: draft.triggers } : {}),
41
+ }, draft.body);
42
+ for (const s of draft.scripts ?? []) {
43
+ const safe = sanitizeSubpath(s.path);
44
+ if (!safe)
45
+ continue; // reject traversal in script paths
46
+ const p = path.join(dir, safe);
47
+ ensureDir(path.dirname(p));
48
+ fs.writeFileSync(p, s.content);
49
+ }
50
+ if (draft.eval) {
51
+ fs.writeFileSync(path.join(dir, 'eval.yaml'), stringifyYaml(draft.eval));
52
+ }
53
+ const now = new Date().toISOString();
54
+ ensureState({ name: draft.name, origin: 'created', lifecycle: 'staged' }, now);
55
+ setLifecycle(draft.name, 'staged', now);
56
+ let smokePassed = null;
57
+ if (draft.smokeTest && draft.smokeTest.length > 0) {
58
+ if (!opts.allowExec) {
59
+ return {
60
+ ok: true,
61
+ dir,
62
+ smokePassed: null,
63
+ detail: 'smoke test skipped: review the generated scripts, then re-run with allowExec to execute it',
64
+ };
65
+ }
66
+ const [cmd, ...args] = draft.smokeTest;
67
+ const res = await runSandboxed(cmd, args, {
68
+ cwd: dir,
69
+ skillId: draft.name,
70
+ allowExec: true,
71
+ timeoutMs: 30_000,
72
+ });
73
+ smokePassed = res.ok;
74
+ if (!res.ok) {
75
+ return { ok: false, dir, smokePassed, detail: `smoke test failed: exit ${res.code}` };
76
+ }
77
+ }
78
+ return { ok: true, dir, smokePassed };
79
+ }
80
+ /**
81
+ * Commit a staged draft: install it from its draft dir (reusing the hardened
82
+ * install path + collision gate) and mark its state committed (origin: created,
83
+ * trusted: false). The draft dir is removed after a successful commit.
84
+ */
85
+ export async function commitDraft(name, opts) {
86
+ const dir = draftDir(name);
87
+ if (!fileExists(path.join(dir, 'SKILL.md'))) {
88
+ throw new Error(`no staged draft for "${name}" (stage it first)`);
89
+ }
90
+ await install({
91
+ source: dir,
92
+ scope: opts.scope,
93
+ agents: opts.agents,
94
+ copy: opts.copy ?? true, // created skills default to copy (draft edits stay isolated)
95
+ force: opts.force,
96
+ });
97
+ const now = new Date().toISOString();
98
+ ensureState({ name, origin: 'created', lifecycle: 'committed' }, now);
99
+ setLifecycle(name, 'committed', now);
100
+ removeDir(dir);
101
+ }
102
+ /** List names of drafts staged but not yet committed. */
103
+ export function listDrafts() {
104
+ try {
105
+ return fs
106
+ .readdirSync(DRAFTS_DIR, { withFileTypes: true })
107
+ .filter((e) => e.isDirectory() && fileExists(path.join(DRAFTS_DIR, e.name, 'SKILL.md')))
108
+ .map((e) => e.name);
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ }
114
+ /** Discard a staged draft. Returns true if removed. */
115
+ export function discardDraft(name) {
116
+ return removeDir(draftDir(name));
117
+ }
@@ -0,0 +1,40 @@
1
+ import { loadCuratedIndex } from './index.js';
2
+ import { scanLocalSkills } from './local.js';
3
+ import { discoverFromRepo } from './github.js';
4
+ function msg(e) {
5
+ return e instanceof Error ? e.message : String(e);
6
+ }
7
+ /**
8
+ * Gather candidates from all enabled sources with per-source isolation: a
9
+ * failure in one source (bad repo, rate limit, unreadable index) yields partial
10
+ * results plus a recorded error, never a total abort (review I7).
11
+ */
12
+ export async function collectSources(opts = {}) {
13
+ const candidates = [];
14
+ const errors = [];
15
+ if (opts.index !== false) {
16
+ try {
17
+ candidates.push(...loadCuratedIndex(opts.indexPath));
18
+ }
19
+ catch (e) {
20
+ errors.push({ source: 'index', message: msg(e) });
21
+ }
22
+ }
23
+ if (opts.local !== false) {
24
+ try {
25
+ candidates.push(...scanLocalSkills(opts.projectDir));
26
+ }
27
+ catch (e) {
28
+ errors.push({ source: 'local', message: msg(e) });
29
+ }
30
+ }
31
+ for (const repo of opts.repos ?? []) {
32
+ try {
33
+ candidates.push(...(await discoverFromRepo(repo)));
34
+ }
35
+ catch (e) {
36
+ errors.push({ source: repo, message: msg(e) });
37
+ }
38
+ }
39
+ return { candidates, errors };
40
+ }
@@ -0,0 +1,27 @@
1
+ import { parseSource } from '../source/parser.js';
2
+ import { resolveSource, cleanupResolved } from '../source/resolver.js';
3
+ /**
4
+ * Clone-and-scan a repo (or local path) source into discovery candidates.
5
+ *
6
+ * This is the always-works baseline (KTD10): it reuses the hardened
7
+ * parseSource/resolveSource path (depth-1 clone, one-level scan, entry-name
8
+ * validation). An optional GitHub Trees/Search API fast path (when GITHUB_TOKEN
9
+ * is set) is deferred per the plan; clone-scan needs no token. The pinned
10
+ * commitSha is carried so a later install can avoid re-resolving (review F1/F2).
11
+ */
12
+ export async function discoverFromRepo(source) {
13
+ const parsed = parseSource(source);
14
+ const resolved = await resolveSource(parsed);
15
+ const origin = parsed.type === 'local' ? 'local' : 'github';
16
+ const candidates = resolved.map((s) => ({
17
+ name: s.name,
18
+ description: s.meta.description,
19
+ source,
20
+ origin,
21
+ tags: Array.isArray(s.meta.tags) ? s.meta.tags : [],
22
+ commitSha: s.commitSha,
23
+ installed: false,
24
+ }));
25
+ cleanupResolved(resolved);
26
+ return candidates;
27
+ }
@@ -0,0 +1,39 @@
1
+ import * as fs from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ /** Path to the shipped curated index (index/index.json at the package root). */
4
+ export function defaultIndexPath() {
5
+ return fileURLToPath(new URL('../../index/index.json', import.meta.url));
6
+ }
7
+ /**
8
+ * Load the curated index into candidates. Returns [] on a missing/corrupt/empty
9
+ * index so discovery degrades gracefully to other sources (review: empty index
10
+ * must not break discovery).
11
+ */
12
+ export function loadCuratedIndex(indexPath = defaultIndexPath()) {
13
+ let raw;
14
+ try {
15
+ raw = fs.readFileSync(indexPath, 'utf-8');
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ let data;
21
+ try {
22
+ data = JSON.parse(raw);
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ if (!data || !Array.isArray(data.skills))
28
+ return [];
29
+ return data.skills
30
+ .filter((e) => e && typeof e.name === 'string' && typeof e.source === 'string')
31
+ .map((e) => ({
32
+ name: e.name,
33
+ description: e.description ?? '',
34
+ source: e.source,
35
+ origin: 'index',
36
+ tags: Array.isArray(e.tags) ? e.tags : [],
37
+ installed: false,
38
+ }));
39
+ }
@@ -0,0 +1,55 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { ALL_AGENTS } from '../agents/registry.js';
4
+ import { readSkillMeta } from '../util/frontmatter.js';
5
+ import { fileExists } from '../util/fs.js';
6
+ import { isSafeEntryName } from '../source/resolver.js';
7
+ /**
8
+ * Scan every agent's global and project skill dirs for installed skills,
9
+ * de-duplicated by name. Best-effort: unreadable dirs and malformed SKILL.md
10
+ * files are skipped, never fatal.
11
+ */
12
+ export function scanLocalSkills(projectDir = process.cwd()) {
13
+ const seen = new Set();
14
+ const out = [];
15
+ for (const agent of ALL_AGENTS) {
16
+ const dirs = [agent.globalSkillsDir, path.join(projectDir, agent.projectSkillsDir)];
17
+ for (const dir of dirs) {
18
+ let entries;
19
+ try {
20
+ entries = fs.readdirSync(dir, { withFileTypes: true });
21
+ }
22
+ catch {
23
+ continue;
24
+ }
25
+ for (const entry of entries) {
26
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
27
+ continue;
28
+ if (!isSafeEntryName(entry.name))
29
+ continue;
30
+ const skillMd = path.join(dir, entry.name, 'SKILL.md');
31
+ if (!fileExists(skillMd))
32
+ continue;
33
+ let meta;
34
+ try {
35
+ meta = readSkillMeta(fs.readFileSync(skillMd, 'utf-8'));
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ if (!meta || seen.has(meta.name))
41
+ continue;
42
+ seen.add(meta.name);
43
+ out.push({
44
+ name: meta.name,
45
+ description: meta.description,
46
+ source: '',
47
+ origin: 'local',
48
+ tags: Array.isArray(meta.tags) ? meta.tags : [],
49
+ installed: true,
50
+ });
51
+ }
52
+ }
53
+ }
54
+ return out;
55
+ }