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/README.md +217 -11
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +1881 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1 -0
- package/dist/src-DSbrKtiC.mjs +5 -0
- package/package.json +40 -5
- package/bin/tunestra.js +0 -5
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 {};
|