tunestra 0.0.0 → 0.1.1

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/dist/cli.mjs ADDED
@@ -0,0 +1,1892 @@
1
+ #!/usr/bin/env node
2
+ import { t as VERSION } from "./src-Dmt0_5_W.mjs";
3
+ import { Command } from "@commander-js/extra-typings";
4
+ import pc from "picocolors";
5
+ import { copyFile, lstat, mkdir, readFile, readdir, readlink, rename, rm, rmdir, stat, symlink, unlink, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
7
+ import { glob } from "tinyglobby";
8
+ import { prettifyError, z } from "zod";
9
+ import { parse } from "yaml";
10
+ import { downloadTemplate } from "giget";
11
+ import { createHash } from "node:crypto";
12
+ //#region src/shared/constants/files.ts
13
+ /** Preset manifest, in the root of every preset. */
14
+ const PRESET_MANIFEST = "tunestra.preset.yaml";
15
+ /** Project config, in the root of the project. */
16
+ const PROJECT_CONFIG = "tunestra.yaml";
17
+ /** Project artifacts: the same layout as a preset. */
18
+ const PROJECT_DIR = ".tunestra";
19
+ /** Main instructions of a level, and the index that tunestra generates. */
20
+ const INSTRUCTIONS_FILE = "AGENTS.md";
21
+ //#endregion
22
+ //#region src/shared/constants/output.ts
23
+ /** What the plan asks to put at a path in the project. */
24
+ const OutputKind = {
25
+ /** A file with the given content. */
26
+ File: "file",
27
+ /** A copy of a source file, byte for byte. */
28
+ Copy: "copy",
29
+ /** A relative symbolic link. */
30
+ Symlink: "symlink"
31
+ };
32
+ /** First line of every generated markdown file; tunestra recognizes its files by it. */
33
+ const GENERATED_MARKER = "<!-- Generated by tunestra";
34
+ const GENERATED_NOTICE = `${GENERATED_MARKER}. Do not edit: change the sources and run \`tunestra setup\`. -->`;
35
+ /** Shared output that any agent can read. */
36
+ const AGENTS_DIR = ".agents";
37
+ /** Rules for every agent, ordered from specific to general by their file names. */
38
+ const AGENTS_RULES_DIR = `${AGENTS_DIR}/rules`;
39
+ /** Skills in the Agent Skills layout that several agents read. */
40
+ const AGENTS_SKILLS_DIR = `${AGENTS_DIR}/skills`;
41
+ /** What tunestra generated last time. */
42
+ const STATE_DIR = ".tunestra/state";
43
+ const GITIGNORE_FILE = ".gitignore";
44
+ /** The registry of outputs from the last setup. */
45
+ const STATE_FILE = `${STATE_DIR}/outputs.json`;
46
+ /** Repositories of git sources, downloaded on every setup. */
47
+ const SOURCES_DIR = `${STATE_DIR}/sources`;
48
+ //#endregion
49
+ //#region src/shared/errors/tunestra-error.ts
50
+ /** An error caused by the user's input: a broken manifest, a missing file and so on. */
51
+ var TunestraError = class extends Error {
52
+ name = "TunestraError";
53
+ /** Every problem behind this error; one item for a simple error. */
54
+ problems;
55
+ constructor(message, problems = [message]) {
56
+ super(message);
57
+ this.problems = problems;
58
+ }
59
+ };
60
+ //#endregion
61
+ //#region src/shared/errors/create-problems-error.ts
62
+ /** Builds one error from several problems, so that the user can fix them all at once. */
63
+ function createProblemsError(problems) {
64
+ const [firstProblem] = problems;
65
+ if (problems.length === 1 && typeof firstProblem === "string") return new TunestraError(firstProblem);
66
+ const list = problems.map(formatProblem).join("\n\n");
67
+ return new TunestraError(`Found ${problems.length} problems:\n\n${list}`, problems);
68
+ }
69
+ /** Numbers a problem and indents its next lines under the number. */
70
+ function formatProblem(problem, index) {
71
+ const number = `${index + 1}. `;
72
+ const indent = " ".repeat(number.length);
73
+ const [firstLine, ...nextLines] = problem.trimEnd().split("\n");
74
+ const indentedLines = nextLines.map((line) => indentLine(line, indent));
75
+ return [`${number}${firstLine}`, ...indentedLines].join("\n");
76
+ }
77
+ /** Leaves empty lines empty, so that the message has no trailing spaces. */
78
+ function indentLine(line, indent) {
79
+ const text = line.trimEnd();
80
+ if (text === "") return text;
81
+ return `${indent}${text}`;
82
+ }
83
+ //#endregion
84
+ //#region src/shared/utils/fs.ts
85
+ /** A missing path is `false`; any other failure, like no permission, is thrown. */
86
+ async function checkExists(path) {
87
+ try {
88
+ await stat(path);
89
+ return true;
90
+ } catch (error) {
91
+ if (isMissingPathError(error)) return false;
92
+ throw error;
93
+ }
94
+ }
95
+ function isMissingPathError(error) {
96
+ if (error instanceof Error) return "code" in error && error.code === "ENOENT";
97
+ return false;
98
+ }
99
+ /**
100
+ * Files matching the pattern, without dotfiles. Sorted so that the output does not depend
101
+ * on the file system.
102
+ */
103
+ async function listFiles(dir, pattern) {
104
+ return (await glob(pattern, {
105
+ cwd: dir,
106
+ onlyFiles: true
107
+ })).sort();
108
+ }
109
+ /** Every file in the directory, dotfiles included, sorted. */
110
+ async function listAllFiles(dir) {
111
+ return (await glob("**/*", {
112
+ cwd: dir,
113
+ dot: true,
114
+ onlyFiles: true
115
+ })).sort();
116
+ }
117
+ /** What is at a path, without following a symbolic link. */
118
+ const PathType = {
119
+ Missing: "missing",
120
+ Symlink: "symlink",
121
+ Directory: "directory",
122
+ File: "file"
123
+ };
124
+ async function getPathType(path) {
125
+ try {
126
+ const stats = await lstat(path);
127
+ const isSymlink = stats.isSymbolicLink();
128
+ const isDirectory = stats.isDirectory();
129
+ if (isSymlink) return PathType.Symlink;
130
+ if (isDirectory) return PathType.Directory;
131
+ return PathType.File;
132
+ } catch (error) {
133
+ if (isMissingPathError(error)) return PathType.Missing;
134
+ throw error;
135
+ }
136
+ }
137
+ /** The text of a file, or `undefined` when there is no such file. */
138
+ async function readFileIfExists(path) {
139
+ try {
140
+ return await readFile(path, "utf8");
141
+ } catch (error) {
142
+ if (isMissingPathError(error)) return;
143
+ throw error;
144
+ }
145
+ }
146
+ /** Removes a directory only when it has nothing inside; a missing directory is fine too. */
147
+ async function removeDirIfEmpty(dir) {
148
+ try {
149
+ if ((await readdir(dir)).length === 0) await rmdir(dir);
150
+ } catch (error) {
151
+ if (isMissingPathError(error)) return;
152
+ throw error;
153
+ }
154
+ }
155
+ //#endregion
156
+ //#region src/shared/constants/agents.ts
157
+ /** Agents that tunestra generates configuration for. */
158
+ const Agent = { Claude: "claude" };
159
+ //#endregion
160
+ //#region src/init/render-config.ts
161
+ const EXTENDS_EXAMPLE = "# - github:<owner>/<repo>/<path>#<ref>";
162
+ /** The first tunestra.yaml of a project, with a hint when there are no presets yet. */
163
+ function renderConfig(presets) {
164
+ const hasPresets = presets.length > 0;
165
+ const presetLines = presets.map((preset) => ` - ${JSON.stringify(preset)}`);
166
+ return [
167
+ "# tunestra: https://github.com/sensetive5/tunestra",
168
+ "# Presets, from general to specific:",
169
+ ...hasPresets ? ["extends:", ...presetLines] : ["extends: []", EXTENDS_EXAMPLE],
170
+ "agents:",
171
+ ` - ${Agent.Claude}`,
172
+ ""
173
+ ].join("\n");
174
+ }
175
+ //#endregion
176
+ //#region src/init/run-init.ts
177
+ const CLAUDE_FILE$1 = "CLAUDE.md";
178
+ const CLAUDE_RULE = `${PROJECT_DIR}/rules/claude-md.md`;
179
+ const PROJECT_INSTRUCTIONS = `${PROJECT_DIR}/${INSTRUCTIONS_FILE}`;
180
+ const CLAUDE_IMPORT = `@${INSTRUCTIONS_FILE}`;
181
+ /**
182
+ * Creates tunestra.yaml. Hand-written AGENTS.md and CLAUDE.md become sources in .tunestra/,
183
+ * because setup will generate both files from now on.
184
+ */
185
+ async function runInit(projectDir, options) {
186
+ const configPath = join(projectDir, PROJECT_CONFIG);
187
+ const agentsPath = join(projectDir, INSTRUCTIONS_FILE);
188
+ const claudePath = join(projectDir, CLAUDE_FILE$1);
189
+ const hasConfig = await checkExists(configPath);
190
+ const agentsContent = await readHandWritten(agentsPath);
191
+ const claudeContent = await readHandWritten(claudePath);
192
+ const problems = await findConflicts(projectDir, hasConfig, agentsContent, claudeContent);
193
+ if (problems.length > 0) throw createProblemsError(problems);
194
+ const steps = [];
195
+ const warnings = [];
196
+ if (typeof agentsContent === "string") {
197
+ await moveFile(projectDir, INSTRUCTIONS_FILE, PROJECT_INSTRUCTIONS);
198
+ steps.push(`Moved ${INSTRUCTIONS_FILE} to ${PROJECT_INSTRUCTIONS}`);
199
+ }
200
+ if (typeof claudeContent === "string") {
201
+ const onlyImportsAgents = claudeContent.trim() === CLAUDE_IMPORT;
202
+ const hasImports = claudeContent.includes("@");
203
+ if (onlyImportsAgents) {
204
+ await rm(claudePath);
205
+ steps.push(`Removed ${CLAUDE_FILE$1}: it only imported ${INSTRUCTIONS_FILE}`);
206
+ } else {
207
+ await moveFile(projectDir, CLAUDE_FILE$1, CLAUDE_RULE);
208
+ steps.push(`Moved ${CLAUDE_FILE$1} to ${CLAUDE_RULE}`);
209
+ if (hasImports) warnings.push(`${CLAUDE_RULE} may have @imports; their paths are relative to the old place, so check them`);
210
+ }
211
+ }
212
+ const config = renderConfig(options.presets);
213
+ await writeFile(configPath, config);
214
+ steps.push(`Created ${PROJECT_CONFIG}`);
215
+ return {
216
+ steps,
217
+ warnings
218
+ };
219
+ }
220
+ /** The text of a file that people wrote; a file that tunestra generated does not count. */
221
+ async function readHandWritten(path) {
222
+ const content = await readFileIfExists(path);
223
+ if (content?.startsWith("<!-- Generated by tunestra") === true) return;
224
+ return content;
225
+ }
226
+ async function findConflicts(projectDir, hasConfig, agentsContent, claudeContent) {
227
+ const problems = [];
228
+ const instructionsPath = join(projectDir, PROJECT_INSTRUCTIONS);
229
+ const claudeRulePath = join(projectDir, CLAUDE_RULE);
230
+ const hasInstructions = await checkExists(instructionsPath);
231
+ const hasClaudeRule = await checkExists(claudeRulePath);
232
+ if (hasConfig) problems.push(`${PROJECT_CONFIG} already exists. Run tunestra setup instead`);
233
+ if (typeof agentsContent === "string" && hasInstructions) problems.push(`${PROJECT_INSTRUCTIONS} already exists, so ${INSTRUCTIONS_FILE} cannot move there`);
234
+ if (typeof claudeContent === "string" && hasClaudeRule) problems.push(`${CLAUDE_RULE} already exists, so ${CLAUDE_FILE$1} cannot move there`);
235
+ return problems;
236
+ }
237
+ async function moveFile(projectDir, from, to) {
238
+ const fromPath = join(projectDir, from);
239
+ const toPath = join(projectDir, to);
240
+ const toDir = dirname(toPath);
241
+ await mkdir(toDir, { recursive: true });
242
+ await rename(fromPath, toPath);
243
+ }
244
+ //#endregion
245
+ //#region src/cli/commands/init.ts
246
+ async function describeInit(projectDir, options) {
247
+ const report = await runInit(projectDir, options);
248
+ const check = pc.green("✔");
249
+ const warningIcon = pc.yellow("⚠");
250
+ const steps = report.steps.map((step) => `${check} ${step}`);
251
+ const warnings = report.warnings.map((warning) => `${warningIcon} ${warning}`);
252
+ const nextStep = options.presets.length > 0 ? "Next: run tunestra setup." : "Next: add presets to extends in tunestra.yaml, then run tunestra setup.";
253
+ return [
254
+ ...steps,
255
+ ...warnings,
256
+ "",
257
+ nextStep
258
+ ].join("\n");
259
+ }
260
+ //#endregion
261
+ //#region src/shared/constants/levels.ts
262
+ /** Levels of a shared preset, from specific to general. */
263
+ const PresetLevel = {
264
+ Team: "team",
265
+ Domain: "domain",
266
+ Base: "base"
267
+ };
268
+ /** Every level of the chain; the project is the most specific one. */
269
+ const Level = {
270
+ Project: "project",
271
+ ...PresetLevel
272
+ };
273
+ /**
274
+ * Order of levels from specific to general. The number also prefixes generated rule files,
275
+ * so that agents read specific rules first: `10-project-architecture.md`.
276
+ */
277
+ const LEVEL_RANK = {
278
+ [Level.Project]: 10,
279
+ [Level.Team]: 20,
280
+ [Level.Domain]: 30,
281
+ [Level.Base]: 40
282
+ };
283
+ //#endregion
284
+ //#region src/shared/utils/guards.ts
285
+ function isString(value) {
286
+ return typeof value === "string";
287
+ }
288
+ function isKeyValueMap(value) {
289
+ if (value === null) return false;
290
+ if (Array.isArray(value)) return false;
291
+ return typeof value === "object";
292
+ }
293
+ //#endregion
294
+ //#region src/shared/schemas/preset.ts
295
+ const keys = z.array(z.string().min(1)).readonly();
296
+ const stringMap = z.record(z.string(), z.string());
297
+ const skillName = z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "Use lowercase letters, digits and single hyphens").max(64);
298
+ const excludeSchema = z.strictObject({
299
+ rules: keys.optional(),
300
+ skills: keys.optional(),
301
+ commands: keys.optional(),
302
+ mcp: keys.optional()
303
+ });
304
+ const mcpPatchSchema = z.strictObject({
305
+ command: z.string().optional(),
306
+ args: z.array(z.string()).readonly().optional(),
307
+ url: z.string().optional(),
308
+ env: stringMap.optional(),
309
+ headers: stringMap.optional()
310
+ });
311
+ const overridesSchema = z.strictObject({ mcp: z.record(z.string(), mcpPatchSchema).optional() });
312
+ const presetManifestSchema = z.strictObject({
313
+ name: z.string().min(1),
314
+ level: z.enum(PresetLevel),
315
+ description: z.string().optional(),
316
+ extends: z.array(z.string().min(1)).readonly().default([]),
317
+ exclude: excludeSchema.default({}),
318
+ overrides: overridesSchema.default({})
319
+ });
320
+ const projectConfigSchema = z.strictObject({
321
+ name: z.string().min(1).optional(),
322
+ extends: z.array(z.string().min(1)).readonly().default([]),
323
+ agents: z.array(z.enum(Agent)).min(1).readonly().default([Agent.Claude]),
324
+ exclude: excludeSchema.default({}),
325
+ overrides: overridesSchema.default({})
326
+ });
327
+ const mcpServerSchema = z.strictObject({
328
+ description: z.string().optional(),
329
+ type: z.enum([
330
+ "stdio",
331
+ "http",
332
+ "sse"
333
+ ]).optional(),
334
+ command: z.string().optional(),
335
+ args: z.array(z.string()).readonly().optional(),
336
+ env: stringMap.optional(),
337
+ url: z.string().optional(),
338
+ headers: stringMap.optional()
339
+ }).refine((server) => [server.command, server.url].filter(isString).length === 1, { message: "Set either \"command\" (local server) or \"url\" (remote server)" });
340
+ //#endregion
341
+ //#region src/shared/errors/get-error-message.ts
342
+ /** The message of anything thrown: `catch` gives `unknown`, not always an `Error`. */
343
+ function getErrorMessage(error) {
344
+ if (error instanceof Error) return error.message;
345
+ return String(error);
346
+ }
347
+ //#endregion
348
+ //#region src/shared/utils/validate.ts
349
+ /** Checks data with a schema; `what` names the checked thing in the error. */
350
+ function validate(data, schema, what) {
351
+ const result = schema.safeParse(data);
352
+ if (result.success) return result.data;
353
+ throw new TunestraError(`Invalid ${what}:\n${prettifyError(result.error)}`);
354
+ }
355
+ //#endregion
356
+ //#region src/shared/utils/yaml.ts
357
+ /** Parses YAML; an empty document becomes an empty object. `what` names it in the error. */
358
+ function parseYaml(content, what) {
359
+ try {
360
+ return parse(content) ?? {};
361
+ } catch (error) {
362
+ throw new TunestraError(`Invalid ${what}: ${getErrorMessage(error)}`);
363
+ }
364
+ }
365
+ async function readYaml(file, schema, what) {
366
+ return validate(parseYaml(await readFile(file, "utf8"), `YAML in ${file}`), schema, `${what} ${file}`);
367
+ }
368
+ //#endregion
369
+ //#region src/shared/utils/settled.ts
370
+ function isFulfilled(result) {
371
+ return result.status === "fulfilled";
372
+ }
373
+ /** Narrows the results of `Promise.allSettled`, tuples included, to fulfilled ones. */
374
+ function isEveryFulfilled(results) {
375
+ return results.every(isFulfilled);
376
+ }
377
+ function getValues(results) {
378
+ return results.map((result) => result.value);
379
+ }
380
+ /**
381
+ * Builds one error from every rejected result, in the order of the results, so the message
382
+ * does not depend on which promise settles first. An error that is not caused by the user's
383
+ * input is returned as is.
384
+ */
385
+ function buildResultsError(results) {
386
+ const problems = [];
387
+ for (const result of results) {
388
+ if (isFulfilled(result)) continue;
389
+ const reason = result.reason;
390
+ if (reason instanceof TunestraError) {
391
+ problems.push(...reason.problems);
392
+ continue;
393
+ }
394
+ return reason;
395
+ }
396
+ return createProblemsError(problems);
397
+ }
398
+ /**
399
+ * Maps every item with an async function at the same time. Returns the values in the order
400
+ * of the items, or throws one error with the problems of every failed item.
401
+ */
402
+ async function mapSettled(items, map) {
403
+ const mappings = items.map((item) => map(item));
404
+ const results = await Promise.allSettled(mappings);
405
+ if (isEveryFulfilled(results)) return getValues(results);
406
+ throw buildResultsError(results);
407
+ }
408
+ //#endregion
409
+ //#region src/shared/schemas/frontmatter.ts
410
+ const ruleFrontmatterSchema = z.looseObject({ description: z.string().optional() });
411
+ const skillFrontmatterSchema = z.looseObject({
412
+ name: skillName.optional(),
413
+ description: z.string().min(1)
414
+ });
415
+ const commandFrontmatterSchema = z.looseObject({ description: z.string().optional() });
416
+ //#endregion
417
+ //#region src/presets/frontmatter.ts
418
+ /**
419
+ * A YAML block between two `---` lines at the very start of the file.
420
+ * Group 1 is the YAML; it is missing when the block is empty.
421
+ *
422
+ * Matches: "---\ndescription: Hi\n---\n# Title" → group 1 = "description: Hi"
423
+ * Matches: "---\n---\nText" → group 1 = undefined
424
+ * Does not match: "# Title\n---\nkey: value\n---" (not at the start of the file)
425
+ */
426
+ const FRONTMATTER_PATTERN = /^---\r?\n(?:([\s\S]*?)\r?\n)?---(?:\r?\n|$)/;
427
+ /** Splits a markdown file into YAML frontmatter and body. A file without frontmatter has empty data. */
428
+ function parseFrontmatter(text, file) {
429
+ const match = FRONTMATTER_PATTERN.exec(text);
430
+ if (match === null) return {
431
+ data: {},
432
+ body: text
433
+ };
434
+ const data = parseYaml(match.at(1) ?? "", `frontmatter in ${file}`);
435
+ if (isKeyValueMap(data)) return {
436
+ data,
437
+ body: text.slice(match[0].length)
438
+ };
439
+ throw new TunestraError(`Invalid frontmatter in ${file}: expected key-value pairs`);
440
+ }
441
+ //#endregion
442
+ //#region src/presets/artifacts/commands.ts
443
+ async function loadCommands(dir) {
444
+ return mapSettled(await listFiles(dir, "commands/*.md"), (source) => loadCommand(dir, source));
445
+ }
446
+ async function loadCommand(dir, source) {
447
+ const file = join(dir, source);
448
+ const content = await readFile(file, "utf8");
449
+ const { data } = parseFrontmatter(content, file);
450
+ const frontmatter = validate(data, commandFrontmatterSchema, `frontmatter in ${file}`);
451
+ return {
452
+ key: basename(source, ".md"),
453
+ source,
454
+ description: frontmatter.description,
455
+ content
456
+ };
457
+ }
458
+ //#endregion
459
+ //#region src/presets/artifacts/mcp-servers.ts
460
+ async function loadMcpServers(dir) {
461
+ return mapSettled(await listFiles(dir, "mcp/*.yaml"), (source) => loadMcpServer(dir, source));
462
+ }
463
+ async function loadMcpServer(dir, source) {
464
+ const { description, ...config } = await readYaml(join(dir, source), mcpServerSchema, "MCP server");
465
+ return {
466
+ key: basename(source, ".yaml"),
467
+ source,
468
+ description,
469
+ config
470
+ };
471
+ }
472
+ //#endregion
473
+ //#region src/presets/artifacts/rules.ts
474
+ const INSTRUCTIONS_DESCRIPTION = "Main instructions of the level";
475
+ function buildInstructionsKey(presetName) {
476
+ return `instructions@${presetName}`;
477
+ }
478
+ /** The level instructions from AGENTS.md come first, then rules/*.md by name. */
479
+ async function loadRules(dir, presetName) {
480
+ const instructionsLoading = loadInstructions(dir, presetName);
481
+ const rulesLoading = mapSettled(await listFiles(dir, "rules/*.md"), (source) => loadRule(dir, source));
482
+ const results = await Promise.allSettled([instructionsLoading, rulesLoading]);
483
+ if (isEveryFulfilled(results) === false) throw buildResultsError(results);
484
+ const [instructionsResult, rulesResult] = results;
485
+ const instructions = instructionsResult.value;
486
+ const rules = rulesResult.value;
487
+ if (instructions === void 0) return rules;
488
+ return [instructions, ...rules];
489
+ }
490
+ async function loadInstructions(dir, presetName) {
491
+ const file = join(dir, INSTRUCTIONS_FILE);
492
+ if (await checkExists(file) === false) return;
493
+ const key = buildInstructionsKey(presetName);
494
+ const body = await readFile(file, "utf8");
495
+ return {
496
+ key,
497
+ source: INSTRUCTIONS_FILE,
498
+ description: INSTRUCTIONS_DESCRIPTION,
499
+ body
500
+ };
501
+ }
502
+ async function loadRule(dir, source) {
503
+ const file = join(dir, source);
504
+ const { data, body } = parseFrontmatter(await readFile(file, "utf8"), file);
505
+ const frontmatter = validate(data, ruleFrontmatterSchema, `frontmatter in ${file}`);
506
+ return {
507
+ key: basename(source, ".md"),
508
+ source,
509
+ description: frontmatter.description,
510
+ body
511
+ };
512
+ }
513
+ //#endregion
514
+ //#region src/presets/artifacts/skills.ts
515
+ async function loadSkills(dir) {
516
+ const skills = await mapSettled(await listFiles(dir, "skills/*/SKILL.md"), (manifest) => loadSkill(dir, manifest));
517
+ const problems = findDuplicateSkills(skills, dir);
518
+ if (problems.length > 0) throw createProblemsError(problems);
519
+ return skills;
520
+ }
521
+ async function loadSkill(dir, manifest) {
522
+ const file = join(dir, manifest);
523
+ const source = dirname(manifest);
524
+ const { data } = parseFrontmatter(await readFile(file, "utf8"), file);
525
+ const frontmatter = validate(data, skillFrontmatterSchema, `frontmatter in ${file}`);
526
+ const folderName = basename(source);
527
+ const key = frontmatter.name ?? validate(folderName, skillName, `skill folder ${source}`);
528
+ const files = await listAllFiles(join(dir, source));
529
+ return {
530
+ key,
531
+ source,
532
+ description: frontmatter.description,
533
+ files
534
+ };
535
+ }
536
+ function findDuplicateSkills(skills, dir) {
537
+ const sources = /* @__PURE__ */ new Map();
538
+ const problems = [];
539
+ for (const skill of skills) {
540
+ const duplicate = sources.get(skill.key);
541
+ if (typeof duplicate === "string") problems.push(`Skills ${duplicate} and ${skill.source} in ${dir} have the same name "${skill.key}"`);
542
+ sources.set(skill.key, skill.source);
543
+ }
544
+ return problems;
545
+ }
546
+ //#endregion
547
+ //#region src/presets/artifacts/load-artifacts.ts
548
+ /** Loads every kind of artifact at the same time and reports the problems of all of them. */
549
+ async function loadArtifacts(dir, presetName) {
550
+ const rulesLoading = loadRules(dir, presetName);
551
+ const skillsLoading = loadSkills(dir);
552
+ const commandsLoading = loadCommands(dir);
553
+ const mcpLoading = loadMcpServers(dir);
554
+ const results = await Promise.allSettled([
555
+ rulesLoading,
556
+ skillsLoading,
557
+ commandsLoading,
558
+ mcpLoading
559
+ ]);
560
+ if (isEveryFulfilled(results) === false) throw buildResultsError(results);
561
+ const [rulesResult, skillsResult, commandsResult, mcpResult] = results;
562
+ return {
563
+ rules: rulesResult.value,
564
+ skills: skillsResult.value,
565
+ commands: commandsResult.value,
566
+ mcp: mcpResult.value
567
+ };
568
+ }
569
+ //#endregion
570
+ //#region src/presets/load-project.ts
571
+ async function loadProject(dir) {
572
+ const root = resolve(dir);
573
+ const configPath = join(root, PROJECT_CONFIG);
574
+ if (await checkExists(configPath) === false) throw new TunestraError(`${PROJECT_CONFIG} not found in ${root}. Run \`tunestra init\` first`);
575
+ const config = await readYaml(configPath, projectConfigSchema, "project config");
576
+ const folderName = basename(root);
577
+ const name = config.name ?? folderName;
578
+ const artifactsDir = join(root, PROJECT_DIR);
579
+ const artifacts = await loadArtifacts(artifactsDir, name);
580
+ return {
581
+ ...config,
582
+ name,
583
+ level: Level.Project,
584
+ description: void 0,
585
+ dir: root,
586
+ artifactsDir,
587
+ remote: void 0,
588
+ ...artifacts
589
+ };
590
+ }
591
+ //#endregion
592
+ //#region src/shared/utils/paths.ts
593
+ /** Generated files always use `/`, so they are the same on every system. */
594
+ function toPosixPath(path) {
595
+ return path.split(sep).join("/");
596
+ }
597
+ function relativePosix(from, to) {
598
+ return toPosixPath(relative(from, to));
599
+ }
600
+ //#endregion
601
+ //#region src/sources/parse-git-source.ts
602
+ /**
603
+ * A reference to a preset in a git repository.
604
+ * Groups: 1 provider, 2 owner, 3 repository, 4 path inside it, 5 ref.
605
+ *
606
+ * Matches: "github:org/presets/team#v1" → github, org, presets, team, v1
607
+ * Matches: "gitlab:org/presets#main" → gitlab, org, presets, undefined, main
608
+ * Matches: "github:org/presets/a/b" → github, org, presets, a/b, undefined
609
+ * Does not match: "../presets/team" (a local path), "github:presets" (no owner)
610
+ */
611
+ const GIT_SOURCE_PATTERN = /^(github|gitlab):([^/#]+)\/([^/#]+)(?:\/([^#]+))?(?:#(.+))?$/;
612
+ /** `undefined` for a reference that is not a git source, like a relative path. */
613
+ function parseGitSource(ref) {
614
+ const match = GIT_SOURCE_PATTERN.exec(ref);
615
+ if (match === null) return;
616
+ const [, provider, owner, repo, path, gitRef] = match;
617
+ if ((provider === "github" || provider === "gitlab") && typeof owner === "string" && typeof repo === "string") return {
618
+ provider,
619
+ owner,
620
+ repo,
621
+ path: path ?? "",
622
+ ref: gitRef
623
+ };
624
+ }
625
+ /** The repository with its ref, without the path: `github:org/presets#v1`. */
626
+ function describeRepository(source) {
627
+ const repository = `${source.provider}:${source.owner}/${source.repo}`;
628
+ if (source.ref === void 0) return repository;
629
+ return `${repository}#${source.ref}`;
630
+ }
631
+ /** The full reference back: `github:org/presets/team#v1`. */
632
+ function describeGitSource(source) {
633
+ return `${`${source.provider}:${source.owner}/${source.repo}`}${source.path === "" ? "" : `/${source.path}`}${source.ref === void 0 ? "" : `#${source.ref}`}`;
634
+ }
635
+ //#endregion
636
+ //#region src/sources/download-repository.ts
637
+ /** Token for private repositories; tunestra's own name, whatever does the download. */
638
+ const GIT_TOKEN_VARIABLE = "TUNESTRA_GIT_TOKEN";
639
+ /** Downloads a repository as an archive over HTTPS, so git is not needed. */
640
+ const downloadRepository = async (source, dir) => {
641
+ const repository = describeRepository(source);
642
+ const token = process.env[GIT_TOKEN_VARIABLE];
643
+ const options = createDownloadOptions(dir, token);
644
+ try {
645
+ await downloadTemplate(repository, options);
646
+ } catch (error) {
647
+ throw new TunestraError(`Could not download ${repository}: ${getErrorMessage(error)}. Check the name and the ref; for a private repository, set ${GIT_TOKEN_VARIABLE} to a token with read access`);
648
+ }
649
+ };
650
+ /** The token is passed only when it is set. */
651
+ function createDownloadOptions(dir, token) {
652
+ const options = {
653
+ dir,
654
+ force: true,
655
+ forceClean: true,
656
+ silent: true
657
+ };
658
+ if (token === void 0) return options;
659
+ return {
660
+ ...options,
661
+ auth: token
662
+ };
663
+ }
664
+ //#endregion
665
+ //#region src/sources/create-source-resolver.ts
666
+ function createSourceResolver(projectDir, download = downloadRepository) {
667
+ const downloads = /* @__PURE__ */ new Map();
668
+ const repositories = [];
669
+ /** Each repository at each ref is downloaded once, however many presets come from it. */
670
+ const fetchRepository = (source) => {
671
+ const key = describeRepository(source);
672
+ const started = downloads.get(key);
673
+ if (typeof started === "object") return started;
674
+ const refName = source.ref ?? "HEAD";
675
+ const refFolder = encodeURIComponent(refName);
676
+ const root = join(projectDir, SOURCES_DIR, source.provider, source.owner, source.repo, refFolder);
677
+ const repository = {
678
+ root,
679
+ source
680
+ };
681
+ const downloading = download(source, root).then(() => repository);
682
+ repositories.push(repository);
683
+ downloads.set(key, downloading);
684
+ return downloading;
685
+ };
686
+ const findRepository = (dir) => {
687
+ return repositories.find((repository) => isInside(repository.root, dir));
688
+ };
689
+ const resolveRef = async (ref, fromDir) => {
690
+ const gitSource = parseGitSource(ref);
691
+ if (typeof gitSource === "object") {
692
+ const repository = await fetchRepository(gitSource);
693
+ return checkInside(repository, join(repository.root, gitSource.path), ref);
694
+ }
695
+ const dir = resolve(fromDir, ref);
696
+ const repository = findRepository(fromDir);
697
+ if (repository === void 0) return dir;
698
+ return checkInside(repository, dir, ref);
699
+ };
700
+ const describeRemote = (dir) => {
701
+ const repository = findRepository(dir);
702
+ if (repository === void 0) return;
703
+ const path = relative(repository.root, dir);
704
+ return describeGitSource({
705
+ ...repository.source,
706
+ path: toPosixPath(path)
707
+ });
708
+ };
709
+ return {
710
+ resolve: resolveRef,
711
+ describeRemote
712
+ };
713
+ }
714
+ /** A preset from git can only reach presets of the same repository with a relative path. */
715
+ function checkInside(repository, dir, ref) {
716
+ if (isInside(repository.root, dir)) return dir;
717
+ throw new TunestraError(`"${ref}" points outside of ${describeRepository(repository.source)}. A preset from git can only extend presets of the same repository or other git sources`);
718
+ }
719
+ function isInside(root, dir) {
720
+ const path = relative(root, dir);
721
+ const isParent = path === ".." || path.startsWith(`..${sep}`);
722
+ const isOtherDrive = isAbsolute(path);
723
+ return isParent === false && isOtherDrive === false;
724
+ }
725
+ //#endregion
726
+ //#region src/chain/check-chain.ts
727
+ /** A level can extend the same or a more general level, never a more specific one. */
728
+ function findLevelProblems(graph) {
729
+ const problems = [];
730
+ const links = [...graph.parents];
731
+ for (const [dir, parentDirs] of links) {
732
+ const child = graph.levels.get(dir);
733
+ if (child === void 0) continue;
734
+ for (const parentDir of parentDirs) {
735
+ const parent = graph.levels.get(parentDir);
736
+ if (parent === void 0) continue;
737
+ if (LEVEL_RANK[parent.level] < LEVEL_RANK[child.level]) problems.push(`${child.name} (${child.level}) extends ${parent.name} (${parent.level}), a more specific level. A level can only extend the same or a more general level`);
738
+ }
739
+ }
740
+ return problems;
741
+ }
742
+ /** Names identify levels in keys like `instructions@team`, so they must be unique. */
743
+ function findDuplicateNames(levels) {
744
+ const dirs = /* @__PURE__ */ new Map();
745
+ const problems = [];
746
+ for (const level of levels) {
747
+ const otherDir = dirs.get(level.name);
748
+ if (typeof otherDir === "string") problems.push(`Two levels have the same name "${level.name}": ${otherDir} and ${level.dir}`);
749
+ dirs.set(level.name, level.dir);
750
+ }
751
+ return problems;
752
+ }
753
+ //#endregion
754
+ //#region src/chain/linearize-chain.ts
755
+ /**
756
+ * Puts the levels in order with a depth-first walk from the project: parents first, in the
757
+ * order of `extends`, then the level itself. A level that is reached twice is added once.
758
+ */
759
+ function linearizeChain(graph) {
760
+ const levels = [];
761
+ const visited = /* @__PURE__ */ new Set();
762
+ const problems = /* @__PURE__ */ new Set();
763
+ const visit = (dir, path) => {
764
+ if (path.includes(dir)) {
765
+ const cycle = describeCycle(graph, path, dir);
766
+ problems.add(`Cycle in extends: ${cycle}`);
767
+ return;
768
+ }
769
+ if (visited.has(dir)) return;
770
+ const parentDirs = graph.parents.get(dir) ?? [];
771
+ const nextPath = [...path, dir];
772
+ for (const parentDir of parentDirs) visit(parentDir, nextPath);
773
+ visited.add(dir);
774
+ const level = graph.levels.get(dir);
775
+ if (level === void 0) return;
776
+ levels.push(level);
777
+ };
778
+ visit(graph.project.dir, []);
779
+ return {
780
+ levels,
781
+ problems: [...problems]
782
+ };
783
+ }
784
+ /** Names of the levels in the cycle, for example `team → base → team`. */
785
+ function describeCycle(graph, path, dir) {
786
+ const start = path.indexOf(dir);
787
+ const names = path.slice(start).map((cycleDir) => describeLevel(graph, cycleDir));
788
+ const repeatedName = describeLevel(graph, dir);
789
+ return [...names, repeatedName].join(" → ");
790
+ }
791
+ function describeLevel(graph, dir) {
792
+ return graph.levels.get(dir)?.name ?? dir;
793
+ }
794
+ //#endregion
795
+ //#region src/presets/load-preset.ts
796
+ async function loadPreset(dir) {
797
+ const root = resolve(dir);
798
+ const manifestPath = join(root, PRESET_MANIFEST);
799
+ if (await checkExists(manifestPath) === false) throw new TunestraError(`${root} is not a preset: ${PRESET_MANIFEST} is missing`);
800
+ const manifest = await readYaml(manifestPath, presetManifestSchema, "preset manifest");
801
+ const artifacts = await loadArtifacts(root, manifest.name);
802
+ return {
803
+ ...manifest,
804
+ description: manifest.description,
805
+ dir: root,
806
+ artifactsDir: root,
807
+ remote: void 0,
808
+ ...artifacts
809
+ };
810
+ }
811
+ //#endregion
812
+ //#region src/chain/load-preset-graph.ts
813
+ /**
814
+ * Loads the presets of the project, then the presets they extend, and so on. Each step loads
815
+ * its presets in parallel; a preset that several levels extend is loaded once.
816
+ */
817
+ async function loadPresetGraph(project, resolver) {
818
+ const levels = /* @__PURE__ */ new Map();
819
+ const parents = /* @__PURE__ */ new Map();
820
+ let pending = [project];
821
+ levels.set(project.dir, project);
822
+ while (pending.length > 0) {
823
+ const dirsToLoad = /* @__PURE__ */ new Set();
824
+ for (const preset of pending) {
825
+ const parentDirs = await mapSettled(preset.extends, (ref) => resolver.resolve(ref, preset.dir));
826
+ parents.set(preset.dir, parentDirs);
827
+ for (const parentDir of parentDirs) if (levels.has(parentDir) === false) dirsToLoad.add(parentDir);
828
+ }
829
+ pending = await mapSettled([...dirsToLoad], (dir) => loadRemotePreset(dir, resolver));
830
+ for (const preset of pending) levels.set(preset.dir, preset);
831
+ }
832
+ return {
833
+ project,
834
+ levels,
835
+ parents
836
+ };
837
+ }
838
+ async function loadRemotePreset(dir, resolver) {
839
+ const preset = await loadPreset(dir);
840
+ const remote = resolver.describeRemote(dir);
841
+ return {
842
+ ...preset,
843
+ remote
844
+ };
845
+ }
846
+ //#endregion
847
+ //#region src/chain/load-chain.ts
848
+ /** Loads the project with every preset it extends and puts them in order. */
849
+ async function loadChain(projectDir, options = {}) {
850
+ const project = await loadProject(projectDir);
851
+ const graph = await loadPresetGraph(project, createSourceResolver(project.dir, options.download));
852
+ const linearization = linearizeChain(graph);
853
+ const levelProblems = findLevelProblems(graph);
854
+ const nameProblems = findDuplicateNames(linearization.levels);
855
+ const problems = [
856
+ ...linearization.problems,
857
+ ...levelProblems,
858
+ ...nameProblems
859
+ ];
860
+ if (problems.length > 0) throw createProblemsError(problems);
861
+ return {
862
+ project,
863
+ levels: linearization.levels
864
+ };
865
+ }
866
+ //#endregion
867
+ //#region src/generate/describe-source.ts
868
+ /** Files built from the whole chain, like AGENTS.md: there is no single file to edit. */
869
+ const SETUP_SOURCE = "tunestra.yaml and its presets";
870
+ /**
871
+ * Where to edit an artifact, for messages to people: a path from the project, or the file
872
+ * in its git source, because the downloaded copy is replaced on every setup.
873
+ */
874
+ function describeArtifactSource(projectDir, origin, source) {
875
+ if (typeof origin.remote === "string") return `${source} in ${origin.remote}`;
876
+ return relativePosix(projectDir, join(origin.artifactsDir, source));
877
+ }
878
+ //#endregion
879
+ //#region src/shared/utils/outputs.ts
880
+ function createFileOutput(path, content, source) {
881
+ return {
882
+ kind: OutputKind.File,
883
+ path,
884
+ source,
885
+ content
886
+ };
887
+ }
888
+ //#endregion
889
+ //#region src/agents/claude/mcp-json.ts
890
+ /** `.mcp.json` in the shape Claude Code reads: `{ "mcpServers": { "<key>": config } }`. */
891
+ function renderMcpJson(servers) {
892
+ const entries = servers.map((server) => [server.artifact.key, server.artifact.config]);
893
+ const content = { mcpServers: Object.fromEntries(entries) };
894
+ return `${JSON.stringify(content, null, 2)}\n`;
895
+ }
896
+ //#endregion
897
+ //#region src/agents/claude/claude-adapter.ts
898
+ const CLAUDE_FILE = "CLAUDE.md";
899
+ const RULES_DIR = ".claude/rules/tunestra";
900
+ const SKILLS_DIR = ".claude/skills";
901
+ const COMMANDS_DIR = ".claude/commands";
902
+ const MCP_FILE = ".mcp.json";
903
+ const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
904
+ /**
905
+ * Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
906
+ * which Claude Code loads in every session; a symlink to a directory outside `.claude`
907
+ * would need an approval. Skills are symlinks to the shared `.agents/skills`.
908
+ */
909
+ const claudeAdapter = {
910
+ agent: Agent.Claude,
911
+ plan: planClaude
912
+ };
913
+ function planClaude(context) {
914
+ const { resolution, ruleFiles } = context;
915
+ const projectDir = resolution.chain.project.dir;
916
+ const claudeContent = `${GENERATED_NOTICE}\n@${INSTRUCTIONS_FILE}\n`;
917
+ const claudeFile = createFileOutput(CLAUDE_FILE, claudeContent, SETUP_SOURCE);
918
+ const ruleCopies = ruleFiles.map(copyRule);
919
+ const skillLinks = resolution.skills.map((skill) => linkSkill(projectDir, skill));
920
+ const commandFiles = resolution.commands.map((command) => {
921
+ const path = `${COMMANDS_DIR}/${command.artifact.key}.md`;
922
+ const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
923
+ return createFileOutput(path, command.artifact.content, source);
924
+ });
925
+ const outputs = [
926
+ claudeFile,
927
+ ...ruleCopies,
928
+ ...skillLinks,
929
+ ...commandFiles
930
+ ];
931
+ if (resolution.mcp.length > 0) {
932
+ const mcpJson = renderMcpJson(resolution.mcp);
933
+ const mcpFile = createFileOutput(MCP_FILE, mcpJson, MCP_SOURCE);
934
+ outputs.push(mcpFile);
935
+ }
936
+ return {
937
+ outputs,
938
+ ignoredPaths: [
939
+ `${RULES_DIR}/`,
940
+ `${SKILLS_DIR}/`,
941
+ `${COMMANDS_DIR}/`,
942
+ MCP_FILE
943
+ ],
944
+ protectedPaths: [CLAUDE_FILE]
945
+ };
946
+ }
947
+ function copyRule(ruleFile) {
948
+ const fileName = posix.basename(ruleFile.path);
949
+ return createFileOutput(`${RULES_DIR}/${fileName}`, ruleFile.content, ruleFile.source);
950
+ }
951
+ function linkSkill(projectDir, skill) {
952
+ const { key } = skill.artifact;
953
+ const path = `${SKILLS_DIR}/${key}`;
954
+ const target = `../../${AGENTS_SKILLS_DIR}/${key}`;
955
+ const source = describeArtifactSource(projectDir, skill.origin, skill.artifact.source);
956
+ return {
957
+ kind: OutputKind.Symlink,
958
+ path,
959
+ source,
960
+ target
961
+ };
962
+ }
963
+ //#endregion
964
+ //#region src/agents/adapters.ts
965
+ /** Every supported agent; Codex comes in v0.2. */
966
+ const AGENT_ADAPTERS = { [Agent.Claude]: claudeAdapter };
967
+ //#endregion
968
+ //#region src/shared/utils/markdown.ts
969
+ /**
970
+ * A pipe or a line break inside a cell would break the table.
971
+ *
972
+ * Matches: "|" in "a | b", "\n" and "\r\n" in "line one\nline two"
973
+ */
974
+ const TABLE_BREAKING_PATTERN = /\||\r?\n/g;
975
+ function renderTable(header, rows) {
976
+ return [
977
+ renderRow(header),
978
+ `|${header.map(() => "---").join("|")}|`,
979
+ ...rows.map(renderRow)
980
+ ].join("\n");
981
+ }
982
+ function renderRow(cells) {
983
+ return `| ${cells.map(escapeCell).join(" | ")} |`;
984
+ }
985
+ function escapeCell(cell) {
986
+ return cell.replace(TABLE_BREAKING_PATTERN, replaceTableBreaking);
987
+ }
988
+ function replaceTableBreaking(match) {
989
+ if (match === "|") return "\\|";
990
+ return " ";
991
+ }
992
+ //#endregion
993
+ //#region src/generate/describe-origin.ts
994
+ /** For example `team, overrides base` or `base, patched by team`. */
995
+ function describeOrigin(resolved) {
996
+ const parts = [resolved.origin.level];
997
+ const hasOverrides = resolved.overrides.length > 0;
998
+ const isPatched = resolved.patchedBy.length > 0;
999
+ if (hasOverrides) {
1000
+ const names = joinNames(resolved.overrides);
1001
+ parts.push(`overrides ${names}`);
1002
+ }
1003
+ if (isPatched) {
1004
+ const names = joinNames(resolved.patchedBy);
1005
+ parts.push(`patched by ${names}`);
1006
+ }
1007
+ return parts.join(", ");
1008
+ }
1009
+ function joinNames(origins) {
1010
+ return origins.map((origin) => origin.name).join(", ");
1011
+ }
1012
+ //#endregion
1013
+ //#region src/generate/rule-file.ts
1014
+ /**
1015
+ * `<rank>-<level>-<key>.md`, so that a plain sort puts specific rules first. Level
1016
+ * instructions become `instructions`, with the preset name when it differs from the level.
1017
+ */
1018
+ function buildRuleFileName(rule) {
1019
+ const rank = LEVEL_RANK[rule.origin.level];
1020
+ const slug = buildRuleSlug(rule);
1021
+ return `${rank}-${rule.origin.level}-${slug}.md`;
1022
+ }
1023
+ /** The rule text with a header that tells where it comes from and what it overrides. */
1024
+ function renderRuleFile(rule) {
1025
+ const sourceFile = join(rule.origin.artifactsDir, rule.artifact.source);
1026
+ const sourcePath = relativePosix(rule.origin.dir, sourceFile);
1027
+ const levelLine = renderLevelLine(rule);
1028
+ return [
1029
+ `${GENERATED_MARKER} from ${rule.origin.name}:${sourcePath}. Do not edit. -->`,
1030
+ levelLine,
1031
+ "",
1032
+ rule.artifact.body
1033
+ ].join("\n");
1034
+ }
1035
+ function buildRuleSlug(rule) {
1036
+ const instructionsKey = buildInstructionsKey(rule.origin.name);
1037
+ const isInstructions = rule.artifact.key === instructionsKey;
1038
+ const isNamedAsLevel = rule.origin.name === rule.origin.level;
1039
+ if (isInstructions === false) return rule.artifact.key;
1040
+ if (isNamedAsLevel) return "instructions";
1041
+ return `instructions-${rule.origin.name}`;
1042
+ }
1043
+ /** For example `> Level: team, preset: team. Overrides: base.` */
1044
+ function renderLevelLine(rule) {
1045
+ const line = `> Level: ${rule.origin.level}, preset: ${rule.origin.name}.`;
1046
+ if (rule.overrides.length > 0) return `${line} Overrides: ${joinNames(rule.overrides)}.`;
1047
+ return line;
1048
+ }
1049
+ //#endregion
1050
+ //#region src/generate/index-sections.ts
1051
+ const HIERARCHY_INTRO = "Instructions are ordered from specific to general. When two instructions conflict, follow the one from the higher level in this list.";
1052
+ const RULES_INTRO = "Always follow these rules. Claude Code loads them automatically; other agents should read them before starting work.";
1053
+ const SKILLS_INTRO = "Use a skill when the task matches its description.";
1054
+ const CHANGING_SECTION = [
1055
+ "## Changing this setup",
1056
+ "",
1057
+ "Do not edit this file or the generated directories. Edit the sources (`tunestra.yaml`, `.tunestra/` or the presets above) and run `tunestra setup`."
1058
+ ].join("\n");
1059
+ function renderHierarchy(chain) {
1060
+ const table = renderTable([
1061
+ "#",
1062
+ "Level",
1063
+ "Preset",
1064
+ "Source"
1065
+ ], chain.levels.toReversed().map((level, index) => {
1066
+ const number = String(index + 1);
1067
+ const source = describeLevelSource(chain, level);
1068
+ return [
1069
+ number,
1070
+ level.level,
1071
+ level.name,
1072
+ source
1073
+ ];
1074
+ }));
1075
+ return renderSection("Hierarchy", HIERARCHY_INTRO, table);
1076
+ }
1077
+ function renderRules(rules) {
1078
+ const table = renderTable([
1079
+ "Rule",
1080
+ "Level",
1081
+ "Description",
1082
+ "File"
1083
+ ], rules.map((rule) => {
1084
+ const origin = describeOrigin(rule);
1085
+ const description = rule.artifact.description ?? "";
1086
+ const fileName = buildRuleFileName(rule);
1087
+ return [
1088
+ rule.artifact.key,
1089
+ origin,
1090
+ description,
1091
+ `${AGENTS_RULES_DIR}/${fileName}`
1092
+ ];
1093
+ }));
1094
+ return renderSection("Rules", RULES_INTRO, table);
1095
+ }
1096
+ function renderSkills(skills) {
1097
+ const table = renderTable([
1098
+ "Skill",
1099
+ "Level",
1100
+ "When to use",
1101
+ "File"
1102
+ ], skills.map((skill) => {
1103
+ const origin = describeOrigin(skill);
1104
+ const file = `${AGENTS_SKILLS_DIR}/${skill.artifact.key}/SKILL.md`;
1105
+ return [
1106
+ skill.artifact.key,
1107
+ origin,
1108
+ skill.artifact.description,
1109
+ file
1110
+ ];
1111
+ }));
1112
+ return renderSection("Skills", SKILLS_INTRO, table);
1113
+ }
1114
+ function renderCommands(commands) {
1115
+ return renderSection("Commands", void 0, renderTable([
1116
+ "Command",
1117
+ "Level",
1118
+ "Description"
1119
+ ], commands.map((command) => {
1120
+ const origin = describeOrigin(command);
1121
+ const description = command.artifact.description ?? "";
1122
+ return [
1123
+ `/${command.artifact.key}`,
1124
+ origin,
1125
+ description
1126
+ ];
1127
+ })));
1128
+ }
1129
+ function renderMcpServers(servers) {
1130
+ return renderSection("MCP servers", void 0, renderTable([
1131
+ "Server",
1132
+ "Level",
1133
+ "Description"
1134
+ ], servers.map((server) => {
1135
+ const origin = describeOrigin(server);
1136
+ const description = server.artifact.description ?? "";
1137
+ return [
1138
+ server.artifact.key,
1139
+ origin,
1140
+ description
1141
+ ];
1142
+ })));
1143
+ }
1144
+ /** The project shows its config file; a preset shows its git reference or its path. */
1145
+ function describeLevelSource(chain, level) {
1146
+ if (level.dir === chain.project.dir) return PROJECT_CONFIG;
1147
+ if (typeof level.remote === "string") return level.remote;
1148
+ return relativePosix(chain.project.dir, level.dir);
1149
+ }
1150
+ function renderSection(title, intro, table) {
1151
+ const heading = `## ${title}`;
1152
+ if (intro === void 0) return [
1153
+ heading,
1154
+ "",
1155
+ table
1156
+ ].join("\n");
1157
+ return [
1158
+ heading,
1159
+ "",
1160
+ intro,
1161
+ "",
1162
+ table
1163
+ ].join("\n");
1164
+ }
1165
+ //#endregion
1166
+ //#region src/generate/agents-index.ts
1167
+ /**
1168
+ * AGENTS.md: an index of the setup, not the rules themselves. It lists the levels, every
1169
+ * rule, skill, command and MCP server, where each comes from and where its file is.
1170
+ */
1171
+ function renderAgentsIndex(resolution) {
1172
+ const title = `# AI agent context: ${resolution.chain.project.name}`;
1173
+ const hierarchy = renderHierarchy(resolution.chain);
1174
+ const sections = [
1175
+ GENERATED_NOTICE,
1176
+ title,
1177
+ hierarchy
1178
+ ];
1179
+ const hasRules = resolution.rules.length > 0;
1180
+ const hasSkills = resolution.skills.length > 0;
1181
+ const hasCommands = resolution.commands.length > 0;
1182
+ const hasMcpServers = resolution.mcp.length > 0;
1183
+ if (hasRules) {
1184
+ const rules = renderRules(resolution.rules);
1185
+ sections.push(rules);
1186
+ }
1187
+ if (hasSkills) {
1188
+ const skills = renderSkills(resolution.skills);
1189
+ sections.push(skills);
1190
+ }
1191
+ if (hasCommands) {
1192
+ const commands = renderCommands(resolution.commands);
1193
+ sections.push(commands);
1194
+ }
1195
+ if (hasMcpServers) {
1196
+ const servers = renderMcpServers(resolution.mcp);
1197
+ sections.push(servers);
1198
+ }
1199
+ sections.push(CHANGING_SECTION);
1200
+ return `${sections.join("\n\n")}\n`;
1201
+ }
1202
+ //#endregion
1203
+ //#region src/generate/plan-setup.ts
1204
+ /**
1205
+ * Builds every output without touching the disk: the shared files that any agent can read
1206
+ * (AGENTS.md, `.agents/`), then the native files of each agent of the project.
1207
+ */
1208
+ function planSetup(resolution) {
1209
+ const projectDir = resolution.chain.project.dir;
1210
+ const index = renderAgentsIndex(resolution);
1211
+ const indexFile = createFileOutput(INSTRUCTIONS_FILE, index, SETUP_SOURCE);
1212
+ const ruleFiles = resolution.rules.map((rule) => planRuleFile(projectDir, rule));
1213
+ const skillCopies = resolution.skills.flatMap((skill) => planSkillCopies(projectDir, skill));
1214
+ const outputs = [
1215
+ indexFile,
1216
+ ...ruleFiles,
1217
+ ...skillCopies
1218
+ ];
1219
+ const ignoredPaths = [`${AGENTS_DIR}/`];
1220
+ const protectedPaths = [INSTRUCTIONS_FILE];
1221
+ const context = {
1222
+ resolution,
1223
+ ruleFiles
1224
+ };
1225
+ for (const agent of resolution.chain.project.agents) {
1226
+ const agentPlan = AGENT_ADAPTERS[agent].plan(context);
1227
+ outputs.push(...agentPlan.outputs);
1228
+ ignoredPaths.push(...agentPlan.ignoredPaths);
1229
+ protectedPaths.push(...agentPlan.protectedPaths);
1230
+ }
1231
+ ignoredPaths.push(`${STATE_DIR}/`);
1232
+ const problems = findPathConflicts(outputs);
1233
+ if (problems.length > 0) throw createProblemsError(problems);
1234
+ return {
1235
+ outputs,
1236
+ ignoredPaths,
1237
+ protectedPaths
1238
+ };
1239
+ }
1240
+ function planRuleFile(projectDir, rule) {
1241
+ const fileName = buildRuleFileName(rule);
1242
+ return createFileOutput(`${AGENTS_RULES_DIR}/${fileName}`, renderRuleFile(rule), describeArtifactSource(projectDir, rule.origin, rule.artifact.source));
1243
+ }
1244
+ /** The skill is copied as a whole, with every file of its directory. */
1245
+ function planSkillCopies(projectDir, skill) {
1246
+ const sourceDir = join(skill.origin.artifactsDir, skill.artifact.source);
1247
+ const outputDir = `${AGENTS_SKILLS_DIR}/${skill.artifact.key}`;
1248
+ return skill.artifact.files.map((file) => {
1249
+ const path = `${outputDir}/${file}`;
1250
+ const from = join(sourceDir, file);
1251
+ const source = relativePosix(projectDir, from);
1252
+ return {
1253
+ kind: OutputKind.Copy,
1254
+ path,
1255
+ source,
1256
+ from
1257
+ };
1258
+ });
1259
+ }
1260
+ /** Two outputs at one path would silently overwrite each other. */
1261
+ function findPathConflicts(outputs) {
1262
+ const seen = /* @__PURE__ */ new Set();
1263
+ const problems = [];
1264
+ for (const output of outputs) {
1265
+ if (seen.has(output.path)) problems.push(`Two generated files have the same path: ${output.path}`);
1266
+ seen.add(output.path);
1267
+ }
1268
+ return problems;
1269
+ }
1270
+ //#endregion
1271
+ //#region src/shared/constants/artifacts.ts
1272
+ /** Kinds of artifacts; the values match the folder-based fields of a preset and of `exclude`. */
1273
+ const ArtifactKind = {
1274
+ Rules: "rules",
1275
+ Skills: "skills",
1276
+ Commands: "commands",
1277
+ Mcp: "mcp"
1278
+ };
1279
+ //#endregion
1280
+ //#region src/inheritance/inherit-artifacts.ts
1281
+ /**
1282
+ * Walks the levels from general to specific. On each level: drop the excluded keys, patch
1283
+ * the inherited artifacts, then add the level's own artifacts; the same key replaces the
1284
+ * inherited one.
1285
+ */
1286
+ function inheritArtifacts(levels, kind, selectArtifacts, patchArtifacts = skipPatches) {
1287
+ const inherited = /* @__PURE__ */ new Map();
1288
+ const problems = [];
1289
+ for (const level of levels) {
1290
+ const excludeProblems = excludeArtifacts(inherited, level, kind);
1291
+ const patchProblems = patchArtifacts(inherited, level);
1292
+ const ownArtifacts = selectArtifacts(level);
1293
+ const origin = createOrigin(level);
1294
+ problems.push(...excludeProblems, ...patchProblems);
1295
+ for (const artifact of ownArtifacts) addArtifact(inherited, artifact, origin);
1296
+ }
1297
+ return {
1298
+ artifacts: orderFromSpecific(levels, inherited, selectArtifacts),
1299
+ problems
1300
+ };
1301
+ }
1302
+ function createOrigin(level) {
1303
+ return {
1304
+ name: level.name,
1305
+ level: level.level,
1306
+ dir: level.dir,
1307
+ artifactsDir: level.artifactsDir,
1308
+ remote: level.remote
1309
+ };
1310
+ }
1311
+ function skipPatches() {
1312
+ return [];
1313
+ }
1314
+ function excludeArtifacts(inherited, level, kind) {
1315
+ const keys = level.exclude[kind] ?? [];
1316
+ const problems = [];
1317
+ for (const key of keys) if (inherited.delete(key) === false) problems.push(`${level.name}: exclude.${kind} lists "${key}", but no more general level has it`);
1318
+ return problems;
1319
+ }
1320
+ function addArtifact(inherited, artifact, origin) {
1321
+ const overrides = collectOverrides(inherited.get(artifact.key));
1322
+ inherited.set(artifact.key, {
1323
+ artifact,
1324
+ origin,
1325
+ overrides,
1326
+ patchedBy: []
1327
+ });
1328
+ }
1329
+ function collectOverrides(previous) {
1330
+ if (previous === void 0) return [];
1331
+ return [...previous.overrides, previous.origin];
1332
+ }
1333
+ /** Specific levels first, so that agents read the rules that win first. */
1334
+ function orderFromSpecific(levels, inherited, selectArtifacts) {
1335
+ const ordered = [];
1336
+ const specificFirst = levels.toReversed();
1337
+ for (const level of specificFirst) {
1338
+ const ownArtifacts = selectArtifacts(level);
1339
+ for (const artifact of ownArtifacts) {
1340
+ const resolved = inherited.get(artifact.key);
1341
+ if (resolved?.origin.name === level.name) ordered.push(resolved);
1342
+ }
1343
+ }
1344
+ return ordered;
1345
+ }
1346
+ //#endregion
1347
+ //#region src/shared/utils/records.ts
1348
+ /** Merges two string maps; keys of `patch` win. Missing on both sides stays missing. */
1349
+ function mergeRecords(base, patch) {
1350
+ if (base === void 0 && patch === void 0) return;
1351
+ return {
1352
+ ...base,
1353
+ ...patch
1354
+ };
1355
+ }
1356
+ //#endregion
1357
+ //#region src/inheritance/patch-mcp-config.ts
1358
+ /** `env` and `headers` are merged by key; `command`, `args` and `url` are replaced. */
1359
+ function patchMcpConfig(config, patch) {
1360
+ const command = patch.command ?? config.command;
1361
+ const args = patch.args ?? config.args;
1362
+ const url = patch.url ?? config.url;
1363
+ const env = mergeRecords(config.env, patch.env);
1364
+ const headers = mergeRecords(config.headers, patch.headers);
1365
+ return {
1366
+ ...config,
1367
+ command,
1368
+ args,
1369
+ url,
1370
+ env,
1371
+ headers
1372
+ };
1373
+ }
1374
+ //#endregion
1375
+ //#region src/inheritance/patch-mcp-servers.ts
1376
+ /** Applies `overrides.mcp` of a level to the MCP servers it inherits. */
1377
+ const patchMcpServers = (inherited, level) => {
1378
+ const patches = level.overrides.mcp ?? {};
1379
+ const patchEntries = Object.entries(patches);
1380
+ const problems = [];
1381
+ for (const [key, patch] of patchEntries) {
1382
+ const server = inherited.get(key);
1383
+ if (server === void 0) {
1384
+ problems.push(`${level.name}: overrides.mcp lists "${key}", but no more general level has this MCP server`);
1385
+ continue;
1386
+ }
1387
+ const patched = applyPatch(server, level, patch);
1388
+ if (mcpServerSchema.safeParse(patched.artifact.config).success === false) problems.push(`${level.name}: after the patch, MCP server "${key}" must have either "command" or "url"`);
1389
+ inherited.set(key, patched);
1390
+ }
1391
+ return problems;
1392
+ };
1393
+ function applyPatch(server, level, patch) {
1394
+ const config = patchMcpConfig(server.artifact.config, patch);
1395
+ const origin = createOrigin(level);
1396
+ const artifact = {
1397
+ ...server.artifact,
1398
+ config
1399
+ };
1400
+ const patchedBy = [...server.patchedBy, origin];
1401
+ return {
1402
+ ...server,
1403
+ artifact,
1404
+ patchedBy
1405
+ };
1406
+ }
1407
+ //#endregion
1408
+ //#region src/inheritance/apply-inheritance.ts
1409
+ /** Resolves every kind of artifact along the chain and reports all problems together. */
1410
+ function applyInheritance(chain) {
1411
+ const rules = inheritArtifacts(chain.levels, ArtifactKind.Rules, (level) => level.rules);
1412
+ const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
1413
+ const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
1414
+ const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
1415
+ const problems = [
1416
+ ...rules.problems,
1417
+ ...skills.problems,
1418
+ ...commands.problems,
1419
+ ...mcp.problems
1420
+ ];
1421
+ if (problems.length > 0) throw createProblemsError(problems);
1422
+ return {
1423
+ chain,
1424
+ rules: rules.artifacts,
1425
+ skills: skills.artifacts,
1426
+ commands: commands.artifacts,
1427
+ mcp: mcp.artifacts
1428
+ };
1429
+ }
1430
+ //#endregion
1431
+ //#region src/shared/schemas/state.ts
1432
+ const fileRecordSchema = z.strictObject({ hash: z.string() });
1433
+ const symlinkRecordSchema = z.strictObject({ target: z.string() });
1434
+ const stateSchema = z.strictObject({
1435
+ version: z.literal(1),
1436
+ outputs: z.record(z.string(), z.union([fileRecordSchema, symlinkRecordSchema]))
1437
+ });
1438
+ //#endregion
1439
+ //#region src/state/read-state.ts
1440
+ /** The state of the last setup; a project that never ran setup has an empty one. */
1441
+ async function readState(projectDir) {
1442
+ const file = join(projectDir, STATE_FILE);
1443
+ const content = await readFileIfExists(file);
1444
+ if (content === void 0) return createEmptyState();
1445
+ return validate(parseJson(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
1446
+ }
1447
+ function createEmptyState() {
1448
+ return {
1449
+ version: 1,
1450
+ outputs: {}
1451
+ };
1452
+ }
1453
+ function parseJson(content, file) {
1454
+ try {
1455
+ return JSON.parse(content);
1456
+ } catch (error) {
1457
+ throw new TunestraError(`Invalid state file ${file}: ${getErrorMessage(error)}. Delete it and run tunestra setup again`);
1458
+ }
1459
+ }
1460
+ //#endregion
1461
+ //#region src/setup/write-outputs.ts
1462
+ /** Writes every output in parallel and reports the problems of all of them together. */
1463
+ async function writeOutputs(projectDir, outputs) {
1464
+ await mapSettled(outputs, (output) => writeOutput(projectDir, output));
1465
+ }
1466
+ async function writeOutput(projectDir, output) {
1467
+ const path = join(projectDir, output.path);
1468
+ const dir = dirname(path);
1469
+ await mkdir(dir, { recursive: true });
1470
+ switch (output.kind) {
1471
+ case OutputKind.File:
1472
+ await removeSymlink(path);
1473
+ await writeFile(path, output.content);
1474
+ return;
1475
+ case OutputKind.Copy:
1476
+ await removeSymlink(path);
1477
+ await copyFile(output.from, path);
1478
+ return;
1479
+ case OutputKind.Symlink:
1480
+ await replaceSymlink(path, output);
1481
+ return;
1482
+ }
1483
+ }
1484
+ /** Replaces only a symlink: a real file or directory at this path belongs to someone else. */
1485
+ async function replaceSymlink(path, output) {
1486
+ const pathType = await getPathType(path);
1487
+ if (pathType === PathType.Directory || pathType === PathType.File) throw new TunestraError(`${output.path} already exists and was not created by tunestra. Move it away and run tunestra setup again`);
1488
+ if (pathType === PathType.Symlink) await unlink(path);
1489
+ await symlink(output.target, path);
1490
+ }
1491
+ /** Writing a file through a symlink would change the file it points to. */
1492
+ async function removeSymlink(path) {
1493
+ if (await getPathType(path) === PathType.Symlink) await unlink(path);
1494
+ }
1495
+ //#endregion
1496
+ //#region src/state/write-state.ts
1497
+ /** Paths are sorted, so that the file only changes when the outputs change. */
1498
+ async function writeState(projectDir, state) {
1499
+ const file = join(projectDir, STATE_FILE);
1500
+ const dir = dirname(file);
1501
+ const entries = Object.keys(state.outputs).sort().map((path) => [path, state.outputs[path]]);
1502
+ const outputs = Object.fromEntries(entries);
1503
+ const content = {
1504
+ version: state.version,
1505
+ outputs
1506
+ };
1507
+ const text = `${JSON.stringify(content, null, 2)}\n`;
1508
+ await mkdir(dir, { recursive: true });
1509
+ await writeFile(file, text);
1510
+ }
1511
+ //#endregion
1512
+ //#region src/sync/remove-outputs.ts
1513
+ /** Removes stale outputs, then the directories that they leave empty. */
1514
+ async function removeOutputs(projectDir, paths) {
1515
+ await mapSettled(paths, (path) => removeOutput(projectDir, path));
1516
+ for (const path of paths) await removeEmptyParents(projectDir, path);
1517
+ }
1518
+ async function removeOutput(projectDir, path) {
1519
+ const fullPath = join(projectDir, path);
1520
+ await rm(fullPath, { force: true });
1521
+ }
1522
+ /** Goes up from the removed path until a directory has other files or the project is reached. */
1523
+ async function removeEmptyParents(projectDir, path) {
1524
+ let dir = dirname(path);
1525
+ while (dir !== ".") {
1526
+ const fullDir = join(projectDir, dir);
1527
+ if ((await readEntries(fullDir))?.length === 0 === false) return;
1528
+ await rmdir(fullDir);
1529
+ dir = dirname(dir);
1530
+ }
1531
+ }
1532
+ /** A directory that is already gone counts as not empty: there is nothing to remove. */
1533
+ async function readEntries(dir) {
1534
+ try {
1535
+ return await readdir(dir);
1536
+ } catch (error) {
1537
+ if (isMissingPathError(error)) return;
1538
+ throw error;
1539
+ }
1540
+ }
1541
+ //#endregion
1542
+ //#region src/sync/apply-sync.ts
1543
+ /** Removes stale outputs first, so that a file can become a directory and back. */
1544
+ async function applySync(projectDir, sync) {
1545
+ await removeOutputs(projectDir, sync.removals);
1546
+ await writeOutputs(projectDir, sync.outputs);
1547
+ await writeState(projectDir, sync.state);
1548
+ }
1549
+ //#endregion
1550
+ //#region src/shared/utils/hash.ts
1551
+ function hashContent(content) {
1552
+ const hash = createHash("sha256");
1553
+ hash.update(content);
1554
+ return hash.digest("hex");
1555
+ }
1556
+ //#endregion
1557
+ //#region src/sync/create-record.ts
1558
+ /** How an output will look on the disk once it is written. */
1559
+ async function createRecord(output) {
1560
+ switch (output.kind) {
1561
+ case OutputKind.File: return { hash: hashContent(output.content) };
1562
+ case OutputKind.Copy: return { hash: hashContent(await readFile(output.from)) };
1563
+ case OutputKind.Symlink: return { target: output.target };
1564
+ }
1565
+ }
1566
+ //#endregion
1567
+ //#region src/sync/inspect-path.ts
1568
+ async function inspectPath(path) {
1569
+ const type = await getPathType(path);
1570
+ if (type === PathType.Symlink) return {
1571
+ type,
1572
+ target: await readlink(path)
1573
+ };
1574
+ if (type === PathType.File) {
1575
+ const content = await readFile(path);
1576
+ return {
1577
+ type,
1578
+ hash: hashContent(content),
1579
+ isMarked: content.subarray(0, 26).toString("utf8") === GENERATED_MARKER
1580
+ };
1581
+ }
1582
+ return { type };
1583
+ }
1584
+ /** A file matches a record by its hash, a symlink by its target. */
1585
+ function matchesRecord(entry, record) {
1586
+ if (entry.type === PathType.File && "hash" in record) return entry.hash === record.hash;
1587
+ if (entry.type === PathType.Symlink && "target" in record) return entry.target === record.target;
1588
+ return false;
1589
+ }
1590
+ //#endregion
1591
+ //#region src/sync/check-output.ts
1592
+ /**
1593
+ * Compares an output with the disk and the last setup. A file that tunestra wrote and
1594
+ * someone edited is replaced with a warning; a file that tunestra never wrote stops setup.
1595
+ */
1596
+ async function checkOutput(output, context) {
1597
+ const path = join(context.projectDir, output.path);
1598
+ const record = await createRecord(output);
1599
+ const disk = await inspectPath(path);
1600
+ const previousRecord = context.previous.outputs[output.path];
1601
+ if (isSafeToWrite(disk, record, previousRecord)) return createCheck(output, record, void 0);
1602
+ const isRecorded = typeof previousRecord === "object";
1603
+ const isReplaceable = disk.type === PathType.File || disk.type === PathType.Symlink;
1604
+ if (isRecorded && isReplaceable) return createCheck(output, record, `${output.path} was edited by hand; tunestra replaced it. Edit ${output.source} instead`);
1605
+ if (context.protectedPaths.includes(output.path)) throw new TunestraError(`${output.path} was written by hand, and setup would overwrite it. Move its text to .tunestra/AGENTS.md or to a rule in .tunestra/rules/, then delete ${output.path} and run tunestra setup again`);
1606
+ throw new TunestraError(`${output.path} already exists, and tunestra did not create it. Move it away and run tunestra setup again`);
1607
+ }
1608
+ /**
1609
+ * Nothing to lose: the path is empty, already has the new content, still has what tunestra
1610
+ * wrote last time, or is a generated markdown file whose record was lost.
1611
+ */
1612
+ function isSafeToWrite(disk, record, previousRecord) {
1613
+ if (disk.type === PathType.Missing) return true;
1614
+ if (matchesRecord(disk, record)) return true;
1615
+ if (previousRecord === void 0) return disk.type === PathType.File && disk.isMarked;
1616
+ return matchesRecord(disk, previousRecord);
1617
+ }
1618
+ function createCheck(output, record, warning) {
1619
+ return {
1620
+ path: output.path,
1621
+ record,
1622
+ warning
1623
+ };
1624
+ }
1625
+ //#endregion
1626
+ //#region src/sync/check-stale-output.ts
1627
+ /**
1628
+ * A path that the last setup wrote and this one does not. Generated files are never kept:
1629
+ * an edited one is removed too, with a warning, so that the lost edit does not go unnoticed.
1630
+ * Only a directory is left alone, because tunestra never writes directories in its place.
1631
+ */
1632
+ async function checkStaleOutput(projectDir, path, record) {
1633
+ const disk = await inspectPath(join(projectDir, path));
1634
+ const isMissing = disk.type === PathType.Missing;
1635
+ const isDirectory = disk.type === PathType.Directory;
1636
+ const isUnchanged = matchesRecord(disk, record);
1637
+ if (isMissing) return createStaleCheck(path, false, void 0);
1638
+ if (isDirectory) return createStaleCheck(path, false, `${path} is no longer generated, but it is a directory now, so tunestra left it`);
1639
+ if (isUnchanged) return createStaleCheck(path, true, void 0);
1640
+ return createStaleCheck(path, true, `${path} is no longer generated and was edited by hand; tunestra removed it`);
1641
+ }
1642
+ function createStaleCheck(path, isRemovable, warning) {
1643
+ return {
1644
+ path,
1645
+ isRemovable,
1646
+ warning
1647
+ };
1648
+ }
1649
+ //#endregion
1650
+ //#region src/sync/plan-sync.ts
1651
+ /** Reads the disk, but writes nothing; problems of every output are reported together. */
1652
+ async function planSync(projectDir, plan, previous) {
1653
+ const context = {
1654
+ projectDir,
1655
+ previous,
1656
+ protectedPaths: plan.protectedPaths
1657
+ };
1658
+ const paths = plan.outputs.map((output) => output.path);
1659
+ const outputPaths = new Set(paths);
1660
+ const staleEntries = Object.entries(previous.outputs).filter(([path]) => outputPaths.has(path) === false);
1661
+ const outputChecks = await mapSettled(plan.outputs, (output) => checkOutput(output, context));
1662
+ const staleChecks = await mapSettled(staleEntries, ([path, record]) => checkStaleOutput(projectDir, path, record));
1663
+ const removals = staleChecks.filter((check) => check.isRemovable).map((check) => check.path);
1664
+ const warnings = [...outputChecks, ...staleChecks].map((check) => check.warning).filter(isString);
1665
+ const recordEntries = outputChecks.map((check) => [check.path, check.record]);
1666
+ const state = {
1667
+ version: 1,
1668
+ outputs: Object.fromEntries(recordEntries)
1669
+ };
1670
+ return {
1671
+ outputs: plan.outputs,
1672
+ removals,
1673
+ warnings,
1674
+ state
1675
+ };
1676
+ }
1677
+ //#endregion
1678
+ //#region src/generate/gitignore.ts
1679
+ const BLOCK_START = "# >>> tunestra (generated, do not edit)";
1680
+ const BLOCK_START_PREFIX = "# >>> tunestra";
1681
+ const BLOCK_END = "# <<< tunestra";
1682
+ /** Paths are relative to the project and get a leading `/`, so they match only at the root. */
1683
+ function renderGitignoreBlock(paths) {
1684
+ const lines = paths.map((path) => `/${path}`);
1685
+ return [
1686
+ BLOCK_START,
1687
+ ...lines,
1688
+ BLOCK_END
1689
+ ].join("\n");
1690
+ }
1691
+ /** Replaces the tunestra block in `.gitignore`, or adds it at the end; other lines stay. */
1692
+ function updateGitignore(existing, block) {
1693
+ if (existing === void 0) return `${block}\n`;
1694
+ const start = existing.indexOf(BLOCK_START_PREFIX);
1695
+ const end = existing.indexOf(BLOCK_END);
1696
+ if (start >= 0 && end > start) return `${existing.slice(0, start)}${block}${existing.slice(end + 14)}`;
1697
+ const content = existing.trimEnd();
1698
+ if (content === "") return `${block}\n`;
1699
+ return `${content}\n\n${block}\n`;
1700
+ }
1701
+ /** Removes the tunestra block and the empty lines around it; other lines stay as they are. */
1702
+ function removeGitignoreBlock(existing) {
1703
+ const start = existing.indexOf(BLOCK_START_PREFIX);
1704
+ const end = existing.indexOf(BLOCK_END);
1705
+ if ((start >= 0 && end > start) === false) return existing;
1706
+ const parts = [existing.slice(0, start).trimEnd(), existing.slice(end + 14).trim()].filter((part) => part.length > 0);
1707
+ if (parts.length === 0) return "";
1708
+ return `${parts.join("\n\n")}\n`;
1709
+ }
1710
+ //#endregion
1711
+ //#region src/setup/update-gitignore-file.ts
1712
+ async function updateGitignoreFile(projectDir, paths) {
1713
+ const file = join(projectDir, GITIGNORE_FILE);
1714
+ const content = updateGitignore(await readFileIfExists(file), renderGitignoreBlock(paths));
1715
+ await writeFile(file, content);
1716
+ }
1717
+ //#endregion
1718
+ //#region src/setup/run-setup.ts
1719
+ /**
1720
+ * Loads the chain, resolves inheritance, compares the outputs with the last setup and
1721
+ * writes the configuration of every agent.
1722
+ */
1723
+ async function runSetup(projectDir, options) {
1724
+ const chain = await loadChain(projectDir);
1725
+ const resolution = applyInheritance(chain);
1726
+ const plan = planSetup(resolution);
1727
+ const previous = await readState(chain.project.dir);
1728
+ const sync = await planSync(chain.project.dir, plan, previous);
1729
+ const report = {
1730
+ resolution,
1731
+ sync
1732
+ };
1733
+ if (options.dryRun) return report;
1734
+ await applySync(chain.project.dir, sync);
1735
+ await updateGitignoreFile(chain.project.dir, plan.ignoredPaths);
1736
+ return report;
1737
+ }
1738
+ //#endregion
1739
+ //#region src/cli/commands/describe-resolution.ts
1740
+ /** For --verbose: every artifact, the level it comes from and the file to edit. */
1741
+ function describeResolution(resolution) {
1742
+ const projectDir = resolution.chain.project.dir;
1743
+ return [
1744
+ describeSection("Rules", resolution.rules, projectDir),
1745
+ describeSection("Skills", resolution.skills, projectDir),
1746
+ describeSection("Commands", resolution.commands, projectDir),
1747
+ describeSection("MCP servers", resolution.mcp, projectDir)
1748
+ ].filter((section) => section.length > 0).join("\n\n");
1749
+ }
1750
+ function describeSection(title, artifacts, projectDir) {
1751
+ if (artifacts.length === 0) return "";
1752
+ const rows = artifacts.map((resolved) => {
1753
+ const origin = describeOrigin(resolved);
1754
+ const source = describeArtifactSource(projectDir, resolved.origin, resolved.artifact.source);
1755
+ return [
1756
+ resolved.artifact.key,
1757
+ origin,
1758
+ source
1759
+ ];
1760
+ });
1761
+ const keyWidth = measureColumn(rows, 0);
1762
+ const originWidth = measureColumn(rows, 1);
1763
+ return [title, ...rows.map(([key = "", origin = "", source = ""]) => {
1764
+ return ` ${key.padEnd(keyWidth)} ${origin.padEnd(originWidth)} ${source}`;
1765
+ })].join("\n");
1766
+ }
1767
+ function measureColumn(rows, column) {
1768
+ const widths = rows.map((row) => row[column]?.length ?? 0);
1769
+ return Math.max(...widths);
1770
+ }
1771
+ //#endregion
1772
+ //#region src/cli/commands/setup.ts
1773
+ async function describeSetup(projectDir, options) {
1774
+ const report = await runSetup(projectDir, options);
1775
+ const summary = describeSync(report.sync, options);
1776
+ if (options.verbose) return `${describeResolution(report.resolution)}
1777
+
1778
+ ${summary}`;
1779
+ return summary;
1780
+ }
1781
+ function describeSync(sync, options) {
1782
+ const warningIcon = pc.yellow("⚠");
1783
+ const lines = sync.warnings.map((warning) => `${warningIcon} ${warning}`);
1784
+ const writeCount = sync.outputs.length;
1785
+ const removeCount = sync.removals.length;
1786
+ if (options.dryRun) {
1787
+ const writes = sync.outputs.map((output) => ` write ${output.path}`);
1788
+ const removals = sync.removals.map((path) => ` remove ${path}`);
1789
+ lines.push(...writes, ...removals, "", "Nothing was written (--dry-run).");
1790
+ return lines.join("\n");
1791
+ }
1792
+ const check = pc.green("✔");
1793
+ lines.push(`${check} Generated ${writeCount} files, removed ${removeCount} stale files.`);
1794
+ lines.push("Commit AGENTS.md and CLAUDE.md; the rest is in .gitignore.");
1795
+ return lines.join("\n");
1796
+ }
1797
+ //#endregion
1798
+ //#region src/uninstall/remove-gitignore-block.ts
1799
+ /** A .gitignore that had only the tunestra block is removed, because tunestra created it. */
1800
+ async function removeGitignoreBlockFile(projectDir) {
1801
+ const file = join(projectDir, GITIGNORE_FILE);
1802
+ const existing = await readFileIfExists(file);
1803
+ if (existing === void 0) return;
1804
+ const content = removeGitignoreBlock(existing);
1805
+ if (content === "") {
1806
+ await rm(file);
1807
+ return;
1808
+ }
1809
+ await writeFile(file, content);
1810
+ }
1811
+ //#endregion
1812
+ //#region src/uninstall/run-uninstall.ts
1813
+ /**
1814
+ * Removes everything that the last setup generated, the block in .gitignore and
1815
+ * .tunestra/state/. The sources, tunestra.yaml and .tunestra/, stay.
1816
+ */
1817
+ async function runUninstall(projectDir, options) {
1818
+ const state = await readState(projectDir);
1819
+ const checks = await mapSettled(Object.entries(state.outputs), ([path, record]) => checkStaleOutput(projectDir, path, record));
1820
+ const removals = checks.filter((check) => check.isRemovable).map((check) => check.path);
1821
+ const warnings = checks.map((check) => check.warning).filter(isString);
1822
+ if (options.dryRun) return {
1823
+ removals,
1824
+ warnings
1825
+ };
1826
+ const stateDir = join(projectDir, STATE_DIR);
1827
+ const tunestraDir = join(projectDir, PROJECT_DIR);
1828
+ await removeOutputs(projectDir, removals);
1829
+ await removeGitignoreBlockFile(projectDir);
1830
+ await rm(stateDir, {
1831
+ recursive: true,
1832
+ force: true
1833
+ });
1834
+ await removeDirIfEmpty(tunestraDir);
1835
+ return {
1836
+ removals,
1837
+ warnings
1838
+ };
1839
+ }
1840
+ //#endregion
1841
+ //#region src/cli/commands/uninstall.ts
1842
+ async function describeUninstall(projectDir, options) {
1843
+ const report = await runUninstall(projectDir, options);
1844
+ const warningIcon = pc.yellow("⚠");
1845
+ const lines = report.warnings.map((warning) => `${warningIcon} ${warning}`);
1846
+ const count = report.removals.length;
1847
+ if (options.dryRun) {
1848
+ const removals = report.removals.map((path) => ` remove ${path}`);
1849
+ lines.push(...removals, "", "Nothing was removed (--dry-run).");
1850
+ return lines.join("\n");
1851
+ }
1852
+ const check = pc.green("✔");
1853
+ lines.push(`${check} Removed ${count} generated files and the tunestra block in .gitignore.`);
1854
+ lines.push("tunestra.yaml and .tunestra/ are kept: they are your sources.");
1855
+ return lines.join("\n");
1856
+ }
1857
+ //#endregion
1858
+ //#region src/cli/run-command.ts
1859
+ /** Prints the report of a command; an error in the user's input is shown without a stack. */
1860
+ async function runCommand(action) {
1861
+ try {
1862
+ const report = await action();
1863
+ console.log(report);
1864
+ } catch (error) {
1865
+ if (error instanceof TunestraError) {
1866
+ const message = pc.red(error.message);
1867
+ console.error(message);
1868
+ process.exitCode = 1;
1869
+ return;
1870
+ }
1871
+ throw error;
1872
+ }
1873
+ }
1874
+ //#endregion
1875
+ //#region src/cli/index.ts
1876
+ const program = new Command().name("tunestra").description("Tune your AI agents to play as one orchestra.").version(VERSION);
1877
+ program.command("init").description("Create tunestra.yaml and move hand-written AGENTS.md and CLAUDE.md to .tunestra/").option("--extends <presets...>", "presets to extend, from general to specific", []).action(async (options) => {
1878
+ const projectDir = process.cwd();
1879
+ const initOptions = { presets: options.extends };
1880
+ await runCommand(() => describeInit(projectDir, initOptions));
1881
+ });
1882
+ program.command("setup").description("Generate agent configuration from tunestra.yaml and its presets").option("--dry-run", "show the plan without writing files", false).option("-v, --verbose", "show where every artifact comes from", false).action(async (options) => {
1883
+ const projectDir = process.cwd();
1884
+ await runCommand(() => describeSetup(projectDir, options));
1885
+ });
1886
+ program.command("uninstall").description("Remove everything tunestra generated; tunestra.yaml and .tunestra/ stay").option("--dry-run", "show what would be removed without removing it", false).action(async (options) => {
1887
+ const projectDir = process.cwd();
1888
+ await runCommand(() => describeUninstall(projectDir, options));
1889
+ });
1890
+ await program.parseAsync();
1891
+ //#endregion
1892
+ export {};