stackfit 0.0.0-stage → 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/src/render.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // Text transforms between the catalog format and each agent's expected files.
2
+
3
+ // Minimal YAML frontmatter editing for top-level scalar keys; enough for upstream overrides.
4
+ export function setFrontmatter(content, fields) {
5
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
6
+ const lines = match ? match[1].split(/\r?\n/) : [];
7
+ const body = match ? content.slice(match[0].length) : content;
8
+ const isKey = (line) => /^[A-Za-z][\w-]*:/.test(line);
9
+ for (const [key, value] of Object.entries(fields)) {
10
+ const start = lines.findIndex((line) => line.startsWith(`${key}:`));
11
+ let end = start + 1;
12
+ if (start !== -1) while (end < lines.length && !isKey(lines[end])) end += 1;
13
+ const rendered = value === null ? [] : [`${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`];
14
+ if (start === -1) lines.push(...rendered);
15
+ else lines.splice(start, end - start, ...rendered);
16
+ }
17
+ return `---\n${lines.join("\n")}\n---\n${body}`;
18
+ }
19
+
20
+ // Reads top-level keys, joining folded/literal block scalars (`description: >-`).
21
+ export function parseFrontmatter(content) {
22
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
23
+ if (!match) return { fields: {}, body: content };
24
+ const fields = {};
25
+ let current = null;
26
+ for (const line of match[1].split(/\r?\n/)) {
27
+ const field = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
28
+ if (field) {
29
+ current = field[1];
30
+ fields[current] = field[2].trim().replace(/^['"]|['"]$/g, "");
31
+ } else if (current && /^\s+\S/.test(line)) {
32
+ fields[current] = `${fields[current].replace(/^[>|]-?$/, "")} ${line.trim()}`.trim();
33
+ }
34
+ }
35
+ return { fields, body: content.slice(match[0].length).replace(/^\r?\n/, "") };
36
+ }
37
+
38
+ // Split a Cursor `globs` value on commas that are not inside `{...}`.
39
+ export function splitGlobs(value) {
40
+ const globs = [];
41
+ let depth = 0;
42
+ let current = "";
43
+ for (const char of value) {
44
+ if (char === "{") depth += 1;
45
+ if (char === "}") depth -= 1;
46
+ if (char === "," && depth === 0) {
47
+ globs.push(current);
48
+ current = "";
49
+ } else current += char;
50
+ }
51
+ globs.push(current);
52
+ return globs.map((glob) => glob.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
53
+ }
54
+
55
+ // Cursor .mdc → Claude Code .claude/rules/*.md. Always-apply rules load every session;
56
+ // glob rules become `paths:`; description-only rules stay routed through AGENTS.md.
57
+ export function claudeRule(content) {
58
+ const { fields, body } = parseFrontmatter(content);
59
+ if (fields.alwaysApply === "true") return body;
60
+ if (fields.globs) {
61
+ const paths = splitGlobs(fields.globs).map((glob) => ` - ${JSON.stringify(glob)}`).join("\n");
62
+ return `---\npaths:\n${paths}\n---\n\n${body}`;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function cursorRule(content, name) {
68
+ if (content.startsWith("---\n")) return content;
69
+ return `---\ndescription: Repository-specific ${name} guidance; read when relevant\nalwaysApply: false\n---\n\n${content}`;
70
+ }
71
+
72
+ // Claude Code treats commands as skills; keep side-effecting workflows manual-only.
73
+ export function claudeCommand(content) {
74
+ if (/^---\r?\n/.test(content)) return content;
75
+ const summary = content.split(/\r?\n/).map((line) => line.trim()).find((line) => line && !line.startsWith("#")) ?? "";
76
+ return `---\ndescription: ${JSON.stringify(summary.slice(0, 200))}\ndisable-model-invocation: true\n---\n\n${content}`;
77
+ }
78
+
79
+ export function claudeAgent(content) {
80
+ return content.replace(/^model:\s*.+$/m, "model: inherit").replace(/^readonly:\s*true$/m, "permissionMode: plan");
81
+ }
82
+
83
+ // Effective skill metadata = SKILL.md frontmatter + render overrides from skill-sources.json.
84
+ export function effectiveMetadata(skillContent, overrides = {}) {
85
+ const { fields } = parseFrontmatter(skillContent);
86
+ return { ...fields, ...Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, String(value)])) };
87
+ }
88
+
89
+ // Characters of a skill that sit in every session: its name + description, unless manual-only.
90
+ export function listingCost(metadata, name) {
91
+ if (!metadata || metadata["disable-model-invocation"] === "true") return 0;
92
+ return (metadata.description?.length ?? 0) + name.length;
93
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, resolve } from "node:path";
3
+ import { loadSources } from "./catalog.mjs";
4
+ import { resolveSelection } from "./detect.mjs";
5
+ import { effectiveMetadata, listingCost } from "./render.mjs";
6
+ import { loadProjectConfig } from "./sync.mjs";
7
+
8
+ // Rough rule of thumb for English prose in current tokenizers.
9
+ export const charsToTokens = (chars) => Math.round(chars / 4);
10
+
11
+ export function skillCosts(catalog, registry) {
12
+ const sources = loadSources(catalog).skills;
13
+ const costs = new Map();
14
+ for (const name of registry.skills) {
15
+ const path = resolve(catalog.skills, name, "SKILL.md");
16
+ const metadata = existsSync(path) ? effectiveMetadata(readFileSync(path, "utf8"), sources[name]?.render?.frontmatter) : null;
17
+ costs.set(name, listingCost(metadata, name));
18
+ }
19
+ return costs;
20
+ }
21
+
22
+ export function projectReport(catalog, registry, project, costs) {
23
+ const { projectConfig } = loadProjectConfig(project.path, registry, project.scaffold);
24
+ const selection = resolveSelection(project.path, registry, projectConfig, project.selection);
25
+ const sum = (names) => names.reduce((total, name) => total + (costs.get(name) ?? 0), 0);
26
+ const everything = sum(registry.skills);
27
+ const selected = sum(selection.skills);
28
+ return {
29
+ project: basename(project.path),
30
+ packs: selection.packs,
31
+ skills: selection.skills.length,
32
+ catalogSkills: registry.skills.length,
33
+ tokens: charsToTokens(selected),
34
+ tokensIfEverything: charsToTokens(everything),
35
+ saved: everything ? Math.round((1 - selected / everything) * 100) : 0,
36
+ };
37
+ }
38
+
39
+ const formatTokens = (tokens) => (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens));
40
+
41
+ export function printReport(rows) {
42
+ const width = Math.max(7, ...rows.map((row) => row.project.length));
43
+ console.log(`${"project".padEnd(width)} skills always-loaded vs. everything packs`);
44
+ for (const row of rows) {
45
+ console.log(
46
+ `${row.project.padEnd(width)} ${`${row.skills}/${row.catalogSkills}`.padStart(6)} `
47
+ + `${`~${formatTokens(row.tokens)} tok`.padStart(13)} ${`-${row.saved}%`.padStart(14)} ${row.packs.join(", ")}`,
48
+ );
49
+ }
50
+ if (rows.length > 1) {
51
+ const tokens = rows.reduce((total, row) => total + row.tokens, 0);
52
+ const everything = rows.reduce((total, row) => total + row.tokensIfEverything, 0);
53
+ console.log(`\n${rows.length} projects load ~${formatTokens(tokens)} tokens of skill listings in total, `
54
+ + `instead of ~${formatTokens(everything)} if every skill went everywhere (-${Math.round((1 - tokens / everything) * 100)}%).`);
55
+ }
56
+ console.log("Counts the skill names and descriptions every session loads; bodies load only when a skill is used. Manual-only skills cost 0.");
57
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, readdir, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { expandHome } from "./catalog.mjs";
6
+
7
+ const STARTER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../starter");
8
+
9
+ async function writeMissing(root, files, { dryRun }) {
10
+ const created = [];
11
+ for (const [key, content] of Object.entries(files)) {
12
+ const destination = resolve(root, key);
13
+ if (existsSync(destination)) continue;
14
+ created.push(key);
15
+ if (dryRun) continue;
16
+ await mkdir(dirname(destination), { recursive: true });
17
+ await writeFile(destination, content);
18
+ }
19
+ return created;
20
+ }
21
+
22
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
23
+
24
+ // Tracked per-project context. Never overwrites.
25
+ export async function initProject(projectPath, options) {
26
+ if (options.scaffold === false) {
27
+ console.log(`${basename(projectPath)}: skipped scaffold (adapter-only project)`);
28
+ return 0;
29
+ }
30
+ const created = await writeMissing(projectPath, {
31
+ "AGENTS.md": "# Repository agent instructions\n\nRead `docs/agent/index.md` first, then load only the pages and repo-owned components relevant to the task. Repository instructions take precedence over shared defaults when they conflict.\n",
32
+ "CLAUDE.md": "@AGENTS.md\n",
33
+ "docs/agent/config.json": json({ schemaVersion: 1, packs: [], excludePacks: [], skills: [], overrides: { skills: [], agents: [], commands: [], rules: [] } }),
34
+ "docs/agent/index.md": "# Agent context\n\nRead only the page relevant to the task:\n\n- `architecture.md`: structure, boundaries, and generated code\n- `design-system.md`: UI primitives, tokens, and visual conventions\n- `workflows.md`: package manager, checks, Git, PR, and release policy\n- `rules/*.md`: additional repository constraints, when relevant\n\nRepository-owned components live under `docs/agent/` (`skills/<name>/SKILL.md`, `commands/*.md`, `agents/*.md`, `rules/*.md`). Run `stackfit sync` to render them for each agent.\n",
35
+ "docs/agent/architecture.md": "# Architecture\n\nDocument stable repository boundaries and where new code belongs. Link existing detailed docs instead of duplicating them.\n",
36
+ "docs/agent/design-system.md": "# Design system\n\nDocument the canonical UI primitives, tokens, typography, and accessibility conventions. Remove this page for repositories without a UI.\n",
37
+ "docs/agent/workflows.md": "# Workflows\n\nDocument the package manager, validation commands, generated files, branch/PR policy, and release automation.\n",
38
+ }, options);
39
+ console.log(`${basename(projectPath)}: ${created.length ? `${created.length} tracked docs file(s)` : "already initialized"}`);
40
+ if (!options.quiet) for (const key of created) console.log(` create ${key}`);
41
+ return created.length;
42
+ }
43
+
44
+ async function listFiles(root) {
45
+ const files = [];
46
+ async function walk(directory) {
47
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
48
+ const path = resolve(directory, entry.name);
49
+ if (entry.isDirectory()) await walk(path);
50
+ else files.push(path);
51
+ }
52
+ }
53
+ await walk(root);
54
+ return files;
55
+ }
56
+
57
+ // A catalog is a plain folder you keep in your own (usually private) Git repository.
58
+ export async function initCatalog(target, options) {
59
+ const root = expandHome(target ?? "~/.stackfit");
60
+ const created = [];
61
+ if (options.starter) {
62
+ for (const source of await listFiles(STARTER_ROOT)) {
63
+ const key = relative(STARTER_ROOT, source);
64
+ const destination = resolve(root, key === "gitignore" ? ".gitignore" : key);
65
+ if (existsSync(destination)) continue;
66
+ created.push(relative(root, destination));
67
+ if (options.dryRun) continue;
68
+ await mkdir(dirname(destination), { recursive: true });
69
+ await cp(source, destination);
70
+ }
71
+ } else {
72
+ created.push(...await writeMissing(root, {
73
+ "stackfit.json": json({ $schema: "https://unpkg.com/stackfit/schemas/stackfit.schema.json", schemaVersion: 3, skills: [], packs: { core: { description: "Every repository.", always: true, skills: [] } }, adapters: { agents: [], commands: [], rules: [] } }),
74
+ "skill-sources.json": json({ schemaVersion: 2, skills: {} }),
75
+ "stackfit.local.example.json": json({ schemaVersion: 1, machines: [{ id: "laptop", match: { platform: "darwin" }, roots: ["~/code"] }] }),
76
+ ".env.example": "# Copy to .env (gitignored). Values here fill {{env:NAME}} placeholders in commands, rules, and agents.\n# STACKFIT_CATALOG=~/path/to/this/catalog\n# GITHUB_TOKEN=\n",
77
+ ".gitignore": "stackfit.local.json\n.env\nskills/.*.refresh/\n.DS_Store\n",
78
+ "skills/.gitkeep": "",
79
+ }, options));
80
+ }
81
+ console.log(`${options.dryRun ? "Would create" : "Created"} catalog at ${root} (${created.length} file(s)).`);
82
+ if (!options.quiet) for (const key of created) console.log(` create ${key}`);
83
+ console.log(options.starter
84
+ ? "\nNext:\n stackfit refresh --all # vendor the starter's upstream skills\n stackfit audit\n stackfit report --root ~/code\n stackfit sync --root ~/code"
85
+ : "\nNext: add skills under skills/<name>/SKILL.md, list them in stackfit.json and a pack, then `stackfit sync --project <repo>`.");
86
+ if (!root.endsWith(".stackfit")) console.log(`\nPoint stackfit at it with --catalog ${target}, STACKFIT_CATALOG, or run commands from inside the folder.`);
87
+ return created.length;
88
+ }
package/src/sync.mjs ADDED
@@ -0,0 +1,279 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { platform } from "node:os";
5
+ import { basename, dirname, relative, resolve, sep } from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { expandEnv, loadJson, loadSources } from "./catalog.mjs";
8
+ import { resolveSelection } from "./detect.mjs";
9
+ import { claudeAgent, claudeCommand, claudeRule, cursorRule, setFrontmatter } from "./render.mjs";
10
+
11
+ export const LOCK_NAME = ".stackfit.lock.json";
12
+ // Older names of the lock, from before the tool was called stackfit; read once, then replaced.
13
+ const LEGACY_LOCK_NAMES = [".skillfit.lock.json", ".agent-config.lock.json"];
14
+ const LOCAL_EXCLUDES = [LOCK_NAME, ".agents/", ".cursor/", ".claude/"];
15
+ const LOCAL_SCAFFOLD_EXCLUDES = ["CLAUDE.md", "docs/agent/"];
16
+
17
+ export function lstatMaybe(path) {
18
+ try {
19
+ return lstatSync(path);
20
+ } catch (error) {
21
+ if (error.code === "ENOENT") return null;
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ const sha256 = (content) => createHash("sha256").update(content).digest("hex");
27
+ export const toPosixPath = (path) => path.replaceAll("\\", "/");
28
+ const projectKey = (projectPath, destination) => toPosixPath(relative(projectPath, destination));
29
+ const normalizeEntries = (entries = {}) => Object.fromEntries(Object.entries(entries).map(([key, value]) => [toPosixPath(key), value]));
30
+
31
+ function loadLock(projectPath) {
32
+ const found = [LOCK_NAME, ...LEGACY_LOCK_NAMES].map((name) => resolve(projectPath, name)).find((path) => existsSync(path));
33
+ const previous = loadJson(found, { files: {}, links: {} }) ?? { files: {}, links: {} };
34
+ return { ...previous, files: normalizeEntries(previous.files), links: normalizeEntries(previous.links) };
35
+ }
36
+
37
+ export function git(args, cwd, stdio = "pipe") {
38
+ return spawnSync("git", args, { cwd, encoding: "utf8", stdio });
39
+ }
40
+
41
+ function currentRevision(cwd) {
42
+ const result = git(["rev-parse", "HEAD"], cwd);
43
+ return result.status === 0 ? result.stdout.trim() : "unknown";
44
+ }
45
+
46
+ async function walkFiles(root) {
47
+ if (!existsSync(root)) return [];
48
+ const files = [];
49
+ async function walk(directory) {
50
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
51
+ const path = resolve(directory, entry.name);
52
+ if (entry.isDirectory()) await walk(path);
53
+ else if (entry.isFile()) files.push(path);
54
+ }
55
+ }
56
+ await walk(root);
57
+ return files.sort();
58
+ }
59
+
60
+ function addFile(plan, source, destination, transform = undefined) {
61
+ if (!existsSync(source)) throw new Error(`Configured source does not exist: ${source}`);
62
+ const raw = readFileSync(source);
63
+ const content = transform ? Buffer.from(transform(raw.toString("utf8"))) : raw;
64
+ plan.set(destination, { source, content, hash: sha256(content) });
65
+ }
66
+
67
+ async function addTree(plan, sourceRoot, destinationRoot, render = {}) {
68
+ const exclude = new Set(render.exclude ?? []);
69
+ for (const source of await walkFiles(sourceRoot)) {
70
+ const file = toPosixPath(relative(sourceRoot, source));
71
+ if (exclude.has(file) || basename(file) === ".DS_Store") continue;
72
+ const transform = file === "SKILL.md" && render.frontmatter ? (content) => setFrontmatter(content, render.frontmatter) : undefined;
73
+ addFile(plan, source, resolve(destinationRoot, file), transform);
74
+ }
75
+ }
76
+
77
+ const componentName = (file) => file.replace(/\.(md|mdc)$/, "");
78
+
79
+ function assertLocalComponentAllowed(projectName, projectConfig, kind, name, sharedNames) {
80
+ if (!sharedNames.has(name)) return;
81
+ if (new Set(projectConfig.overrides?.[kind] ?? []).has(name)) return;
82
+ throw new Error(
83
+ `${projectName}: repo-owned ${kind.slice(0, -1)} '${name}' conflicts with shared ${kind}; `
84
+ + `rename it or add '${name}' to overrides.${kind} in docs/agent/config.json`,
85
+ );
86
+ }
87
+
88
+ function addCommand(plan, projectPath, source, file) {
89
+ const fill = (content) => expandEnv(content, `commands/${file}`);
90
+ addFile(plan, source, resolve(projectPath, ".cursor/commands", file), fill);
91
+ addFile(plan, source, resolve(projectPath, ".claude/commands", file), (content) => claudeCommand(fill(content)));
92
+ }
93
+
94
+ function addRule(plan, projectPath, source, name, cursorTransform = (content) => content) {
95
+ const cursorContent = expandEnv(cursorTransform(readFileSync(source, "utf8")), `rules/${name}`);
96
+ addFile(plan, source, resolve(projectPath, ".cursor/rules", `${name}.mdc`), () => cursorContent);
97
+ const claudeContent = claudeRule(cursorContent);
98
+ if (claudeContent !== null) addFile(plan, source, resolve(projectPath, ".claude/rules", `${name}.md`), () => claudeContent);
99
+ }
100
+
101
+ function addAgent(plan, projectPath, source, file) {
102
+ const fill = (content) => expandEnv(content, `agents/${file}`);
103
+ addFile(plan, source, resolve(projectPath, ".cursor/agents", file), fill);
104
+ addFile(plan, source, resolve(projectPath, ".claude/agents", file), (content) => claudeAgent(fill(content)));
105
+ }
106
+
107
+ export function loadProjectConfig(projectPath, registry, scaffold) {
108
+ const configPath = resolve(projectPath, registry.projectConfigPath);
109
+ const projectConfig = scaffold ? loadJson(configPath, { schemaVersion: 1 }) : { schemaVersion: 1 };
110
+ if (projectConfig.schemaVersion !== 1) throw new Error(`${basename(projectPath)}: unsupported ${registry.projectConfigPath} schema`);
111
+ return { projectConfig, configPath };
112
+ }
113
+
114
+ export async function createPlan(catalog, projectPath, registry, { scaffold = true, selection: localSelection = {} } = {}) {
115
+ const projectName = basename(projectPath);
116
+ const { projectConfig, configPath } = loadProjectConfig(projectPath, registry, scaffold);
117
+ const selection = resolveSelection(projectPath, registry, projectConfig, localSelection);
118
+ const renderOptions = loadSources(catalog).skills;
119
+ const sharedSkills = new Set(selection.skills);
120
+ const sharedAgents = new Set(registry.adapters.agents.map(componentName));
121
+ const sharedCommands = new Set(registry.adapters.commands.map(componentName));
122
+ const sharedRules = new Set(registry.adapters.rules.map(componentName));
123
+ const plan = new Map();
124
+ const links = new Map([
125
+ [resolve(projectPath, ".claude/skills"), "../.agents/skills"],
126
+ [resolve(projectPath, ".cursor/skills"), "../.agents/skills"],
127
+ ]);
128
+
129
+ for (const skill of selection.skills) {
130
+ const source = resolve(catalog.skills, skill);
131
+ if (!existsSync(resolve(source, "SKILL.md"))) {
132
+ throw new Error(`Skill '${skill}' is enabled but not in the catalog. Run \`stackfit refresh --skill ${skill}\` or add skills/${skill}/SKILL.md.`);
133
+ }
134
+ await addTree(plan, source, resolve(projectPath, ".agents/skills", skill), renderOptions[skill]?.render);
135
+ }
136
+ for (const file of registry.adapters.agents) addAgent(plan, projectPath, resolve(catalog.agents, file), file);
137
+ for (const file of registry.adapters.commands) addCommand(plan, projectPath, resolve(catalog.commands, file), file);
138
+ for (const file of registry.adapters.rules) addRule(plan, projectPath, resolve(catalog.rules, file), componentName(file));
139
+
140
+ if (scaffold) {
141
+ const localSkills = resolve(projectPath, "docs/agent/skills");
142
+ if (existsSync(localSkills)) {
143
+ for (const entry of await readdir(localSkills, { withFileTypes: true })) {
144
+ if (!entry.isDirectory()) continue;
145
+ const source = resolve(localSkills, entry.name);
146
+ if (!existsSync(resolve(source, "SKILL.md"))) throw new Error(`${projectName}: docs/agent/skills/${entry.name} lacks SKILL.md`);
147
+ assertLocalComponentAllowed(projectName, projectConfig, "skills", entry.name, sharedSkills);
148
+ await addTree(plan, source, resolve(projectPath, ".agents/skills", entry.name));
149
+ }
150
+ }
151
+ const localKinds = [
152
+ ["agents", "docs/agent/agents", sharedAgents, (source, file) => addAgent(plan, projectPath, source, file)],
153
+ ["commands", "docs/agent/commands", sharedCommands, (source, file) => addCommand(plan, projectPath, source, file)],
154
+ ["rules", "docs/agent/rules", sharedRules, (source, file) => {
155
+ const name = componentName(file);
156
+ addRule(plan, projectPath, source, name, (content) => cursorRule(content, name));
157
+ }],
158
+ ];
159
+ for (const [kind, folder, shared, add] of localKinds) {
160
+ const root = resolve(projectPath, folder);
161
+ for (const source of await walkFiles(root)) {
162
+ if (!source.endsWith(".md")) continue;
163
+ const file = relative(root, source);
164
+ assertLocalComponentAllowed(projectName, projectConfig, kind, componentName(file), shared);
165
+ add(source, file);
166
+ }
167
+ }
168
+ }
169
+
170
+ return { plan, links, projectName, scaffold, packs: selection.packs, skills: selection.skills, configPath: scaffold ? relative(projectPath, configPath) : null };
171
+ }
172
+
173
+ async function ensureLocalGitExclude(projectPath, { scaffold = true } = {}) {
174
+ const gitPath = git(["rev-parse", "--git-path", "info/exclude"], projectPath);
175
+ const excludePath = gitPath.status === 0 ? resolve(projectPath, gitPath.stdout.trim()) : resolve(projectPath, ".git/info/exclude");
176
+ const existing = existsSync(excludePath) ? await readFile(excludePath, "utf8") : "";
177
+ const lines = new Set(existing.split(/\r?\n/));
178
+ const wanted = scaffold ? LOCAL_EXCLUDES : [...LOCAL_EXCLUDES, ...LOCAL_SCAFFOLD_EXCLUDES];
179
+ const additions = wanted.filter((entry) => !lines.has(entry));
180
+ if (!additions.length) return;
181
+ await mkdir(dirname(excludePath), { recursive: true });
182
+ const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
183
+ await writeFile(excludePath, `${existing}${prefix}# Managed locally by stackfit\n${additions.join("\n")}\n`);
184
+ }
185
+
186
+ // After removing a dropped skill's files, remove its now-empty folders (never the project root).
187
+ async function pruneEmptyParents(projectPath, directory) {
188
+ let current = directory;
189
+ while (current.startsWith(`${projectPath}${sep}`) && !lstatMaybe(current)?.isSymbolicLink()) {
190
+ let entries;
191
+ try {
192
+ entries = await readdir(current);
193
+ } catch {
194
+ return;
195
+ }
196
+ if (entries.length) return;
197
+ await rm(current, { recursive: true, force: true });
198
+ current = dirname(current);
199
+ }
200
+ }
201
+
202
+ // Windows junctions need no admin rights or developer mode; they store absolute targets.
203
+ const LINK_TYPE = platform() === "win32" ? "junction" : "dir";
204
+ const sameLinkTarget = (linkPath, target) => resolve(dirname(linkPath), readlinkSync(linkPath)) === resolve(dirname(linkPath), target);
205
+
206
+ export async function applyPlan(catalog, projectPath, planData, options) {
207
+ const previous = loadLock(projectPath);
208
+ const nextFiles = {};
209
+ const nextLinks = {};
210
+ const actions = [];
211
+ // Managed symlinks this plan no longer wants. Files "found" through them are not
212
+ // real collisions: the link is deleted before writes.
213
+ const retiredLinks = Object.keys(previous.links ?? {})
214
+ .map((key) => resolve(projectPath, key))
215
+ .filter((linkPath) => !planData.links.has(linkPath) && lstatMaybe(linkPath)?.isSymbolicLink());
216
+ const throughRetiredLink = (destination) => retiredLinks.some((linkPath) => destination.startsWith(`${linkPath}${sep}`));
217
+
218
+ for (const [destination, item] of planData.plan) {
219
+ const key = projectKey(projectPath, destination);
220
+ nextFiles[key] = item.hash;
221
+ const existing = existsSync(destination) && !throughRetiredLink(destination) ? await readFile(destination) : null;
222
+ if (existing && sha256(existing) === item.hash) continue;
223
+ if (existing && !previous.files[key] && !options.force) {
224
+ throw new Error(`${planData.projectName}: unmanaged collision at ${key}; review it, then rerun with --force`);
225
+ }
226
+ actions.push({ type: existing ? "update" : "create", key, destination, item });
227
+ }
228
+ for (const key of Object.keys(previous.files)) {
229
+ if (nextFiles[key]) continue;
230
+ const destination = resolve(projectPath, key);
231
+ const underLink = [...planData.links.keys()].find((linkPath) => destination.startsWith(`${linkPath}${sep}`) && lstatMaybe(linkPath)?.isSymbolicLink());
232
+ if (!underLink && lstatMaybe(destination)) actions.push({ type: "delete", key, destination });
233
+ }
234
+ for (const [destination, target] of planData.links) {
235
+ const key = projectKey(projectPath, destination);
236
+ nextLinks[key] = target;
237
+ const stat = lstatMaybe(destination);
238
+ if (stat?.isSymbolicLink() && sameLinkTarget(destination, target)) continue;
239
+ if (stat?.isDirectory()) {
240
+ const unmanaged = (await walkFiles(destination)).map((path) => projectKey(projectPath, path)).filter((path) => !previous.files[path]);
241
+ if (unmanaged.length && !options.force) throw new Error(`${planData.projectName}: unmanaged collision under ${key}: ${unmanaged[0]}`);
242
+ } else if (stat && !previous.links?.[key] && !options.force) {
243
+ throw new Error(`${planData.projectName}: unmanaged collision at ${key}; review it, then rerun with --force`);
244
+ }
245
+ actions.push({ type: stat ? "link" : "create-link", key, destination, target });
246
+ }
247
+ for (const key of Object.keys(previous.links ?? {})) {
248
+ if (!nextLinks[key] && lstatMaybe(resolve(projectPath, key))) actions.push({ type: "delete", key, destination: resolve(projectPath, key) });
249
+ }
250
+
251
+ if (!options.dryRun) {
252
+ await ensureLocalGitExclude(projectPath, { scaffold: planData.scaffold !== false });
253
+ for (const action of actions.filter((item) => item.type === "delete")) {
254
+ await rm(action.destination, { recursive: true, force: true });
255
+ await pruneEmptyParents(projectPath, dirname(action.destination));
256
+ }
257
+ for (const action of actions.filter((item) => ["create", "update"].includes(item.type))) {
258
+ await mkdir(dirname(action.destination), { recursive: true });
259
+ await writeFile(action.destination, action.item.content);
260
+ }
261
+ for (const action of actions.filter((item) => ["link", "create-link"].includes(item.type))) {
262
+ await rm(action.destination, { recursive: true, force: true });
263
+ await mkdir(dirname(action.destination), { recursive: true });
264
+ await symlink(action.target, action.destination, LINK_TYPE);
265
+ }
266
+ await writeFile(resolve(projectPath, LOCK_NAME), `${JSON.stringify({
267
+ schemaVersion: 3,
268
+ sourceRevision: currentRevision(catalog.root),
269
+ projectConfig: planData.configPath,
270
+ scaffold: planData.scaffold !== false,
271
+ packs: planData.packs,
272
+ generatedAt: new Date().toISOString(),
273
+ files: nextFiles,
274
+ links: nextLinks,
275
+ }, null, 2)}\n`);
276
+ for (const name of LEGACY_LOCK_NAMES) await rm(resolve(projectPath, name), { force: true });
277
+ }
278
+ return actions;
279
+ }
@@ -0,0 +1,10 @@
1
+ # Copy to .env next to stackfit.json (it is gitignored). Never commit real values.
2
+
3
+ # Optional: where this catalog lives, so stackfit works from any folder.
4
+ # STACKFIT_CATALOG=~/code/my-skill-catalog
5
+
6
+ # Optional: lets `stackfit refresh` read private GitHub repositories.
7
+ # GITHUB_TOKEN=
8
+
9
+ # Any NAME here can be used as {{env:NAME}} in commands/, rules/, and agents/, e.g.
10
+ # REVIEWER_HANDLE=@your-handle
@@ -0,0 +1,12 @@
1
+ # My stackfit catalog
2
+
3
+ Started from the stackfit starter. This folder is yours: keep it in a (private) Git repository.
4
+
5
+ - `stackfit.json`: enabled skills and packs (which skills reach which repos).
6
+ - `skill-sources.json`: where each vendored skill comes from, when you last reviewed it, and sync-time overrides.
7
+ - `skills/`: the skills themselves. `stackfit refresh --all` vendors the upstream ones.
8
+ - `rules/`, `commands/`, `agents/`: shared adapters rendered for Cursor and Claude Code.
9
+ - `stackfit.local.json` (gitignored): where your projects live on each machine. Copy the example.
10
+ - `.env` (gitignored): personal values for `{{env:NAME}}` placeholders. Copy `.env.example`.
11
+
12
+ Add your own skills under `skills/<name>/SKILL.md`, list them in `stackfit.json` and a pack, set `kind: custom` in `skill-sources.json`, then `stackfit audit`.
@@ -0,0 +1,4 @@
1
+ stackfit.local.json
2
+ .env
3
+ skills/.*.refresh/
4
+ .DS_Store
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: Starter defaults for any repository (edit or replace with your own)
3
+ alwaysApply: true
4
+ ---
5
+
6
+ # Defaults
7
+
8
+ - Follow the repository's own instructions (`AGENTS.md`, `CLAUDE.md`, contributing docs) over these.
9
+ - Match existing conventions, package manager, and scripts before introducing new ones.
10
+ - Keep changes focused on the request and say which checks you ran.
11
+ - Ask before running anything that changes external systems.