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