skillshelf 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/LICENSE +21 -0
- package/README.md +208 -0
- package/package.json +42 -0
- package/src/adapters/inference/agent.ts +253 -0
- package/src/adapters/inference/api.ts +309 -0
- package/src/cli.ts +127 -0
- package/src/commands/add.ts +222 -0
- package/src/commands/drop.ts +89 -0
- package/src/commands/index.ts +46 -0
- package/src/commands/infer.ts +282 -0
- package/src/commands/init.ts +124 -0
- package/src/commands/ls.ts +80 -0
- package/src/commands/new.ts +163 -0
- package/src/commands/outdated.ts +113 -0
- package/src/commands/search.ts +61 -0
- package/src/commands/show.ts +70 -0
- package/src/commands/status.ts +117 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +100 -0
- package/src/config.ts +107 -0
- package/src/core/bundle.ts +62 -0
- package/src/core/core.test.ts +68 -0
- package/src/core/crawl.ts +267 -0
- package/src/core/dedupe.ts +67 -0
- package/src/core/fetch.ts +545 -0
- package/src/core/indexgen.ts +89 -0
- package/src/core/library.ts +101 -0
- package/src/core/overlay.ts +63 -0
- package/src/core/provenance.ts +130 -0
- package/src/lib/frontmatter.test.ts +58 -0
- package/src/lib/frontmatter.ts +231 -0
- package/src/lib/fs.ts +186 -0
- package/src/types.ts +186 -0
package/src/lib/fs.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Filesystem helpers: realpath-dedupe, safe symlink ops, directory walking.
|
|
2
|
+
// Bun built-ins + node:fs/node:path only. No external deps.
|
|
3
|
+
|
|
4
|
+
import { realpathSync, existsSync, lstatSync } from "node:fs";
|
|
5
|
+
import {
|
|
6
|
+
mkdir,
|
|
7
|
+
readdir,
|
|
8
|
+
symlink,
|
|
9
|
+
rm,
|
|
10
|
+
lstat,
|
|
11
|
+
realpath,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Resolve a path to its canonical real location; falls back to resolve() if it doesn't exist. */
|
|
16
|
+
export function realpathOrSelf(p: string): string {
|
|
17
|
+
try {
|
|
18
|
+
return realpathSync(p);
|
|
19
|
+
} catch {
|
|
20
|
+
return resolve(p);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Async variant. */
|
|
25
|
+
export async function realpathOrSelfAsync(p: string): Promise<string> {
|
|
26
|
+
try {
|
|
27
|
+
return await realpath(p);
|
|
28
|
+
} catch {
|
|
29
|
+
return resolve(p);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* De-duplicate a list of paths by their realpath (handles aliased mounts like
|
|
35
|
+
* cloud-sync mirror locations). Returns the first-seen path for each realpath.
|
|
36
|
+
*/
|
|
37
|
+
export function dedupeByRealpath(paths: string[]): string[] {
|
|
38
|
+
const seen = new Set<string>();
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
for (const p of paths) {
|
|
41
|
+
const rp = realpathOrSelf(p);
|
|
42
|
+
if (seen.has(rp)) continue;
|
|
43
|
+
seen.add(rp);
|
|
44
|
+
out.push(p);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function pathExists(p: string): boolean {
|
|
50
|
+
return existsSync(p);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isSymlink(p: string): boolean {
|
|
54
|
+
try {
|
|
55
|
+
return lstatSync(p).isSymbolicLink();
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function isDirectory(p: string): Promise<boolean> {
|
|
62
|
+
try {
|
|
63
|
+
const st = await lstat(p);
|
|
64
|
+
if (st.isSymbolicLink()) {
|
|
65
|
+
const rp = await realpathOrSelfAsync(p);
|
|
66
|
+
const rst = await lstat(rp).catch(() => null);
|
|
67
|
+
return rst?.isDirectory() ?? false;
|
|
68
|
+
}
|
|
69
|
+
return st.isDirectory();
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Create a symlink, creating parent dirs. If `force`, replace any existing entry. */
|
|
76
|
+
export async function safeSymlink(
|
|
77
|
+
target: string,
|
|
78
|
+
linkPath: string,
|
|
79
|
+
opts: { force?: boolean } = {},
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
82
|
+
if (existsSync(linkPath) || isSymlink(linkPath)) {
|
|
83
|
+
if (!opts.force) {
|
|
84
|
+
// idempotent: if it already points at target, do nothing
|
|
85
|
+
try {
|
|
86
|
+
const cur = await realpath(linkPath);
|
|
87
|
+
if (cur === (await realpathOrSelfAsync(target))) return;
|
|
88
|
+
} catch {
|
|
89
|
+
/* fallthrough to remove */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
await symlink(target, linkPath);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Remove a symlink (only if it is a symlink, unless force). Returns true if removed. */
|
|
98
|
+
export async function removeSymlink(
|
|
99
|
+
linkPath: string,
|
|
100
|
+
opts: { force?: boolean } = {},
|
|
101
|
+
): Promise<boolean> {
|
|
102
|
+
if (!isSymlink(linkPath)) {
|
|
103
|
+
if (!opts.force) return false;
|
|
104
|
+
if (!existsSync(linkPath)) return false;
|
|
105
|
+
}
|
|
106
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface WalkOptions {
|
|
111
|
+
/** max depth (0 = only the root's direct entries). Default Infinity. */
|
|
112
|
+
maxDepth?: number;
|
|
113
|
+
/** directory names to skip entirely (e.g. node_modules). */
|
|
114
|
+
skipDirs?: Set<string>;
|
|
115
|
+
/** follow symlinked dirs. Default false. */
|
|
116
|
+
followSymlinks?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WalkEntry {
|
|
120
|
+
path: string;
|
|
121
|
+
name: string;
|
|
122
|
+
isDirectory: boolean;
|
|
123
|
+
isSymlink: boolean;
|
|
124
|
+
depth: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Recursively walk a directory yielding entries. Never throws on individual
|
|
129
|
+
* unreadable dirs (skips them). Skips `skipDirs` by name at any depth.
|
|
130
|
+
*/
|
|
131
|
+
export async function* walk(
|
|
132
|
+
root: string,
|
|
133
|
+
opts: WalkOptions = {},
|
|
134
|
+
): AsyncGenerator<WalkEntry> {
|
|
135
|
+
const maxDepth = opts.maxDepth ?? Infinity;
|
|
136
|
+
const skipDirs = opts.skipDirs ?? new Set(["node_modules", ".git"]);
|
|
137
|
+
const follow = opts.followSymlinks ?? false;
|
|
138
|
+
|
|
139
|
+
async function* recurse(dir: string, depth: number): AsyncGenerator<WalkEntry> {
|
|
140
|
+
let entries: Awaited<ReturnType<typeof readdir>> = [];
|
|
141
|
+
try {
|
|
142
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
143
|
+
} catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const e of entries) {
|
|
147
|
+
const full = join(dir, e.name);
|
|
148
|
+
const link = e.isSymbolicLink();
|
|
149
|
+
let isDir = e.isDirectory();
|
|
150
|
+
if (link) {
|
|
151
|
+
isDir = await isDirectory(full);
|
|
152
|
+
}
|
|
153
|
+
yield { path: full, name: e.name, isDirectory: isDir, isSymlink: link, depth };
|
|
154
|
+
if (isDir && depth < maxDepth) {
|
|
155
|
+
if (skipDirs.has(e.name)) continue;
|
|
156
|
+
if (link && !follow) continue;
|
|
157
|
+
yield* recurse(full, depth + 1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
yield* recurse(root, 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Ensure a directory exists. */
|
|
166
|
+
export async function ensureDir(p: string): Promise<void> {
|
|
167
|
+
await mkdir(p, { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** List immediate subdirectory names of a dir (non-recursive). Empty on error. */
|
|
171
|
+
export async function listDirNames(dir: string): Promise<string[]> {
|
|
172
|
+
try {
|
|
173
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
174
|
+
const out: string[] = [];
|
|
175
|
+
for (const e of entries) {
|
|
176
|
+
if (e.isDirectory()) {
|
|
177
|
+
out.push(e.name);
|
|
178
|
+
} else if (e.isSymbolicLink() && (await isDirectory(join(dir, e.name)))) {
|
|
179
|
+
out.push(e.name);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
} catch {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// skillshelf — domain model + command contract.
|
|
2
|
+
// This file is the source-of-truth type surface every command author codes against.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where an imported / third-party skill came from. `null` for hand-written skills.
|
|
6
|
+
* Mirrors the provenance lockfile shape (see Lockfile / LockEntry below).
|
|
7
|
+
*/
|
|
8
|
+
export interface Provenance {
|
|
9
|
+
/** e.g. "github:owner/repo@path" */
|
|
10
|
+
source: string;
|
|
11
|
+
/** installed commit SHA or version tag */
|
|
12
|
+
ref: string;
|
|
13
|
+
/** where it was fetched from */
|
|
14
|
+
channel: "github" | "vercel-registry" | string;
|
|
15
|
+
/** ISO-8601 timestamp of install */
|
|
16
|
+
installedAt: string;
|
|
17
|
+
/** true if the local upstream body diverged from the pristine fetched copy */
|
|
18
|
+
localEdits: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single skill as discovered on disk (crawl) or in the canonical library.
|
|
23
|
+
* `domains` is the *effective* tag list (upstream frontmatter + overlay merged).
|
|
24
|
+
*/
|
|
25
|
+
export interface Skill {
|
|
26
|
+
/** unique slug, from frontmatter `name` or directory name */
|
|
27
|
+
name: string;
|
|
28
|
+
/** frontmatter `description` (may be multi-line) */
|
|
29
|
+
description: string;
|
|
30
|
+
/** primary domain folder this skill physically lives under (library), or inferred. null if unknown */
|
|
31
|
+
primaryDomain: string | null;
|
|
32
|
+
/** effective domain tags (primary first), de-duplicated */
|
|
33
|
+
domains: string[];
|
|
34
|
+
/** absolute path to the skill directory (the dir containing SKILL.md) */
|
|
35
|
+
path: string;
|
|
36
|
+
/** absolute path to the SKILL.md body file */
|
|
37
|
+
bodyPath: string;
|
|
38
|
+
/** absolute paths to bundled reference files (everything in the dir besides SKILL.md / overlay / lock) */
|
|
39
|
+
refFiles: string[];
|
|
40
|
+
/** provenance for third-party skills; null for hand-written */
|
|
41
|
+
source: Provenance | null;
|
|
42
|
+
/** true if found under a _retired/ dir — tagged, not activated */
|
|
43
|
+
retired: boolean;
|
|
44
|
+
/** if this is a bridge mirror (.agents/skills) of another skill, the canonical skill's path; else null */
|
|
45
|
+
mirrorOf: string | null;
|
|
46
|
+
/** sha-256 of the SKILL.md body content (for dedupe/drift detection) */
|
|
47
|
+
contentHash: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sidecar overlay stored as `<skill>.shelf.json` next to SKILL.md.
|
|
52
|
+
* Holds *your* additions that survive upstream `update`.
|
|
53
|
+
*/
|
|
54
|
+
export interface Overlay {
|
|
55
|
+
/** extra/override domain tags */
|
|
56
|
+
domains?: string[];
|
|
57
|
+
/** explicit bundle membership names */
|
|
58
|
+
bundles?: string[];
|
|
59
|
+
/** free-form notes */
|
|
60
|
+
notes?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A single provenance lockfile entry. */
|
|
64
|
+
export interface LockEntry {
|
|
65
|
+
name: string;
|
|
66
|
+
/** e.g. "github:owner/repo@path" */
|
|
67
|
+
source: string;
|
|
68
|
+
/** installed commit SHA or version tag */
|
|
69
|
+
ref: string;
|
|
70
|
+
channel: "github" | "vercel-registry" | string;
|
|
71
|
+
/** ISO-8601 */
|
|
72
|
+
installedAt: string;
|
|
73
|
+
/** true if upstream body diverged locally */
|
|
74
|
+
localEdits: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Hash of the upstream SKILL.md body as it was at install/update time.
|
|
77
|
+
* Enables true 3-way divergence: local == installedHash => user did NOT edit
|
|
78
|
+
* (safe to re-pull even if upstream moved); local != installedHash => user
|
|
79
|
+
* hand-edited (do not clobber without --force). Optional for legacy entries.
|
|
80
|
+
*/
|
|
81
|
+
installedHash?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The whole lockfile (`shelf.lock.json` at the library root). */
|
|
85
|
+
export interface Lockfile {
|
|
86
|
+
version: 1;
|
|
87
|
+
entries: Record<string, LockEntry>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A bundle = a tag query over `domains[]`. Resolving a bundle yields every skill
|
|
92
|
+
* tagged with the bundle's domain. Bundles are virtual, never folders.
|
|
93
|
+
*/
|
|
94
|
+
export interface Bundle {
|
|
95
|
+
/** bundle name == the domain tag it queries (e.g. "bioinfo") */
|
|
96
|
+
name: string;
|
|
97
|
+
/** skills resolved into this bundle */
|
|
98
|
+
skills: Skill[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A duplicate/drift group produced by dedupe: skills sharing a name (and/or hash).
|
|
103
|
+
* `canonical` is the chosen authoritative copy; `divergent` are drifted copies.
|
|
104
|
+
*/
|
|
105
|
+
export interface DuplicateGroup {
|
|
106
|
+
name: string;
|
|
107
|
+
/** the chosen canonical skill (prefers library/non-mirror/non-retired) */
|
|
108
|
+
canonical: Skill;
|
|
109
|
+
/** other copies that differ in content hash (drifted) */
|
|
110
|
+
divergent: Skill[];
|
|
111
|
+
/** exact-duplicate copies (same hash, different path) */
|
|
112
|
+
duplicates: Skill[];
|
|
113
|
+
/** true if every copy shares the same content hash */
|
|
114
|
+
identical: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Snapshot fed to the AI inference pass (`skl infer`). Deterministic core only
|
|
119
|
+
* assembles this; the LLM call lives elsewhere.
|
|
120
|
+
*/
|
|
121
|
+
export interface InferenceCorpus {
|
|
122
|
+
skills: Array<{
|
|
123
|
+
name: string;
|
|
124
|
+
description: string;
|
|
125
|
+
currentDomains: string[];
|
|
126
|
+
bodyPreview: string;
|
|
127
|
+
}>;
|
|
128
|
+
/** domain vocabulary observed across the library */
|
|
129
|
+
observedDomains: string[];
|
|
130
|
+
generatedAt: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Resolved configuration for a skillshelf invocation. */
|
|
134
|
+
export interface Config {
|
|
135
|
+
/** absolute path to the canonical library (skill content) */
|
|
136
|
+
libraryPath: string;
|
|
137
|
+
/** absolute path to the global-core symlink target (~/.claude/skills) */
|
|
138
|
+
globalCoreTarget: string;
|
|
139
|
+
/** absolute path to the config file that was read, if any */
|
|
140
|
+
configFile: string | null;
|
|
141
|
+
/** how libraryPath was resolved */
|
|
142
|
+
source: "env" | "config" | "default";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Optional on-disk config file (~/.skillshelf/config.json). */
|
|
146
|
+
export interface ConfigFile {
|
|
147
|
+
/** override library path */
|
|
148
|
+
library?: string;
|
|
149
|
+
/** override global-core target */
|
|
150
|
+
globalCore?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The execution context handed to every command's `run()`.
|
|
155
|
+
* Built by `loadContext()` in src/config.ts.
|
|
156
|
+
*/
|
|
157
|
+
export interface Ctx {
|
|
158
|
+
/** resolved config (paths + provenance) */
|
|
159
|
+
config: Config;
|
|
160
|
+
/** convenience alias for config.libraryPath */
|
|
161
|
+
libraryPath: string;
|
|
162
|
+
/** load the canonical library (effective skills, overlays merged) */
|
|
163
|
+
loadLibrary: () => Promise<Skill[]>;
|
|
164
|
+
/** human-readable logging to stdout */
|
|
165
|
+
log: (...args: unknown[]) => void;
|
|
166
|
+
/** machine-parseable single-line JSON to stdout */
|
|
167
|
+
json: (value: unknown) => void;
|
|
168
|
+
/** error logging to stderr */
|
|
169
|
+
error: (...args: unknown[]) => void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Metadata every command module must export. */
|
|
173
|
+
export interface CommandMeta {
|
|
174
|
+
name: string;
|
|
175
|
+
summary: string;
|
|
176
|
+
usage: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The function signature every command module must export as `run`. */
|
|
180
|
+
export type CommandRun = (argv: string[], ctx: Ctx) => Promise<number>;
|
|
181
|
+
|
|
182
|
+
/** Full shape of a command module (`src/commands/*.ts`). */
|
|
183
|
+
export interface CommandModule {
|
|
184
|
+
meta: CommandMeta;
|
|
185
|
+
run: CommandRun;
|
|
186
|
+
}
|