tunestra 0.4.0 → 0.6.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 +76 -6
- package/dist/cli.mjs +1088 -459
- package/dist/index.mjs +1 -1
- package/dist/{src-Bh7d7ERC.mjs → src-DTrYA2yZ.mjs} +1 -1
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as VERSION } from "./src-
|
|
2
|
+
import { t as VERSION } from "./src-DTrYA2yZ.mjs";
|
|
3
3
|
import { Command } from "@commander-js/extra-typings";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import { copyFile, lstat, mkdir, readFile, readdir, readlink, rename, rm, rmdir, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
@@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } fr
|
|
|
7
7
|
import { glob } from "tinyglobby";
|
|
8
8
|
import { prettifyError, z } from "zod";
|
|
9
9
|
import { parse, stringify } from "yaml";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
import { downloadTemplate } from "giget";
|
|
11
12
|
import { stringify as stringify$1 } from "smol-toml";
|
|
12
13
|
import { createHash } from "node:crypto";
|
|
@@ -17,6 +18,10 @@ const PRESET_MANIFEST = "tunestra.preset.yaml";
|
|
|
17
18
|
const PROJECT_CONFIG = "tunestra.yaml";
|
|
18
19
|
/** Project artifacts: the same layout as a preset. */
|
|
19
20
|
const PROJECT_DIR = ".tunestra";
|
|
21
|
+
/** The personal level of a developer; it is not committed. */
|
|
22
|
+
const LOCAL_CONFIG = "tunestra.local.yaml";
|
|
23
|
+
/** Artifacts of the personal level: the same layout as a preset. */
|
|
24
|
+
const LOCAL_DIR = ".tunestra.local";
|
|
20
25
|
/** Main instructions of a level, and the index that tunestra generates. */
|
|
21
26
|
const INSTRUCTIONS_FILE = "AGENTS.md";
|
|
22
27
|
//#endregion
|
|
@@ -168,6 +173,7 @@ const Agent = {
|
|
|
168
173
|
//#endregion
|
|
169
174
|
//#region src/init/render-config.ts
|
|
170
175
|
const EXTENDS_EXAMPLE = "# - github:<owner>/<repo>/<path>#<ref>";
|
|
176
|
+
const LOCAL_EXTENDS_EXAMPLE = "# - ~/presets/me";
|
|
171
177
|
/** The first tunestra.yaml of a project, with a hint when there are no presets yet. */
|
|
172
178
|
function renderConfig(presets) {
|
|
173
179
|
const hasPresets = presets.length > 0;
|
|
@@ -181,6 +187,23 @@ function renderConfig(presets) {
|
|
|
181
187
|
""
|
|
182
188
|
].join("\n");
|
|
183
189
|
}
|
|
190
|
+
/** The first tunestra.local.yaml, with commented examples of what a personal level can do. */
|
|
191
|
+
function renderLocalConfig(presets) {
|
|
192
|
+
const hasPresets = presets.length > 0;
|
|
193
|
+
const presetLines = presets.map((preset) => ` - ${JSON.stringify(preset)}`);
|
|
194
|
+
return [
|
|
195
|
+
"# Your personal level of tunestra: only for you, never committed.",
|
|
196
|
+
"# Personal presets with level: local, from general to specific:",
|
|
197
|
+
...hasPresets ? ["extends:", ...presetLines] : ["extends: []", LOCAL_EXTENDS_EXAMPLE],
|
|
198
|
+
"# exclude:",
|
|
199
|
+
"# skills: [<skill>]",
|
|
200
|
+
"# env:",
|
|
201
|
+
"# HTTPS_PROXY: http://localhost:3128",
|
|
202
|
+
"# permissions:",
|
|
203
|
+
"# allow: [Bash(make *)]",
|
|
204
|
+
""
|
|
205
|
+
].join("\n");
|
|
206
|
+
}
|
|
184
207
|
//#endregion
|
|
185
208
|
//#region src/init/run-init.ts
|
|
186
209
|
const CLAUDE_FILE$1 = "CLAUDE.md";
|
|
@@ -251,30 +274,70 @@ async function moveFile(projectDir, from, to) {
|
|
|
251
274
|
await rename(fromPath, toPath);
|
|
252
275
|
}
|
|
253
276
|
//#endregion
|
|
277
|
+
//#region src/init/run-init-local.ts
|
|
278
|
+
const LOCAL_RULES_DIR = `${LOCAL_DIR}/rules`;
|
|
279
|
+
/**
|
|
280
|
+
* Creates the personal level in a project that already uses tunestra: tunestra.local.yaml
|
|
281
|
+
* and `.tunestra.local/rules/` for the first personal rule.
|
|
282
|
+
*/
|
|
283
|
+
async function runInitLocal(projectDir, options) {
|
|
284
|
+
const configPath = join(projectDir, PROJECT_CONFIG);
|
|
285
|
+
const localPath = join(projectDir, LOCAL_CONFIG);
|
|
286
|
+
const rulesDir = join(projectDir, LOCAL_RULES_DIR);
|
|
287
|
+
const hasConfig = await checkExists(configPath);
|
|
288
|
+
const hasLocal = await checkExists(localPath);
|
|
289
|
+
const problems = [];
|
|
290
|
+
if (hasConfig === false) problems.push(`${PROJECT_CONFIG} not found. Run \`tunestra init\` first`);
|
|
291
|
+
if (hasLocal) problems.push(`${LOCAL_CONFIG} already exists`);
|
|
292
|
+
if (problems.length > 0) throw createProblemsError(problems);
|
|
293
|
+
const config = renderLocalConfig(options.presets);
|
|
294
|
+
await writeFile(localPath, config);
|
|
295
|
+
await mkdir(rulesDir, { recursive: true });
|
|
296
|
+
return {
|
|
297
|
+
steps: [`Created ${LOCAL_CONFIG}`, `Created ${LOCAL_RULES_DIR}/`],
|
|
298
|
+
warnings: []
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
254
302
|
//#region src/cli/commands/init.ts
|
|
255
303
|
async function describeInit(projectDir, options) {
|
|
256
|
-
const report = await runInit(projectDir, options);
|
|
257
|
-
const
|
|
258
|
-
const warningIcon = pc.yellow("⚠");
|
|
259
|
-
const steps = report.steps.map((step) => `${check} ${step}`);
|
|
260
|
-
const warnings = report.warnings.map((warning) => `${warningIcon} ${warning}`);
|
|
261
|
-
const nextStep = options.presets.length > 0 ? "Next: run tunestra setup." : "Next: add presets to extends in tunestra.yaml, then run tunestra setup.";
|
|
304
|
+
const report = options.local ? await runInitLocal(projectDir, options) : await runInit(projectDir, options);
|
|
305
|
+
const nextStep = describeNextStep(options);
|
|
262
306
|
return [
|
|
263
|
-
...
|
|
264
|
-
...warnings,
|
|
307
|
+
...describeReport(report),
|
|
265
308
|
"",
|
|
266
309
|
nextStep
|
|
267
310
|
].join("\n");
|
|
268
311
|
}
|
|
312
|
+
function describeReport(report) {
|
|
313
|
+
const check = pc.green("✔");
|
|
314
|
+
const warningIcon = pc.yellow("⚠");
|
|
315
|
+
const steps = report.steps.map((step) => `${check} ${step}`);
|
|
316
|
+
const warnings = report.warnings.map((warning) => `${warningIcon} ${warning}`);
|
|
317
|
+
return [...steps, ...warnings];
|
|
318
|
+
}
|
|
319
|
+
function describeNextStep(options) {
|
|
320
|
+
const hasPresets = options.presets.length > 0;
|
|
321
|
+
if (options.local) return "Next: add your rules to .tunestra.local/ or presets to tunestra.local.yaml, then run tunestra setup. setup puts both in .gitignore.";
|
|
322
|
+
if (hasPresets) return "Next: run tunestra setup.";
|
|
323
|
+
return "Next: add presets to extends in tunestra.yaml, then run tunestra setup.";
|
|
324
|
+
}
|
|
269
325
|
//#endregion
|
|
270
326
|
//#region src/shared/constants/levels.ts
|
|
271
|
-
/**
|
|
327
|
+
/**
|
|
328
|
+
* Levels of a preset, from specific to general. A `local` preset is personal: only the
|
|
329
|
+
* personal level of a developer can extend it.
|
|
330
|
+
*/
|
|
272
331
|
const PresetLevel = {
|
|
332
|
+
Local: "local",
|
|
273
333
|
Team: "team",
|
|
274
334
|
Domain: "domain",
|
|
275
335
|
Base: "base"
|
|
276
336
|
};
|
|
277
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* Every level of the chain. The project is the most specific shared level; the personal
|
|
339
|
+
* `local` level of a developer comes after it.
|
|
340
|
+
*/
|
|
278
341
|
const Level = {
|
|
279
342
|
Project: "project",
|
|
280
343
|
...PresetLevel
|
|
@@ -284,6 +347,7 @@ const Level = {
|
|
|
284
347
|
* so that agents read specific rules first: `10-project-architecture.md`.
|
|
285
348
|
*/
|
|
286
349
|
const LEVEL_RANK = {
|
|
350
|
+
[Level.Local]: 0,
|
|
287
351
|
[Level.Project]: 10,
|
|
288
352
|
[Level.Team]: 20,
|
|
289
353
|
[Level.Domain]: 30,
|
|
@@ -350,6 +414,7 @@ const excludeSchema = z.strictObject({
|
|
|
350
414
|
skills: keys.optional(),
|
|
351
415
|
commands: keys.optional(),
|
|
352
416
|
mcp: keys.optional(),
|
|
417
|
+
subagents: keys.optional(),
|
|
353
418
|
env: keys.optional(),
|
|
354
419
|
/** Inherited `allow` and `ask` rules; a `deny` rule cannot be removed. */
|
|
355
420
|
permissions: keys.optional()
|
|
@@ -381,6 +446,15 @@ const projectConfigSchema = z.strictObject({
|
|
|
381
446
|
env: envSchema.default({}),
|
|
382
447
|
permissions: permissionsSchema.default({})
|
|
383
448
|
});
|
|
449
|
+
/** `tunestra.local.yaml`: a level like the project, without the list of agents. */
|
|
450
|
+
const localConfigSchema = z.strictObject({
|
|
451
|
+
name: z.string().min(1).default("local"),
|
|
452
|
+
extends: z.array(z.string().min(1)).readonly().default([]),
|
|
453
|
+
exclude: excludeSchema.default({}),
|
|
454
|
+
overrides: overridesSchema.default({}),
|
|
455
|
+
env: envSchema.default({}),
|
|
456
|
+
permissions: permissionsSchema.default({})
|
|
457
|
+
});
|
|
384
458
|
const mcpServerSchema = z.strictObject({
|
|
385
459
|
description: z.string().optional(),
|
|
386
460
|
type: z.enum([
|
|
@@ -463,6 +537,15 @@ async function mapSettled(items, map) {
|
|
|
463
537
|
throw buildResultsError(results);
|
|
464
538
|
}
|
|
465
539
|
//#endregion
|
|
540
|
+
//#region src/shared/constants/subagents.ts
|
|
541
|
+
/** Reasoning effort of a subagent: the levels that both Claude Code and Codex accept. */
|
|
542
|
+
const SubagentEffort = {
|
|
543
|
+
Low: "low",
|
|
544
|
+
Medium: "medium",
|
|
545
|
+
High: "high",
|
|
546
|
+
ExtraHigh: "xhigh"
|
|
547
|
+
};
|
|
548
|
+
//#endregion
|
|
466
549
|
//#region src/shared/schemas/frontmatter.ts
|
|
467
550
|
const ruleFrontmatterSchema = z.looseObject({ description: z.string().optional() });
|
|
468
551
|
const skillFrontmatterSchema = z.looseObject({
|
|
@@ -470,6 +553,15 @@ const skillFrontmatterSchema = z.looseObject({
|
|
|
470
553
|
description: z.string().min(1)
|
|
471
554
|
});
|
|
472
555
|
const commandFrontmatterSchema = z.looseObject({ description: z.string().optional() });
|
|
556
|
+
/** The model names differ between agents, so a subagent sets one for each agent. */
|
|
557
|
+
const subagentModelSchema = z.partialRecord(z.enum(Agent), z.string().min(1));
|
|
558
|
+
/** tunestra writes the files of the agents itself, so it knows every field here. */
|
|
559
|
+
const subagentFrontmatterSchema = z.strictObject({
|
|
560
|
+
description: z.string().min(1),
|
|
561
|
+
model: subagentModelSchema.default({}),
|
|
562
|
+
effort: z.enum(SubagentEffort).optional(),
|
|
563
|
+
readOnly: z.boolean().default(false)
|
|
564
|
+
});
|
|
473
565
|
//#endregion
|
|
474
566
|
//#region src/presets/frontmatter.ts
|
|
475
567
|
/**
|
|
@@ -602,6 +694,28 @@ function findDuplicateSkills(skills, dir) {
|
|
|
602
694
|
return problems;
|
|
603
695
|
}
|
|
604
696
|
//#endregion
|
|
697
|
+
//#region src/presets/artifacts/subagents.ts
|
|
698
|
+
/** Subagents live in `agents/`, like in Claude Code and Codex. */
|
|
699
|
+
async function loadSubagents(dir) {
|
|
700
|
+
return mapSettled(await listFiles(dir, "agents/*.md"), (source) => loadSubagent(dir, source));
|
|
701
|
+
}
|
|
702
|
+
async function loadSubagent(dir, source) {
|
|
703
|
+
const file = join(dir, source);
|
|
704
|
+
const { data, body } = parseFrontmatter(await readFile(file, "utf8"), file);
|
|
705
|
+
const frontmatter = validate(data, subagentFrontmatterSchema, `frontmatter in ${file}`);
|
|
706
|
+
const key = validate(basename(source, ".md"), skillName, `subagent file ${source}`);
|
|
707
|
+
if (body.trim().length > 0 === false) throw new TunestraError(`${file} has no instructions: write them after the frontmatter`);
|
|
708
|
+
return {
|
|
709
|
+
key,
|
|
710
|
+
source,
|
|
711
|
+
description: frontmatter.description,
|
|
712
|
+
model: frontmatter.model,
|
|
713
|
+
effort: frontmatter.effort,
|
|
714
|
+
readOnly: frontmatter.readOnly,
|
|
715
|
+
body
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
//#endregion
|
|
605
719
|
//#region src/presets/artifacts/load-artifacts.ts
|
|
606
720
|
/** Loads every kind of artifact at the same time and reports the problems of all of them. */
|
|
607
721
|
async function loadArtifacts(dir, presetName) {
|
|
@@ -609,19 +723,22 @@ async function loadArtifacts(dir, presetName) {
|
|
|
609
723
|
const skillsLoading = loadSkills(dir);
|
|
610
724
|
const commandsLoading = loadCommands(dir);
|
|
611
725
|
const mcpLoading = loadMcpServers(dir);
|
|
726
|
+
const subagentsLoading = loadSubagents(dir);
|
|
612
727
|
const results = await Promise.allSettled([
|
|
613
728
|
rulesLoading,
|
|
614
729
|
skillsLoading,
|
|
615
730
|
commandsLoading,
|
|
616
|
-
mcpLoading
|
|
731
|
+
mcpLoading,
|
|
732
|
+
subagentsLoading
|
|
617
733
|
]);
|
|
618
734
|
if (isEveryFulfilled(results) === false) throw buildResultsError(results);
|
|
619
|
-
const [rulesResult, skillsResult, commandsResult, mcpResult] = results;
|
|
735
|
+
const [rulesResult, skillsResult, commandsResult, mcpResult, subagentsResult] = results;
|
|
620
736
|
return {
|
|
621
737
|
rules: rulesResult.value,
|
|
622
738
|
skills: skillsResult.value,
|
|
623
739
|
commands: commandsResult.value,
|
|
624
|
-
mcp: mcpResult.value
|
|
740
|
+
mcp: mcpResult.value,
|
|
741
|
+
subagents: subagentsResult.value
|
|
625
742
|
};
|
|
626
743
|
}
|
|
627
744
|
//#endregion
|
|
@@ -657,12 +774,12 @@ const PermissionDecision = {
|
|
|
657
774
|
};
|
|
658
775
|
//#endregion
|
|
659
776
|
//#region src/presets/permissions.ts
|
|
660
|
-
const DECISIONS$
|
|
777
|
+
const DECISIONS$2 = Object.values(PermissionDecision);
|
|
661
778
|
/** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
|
|
662
779
|
function createPermissionRules(permissions, manifestPath, artifactsDir) {
|
|
663
780
|
const source = relativePosix(artifactsDir, manifestPath);
|
|
664
781
|
const rules = [];
|
|
665
|
-
for (const decision of DECISIONS$
|
|
782
|
+
for (const decision of DECISIONS$2) {
|
|
666
783
|
const keys = permissions[decision] ?? [];
|
|
667
784
|
for (const key of keys) rules.push({
|
|
668
785
|
key,
|
|
@@ -764,6 +881,7 @@ function createDownloadOptions(dir, token) {
|
|
|
764
881
|
}
|
|
765
882
|
//#endregion
|
|
766
883
|
//#region src/sources/create-source-resolver.ts
|
|
884
|
+
const HOME_PREFIX = "~/";
|
|
767
885
|
function createSourceResolver(projectDir, download = downloadRepository) {
|
|
768
886
|
const downloads = /* @__PURE__ */ new Map();
|
|
769
887
|
const repositories = [];
|
|
@@ -793,7 +911,7 @@ function createSourceResolver(projectDir, download = downloadRepository) {
|
|
|
793
911
|
const repository = await fetchRepository(gitSource);
|
|
794
912
|
return checkInside(repository, join(repository.root, gitSource.path), ref);
|
|
795
913
|
}
|
|
796
|
-
const dir =
|
|
914
|
+
const dir = resolveLocalPath(ref, fromDir);
|
|
797
915
|
const repository = findRepository(fromDir);
|
|
798
916
|
if (repository === void 0) return dir;
|
|
799
917
|
return checkInside(repository, dir, ref);
|
|
@@ -823,6 +941,17 @@ function isInside(root, dir) {
|
|
|
823
941
|
const isOtherDrive = isAbsolute(path);
|
|
824
942
|
return isParent === false && isOtherDrive === false;
|
|
825
943
|
}
|
|
944
|
+
/**
|
|
945
|
+
* A path from `extends`. `~/` starts at the home directory, so that a personal preset can
|
|
946
|
+
* live there; any other path starts at the directory that refers to it.
|
|
947
|
+
*/
|
|
948
|
+
function resolveLocalPath(ref, fromDir) {
|
|
949
|
+
if (ref.startsWith(HOME_PREFIX)) {
|
|
950
|
+
const path = ref.slice(2);
|
|
951
|
+
return resolve(homedir(), path);
|
|
952
|
+
}
|
|
953
|
+
return resolve(fromDir, ref);
|
|
954
|
+
}
|
|
826
955
|
//#endregion
|
|
827
956
|
//#region src/chain/check-chain.ts
|
|
828
957
|
/** A level can extend the same or a more general level, never a more specific one. */
|
|
@@ -854,7 +983,7 @@ function findDuplicateNames(levels) {
|
|
|
854
983
|
//#endregion
|
|
855
984
|
//#region src/chain/linearize-chain.ts
|
|
856
985
|
/**
|
|
857
|
-
* Puts the levels in order with a depth-first walk from the
|
|
986
|
+
* Puts the levels in order with a depth-first walk from the root: parents first, in the
|
|
858
987
|
* order of `extends`, then the level itself. A level that is reached twice is added once.
|
|
859
988
|
*/
|
|
860
989
|
function linearizeChain(graph) {
|
|
@@ -876,7 +1005,7 @@ function linearizeChain(graph) {
|
|
|
876
1005
|
if (level === void 0) return;
|
|
877
1006
|
levels.push(level);
|
|
878
1007
|
};
|
|
879
|
-
visit(graph.
|
|
1008
|
+
visit(graph.root.dir, []);
|
|
880
1009
|
return {
|
|
881
1010
|
levels,
|
|
882
1011
|
problems: [...problems]
|
|
@@ -916,14 +1045,14 @@ async function loadPreset(dir) {
|
|
|
916
1045
|
//#endregion
|
|
917
1046
|
//#region src/chain/load-preset-graph.ts
|
|
918
1047
|
/**
|
|
919
|
-
* Loads the presets of the
|
|
1048
|
+
* Loads the presets of the root, then the presets they extend, and so on. Each step loads
|
|
920
1049
|
* its presets in parallel; a preset that several levels extend is loaded once.
|
|
921
1050
|
*/
|
|
922
|
-
async function loadPresetGraph(
|
|
1051
|
+
async function loadPresetGraph(root, resolver) {
|
|
923
1052
|
const levels = /* @__PURE__ */ new Map();
|
|
924
1053
|
const parents = /* @__PURE__ */ new Map();
|
|
925
|
-
let pending = [
|
|
926
|
-
levels.set(
|
|
1054
|
+
let pending = [root];
|
|
1055
|
+
levels.set(root.dir, root);
|
|
927
1056
|
while (pending.length > 0) {
|
|
928
1057
|
const dirsToLoad = /* @__PURE__ */ new Set();
|
|
929
1058
|
for (const preset of pending) {
|
|
@@ -935,7 +1064,7 @@ async function loadPresetGraph(project, resolver) {
|
|
|
935
1064
|
for (const preset of pending) levels.set(preset.dir, preset);
|
|
936
1065
|
}
|
|
937
1066
|
return {
|
|
938
|
-
|
|
1067
|
+
root,
|
|
939
1068
|
levels,
|
|
940
1069
|
parents
|
|
941
1070
|
};
|
|
@@ -969,6 +1098,67 @@ async function loadChain(projectDir, options = {}) {
|
|
|
969
1098
|
};
|
|
970
1099
|
}
|
|
971
1100
|
//#endregion
|
|
1101
|
+
//#region src/presets/load-local.ts
|
|
1102
|
+
/** The personal level of the developer, when the project has `tunestra.local.yaml`. */
|
|
1103
|
+
async function loadLocal(projectDir) {
|
|
1104
|
+
const root = resolve(projectDir);
|
|
1105
|
+
const configPath = join(root, LOCAL_CONFIG);
|
|
1106
|
+
if (await checkExists(configPath) === false) return;
|
|
1107
|
+
const config = await readYaml(configPath, localConfigSchema, "personal config");
|
|
1108
|
+
const artifactsDir = join(root, LOCAL_DIR);
|
|
1109
|
+
const artifacts = await loadArtifacts(artifactsDir, config.name);
|
|
1110
|
+
const env = createEnvVariables(config.env, configPath, artifactsDir);
|
|
1111
|
+
const permissions = createPermissionRules(config.permissions, configPath, artifactsDir);
|
|
1112
|
+
return {
|
|
1113
|
+
...config,
|
|
1114
|
+
level: Level.Local,
|
|
1115
|
+
description: void 0,
|
|
1116
|
+
dir: root,
|
|
1117
|
+
artifactsDir,
|
|
1118
|
+
remote: void 0,
|
|
1119
|
+
...artifacts,
|
|
1120
|
+
env,
|
|
1121
|
+
permissions
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
//#endregion
|
|
1125
|
+
//#region src/chain/load-local-chain.ts
|
|
1126
|
+
/**
|
|
1127
|
+
* The shared chain with the personal level of the developer and its presets on top, or
|
|
1128
|
+
* `undefined` when the project has no `tunestra.local.yaml`.
|
|
1129
|
+
*/
|
|
1130
|
+
async function loadLocalChain(shared, options = {}) {
|
|
1131
|
+
const local = await loadLocal(shared.project.dir);
|
|
1132
|
+
if (local === void 0) return;
|
|
1133
|
+
const linearization = linearizeChain(await loadPresetGraph(local, createSourceResolver(shared.project.dir, options.download)));
|
|
1134
|
+
const levels = [...shared.levels, ...linearization.levels];
|
|
1135
|
+
const levelProblems = linearization.levels.flatMap(findLocalProblems);
|
|
1136
|
+
const nameProblems = findDuplicateNames(levels);
|
|
1137
|
+
const problems = [
|
|
1138
|
+
...linearization.problems,
|
|
1139
|
+
...levelProblems,
|
|
1140
|
+
...nameProblems
|
|
1141
|
+
];
|
|
1142
|
+
if (problems.length > 0) throw createProblemsError(problems);
|
|
1143
|
+
return {
|
|
1144
|
+
project: shared.project,
|
|
1145
|
+
levels
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* A personal level changes only the files that are not committed. Claude Code joins
|
|
1150
|
+
* `.claude/settings.local.json` with the committed `.claude/settings.json`, so a personal
|
|
1151
|
+
* level can add env and permissions, but cannot remove them.
|
|
1152
|
+
*/
|
|
1153
|
+
function findLocalProblems(level) {
|
|
1154
|
+
const problems = [];
|
|
1155
|
+
if (level.level !== Level.Local) problems.push(`${level.name} (${level.level}) is extended by the personal level. A personal level can only extend presets with level: local`);
|
|
1156
|
+
const excludesEnv = (level.exclude.env?.length ?? 0) > 0;
|
|
1157
|
+
const excludesPermissions = (level.exclude.permissions?.length ?? 0) > 0;
|
|
1158
|
+
if (excludesEnv || excludesPermissions) problems.push(`${level.name}: a personal level cannot exclude env or permissions, because Claude Code joins .claude/settings.local.json with the committed .claude/settings.json`);
|
|
1159
|
+
return problems;
|
|
1160
|
+
}
|
|
1161
|
+
//#endregion
|
|
972
1162
|
//#region src/generate/describe-source.ts
|
|
973
1163
|
/** Files built from the whole chain, like AGENTS.md: there is no single file to edit. */
|
|
974
1164
|
const SETUP_SOURCE = "tunestra.yaml and its presets";
|
|
@@ -981,6 +1171,139 @@ function describeArtifactSource(projectDir, origin, source) {
|
|
|
981
1171
|
return relativePosix(projectDir, join(origin.artifactsDir, source));
|
|
982
1172
|
}
|
|
983
1173
|
//#endregion
|
|
1174
|
+
//#region src/shared/utils/markdown.ts
|
|
1175
|
+
/**
|
|
1176
|
+
* A pipe or a line break inside a cell would break the table.
|
|
1177
|
+
*
|
|
1178
|
+
* Matches: "|" in "a | b", "\n" and "\r\n" in "line one\nline two"
|
|
1179
|
+
*/
|
|
1180
|
+
const TABLE_BREAKING_PATTERN = /\||\r?\n/g;
|
|
1181
|
+
function renderTable(header, rows) {
|
|
1182
|
+
return [
|
|
1183
|
+
renderRow(header),
|
|
1184
|
+
`|${header.map(() => "---").join("|")}|`,
|
|
1185
|
+
...rows.map(renderRow)
|
|
1186
|
+
].join("\n");
|
|
1187
|
+
}
|
|
1188
|
+
function renderRow(cells) {
|
|
1189
|
+
return `| ${cells.map(escapeCell).join(" | ")} |`;
|
|
1190
|
+
}
|
|
1191
|
+
function escapeCell(cell) {
|
|
1192
|
+
return cell.replace(TABLE_BREAKING_PATTERN, replaceTableBreaking);
|
|
1193
|
+
}
|
|
1194
|
+
function replaceTableBreaking(match) {
|
|
1195
|
+
if (match === "|") return "\\|";
|
|
1196
|
+
return " ";
|
|
1197
|
+
}
|
|
1198
|
+
//#endregion
|
|
1199
|
+
//#region src/generate/describe-origin.ts
|
|
1200
|
+
/** For example `team, overrides base` or `base, patched by team`. */
|
|
1201
|
+
function describeOrigin(resolved) {
|
|
1202
|
+
const parts = [resolved.origin.level];
|
|
1203
|
+
const hasOverrides = resolved.overrides.length > 0;
|
|
1204
|
+
const isPatched = resolved.patchedBy.length > 0;
|
|
1205
|
+
if (hasOverrides) {
|
|
1206
|
+
const names = joinNames(resolved.overrides);
|
|
1207
|
+
parts.push(`overrides ${names}`);
|
|
1208
|
+
}
|
|
1209
|
+
if (isPatched) {
|
|
1210
|
+
const names = joinNames(resolved.patchedBy);
|
|
1211
|
+
parts.push(`patched by ${names}`);
|
|
1212
|
+
}
|
|
1213
|
+
return parts.join(", ");
|
|
1214
|
+
}
|
|
1215
|
+
function joinNames(origins) {
|
|
1216
|
+
return origins.map((origin) => origin.name).join(", ");
|
|
1217
|
+
}
|
|
1218
|
+
//#endregion
|
|
1219
|
+
//#region src/generate/local-changes.ts
|
|
1220
|
+
const INTRO = `The personal level of this developer (\`${LOCAL_CONFIG}\`) changes the setup in AGENTS.md on this machine only. When this file and AGENTS.md disagree, follow this file.`;
|
|
1221
|
+
const TURNED_OFF_INTRO = "These artifacts of AGENTS.md are turned off on this machine.";
|
|
1222
|
+
const NO_CHANGES = "The personal level changes nothing yet.";
|
|
1223
|
+
const CHANGING = `Edit \`${LOCAL_CONFIG}\` or \`${LOCAL_DIR}/\` and run \`tunestra setup\`.`;
|
|
1224
|
+
/**
|
|
1225
|
+
* What the personal level adds, overrides and turns off, compared with the committed
|
|
1226
|
+
* AGENTS.md. Claude Code reads it after CLAUDE.md as `CLAUDE.local.md`.
|
|
1227
|
+
*/
|
|
1228
|
+
function renderLocalChanges(shared, local) {
|
|
1229
|
+
const groups = listGroups(shared, local);
|
|
1230
|
+
const changedRows = groups.flatMap(listChangedRows);
|
|
1231
|
+
const turnedOffRows = groups.flatMap(listTurnedOffRows);
|
|
1232
|
+
const sections = [
|
|
1233
|
+
GENERATED_NOTICE,
|
|
1234
|
+
"# Personal setup",
|
|
1235
|
+
INTRO
|
|
1236
|
+
];
|
|
1237
|
+
if (changedRows.length > 0) {
|
|
1238
|
+
const table = renderTable([
|
|
1239
|
+
"Kind",
|
|
1240
|
+
"Name",
|
|
1241
|
+
"Level",
|
|
1242
|
+
"Description"
|
|
1243
|
+
], changedRows);
|
|
1244
|
+
sections.push("## Added and changed", table);
|
|
1245
|
+
}
|
|
1246
|
+
if (turnedOffRows.length > 0) {
|
|
1247
|
+
const table = renderTable(["Kind", "Name"], turnedOffRows);
|
|
1248
|
+
sections.push("## Turned off", TURNED_OFF_INTRO, table);
|
|
1249
|
+
}
|
|
1250
|
+
if ((changedRows.length > 0 || turnedOffRows.length > 0) === false) sections.push(NO_CHANGES);
|
|
1251
|
+
sections.push(CHANGING);
|
|
1252
|
+
return `${sections.join("\n\n")}\n`;
|
|
1253
|
+
}
|
|
1254
|
+
function listGroups(shared, local) {
|
|
1255
|
+
return [
|
|
1256
|
+
createGroup("Rule", shared.rules, local.rules),
|
|
1257
|
+
createGroup("Skill", shared.skills, local.skills),
|
|
1258
|
+
createGroup("Command", shared.commands.map(labelCommand), local.commands.map(labelCommand)),
|
|
1259
|
+
createGroup("MCP server", shared.mcp, local.mcp),
|
|
1260
|
+
createGroup("Subagent", shared.subagents, local.subagents),
|
|
1261
|
+
createGroup("Variable", shared.env, local.env),
|
|
1262
|
+
createGroup("Permission", shared.permissions.map(labelPermission$1), local.permissions.map(labelPermission$1))
|
|
1263
|
+
];
|
|
1264
|
+
}
|
|
1265
|
+
function createGroup(kind, shared, local) {
|
|
1266
|
+
return {
|
|
1267
|
+
kind,
|
|
1268
|
+
shared,
|
|
1269
|
+
local
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function listChangedRows(group) {
|
|
1273
|
+
return group.local.filter((resolved) => resolved.origin.level === Level.Local).map((resolved) => {
|
|
1274
|
+
const origin = describeOrigin(resolved);
|
|
1275
|
+
const description = resolved.artifact.description ?? "";
|
|
1276
|
+
return [
|
|
1277
|
+
group.kind,
|
|
1278
|
+
resolved.artifact.key,
|
|
1279
|
+
origin,
|
|
1280
|
+
description
|
|
1281
|
+
];
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
function listTurnedOffRows(group) {
|
|
1285
|
+
const localKeys = new Set(group.local.map((resolved) => resolved.artifact.key));
|
|
1286
|
+
return group.shared.filter((resolved) => localKeys.has(resolved.artifact.key) === false).map((resolved) => [group.kind, resolved.artifact.key]);
|
|
1287
|
+
}
|
|
1288
|
+
function labelCommand(resolved) {
|
|
1289
|
+
const artifact = {
|
|
1290
|
+
key: `/${resolved.artifact.key}`,
|
|
1291
|
+
description: resolved.artifact.description
|
|
1292
|
+
};
|
|
1293
|
+
return {
|
|
1294
|
+
...resolved,
|
|
1295
|
+
artifact
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/** For example `deny Read(./.env)`: the same rule can be in two lists. */
|
|
1299
|
+
function labelPermission$1(resolved) {
|
|
1300
|
+
const artifact = { key: `${resolved.artifact.decision} ${resolved.artifact.key}` };
|
|
1301
|
+
return {
|
|
1302
|
+
...resolved,
|
|
1303
|
+
artifact
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
984
1307
|
//#region src/shared/utils/outputs.ts
|
|
985
1308
|
function createFileOutput(path, content, source) {
|
|
986
1309
|
return {
|
|
@@ -1096,7 +1419,7 @@ function compareByName([a], [b]) {
|
|
|
1096
1419
|
//#endregion
|
|
1097
1420
|
//#region src/agents/claude/settings-json.ts
|
|
1098
1421
|
const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
|
|
1099
|
-
const DECISIONS = Object.values(PermissionDecision);
|
|
1422
|
+
const DECISIONS$1 = Object.values(PermissionDecision);
|
|
1100
1423
|
/** The keys that tunestra writes; a file with only these keys is a generated one. */
|
|
1101
1424
|
const SETTINGS_KEYS = [
|
|
1102
1425
|
"$schema",
|
|
@@ -1112,10 +1435,23 @@ function renderSettingsJson(settings) {
|
|
|
1112
1435
|
if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
|
|
1113
1436
|
return `${JSON.stringify(content, null, 2)}\n`;
|
|
1114
1437
|
}
|
|
1438
|
+
/** Every list, empty ones included, for entries that setup adds to a file of people. */
|
|
1439
|
+
function buildSettingsEntries(env, permissions) {
|
|
1440
|
+
const envMap = buildEnvMap(env);
|
|
1441
|
+
const lists = buildPermissionLists(permissions);
|
|
1442
|
+
return {
|
|
1443
|
+
env: envMap,
|
|
1444
|
+
permissions: {
|
|
1445
|
+
[PermissionDecision.Allow]: lists[PermissionDecision.Allow] ?? [],
|
|
1446
|
+
[PermissionDecision.Ask]: lists[PermissionDecision.Ask] ?? [],
|
|
1447
|
+
[PermissionDecision.Deny]: lists[PermissionDecision.Deny] ?? []
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1115
1451
|
/** Only the lists that have rules; a rule repeated on several levels is written once. */
|
|
1116
1452
|
function buildPermissionLists(permissions) {
|
|
1117
1453
|
const lists = {};
|
|
1118
|
-
for (const decision of DECISIONS) {
|
|
1454
|
+
for (const decision of DECISIONS$1) {
|
|
1119
1455
|
const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
|
|
1120
1456
|
const sortedKeys = [...new Set(keys)].toSorted();
|
|
1121
1457
|
if (sortedKeys.length > 0) lists[decision] = sortedKeys;
|
|
@@ -1123,16 +1459,40 @@ function buildPermissionLists(permissions) {
|
|
|
1123
1459
|
return lists;
|
|
1124
1460
|
}
|
|
1125
1461
|
//#endregion
|
|
1462
|
+
//#region src/agents/claude/subagent-md.ts
|
|
1463
|
+
/** Tools that change files; a read-only subagent keeps the tools that read and search. */
|
|
1464
|
+
const WRITING_TOOLS = [
|
|
1465
|
+
"Write",
|
|
1466
|
+
"Edit",
|
|
1467
|
+
"NotebookEdit",
|
|
1468
|
+
"Bash"
|
|
1469
|
+
];
|
|
1470
|
+
/** `.claude/agents/<name>.md`: the frontmatter of Claude Code and the instructions. */
|
|
1471
|
+
function renderSubagentMd(subagent) {
|
|
1472
|
+
const { key, description, model, effort, readOnly, body } = subagent;
|
|
1473
|
+
const frontmatter = {
|
|
1474
|
+
name: key,
|
|
1475
|
+
description
|
|
1476
|
+
};
|
|
1477
|
+
if (typeof model.claude === "string") frontmatter.model = model.claude;
|
|
1478
|
+
if (typeof effort === "string") frontmatter.effort = effort;
|
|
1479
|
+
if (readOnly) frontmatter.disallowedTools = WRITING_TOOLS;
|
|
1480
|
+
return `---\n${stringify(frontmatter)}---\n${body}`;
|
|
1481
|
+
}
|
|
1482
|
+
//#endregion
|
|
1126
1483
|
//#region src/agents/claude/claude-adapter.ts
|
|
1127
1484
|
const CLAUDE_FILE = "CLAUDE.md";
|
|
1128
1485
|
const RULES_DIR = ".claude/rules/tunestra";
|
|
1129
1486
|
const SKILLS_DIR = ".claude/skills";
|
|
1130
1487
|
const COMMANDS_DIR = ".claude/commands";
|
|
1488
|
+
const SUBAGENTS_DIR$1 = ".claude/agents";
|
|
1131
1489
|
const MCP_FILE = ".mcp.json";
|
|
1132
1490
|
const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
|
|
1133
1491
|
const SETTINGS_FILE = ".claude/settings.json";
|
|
1134
1492
|
const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
|
|
1135
1493
|
const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
|
|
1494
|
+
const LOCAL_CLAUDE_FILE = "CLAUDE.local.md";
|
|
1495
|
+
const LOCAL_SOURCE = `${LOCAL_CONFIG} and ${LOCAL_DIR}/`;
|
|
1136
1496
|
/**
|
|
1137
1497
|
* Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
|
|
1138
1498
|
* which Claude Code loads in every session; a symlink to a directory outside `.claude`
|
|
@@ -1142,8 +1502,40 @@ const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
|
|
|
1142
1502
|
*/
|
|
1143
1503
|
const claudeAdapter = {
|
|
1144
1504
|
agent: Agent.Claude,
|
|
1145
|
-
plan: planClaude
|
|
1505
|
+
plan: planClaude,
|
|
1506
|
+
planLocal: planClaudeLocal
|
|
1146
1507
|
};
|
|
1508
|
+
/**
|
|
1509
|
+
* Claude Code reads `CLAUDE.local.md` after CLAUDE.md, so it says what the personal level
|
|
1510
|
+
* changes. Personal env and permissions are added to `.claude/settings.local.json`, which
|
|
1511
|
+
* Claude Code applies over the committed settings; the entries of people and of Claude
|
|
1512
|
+
* Code itself stay.
|
|
1513
|
+
*/
|
|
1514
|
+
function planClaudeLocal(context) {
|
|
1515
|
+
const { shared, local } = context;
|
|
1516
|
+
const content = renderLocalChanges(shared, local);
|
|
1517
|
+
const localFile = createFileOutput(LOCAL_CLAUDE_FILE, content, LOCAL_SOURCE);
|
|
1518
|
+
const env = local.env.filter(isFromLocal);
|
|
1519
|
+
const permissions = local.permissions.filter(isFromLocal);
|
|
1520
|
+
if ((env.length > 0 || permissions.length > 0) === false) return {
|
|
1521
|
+
outputs: [localFile],
|
|
1522
|
+
ignoredPaths: [LOCAL_CLAUDE_FILE],
|
|
1523
|
+
merges: []
|
|
1524
|
+
};
|
|
1525
|
+
const entries = buildSettingsEntries(env, permissions);
|
|
1526
|
+
return {
|
|
1527
|
+
outputs: [localFile],
|
|
1528
|
+
ignoredPaths: [LOCAL_CLAUDE_FILE, LOCAL_SETTINGS_FILE],
|
|
1529
|
+
merges: [{
|
|
1530
|
+
path: LOCAL_SETTINGS_FILE,
|
|
1531
|
+
source: LOCAL_SOURCE,
|
|
1532
|
+
entries
|
|
1533
|
+
}]
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
function isFromLocal(resolved) {
|
|
1537
|
+
return resolved.origin.level === Level.Local;
|
|
1538
|
+
}
|
|
1147
1539
|
function planClaude(context) {
|
|
1148
1540
|
const { resolution, ruleFiles } = context;
|
|
1149
1541
|
const projectDir = resolution.chain.project.dir;
|
|
@@ -1156,11 +1548,15 @@ function planClaude(context) {
|
|
|
1156
1548
|
const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
|
|
1157
1549
|
return createFileOutput(path, command.artifact.content, source);
|
|
1158
1550
|
});
|
|
1551
|
+
const subagentFiles = resolution.subagents.map((subagent) => {
|
|
1552
|
+
return createFileOutput(`${SUBAGENTS_DIR$1}/${subagent.artifact.key}.md`, renderSubagentMd(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
|
|
1553
|
+
});
|
|
1159
1554
|
const outputs = [
|
|
1160
1555
|
claudeFile,
|
|
1161
1556
|
...ruleCopies,
|
|
1162
1557
|
...skillLinks,
|
|
1163
|
-
...commandFiles
|
|
1558
|
+
...commandFiles,
|
|
1559
|
+
...subagentFiles
|
|
1164
1560
|
];
|
|
1165
1561
|
if (resolution.mcp.length > 0) {
|
|
1166
1562
|
const mcpJson = renderMcpJson(resolution.mcp);
|
|
@@ -1179,10 +1575,12 @@ function planClaude(context) {
|
|
|
1179
1575
|
`${RULES_DIR}/`,
|
|
1180
1576
|
`${SKILLS_DIR}/`,
|
|
1181
1577
|
`${COMMANDS_DIR}/`,
|
|
1578
|
+
`${SUBAGENTS_DIR$1}/`,
|
|
1182
1579
|
MCP_FILE
|
|
1183
1580
|
],
|
|
1184
1581
|
protectedPaths,
|
|
1185
|
-
notes: []
|
|
1582
|
+
notes: [],
|
|
1583
|
+
merges: []
|
|
1186
1584
|
};
|
|
1187
1585
|
}
|
|
1188
1586
|
/** Claude Code drops some variables from project settings; setup says so instead. */
|
|
@@ -1220,362 +1618,48 @@ function linkSkill(projectDir, skill) {
|
|
|
1220
1618
|
};
|
|
1221
1619
|
}
|
|
1222
1620
|
//#endregion
|
|
1223
|
-
//#region src/
|
|
1224
|
-
/** Codex starts the skill only when people call it, like a command. */
|
|
1225
|
-
const OPENAI_YAML = [
|
|
1226
|
-
GENERATED_HASH_NOTICE,
|
|
1227
|
-
"policy:",
|
|
1228
|
-
" allow_implicit_invocation: false",
|
|
1229
|
-
""
|
|
1230
|
-
].join("\n");
|
|
1621
|
+
//#region src/generate/rule-file.ts
|
|
1231
1622
|
/**
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* command belongs to Claude Code.
|
|
1623
|
+
* `<rank>-<level>-<key>.md`, so that a plain sort puts specific rules first. Level
|
|
1624
|
+
* instructions become `instructions`, with the preset name when it differs from the level.
|
|
1235
1625
|
*/
|
|
1236
|
-
function
|
|
1237
|
-
const
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
const
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
/**
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
function renderConfigToml(config) {
|
|
1267
|
-
const parts = [GENERATED_HASH_NOTICE];
|
|
1268
|
-
if (typeof config.instructions === "string") {
|
|
1269
|
-
const line = renderInstructions$1(config.instructions);
|
|
1270
|
-
parts.push(line);
|
|
1271
|
-
}
|
|
1272
|
-
const tables = {};
|
|
1273
|
-
const hasEnv = config.env.length > 0;
|
|
1274
|
-
const hasServers = config.servers.length > 0;
|
|
1275
|
-
if (hasEnv) tables.shell_environment_policy = { set: buildEnvMap(config.env) };
|
|
1276
|
-
if (hasServers) {
|
|
1277
|
-
const entries = config.servers.map((server) => {
|
|
1278
|
-
const serverConfig = convertMcpConfig(server.artifact.config);
|
|
1279
|
-
return [server.artifact.key, serverConfig];
|
|
1280
|
-
});
|
|
1281
|
-
tables.mcp_servers = Object.fromEntries(entries);
|
|
1282
|
-
}
|
|
1283
|
-
if (hasEnv || hasServers) {
|
|
1284
|
-
const text = stringify$1(tables).trimEnd();
|
|
1285
|
-
parts.push(text);
|
|
1286
|
-
}
|
|
1287
|
-
return `${parts.join("\n\n")}\n`;
|
|
1288
|
-
}
|
|
1289
|
-
function renderInstructions$1(text) {
|
|
1290
|
-
const hasDelimiter = text.includes(LITERAL_DELIMITER);
|
|
1291
|
-
const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
|
|
1292
|
-
if (hasDelimiter || hasControlCharacter) return stringify$1({ developer_instructions: text });
|
|
1293
|
-
return `developer_instructions = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
|
|
1294
|
-
}
|
|
1295
|
-
/** Codex picks the transport by `command` or `url` and has no `type`. */
|
|
1296
|
-
function convertMcpConfig(config) {
|
|
1297
|
-
const { type: _type, headers, ...server } = config;
|
|
1298
|
-
if (headers === void 0) return server;
|
|
1299
|
-
return {
|
|
1300
|
-
...server,
|
|
1301
|
-
http_headers: headers
|
|
1302
|
-
};
|
|
1303
|
-
}
|
|
1304
|
-
//#endregion
|
|
1305
|
-
//#region src/agents/codex/exec-rules.ts
|
|
1306
|
-
/** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
|
|
1307
|
-
const BASH_RULE_START = "Bash(";
|
|
1308
|
-
const BASH_RULE_END = ")";
|
|
1309
|
-
/**
|
|
1310
|
-
* Shell syntax and wildcards that a Codex prefix rule cannot express.
|
|
1311
|
-
*
|
|
1312
|
-
* Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
|
|
1313
|
-
* Does not match: "npm test", "git push --force", "rm -rf"
|
|
1314
|
-
*/
|
|
1315
|
-
const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
|
|
1316
|
-
const WHITESPACE_PATTERN = /\s+/;
|
|
1317
|
-
/** A trailing ` *` or the older `:*` means "this command with any arguments". */
|
|
1318
|
-
const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
|
|
1319
|
-
/**
|
|
1320
|
-
* Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
|
|
1321
|
-
* outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
|
|
1322
|
-
*/
|
|
1323
|
-
const CODEX_DECISIONS = {
|
|
1324
|
-
[PermissionDecision.Deny]: "forbidden",
|
|
1325
|
-
[PermissionDecision.Ask]: "prompt"
|
|
1326
|
-
};
|
|
1327
|
-
/**
|
|
1328
|
-
* The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
|
|
1329
|
-
* A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
|
|
1330
|
-
*/
|
|
1331
|
-
function convertBashRule(rule) {
|
|
1332
|
-
if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
|
|
1333
|
-
const specifier = rule.slice(5, -1);
|
|
1334
|
-
const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
|
|
1335
|
-
const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
|
|
1336
|
-
if (SHELL_SYNTAX_PATTERN.test(command)) return;
|
|
1337
|
-
const words = command.trim().split(WHITESPACE_PATTERN);
|
|
1338
|
-
if (words.every((word) => word.length > 0)) return words;
|
|
1339
|
-
}
|
|
1340
|
-
function hasCodexDecision(decision) {
|
|
1341
|
-
return decision in CODEX_DECISIONS;
|
|
1342
|
-
}
|
|
1343
|
-
function createExecRule(resolved, decision, pattern) {
|
|
1344
|
-
return {
|
|
1345
|
-
pattern,
|
|
1346
|
-
decision: CODEX_DECISIONS[decision],
|
|
1347
|
-
justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
|
|
1348
|
-
};
|
|
1349
|
-
}
|
|
1350
|
-
/** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
|
|
1351
|
-
function renderExecRules(rules) {
|
|
1352
|
-
const blocks = rules.map(renderExecRule);
|
|
1353
|
-
return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
|
|
1354
|
-
}
|
|
1355
|
-
/** JSON strings are valid Starlark strings. */
|
|
1356
|
-
function renderExecRule(rule) {
|
|
1357
|
-
const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
|
|
1358
|
-
const decision = JSON.stringify(rule.decision);
|
|
1359
|
-
const justification = JSON.stringify(rule.justification);
|
|
1360
|
-
return [
|
|
1361
|
-
"prefix_rule(",
|
|
1362
|
-
` pattern = [${pattern}],`,
|
|
1363
|
-
` decision = ${decision},`,
|
|
1364
|
-
` justification = ${justification},`,
|
|
1365
|
-
")"
|
|
1366
|
-
].join("\n");
|
|
1367
|
-
}
|
|
1368
|
-
//#endregion
|
|
1369
|
-
//#region src/agents/codex/codex-adapter.ts
|
|
1370
|
-
const CONFIG_FILE = ".codex/config.toml";
|
|
1371
|
-
const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
|
|
1372
|
-
const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
|
|
1373
|
-
const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
|
|
1374
|
-
const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
|
|
1375
|
-
const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
|
|
1376
|
-
/**
|
|
1377
|
-
* Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
|
|
1378
|
-
* `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
|
|
1379
|
-
* in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
|
|
1380
|
-
* permission rules for shell commands become `.codex/rules/tunestra.rules`.
|
|
1381
|
-
*/
|
|
1382
|
-
const codexAdapter = {
|
|
1383
|
-
agent: Agent.Codex,
|
|
1384
|
-
plan: planCodex
|
|
1385
|
-
};
|
|
1386
|
-
function planCodex(context) {
|
|
1387
|
-
const { resolution, ruleFiles } = context;
|
|
1388
|
-
const projectDir = resolution.chain.project.dir;
|
|
1389
|
-
const notes = [];
|
|
1390
|
-
const outputs = [];
|
|
1391
|
-
const commandSkills = resolution.commands.filter((command) => {
|
|
1392
|
-
if (skillName.safeParse(command.artifact.key).success) return true;
|
|
1393
|
-
notes.push(`Codex skips the command "${command.artifact.key}": a skill name needs lowercase letters, digits and single hyphens.`);
|
|
1394
|
-
return false;
|
|
1395
|
-
}).flatMap((command) => planCommandSkill(projectDir, command));
|
|
1396
|
-
outputs.push(...commandSkills);
|
|
1397
|
-
const configFile = planConfigFile(ruleFiles, resolution.mcp.filter((server) => {
|
|
1398
|
-
if (server.artifact.config.type === "sse") {
|
|
1399
|
-
notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
|
|
1400
|
-
return false;
|
|
1401
|
-
}
|
|
1402
|
-
return true;
|
|
1403
|
-
}), resolution.env);
|
|
1404
|
-
const execRules = planExecRules(resolution.permissions);
|
|
1405
|
-
const ignoredPaths = [];
|
|
1406
|
-
notes.push(...execRules.notes);
|
|
1407
|
-
if (typeof configFile === "object") {
|
|
1408
|
-
outputs.push(configFile);
|
|
1409
|
-
ignoredPaths.push(CONFIG_FILE);
|
|
1410
|
-
}
|
|
1411
|
-
if (typeof execRules.file === "object") {
|
|
1412
|
-
outputs.push(execRules.file);
|
|
1413
|
-
ignoredPaths.push(EXEC_RULES_FILE);
|
|
1414
|
-
}
|
|
1415
|
-
if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
|
|
1416
|
-
return {
|
|
1417
|
-
outputs,
|
|
1418
|
-
ignoredPaths,
|
|
1419
|
-
protectedPaths: [],
|
|
1420
|
-
notes
|
|
1421
|
-
};
|
|
1422
|
-
}
|
|
1423
|
-
/**
|
|
1424
|
-
* `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
|
|
1425
|
-
* for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
|
|
1426
|
-
*/
|
|
1427
|
-
function planExecRules(permissions) {
|
|
1428
|
-
const execRules = [];
|
|
1429
|
-
const skippedRules = [];
|
|
1430
|
-
const allowRules = [];
|
|
1431
|
-
for (const resolved of permissions) {
|
|
1432
|
-
const { key, decision } = resolved.artifact;
|
|
1433
|
-
if (hasCodexDecision(decision) === false) {
|
|
1434
|
-
allowRules.push(key);
|
|
1435
|
-
continue;
|
|
1436
|
-
}
|
|
1437
|
-
const pattern = convertBashRule(key);
|
|
1438
|
-
if (pattern === void 0) {
|
|
1439
|
-
skippedRules.push(key);
|
|
1440
|
-
continue;
|
|
1441
|
-
}
|
|
1442
|
-
const execRule = createExecRule(resolved, decision, pattern);
|
|
1443
|
-
execRules.push(execRule);
|
|
1444
|
-
}
|
|
1445
|
-
const notes = [];
|
|
1446
|
-
const hasSkippedRules = skippedRules.length > 0;
|
|
1447
|
-
const hasAllowRules = allowRules.length > 0;
|
|
1448
|
-
if (hasSkippedRules) {
|
|
1449
|
-
const rules = skippedRules.join(", ");
|
|
1450
|
-
notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
|
|
1451
|
-
}
|
|
1452
|
-
if (hasAllowRules) {
|
|
1453
|
-
const rules = allowRules.join(", ");
|
|
1454
|
-
notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
|
|
1455
|
-
}
|
|
1456
|
-
if (execRules.length > 0) {
|
|
1457
|
-
const content = renderExecRules(execRules);
|
|
1458
|
-
return {
|
|
1459
|
-
file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
|
|
1460
|
-
notes
|
|
1461
|
-
};
|
|
1462
|
-
}
|
|
1463
|
-
return {
|
|
1464
|
-
file: void 0,
|
|
1465
|
-
notes
|
|
1466
|
-
};
|
|
1467
|
-
}
|
|
1468
|
-
function planConfigFile(ruleFiles, servers, env) {
|
|
1469
|
-
const hasRules = ruleFiles.length > 0;
|
|
1470
|
-
const hasServers = servers.length > 0;
|
|
1471
|
-
const hasEnv = env.length > 0;
|
|
1472
|
-
if (hasRules === false && hasServers === false && hasEnv === false) return;
|
|
1473
|
-
const content = renderConfigToml({
|
|
1474
|
-
instructions: hasRules ? renderInstructions(ruleFiles) : void 0,
|
|
1475
|
-
servers,
|
|
1476
|
-
env
|
|
1477
|
-
});
|
|
1478
|
-
return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
|
|
1479
|
-
}
|
|
1480
|
-
/** The rule files as they are, with the header that names the source of each. */
|
|
1481
|
-
function renderInstructions(ruleFiles) {
|
|
1482
|
-
const rules = ruleFiles.map((file) => file.content.trimEnd());
|
|
1483
|
-
return `${[INSTRUCTIONS_INTRO, ...rules].join("\n\n")}\n`;
|
|
1484
|
-
}
|
|
1485
|
-
//#endregion
|
|
1486
|
-
//#region src/agents/adapters.ts
|
|
1487
|
-
/** Every supported agent. */
|
|
1488
|
-
const AGENT_ADAPTERS = {
|
|
1489
|
-
[Agent.Claude]: claudeAdapter,
|
|
1490
|
-
[Agent.Codex]: codexAdapter
|
|
1491
|
-
};
|
|
1492
|
-
//#endregion
|
|
1493
|
-
//#region src/shared/utils/markdown.ts
|
|
1494
|
-
/**
|
|
1495
|
-
* A pipe or a line break inside a cell would break the table.
|
|
1496
|
-
*
|
|
1497
|
-
* Matches: "|" in "a | b", "\n" and "\r\n" in "line one\nline two"
|
|
1498
|
-
*/
|
|
1499
|
-
const TABLE_BREAKING_PATTERN = /\||\r?\n/g;
|
|
1500
|
-
function renderTable(header, rows) {
|
|
1501
|
-
return [
|
|
1502
|
-
renderRow(header),
|
|
1503
|
-
`|${header.map(() => "---").join("|")}|`,
|
|
1504
|
-
...rows.map(renderRow)
|
|
1505
|
-
].join("\n");
|
|
1506
|
-
}
|
|
1507
|
-
function renderRow(cells) {
|
|
1508
|
-
return `| ${cells.map(escapeCell).join(" | ")} |`;
|
|
1509
|
-
}
|
|
1510
|
-
function escapeCell(cell) {
|
|
1511
|
-
return cell.replace(TABLE_BREAKING_PATTERN, replaceTableBreaking);
|
|
1512
|
-
}
|
|
1513
|
-
function replaceTableBreaking(match) {
|
|
1514
|
-
if (match === "|") return "\\|";
|
|
1515
|
-
return " ";
|
|
1516
|
-
}
|
|
1517
|
-
//#endregion
|
|
1518
|
-
//#region src/generate/describe-origin.ts
|
|
1519
|
-
/** For example `team, overrides base` or `base, patched by team`. */
|
|
1520
|
-
function describeOrigin(resolved) {
|
|
1521
|
-
const parts = [resolved.origin.level];
|
|
1522
|
-
const hasOverrides = resolved.overrides.length > 0;
|
|
1523
|
-
const isPatched = resolved.patchedBy.length > 0;
|
|
1524
|
-
if (hasOverrides) {
|
|
1525
|
-
const names = joinNames(resolved.overrides);
|
|
1526
|
-
parts.push(`overrides ${names}`);
|
|
1527
|
-
}
|
|
1528
|
-
if (isPatched) {
|
|
1529
|
-
const names = joinNames(resolved.patchedBy);
|
|
1530
|
-
parts.push(`patched by ${names}`);
|
|
1531
|
-
}
|
|
1532
|
-
return parts.join(", ");
|
|
1533
|
-
}
|
|
1534
|
-
function joinNames(origins) {
|
|
1535
|
-
return origins.map((origin) => origin.name).join(", ");
|
|
1536
|
-
}
|
|
1537
|
-
//#endregion
|
|
1538
|
-
//#region src/generate/rule-file.ts
|
|
1539
|
-
/**
|
|
1540
|
-
* `<rank>-<level>-<key>.md`, so that a plain sort puts specific rules first. Level
|
|
1541
|
-
* instructions become `instructions`, with the preset name when it differs from the level.
|
|
1542
|
-
*/
|
|
1543
|
-
function buildRuleFileName(rule) {
|
|
1544
|
-
const rank = LEVEL_RANK[rule.origin.level];
|
|
1545
|
-
const slug = buildRuleSlug(rule);
|
|
1546
|
-
return `${rank}-${rule.origin.level}-${slug}.md`;
|
|
1547
|
-
}
|
|
1548
|
-
/** The rule text with a header that tells where it comes from and what it overrides. */
|
|
1549
|
-
function renderRuleFile(rule) {
|
|
1550
|
-
const sourceFile = join(rule.origin.artifactsDir, rule.artifact.source);
|
|
1551
|
-
const sourcePath = relativePosix(rule.origin.dir, sourceFile);
|
|
1552
|
-
const levelLine = renderLevelLine(rule);
|
|
1553
|
-
return [
|
|
1554
|
-
`${GENERATED_MARKER} from ${rule.origin.name}:${sourcePath}. Do not edit. -->`,
|
|
1555
|
-
levelLine,
|
|
1556
|
-
"",
|
|
1557
|
-
rule.artifact.body
|
|
1558
|
-
].join("\n");
|
|
1559
|
-
}
|
|
1560
|
-
function buildRuleSlug(rule) {
|
|
1561
|
-
const instructionsKey = buildInstructionsKey(rule.origin.name);
|
|
1562
|
-
const isInstructions = rule.artifact.key === instructionsKey;
|
|
1563
|
-
const isNamedAsLevel = rule.origin.name === rule.origin.level;
|
|
1564
|
-
if (isInstructions === false) return rule.artifact.key;
|
|
1565
|
-
if (isNamedAsLevel) return "instructions";
|
|
1566
|
-
return `instructions-${rule.origin.name}`;
|
|
1567
|
-
}
|
|
1568
|
-
/** For example `> Level: team, preset: team. Overrides: base.` */
|
|
1569
|
-
function renderLevelLine(rule) {
|
|
1570
|
-
const line = `> Level: ${rule.origin.level}, preset: ${rule.origin.name}.`;
|
|
1571
|
-
if (rule.overrides.length > 0) return `${line} Overrides: ${joinNames(rule.overrides)}.`;
|
|
1572
|
-
return line;
|
|
1626
|
+
function buildRuleFileName(rule) {
|
|
1627
|
+
const rank = String(LEVEL_RANK[rule.origin.level]).padStart(2, "0");
|
|
1628
|
+
const slug = buildRuleSlug(rule);
|
|
1629
|
+
return `${rank}-${rule.origin.level}-${slug}.md`;
|
|
1630
|
+
}
|
|
1631
|
+
/** The rule text with a header that tells where it comes from and what it overrides. */
|
|
1632
|
+
function renderRuleFile(rule) {
|
|
1633
|
+
const sourceFile = join(rule.origin.artifactsDir, rule.artifact.source);
|
|
1634
|
+
const sourcePath = relativePosix(rule.origin.dir, sourceFile);
|
|
1635
|
+
const levelLine = renderLevelLine(rule);
|
|
1636
|
+
return [
|
|
1637
|
+
`${GENERATED_MARKER} from ${rule.origin.name}:${sourcePath}. Do not edit. -->`,
|
|
1638
|
+
levelLine,
|
|
1639
|
+
"",
|
|
1640
|
+
rule.artifact.body
|
|
1641
|
+
].join("\n");
|
|
1642
|
+
}
|
|
1643
|
+
function buildRuleSlug(rule) {
|
|
1644
|
+
const instructionsKey = buildInstructionsKey(rule.origin.name);
|
|
1645
|
+
const isInstructions = rule.artifact.key === instructionsKey;
|
|
1646
|
+
const isNamedAsLevel = rule.origin.name === rule.origin.level;
|
|
1647
|
+
if (isInstructions === false) return rule.artifact.key;
|
|
1648
|
+
if (isNamedAsLevel) return "instructions";
|
|
1649
|
+
return `instructions-${rule.origin.name}`;
|
|
1650
|
+
}
|
|
1651
|
+
/** For example `> Level: team, preset: team. Overrides: base.` */
|
|
1652
|
+
function renderLevelLine(rule) {
|
|
1653
|
+
const line = `> Level: ${rule.origin.level}, preset: ${rule.origin.name}.`;
|
|
1654
|
+
if (rule.overrides.length > 0) return `${line} Overrides: ${joinNames(rule.overrides)}.`;
|
|
1655
|
+
return line;
|
|
1573
1656
|
}
|
|
1574
1657
|
//#endregion
|
|
1575
1658
|
//#region src/generate/index-sections.ts
|
|
1576
1659
|
const HIERARCHY_INTRO = "Instructions are ordered from specific to general. When two instructions conflict, follow the one from the higher level in this list.";
|
|
1577
1660
|
const RULES_INTRO = "Always follow these rules. Claude Code and Codex load them automatically; other agents should read them before starting work.";
|
|
1578
1661
|
const SKILLS_INTRO = "Use a skill when the task matches its description.";
|
|
1662
|
+
const SUBAGENTS_INTRO = "Hand a task to a subagent when the task matches its description.";
|
|
1579
1663
|
const ENV_INTRO = "Agents set these variables in every session, also for the commands they run. The values are in the presets.";
|
|
1580
1664
|
const CHANGING_SECTION = [
|
|
1581
1665
|
"## Changing this setup",
|
|
@@ -1678,6 +1762,21 @@ function renderMcpServers(servers) {
|
|
|
1678
1762
|
];
|
|
1679
1763
|
})));
|
|
1680
1764
|
}
|
|
1765
|
+
function renderSubagents(subagents) {
|
|
1766
|
+
const table = renderTable([
|
|
1767
|
+
"Subagent",
|
|
1768
|
+
"Level",
|
|
1769
|
+
"When to use"
|
|
1770
|
+
], subagents.map((subagent) => {
|
|
1771
|
+
const origin = describeOrigin(subagent);
|
|
1772
|
+
return [
|
|
1773
|
+
subagent.artifact.key,
|
|
1774
|
+
origin,
|
|
1775
|
+
subagent.artifact.description
|
|
1776
|
+
];
|
|
1777
|
+
}));
|
|
1778
|
+
return renderSection("Subagents", SUBAGENTS_INTRO, table);
|
|
1779
|
+
}
|
|
1681
1780
|
function renderEnv(env) {
|
|
1682
1781
|
const table = renderTable(["Variable", "Level"], env.map((variable) => {
|
|
1683
1782
|
const origin = describeOrigin(variable);
|
|
@@ -1685,76 +1784,446 @@ function renderEnv(env) {
|
|
|
1685
1784
|
}));
|
|
1686
1785
|
return renderSection("Environment", ENV_INTRO, table);
|
|
1687
1786
|
}
|
|
1688
|
-
/** The project
|
|
1689
|
-
function describeLevelSource(chain, level) {
|
|
1690
|
-
if (level.dir === chain.project.dir) return PROJECT_CONFIG;
|
|
1691
|
-
if (typeof level.remote === "string") return level.remote;
|
|
1692
|
-
return relativePosix(chain.project.dir, level.dir);
|
|
1787
|
+
/** The project and the personal level show their config files; a preset shows its git reference or its path. */
|
|
1788
|
+
function describeLevelSource(chain, level) {
|
|
1789
|
+
if (level.dir === chain.project.dir) return level.level === Level.Local ? LOCAL_CONFIG : PROJECT_CONFIG;
|
|
1790
|
+
if (typeof level.remote === "string") return level.remote;
|
|
1791
|
+
return relativePosix(chain.project.dir, level.dir);
|
|
1792
|
+
}
|
|
1793
|
+
function renderSection(title, intro, table) {
|
|
1794
|
+
const heading = `## ${title}`;
|
|
1795
|
+
if (intro === void 0) return [
|
|
1796
|
+
heading,
|
|
1797
|
+
"",
|
|
1798
|
+
table
|
|
1799
|
+
].join("\n");
|
|
1800
|
+
return [
|
|
1801
|
+
heading,
|
|
1802
|
+
"",
|
|
1803
|
+
intro,
|
|
1804
|
+
"",
|
|
1805
|
+
table
|
|
1806
|
+
].join("\n");
|
|
1807
|
+
}
|
|
1808
|
+
//#endregion
|
|
1809
|
+
//#region src/generate/agents-index.ts
|
|
1810
|
+
/**
|
|
1811
|
+
* AGENTS.md: an index of the setup, not the rules themselves. It lists the levels, every
|
|
1812
|
+
* rule, skill, command, MCP server and subagent, where each comes from and where its file is.
|
|
1813
|
+
*/
|
|
1814
|
+
function renderAgentsIndex(resolution) {
|
|
1815
|
+
const title = `# AI agent context: ${resolution.chain.project.name}`;
|
|
1816
|
+
const hierarchy = renderHierarchy(resolution.chain);
|
|
1817
|
+
const sections = [
|
|
1818
|
+
GENERATED_NOTICE,
|
|
1819
|
+
title,
|
|
1820
|
+
hierarchy
|
|
1821
|
+
];
|
|
1822
|
+
const hasRules = resolution.rules.length > 0;
|
|
1823
|
+
const hasSkills = resolution.skills.length > 0;
|
|
1824
|
+
const hasCommands = resolution.commands.length > 0;
|
|
1825
|
+
const hasMcpServers = resolution.mcp.length > 0;
|
|
1826
|
+
const hasSubagents = resolution.subagents.length > 0;
|
|
1827
|
+
const hasEnv = resolution.env.length > 0;
|
|
1828
|
+
if (hasRules) {
|
|
1829
|
+
const rules = renderRules(resolution.rules);
|
|
1830
|
+
sections.push(rules);
|
|
1831
|
+
}
|
|
1832
|
+
if (hasSkills) {
|
|
1833
|
+
const skills = renderSkills(resolution.skills);
|
|
1834
|
+
sections.push(skills);
|
|
1835
|
+
}
|
|
1836
|
+
if (hasCommands) {
|
|
1837
|
+
const commands = renderCommands(resolution.commands, resolution.chain.project.agents);
|
|
1838
|
+
sections.push(commands);
|
|
1839
|
+
}
|
|
1840
|
+
if (hasMcpServers) {
|
|
1841
|
+
const servers = renderMcpServers(resolution.mcp);
|
|
1842
|
+
sections.push(servers);
|
|
1843
|
+
}
|
|
1844
|
+
if (hasSubagents) {
|
|
1845
|
+
const subagents = renderSubagents(resolution.subagents);
|
|
1846
|
+
sections.push(subagents);
|
|
1847
|
+
}
|
|
1848
|
+
if (hasEnv) {
|
|
1849
|
+
const env = renderEnv(resolution.env);
|
|
1850
|
+
sections.push(env);
|
|
1851
|
+
}
|
|
1852
|
+
sections.push(CHANGING_SECTION);
|
|
1853
|
+
return `${sections.join("\n\n")}\n`;
|
|
1854
|
+
}
|
|
1855
|
+
//#endregion
|
|
1856
|
+
//#region src/agents/codex/command-skill.ts
|
|
1857
|
+
/** Codex starts the skill only when people call it, like a command. */
|
|
1858
|
+
const OPENAI_YAML = [
|
|
1859
|
+
GENERATED_HASH_NOTICE,
|
|
1860
|
+
"policy:",
|
|
1861
|
+
" allow_implicit_invocation: false",
|
|
1862
|
+
""
|
|
1863
|
+
].join("\n");
|
|
1864
|
+
/**
|
|
1865
|
+
* Codex has no project commands, so a command becomes a skill with the same name that runs
|
|
1866
|
+
* only on `$<name>`. The skill gets `name` and `description`; other frontmatter of the
|
|
1867
|
+
* command belongs to Claude Code.
|
|
1868
|
+
*/
|
|
1869
|
+
function planCommandSkill(projectDir, command) {
|
|
1870
|
+
const { key, description, body } = command.artifact;
|
|
1871
|
+
const dir = `${AGENTS_SKILLS_DIR}/${key}`;
|
|
1872
|
+
const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
|
|
1873
|
+
const frontmatter = {
|
|
1874
|
+
name: key,
|
|
1875
|
+
description: description ?? `The /${key} command`
|
|
1876
|
+
};
|
|
1877
|
+
const skill = `---\n${stringify(frontmatter)}---\n${body}`;
|
|
1878
|
+
const skillPath = `${dir}/SKILL.md`;
|
|
1879
|
+
const policyPath = `${dir}/agents/openai.yaml`;
|
|
1880
|
+
return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
|
|
1881
|
+
}
|
|
1882
|
+
//#endregion
|
|
1883
|
+
//#region src/agents/codex/toml-text.ts
|
|
1884
|
+
/** Starts and ends a multi-line literal string, where TOML escapes nothing. */
|
|
1885
|
+
const LITERAL_DELIMITER = "'''";
|
|
1886
|
+
/**
|
|
1887
|
+
* Control characters that a TOML literal string cannot hold. Tab and line feed are allowed;
|
|
1888
|
+
* a carriage return is in the list, because only `\r\n` would be valid.
|
|
1889
|
+
*
|
|
1890
|
+
* Matches: "\u0000", "\r", "\u001B" (escape), "\u007F" (delete)
|
|
1891
|
+
* Does not match: "\t", "\n", "é", "→"
|
|
1892
|
+
*/
|
|
1893
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
|
|
1894
|
+
/**
|
|
1895
|
+
* A key with a long text, like `developer_instructions`. The text is a literal string, so
|
|
1896
|
+
* that people can read it; a text that a literal string cannot hold is escaped.
|
|
1897
|
+
*/
|
|
1898
|
+
function renderTomlText(key, text) {
|
|
1899
|
+
const hasDelimiter = text.includes(LITERAL_DELIMITER);
|
|
1900
|
+
const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
|
|
1901
|
+
if (hasDelimiter || hasControlCharacter) return stringify$1({ [key]: text });
|
|
1902
|
+
return `${key} = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
|
|
1903
|
+
}
|
|
1904
|
+
//#endregion
|
|
1905
|
+
//#region src/agents/codex/config-toml.ts
|
|
1906
|
+
/**
|
|
1907
|
+
* `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
|
|
1908
|
+
* session, the environment of the commands that Codex runs, and the MCP servers. The rules
|
|
1909
|
+
* are a literal string, so that people can read them.
|
|
1910
|
+
*/
|
|
1911
|
+
function renderConfigToml(config) {
|
|
1912
|
+
const parts = [GENERATED_HASH_NOTICE];
|
|
1913
|
+
if (typeof config.instructions === "string") {
|
|
1914
|
+
const line = renderTomlText("developer_instructions", config.instructions);
|
|
1915
|
+
parts.push(line);
|
|
1916
|
+
}
|
|
1917
|
+
const tables = {};
|
|
1918
|
+
const hasEnv = config.env.length > 0;
|
|
1919
|
+
const hasServers = config.servers.length > 0;
|
|
1920
|
+
if (hasEnv) tables.shell_environment_policy = { set: buildEnvMap(config.env) };
|
|
1921
|
+
if (hasServers) {
|
|
1922
|
+
const entries = config.servers.map((server) => {
|
|
1923
|
+
const serverConfig = convertMcpConfig(server.artifact.config);
|
|
1924
|
+
return [server.artifact.key, serverConfig];
|
|
1925
|
+
});
|
|
1926
|
+
tables.mcp_servers = Object.fromEntries(entries);
|
|
1927
|
+
}
|
|
1928
|
+
if (hasEnv || hasServers) {
|
|
1929
|
+
const text = stringify$1(tables).trimEnd();
|
|
1930
|
+
parts.push(text);
|
|
1931
|
+
}
|
|
1932
|
+
return `${parts.join("\n\n")}\n`;
|
|
1933
|
+
}
|
|
1934
|
+
/** Codex picks the transport by `command` or `url` and has no `type`. */
|
|
1935
|
+
function convertMcpConfig(config) {
|
|
1936
|
+
const { type: _type, headers, ...server } = config;
|
|
1937
|
+
if (headers === void 0) return server;
|
|
1938
|
+
return {
|
|
1939
|
+
...server,
|
|
1940
|
+
http_headers: headers
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
//#endregion
|
|
1944
|
+
//#region src/agents/codex/exec-rules.ts
|
|
1945
|
+
/** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
|
|
1946
|
+
const BASH_RULE_START = "Bash(";
|
|
1947
|
+
const BASH_RULE_END = ")";
|
|
1948
|
+
/**
|
|
1949
|
+
* Shell syntax and wildcards that a Codex prefix rule cannot express.
|
|
1950
|
+
*
|
|
1951
|
+
* Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
|
|
1952
|
+
* Does not match: "npm test", "git push --force", "rm -rf"
|
|
1953
|
+
*/
|
|
1954
|
+
const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
|
|
1955
|
+
const WHITESPACE_PATTERN = /\s+/;
|
|
1956
|
+
/** A trailing ` *` or the older `:*` means "this command with any arguments". */
|
|
1957
|
+
const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
|
|
1958
|
+
/**
|
|
1959
|
+
* Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
|
|
1960
|
+
* outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
|
|
1961
|
+
*/
|
|
1962
|
+
const CODEX_DECISIONS = {
|
|
1963
|
+
[PermissionDecision.Deny]: "forbidden",
|
|
1964
|
+
[PermissionDecision.Ask]: "prompt"
|
|
1965
|
+
};
|
|
1966
|
+
/**
|
|
1967
|
+
* The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
|
|
1968
|
+
* A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
|
|
1969
|
+
*/
|
|
1970
|
+
function convertBashRule(rule) {
|
|
1971
|
+
if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
|
|
1972
|
+
const specifier = rule.slice(5, -1);
|
|
1973
|
+
const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
|
|
1974
|
+
const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
|
|
1975
|
+
if (SHELL_SYNTAX_PATTERN.test(command)) return;
|
|
1976
|
+
const words = command.trim().split(WHITESPACE_PATTERN);
|
|
1977
|
+
if (words.every((word) => word.length > 0)) return words;
|
|
1978
|
+
}
|
|
1979
|
+
function hasCodexDecision(decision) {
|
|
1980
|
+
return decision in CODEX_DECISIONS;
|
|
1981
|
+
}
|
|
1982
|
+
function createExecRule(resolved, decision, pattern) {
|
|
1983
|
+
return {
|
|
1984
|
+
pattern,
|
|
1985
|
+
decision: CODEX_DECISIONS[decision],
|
|
1986
|
+
justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
/** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
|
|
1990
|
+
function renderExecRules(rules) {
|
|
1991
|
+
const blocks = rules.map(renderExecRule);
|
|
1992
|
+
return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
|
|
1693
1993
|
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
table
|
|
1700
|
-
].join("\n");
|
|
1994
|
+
/** JSON strings are valid Starlark strings. */
|
|
1995
|
+
function renderExecRule(rule) {
|
|
1996
|
+
const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
|
|
1997
|
+
const decision = JSON.stringify(rule.decision);
|
|
1998
|
+
const justification = JSON.stringify(rule.justification);
|
|
1701
1999
|
return [
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
2000
|
+
"prefix_rule(",
|
|
2001
|
+
` pattern = [${pattern}],`,
|
|
2002
|
+
` decision = ${decision},`,
|
|
2003
|
+
` justification = ${justification},`,
|
|
2004
|
+
")"
|
|
1707
2005
|
].join("\n");
|
|
1708
2006
|
}
|
|
1709
2007
|
//#endregion
|
|
1710
|
-
//#region src/
|
|
2008
|
+
//#region src/agents/codex/subagent-toml.ts
|
|
1711
2009
|
/**
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
2010
|
+
* Codex has no read-only mode for a subagent: it can turn off the shell, but the subagent
|
|
2011
|
+
* still edits files with patches. So the instructions ask it not to.
|
|
1714
2012
|
*/
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
2013
|
+
const READ_ONLY_INSTRUCTION = "Do not create, edit or delete files.";
|
|
2014
|
+
/**
|
|
2015
|
+
* `.codex/agents/<name>.toml`. Codex gives a subagent the sandbox, the permissions and the
|
|
2016
|
+
* MCP servers of the main session, so the file has only the model and the instructions.
|
|
2017
|
+
*/
|
|
2018
|
+
function renderSubagentToml(subagent) {
|
|
2019
|
+
const { key, description, model, effort, readOnly, body } = subagent;
|
|
2020
|
+
const fields = {
|
|
2021
|
+
name: key,
|
|
2022
|
+
description
|
|
2023
|
+
};
|
|
2024
|
+
if (typeof model.codex === "string") fields.model = model.codex;
|
|
2025
|
+
if (typeof effort === "string") fields.model_reasoning_effort = effort;
|
|
2026
|
+
const instructions = readOnly ? `${READ_ONLY_INSTRUCTION}\n\n${body}` : body;
|
|
2027
|
+
const parts = [
|
|
2028
|
+
GENERATED_HASH_NOTICE,
|
|
2029
|
+
stringify$1(fields).trimEnd(),
|
|
2030
|
+
renderTomlText("developer_instructions", instructions)
|
|
1722
2031
|
];
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
2032
|
+
if (readOnly) parts.push(stringify$1({ features: { shell_tool: false } }).trimEnd());
|
|
2033
|
+
return `${parts.join("\n\n")}\n`;
|
|
2034
|
+
}
|
|
2035
|
+
//#endregion
|
|
2036
|
+
//#region src/agents/codex/codex-adapter.ts
|
|
2037
|
+
const CONFIG_FILE = ".codex/config.toml";
|
|
2038
|
+
const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
|
|
2039
|
+
const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
|
|
2040
|
+
const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
|
|
2041
|
+
const SUBAGENTS_DIR = ".codex/agents";
|
|
2042
|
+
const OVERRIDE_FILE = "AGENTS.override.md";
|
|
2043
|
+
const OVERRIDE_SOURCE = `${LOCAL_CONFIG}, ${LOCAL_DIR}/ and the levels in ${INSTRUCTIONS_FILE}`;
|
|
2044
|
+
const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
|
|
2045
|
+
const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
|
|
2046
|
+
/**
|
|
2047
|
+
* Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
|
|
2048
|
+
* `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
|
|
2049
|
+
* in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
|
|
2050
|
+
* permission rules for shell commands become `.codex/rules/tunestra.rules`. Subagents go
|
|
2051
|
+
* to `.codex/agents`.
|
|
2052
|
+
*/
|
|
2053
|
+
const codexAdapter = {
|
|
2054
|
+
agent: Agent.Codex,
|
|
2055
|
+
plan: planCodex,
|
|
2056
|
+
planLocal: planCodexLocal
|
|
2057
|
+
};
|
|
2058
|
+
function planCodex(context) {
|
|
2059
|
+
const { resolution, ruleFiles } = context;
|
|
2060
|
+
const projectDir = resolution.chain.project.dir;
|
|
2061
|
+
const notes = [];
|
|
2062
|
+
const outputs = [];
|
|
2063
|
+
const commandSkills = resolution.commands.filter((command) => {
|
|
2064
|
+
if (skillName.safeParse(command.artifact.key).success) return true;
|
|
2065
|
+
notes.push(`Codex skips the command "${command.artifact.key}": a skill name needs lowercase letters, digits and single hyphens.`);
|
|
2066
|
+
return false;
|
|
2067
|
+
}).flatMap((command) => planCommandSkill(projectDir, command));
|
|
2068
|
+
outputs.push(...commandSkills);
|
|
2069
|
+
const servers = resolution.mcp.filter((server) => {
|
|
2070
|
+
if (server.artifact.config.type === "sse") {
|
|
2071
|
+
notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
|
|
2072
|
+
return false;
|
|
2073
|
+
}
|
|
2074
|
+
return true;
|
|
2075
|
+
});
|
|
2076
|
+
const subagentFiles = resolution.subagents.map((subagent) => {
|
|
2077
|
+
return createFileOutput(`${SUBAGENTS_DIR}/${subagent.artifact.key}.toml`, renderSubagentToml(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
|
|
2078
|
+
});
|
|
2079
|
+
const readOnlySubagents = resolution.subagents.filter((subagent) => subagent.artifact.readOnly).map((subagent) => subagent.artifact.key);
|
|
2080
|
+
const hasReadOnlySubagents = readOnlySubagents.length > 0;
|
|
2081
|
+
outputs.push(...subagentFiles);
|
|
2082
|
+
if (hasReadOnlySubagents) {
|
|
2083
|
+
const names = readOnlySubagents.join(", ");
|
|
2084
|
+
notes.push(`Codex cannot make subagents read-only: ${names}. It turns off their shell and asks them not to edit files.`);
|
|
1731
2085
|
}
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2086
|
+
const configFile = planConfigFile(ruleFiles, servers, resolution.env);
|
|
2087
|
+
const execRules = planExecRules(resolution.permissions);
|
|
2088
|
+
const ignoredPaths = [];
|
|
2089
|
+
notes.push(...execRules.notes);
|
|
2090
|
+
if (typeof configFile === "object") {
|
|
2091
|
+
outputs.push(configFile);
|
|
2092
|
+
ignoredPaths.push(CONFIG_FILE);
|
|
1735
2093
|
}
|
|
1736
|
-
if (
|
|
1737
|
-
|
|
1738
|
-
|
|
2094
|
+
if (typeof execRules.file === "object") {
|
|
2095
|
+
outputs.push(execRules.file);
|
|
2096
|
+
ignoredPaths.push(EXEC_RULES_FILE);
|
|
1739
2097
|
}
|
|
1740
|
-
if (
|
|
1741
|
-
|
|
1742
|
-
|
|
2098
|
+
if (subagentFiles.length > 0) ignoredPaths.push(`${SUBAGENTS_DIR}/`);
|
|
2099
|
+
if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
|
|
2100
|
+
return {
|
|
2101
|
+
outputs,
|
|
2102
|
+
ignoredPaths,
|
|
2103
|
+
protectedPaths: [],
|
|
2104
|
+
notes,
|
|
2105
|
+
merges: []
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Codex reads `AGENTS.override.md` instead of AGENTS.md in the same folder, so the file
|
|
2110
|
+
* holds the whole index with the personal level.
|
|
2111
|
+
*/
|
|
2112
|
+
function planCodexLocal(context) {
|
|
2113
|
+
const index = renderAgentsIndex(context.local);
|
|
2114
|
+
return {
|
|
2115
|
+
outputs: [createFileOutput(OVERRIDE_FILE, index, OVERRIDE_SOURCE)],
|
|
2116
|
+
ignoredPaths: [OVERRIDE_FILE],
|
|
2117
|
+
merges: []
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
|
|
2122
|
+
* for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
|
|
2123
|
+
*/
|
|
2124
|
+
function planExecRules(permissions) {
|
|
2125
|
+
const execRules = [];
|
|
2126
|
+
const skippedRules = [];
|
|
2127
|
+
const allowRules = [];
|
|
2128
|
+
for (const resolved of permissions) {
|
|
2129
|
+
const { key, decision } = resolved.artifact;
|
|
2130
|
+
if (hasCodexDecision(decision) === false) {
|
|
2131
|
+
allowRules.push(key);
|
|
2132
|
+
continue;
|
|
2133
|
+
}
|
|
2134
|
+
const pattern = convertBashRule(key);
|
|
2135
|
+
if (pattern === void 0) {
|
|
2136
|
+
skippedRules.push(key);
|
|
2137
|
+
continue;
|
|
2138
|
+
}
|
|
2139
|
+
const execRule = createExecRule(resolved, decision, pattern);
|
|
2140
|
+
execRules.push(execRule);
|
|
1743
2141
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
2142
|
+
const notes = [];
|
|
2143
|
+
const hasSkippedRules = skippedRules.length > 0;
|
|
2144
|
+
const hasAllowRules = allowRules.length > 0;
|
|
2145
|
+
if (hasSkippedRules) {
|
|
2146
|
+
const rules = skippedRules.join(", ");
|
|
2147
|
+
notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
|
|
1747
2148
|
}
|
|
1748
|
-
|
|
1749
|
-
|
|
2149
|
+
if (hasAllowRules) {
|
|
2150
|
+
const rules = allowRules.join(", ");
|
|
2151
|
+
notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
|
|
2152
|
+
}
|
|
2153
|
+
if (execRules.length > 0) {
|
|
2154
|
+
const content = renderExecRules(execRules);
|
|
2155
|
+
return {
|
|
2156
|
+
file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
|
|
2157
|
+
notes
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
return {
|
|
2161
|
+
file: void 0,
|
|
2162
|
+
notes
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
function planConfigFile(ruleFiles, servers, env) {
|
|
2166
|
+
const hasRules = ruleFiles.length > 0;
|
|
2167
|
+
const hasServers = servers.length > 0;
|
|
2168
|
+
const hasEnv = env.length > 0;
|
|
2169
|
+
if (hasRules === false && hasServers === false && hasEnv === false) return;
|
|
2170
|
+
const content = renderConfigToml({
|
|
2171
|
+
instructions: hasRules ? renderInstructions(ruleFiles) : void 0,
|
|
2172
|
+
servers,
|
|
2173
|
+
env
|
|
2174
|
+
});
|
|
2175
|
+
return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
|
|
2176
|
+
}
|
|
2177
|
+
/** The rule files as they are, with the header that names the source of each. */
|
|
2178
|
+
function renderInstructions(ruleFiles) {
|
|
2179
|
+
const rules = ruleFiles.map((file) => file.content.trimEnd());
|
|
2180
|
+
return `${[INSTRUCTIONS_INTRO, ...rules].join("\n\n")}\n`;
|
|
1750
2181
|
}
|
|
1751
2182
|
//#endregion
|
|
2183
|
+
//#region src/agents/adapters.ts
|
|
2184
|
+
/** Every supported agent. */
|
|
2185
|
+
const AGENT_ADAPTERS = {
|
|
2186
|
+
[Agent.Claude]: claudeAdapter,
|
|
2187
|
+
[Agent.Codex]: codexAdapter
|
|
2188
|
+
};
|
|
2189
|
+
//#endregion
|
|
1752
2190
|
//#region src/generate/plan-setup.ts
|
|
1753
2191
|
/**
|
|
1754
|
-
* Builds every output without touching the disk
|
|
1755
|
-
*
|
|
2192
|
+
* Builds every output without touching the disk. Without a personal level, it is the plan
|
|
2193
|
+
* of the shared chain. With one, the committed files still come from the shared chain, so
|
|
2194
|
+
* that they are the same for the whole team; the other files come from the chain with the
|
|
2195
|
+
* personal level, and every agent adds the files that tell it about the personal level.
|
|
1756
2196
|
*/
|
|
1757
|
-
function planSetup(
|
|
2197
|
+
function planSetup(shared, local) {
|
|
2198
|
+
const sharedPlan = planChain(shared);
|
|
2199
|
+
if (local === void 0) return finishPlan(sharedPlan);
|
|
2200
|
+
const localPlan = planChain(local);
|
|
2201
|
+
const committedPaths = sharedPlan.protectedPaths;
|
|
2202
|
+
const sharedOutputs = sharedPlan.outputs.filter((output) => committedPaths.includes(output.path));
|
|
2203
|
+
const localOutputs = localPlan.outputs.filter((output) => localPlan.protectedPaths.includes(output.path) === false);
|
|
2204
|
+
const outputs = [...sharedOutputs, ...localOutputs];
|
|
2205
|
+
const ignoredPaths = [...localPlan.ignoredPaths];
|
|
2206
|
+
const merges = [];
|
|
2207
|
+
const context = {
|
|
2208
|
+
shared,
|
|
2209
|
+
local
|
|
2210
|
+
};
|
|
2211
|
+
for (const agent of local.chain.project.agents) {
|
|
2212
|
+
const agentPlan = AGENT_ADAPTERS[agent].planLocal(context);
|
|
2213
|
+
outputs.push(...agentPlan.outputs);
|
|
2214
|
+
ignoredPaths.push(...agentPlan.ignoredPaths);
|
|
2215
|
+
merges.push(...agentPlan.merges);
|
|
2216
|
+
}
|
|
2217
|
+
return finishPlan({
|
|
2218
|
+
outputs,
|
|
2219
|
+
ignoredPaths,
|
|
2220
|
+
protectedPaths: committedPaths,
|
|
2221
|
+
notes: localPlan.notes,
|
|
2222
|
+
merges
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
/** The shared files that any agent can read (AGENTS.md, `.agents/`), then each agent. */
|
|
2226
|
+
function planChain(resolution) {
|
|
1758
2227
|
const projectDir = resolution.chain.project.dir;
|
|
1759
2228
|
const index = renderAgentsIndex(resolution);
|
|
1760
2229
|
const indexFile = createFileOutput(INSTRUCTIONS_FILE, index, SETUP_SOURCE);
|
|
@@ -1779,14 +2248,30 @@ function planSetup(resolution) {
|
|
|
1779
2248
|
protectedPaths.push(...agentPlan.protectedPaths);
|
|
1780
2249
|
notes.push(...agentPlan.notes);
|
|
1781
2250
|
}
|
|
1782
|
-
ignoredPaths.push(`${STATE_DIR}/`);
|
|
1783
|
-
const problems = findPathConflicts(outputs);
|
|
1784
|
-
if (problems.length > 0) throw createProblemsError(problems);
|
|
1785
2251
|
return {
|
|
1786
2252
|
outputs,
|
|
1787
2253
|
ignoredPaths,
|
|
1788
2254
|
protectedPaths,
|
|
1789
|
-
notes
|
|
2255
|
+
notes,
|
|
2256
|
+
merges: []
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* The sources of the personal level are always ignored, so that nobody commits them by
|
|
2261
|
+
* mistake; so is the state.
|
|
2262
|
+
*/
|
|
2263
|
+
function finishPlan(plan) {
|
|
2264
|
+
const ignoredPaths = [
|
|
2265
|
+
...plan.ignoredPaths,
|
|
2266
|
+
LOCAL_CONFIG,
|
|
2267
|
+
`${LOCAL_DIR}/`,
|
|
2268
|
+
`${STATE_DIR}/`
|
|
2269
|
+
];
|
|
2270
|
+
const problems = findPathConflicts(plan.outputs);
|
|
2271
|
+
if (problems.length > 0) throw createProblemsError(problems);
|
|
2272
|
+
return {
|
|
2273
|
+
...plan,
|
|
2274
|
+
ignoredPaths
|
|
1790
2275
|
};
|
|
1791
2276
|
}
|
|
1792
2277
|
function planRuleFile(projectDir, rule) {
|
|
@@ -1830,6 +2315,7 @@ const ArtifactKind = {
|
|
|
1830
2315
|
Skills: "skills",
|
|
1831
2316
|
Commands: "commands",
|
|
1832
2317
|
Mcp: "mcp",
|
|
2318
|
+
Subagents: "subagents",
|
|
1833
2319
|
Env: "env"
|
|
1834
2320
|
};
|
|
1835
2321
|
//#endregion
|
|
@@ -2024,6 +2510,7 @@ function applyInheritance(chain) {
|
|
|
2024
2510
|
const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
|
|
2025
2511
|
const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
|
|
2026
2512
|
const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
|
|
2513
|
+
const subagents = inheritArtifacts(chain.levels, ArtifactKind.Subagents, (level) => level.subagents);
|
|
2027
2514
|
const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
|
|
2028
2515
|
const permissions = inheritPermissions(chain.levels);
|
|
2029
2516
|
const problems = [
|
|
@@ -2031,6 +2518,7 @@ function applyInheritance(chain) {
|
|
|
2031
2518
|
...skills.problems,
|
|
2032
2519
|
...commands.problems,
|
|
2033
2520
|
...mcp.problems,
|
|
2521
|
+
...subagents.problems,
|
|
2034
2522
|
...env.problems,
|
|
2035
2523
|
...permissions.problems
|
|
2036
2524
|
];
|
|
@@ -2041,6 +2529,7 @@ function applyInheritance(chain) {
|
|
|
2041
2529
|
skills: skills.artifacts,
|
|
2042
2530
|
commands: commands.artifacts,
|
|
2043
2531
|
mcp: mcp.artifacts,
|
|
2532
|
+
subagents: subagents.artifacts,
|
|
2044
2533
|
env: env.artifacts,
|
|
2045
2534
|
permissions: permissions.artifacts
|
|
2046
2535
|
};
|
|
@@ -2049,9 +2538,20 @@ function applyInheritance(chain) {
|
|
|
2049
2538
|
//#region src/shared/schemas/state.ts
|
|
2050
2539
|
const fileRecordSchema = z.strictObject({ hash: z.string() });
|
|
2051
2540
|
const symlinkRecordSchema = z.strictObject({ target: z.string() });
|
|
2541
|
+
const ruleList = z.array(z.string()).readonly();
|
|
2542
|
+
const settingsEntriesSchema = z.strictObject({
|
|
2543
|
+
env: z.record(z.string(), z.string()),
|
|
2544
|
+
permissions: z.strictObject({
|
|
2545
|
+
allow: ruleList,
|
|
2546
|
+
ask: ruleList,
|
|
2547
|
+
deny: ruleList
|
|
2548
|
+
})
|
|
2549
|
+
});
|
|
2052
2550
|
const stateSchema = z.strictObject({
|
|
2053
2551
|
version: z.literal(1),
|
|
2054
|
-
outputs: z.record(z.string(), z.union([fileRecordSchema, symlinkRecordSchema]))
|
|
2552
|
+
outputs: z.record(z.string(), z.union([fileRecordSchema, symlinkRecordSchema])),
|
|
2553
|
+
/** Older states have no merges. */
|
|
2554
|
+
merges: z.record(z.string(), settingsEntriesSchema).default({})
|
|
2055
2555
|
});
|
|
2056
2556
|
//#endregion
|
|
2057
2557
|
//#region src/state/read-state.ts
|
|
@@ -2060,15 +2560,16 @@ async function readState(projectDir) {
|
|
|
2060
2560
|
const file = join(projectDir, STATE_FILE);
|
|
2061
2561
|
const content = await readFileIfExists(file);
|
|
2062
2562
|
if (content === void 0) return createEmptyState();
|
|
2063
|
-
return validate(parseJson$
|
|
2563
|
+
return validate(parseJson$2(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
|
|
2064
2564
|
}
|
|
2065
2565
|
function createEmptyState() {
|
|
2066
2566
|
return {
|
|
2067
2567
|
version: 1,
|
|
2068
|
-
outputs: {}
|
|
2568
|
+
outputs: {},
|
|
2569
|
+
merges: {}
|
|
2069
2570
|
};
|
|
2070
2571
|
}
|
|
2071
|
-
function parseJson$
|
|
2572
|
+
function parseJson$2(content, file) {
|
|
2072
2573
|
try {
|
|
2073
2574
|
return JSON.parse(content);
|
|
2074
2575
|
} catch (error) {
|
|
@@ -2116,16 +2617,21 @@ async function removeSymlink(path) {
|
|
|
2116
2617
|
async function writeState(projectDir, state) {
|
|
2117
2618
|
const file = join(projectDir, STATE_FILE);
|
|
2118
2619
|
const dir = dirname(file);
|
|
2119
|
-
const
|
|
2120
|
-
const
|
|
2620
|
+
const outputs = sortByKey(state.outputs);
|
|
2621
|
+
const merges = sortByKey(state.merges);
|
|
2121
2622
|
const content = {
|
|
2122
2623
|
version: state.version,
|
|
2123
|
-
outputs
|
|
2624
|
+
outputs,
|
|
2625
|
+
merges
|
|
2124
2626
|
};
|
|
2125
2627
|
const text = `${JSON.stringify(content, null, 2)}\n`;
|
|
2126
2628
|
await mkdir(dir, { recursive: true });
|
|
2127
2629
|
await writeFile(file, text);
|
|
2128
2630
|
}
|
|
2631
|
+
function sortByKey(record) {
|
|
2632
|
+
const sorted = Object.entries(record).toSorted(([left], [right]) => left < right ? -1 : 1);
|
|
2633
|
+
return Object.fromEntries(sorted);
|
|
2634
|
+
}
|
|
2129
2635
|
//#endregion
|
|
2130
2636
|
//#region src/sync/remove-outputs.ts
|
|
2131
2637
|
/** Removes stale outputs, then the directories that they leave empty. */
|
|
@@ -2188,10 +2694,10 @@ async function createRecord(output) {
|
|
|
2188
2694
|
* object has no keys that tunestra could own, so the result is `undefined`.
|
|
2189
2695
|
*/
|
|
2190
2696
|
async function findForeignKeys(path, ownedKeys) {
|
|
2191
|
-
const data = parseJson(await readFile(path, "utf8"));
|
|
2697
|
+
const data = parseJson$1(await readFile(path, "utf8"));
|
|
2192
2698
|
if (isKeyValueMap(data)) return Object.keys(data).filter((key) => ownedKeys.includes(key) === false);
|
|
2193
2699
|
}
|
|
2194
|
-
function parseJson(text) {
|
|
2700
|
+
function parseJson$1(text) {
|
|
2195
2701
|
try {
|
|
2196
2702
|
return JSON.parse(text);
|
|
2197
2703
|
} catch (error) {
|
|
@@ -2300,6 +2806,117 @@ function createStaleCheck(path, isRemovable, warning) {
|
|
|
2300
2806
|
};
|
|
2301
2807
|
}
|
|
2302
2808
|
//#endregion
|
|
2809
|
+
//#region src/sync/merge-settings.ts
|
|
2810
|
+
const DECISIONS = Object.values(PermissionDecision);
|
|
2811
|
+
/**
|
|
2812
|
+
* The settings of people with the entries of setup: the entries from the last setup are
|
|
2813
|
+
* taken out, the new ones are added. A variable that someone changed after setup wrote it
|
|
2814
|
+
* is theirs now and stays. Other keys keep their order.
|
|
2815
|
+
*/
|
|
2816
|
+
function mergeSettings(current, previous, next) {
|
|
2817
|
+
const result = { ...current };
|
|
2818
|
+
const env = mergeEnv(current.env, previous, next);
|
|
2819
|
+
const permissions = mergePermissions(current.permissions, previous, next);
|
|
2820
|
+
setOrDelete(result, "env", env);
|
|
2821
|
+
setOrDelete(result, "permissions", permissions);
|
|
2822
|
+
return result;
|
|
2823
|
+
}
|
|
2824
|
+
function mergeEnv(current, previous, next) {
|
|
2825
|
+
const env = isKeyValueMap(current) ? { ...current } : {};
|
|
2826
|
+
const previousEnv = previous?.env ?? {};
|
|
2827
|
+
const previousEntries = Object.entries(previousEnv);
|
|
2828
|
+
for (const [name, value] of previousEntries) if (env[name] === value) delete env[name];
|
|
2829
|
+
const nextEnv = next?.env ?? {};
|
|
2830
|
+
return {
|
|
2831
|
+
...env,
|
|
2832
|
+
...nextEnv
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
function mergePermissions(current, previous, next) {
|
|
2836
|
+
const permissions = isKeyValueMap(current) ? { ...current } : {};
|
|
2837
|
+
for (const decision of DECISIONS) {
|
|
2838
|
+
const list = permissions[decision];
|
|
2839
|
+
const rules = Array.isArray(list) ? list : [];
|
|
2840
|
+
const previousRules = previous?.permissions[decision] ?? [];
|
|
2841
|
+
const nextRules = next?.permissions[decision] ?? [];
|
|
2842
|
+
const keptRules = rules.filter((rule) => previousRules.includes(rule) === false);
|
|
2843
|
+
const addedRules = nextRules.filter((rule) => keptRules.includes(rule) === false);
|
|
2844
|
+
setOrDelete(permissions, decision, [...keptRules, ...addedRules]);
|
|
2845
|
+
}
|
|
2846
|
+
return permissions;
|
|
2847
|
+
}
|
|
2848
|
+
/** An empty list or object is left out, as if nobody had written it. */
|
|
2849
|
+
function setOrDelete(target, key, value) {
|
|
2850
|
+
const isEmptyList = Array.isArray(value) && value.length === 0;
|
|
2851
|
+
const isEmptyRecord = isKeyValueMap(value) && Object.keys(value).length === 0;
|
|
2852
|
+
if (isEmptyList || isEmptyRecord) {
|
|
2853
|
+
delete target[key];
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
target[key] = value;
|
|
2857
|
+
}
|
|
2858
|
+
//#endregion
|
|
2859
|
+
//#region src/sync/plan-merges.ts
|
|
2860
|
+
/**
|
|
2861
|
+
* Adds the entries of every merge to its file and takes the entries of the last setup out,
|
|
2862
|
+
* also from files that this setup does not merge into any more. Reads the disk only.
|
|
2863
|
+
*/
|
|
2864
|
+
async function planMerges(projectDir, merges, previous) {
|
|
2865
|
+
const paths = new Set(merges.map((merge) => merge.path));
|
|
2866
|
+
const stalePaths = Object.keys(previous).filter((path) => paths.has(path) === false);
|
|
2867
|
+
const nextSteps = merges.map((merge) => ({
|
|
2868
|
+
path: merge.path,
|
|
2869
|
+
source: merge.source,
|
|
2870
|
+
next: merge.entries
|
|
2871
|
+
}));
|
|
2872
|
+
const staleSteps = stalePaths.map((path) => ({
|
|
2873
|
+
path,
|
|
2874
|
+
source: "the last setup",
|
|
2875
|
+
next: void 0
|
|
2876
|
+
}));
|
|
2877
|
+
const results = await mapSettled([...nextSteps, ...staleSteps], (step) => planMerge(projectDir, step, previous));
|
|
2878
|
+
const writes = results.map((result) => result.write).filter((write) => write !== void 0);
|
|
2879
|
+
const removals = results.map((result) => result.removal).filter((removal) => removal !== void 0);
|
|
2880
|
+
const recordEntries = merges.map((merge) => [merge.path, merge.entries]);
|
|
2881
|
+
return {
|
|
2882
|
+
writes,
|
|
2883
|
+
removals,
|
|
2884
|
+
records: Object.fromEntries(recordEntries)
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
async function planMerge(projectDir, step, previous) {
|
|
2888
|
+
const content = await readFileIfExists(join(projectDir, step.path));
|
|
2889
|
+
const current = content === void 0 ? {} : parseSettings(content, step.path);
|
|
2890
|
+
const merged = mergeSettings(current, previous[step.path], step.next);
|
|
2891
|
+
const isEmpty = Object.keys(merged).length === 0;
|
|
2892
|
+
const isUnchanged = JSON.stringify(merged) === JSON.stringify(current);
|
|
2893
|
+
if (isEmpty) return {
|
|
2894
|
+
write: void 0,
|
|
2895
|
+
removal: content === void 0 ? void 0 : step.path
|
|
2896
|
+
};
|
|
2897
|
+
if (isUnchanged && content !== void 0) return {
|
|
2898
|
+
write: void 0,
|
|
2899
|
+
removal: void 0
|
|
2900
|
+
};
|
|
2901
|
+
const json = JSON.stringify(merged, null, 2);
|
|
2902
|
+
return {
|
|
2903
|
+
write: createFileOutput(step.path, `${json}\n`, step.source),
|
|
2904
|
+
removal: void 0
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
function parseSettings(content, path) {
|
|
2908
|
+
const data = parseJson(content, path);
|
|
2909
|
+
if (isKeyValueMap(data) === false) throw new TunestraError(`${path} is not a JSON object, so setup cannot add its settings. Fix or delete it and run tunestra setup again`);
|
|
2910
|
+
return data;
|
|
2911
|
+
}
|
|
2912
|
+
function parseJson(content, path) {
|
|
2913
|
+
try {
|
|
2914
|
+
return JSON.parse(content);
|
|
2915
|
+
} catch (error) {
|
|
2916
|
+
throw new TunestraError(`${path} is not valid JSON: ${getErrorMessage(error)}. Fix or delete it and run tunestra setup again`);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
//#endregion
|
|
2303
2920
|
//#region src/sync/plan-sync.ts
|
|
2304
2921
|
/** Reads the disk, but writes nothing; problems of every output are reported together. */
|
|
2305
2922
|
async function planSync(projectDir, plan, previous) {
|
|
@@ -2312,16 +2929,18 @@ async function planSync(projectDir, plan, previous) {
|
|
|
2312
2929
|
const outputPaths = new Set(paths);
|
|
2313
2930
|
const staleEntries = Object.entries(previous.outputs).filter(([path]) => outputPaths.has(path) === false);
|
|
2314
2931
|
const outputChecks = await mapSettled(plan.outputs, (output) => checkOutput(output, context));
|
|
2932
|
+
const mergePlan = await planMerges(projectDir, plan.merges, previous.merges);
|
|
2315
2933
|
const staleChecks = await mapSettled(staleEntries, ([path, record]) => checkStaleOutput(projectDir, path, record));
|
|
2316
|
-
const removals = staleChecks.filter((check) => check.isRemovable).map((check) => check.path);
|
|
2934
|
+
const removals = [...staleChecks.filter((check) => check.isRemovable).map((check) => check.path), ...mergePlan.removals];
|
|
2317
2935
|
const warnings = [...outputChecks, ...staleChecks].map((check) => check.warning).filter(isString);
|
|
2318
2936
|
const recordEntries = outputChecks.map((check) => [check.path, check.record]);
|
|
2319
2937
|
const state = {
|
|
2320
2938
|
version: 1,
|
|
2321
|
-
outputs: Object.fromEntries(recordEntries)
|
|
2939
|
+
outputs: Object.fromEntries(recordEntries),
|
|
2940
|
+
merges: mergePlan.records
|
|
2322
2941
|
};
|
|
2323
2942
|
return {
|
|
2324
|
-
outputs: plan.outputs,
|
|
2943
|
+
outputs: [...plan.outputs, ...mergePlan.writes],
|
|
2325
2944
|
removals,
|
|
2326
2945
|
warnings,
|
|
2327
2946
|
state
|
|
@@ -2375,8 +2994,11 @@ async function updateGitignoreFile(projectDir, paths) {
|
|
|
2375
2994
|
*/
|
|
2376
2995
|
async function runSetup(projectDir, options) {
|
|
2377
2996
|
const chain = await loadChain(projectDir);
|
|
2378
|
-
const
|
|
2379
|
-
const
|
|
2997
|
+
const localChain = await loadLocalChain(chain);
|
|
2998
|
+
const sharedResolution = applyInheritance(chain);
|
|
2999
|
+
const localResolution = localChain === void 0 ? void 0 : applyInheritance(localChain);
|
|
3000
|
+
const resolution = localResolution ?? sharedResolution;
|
|
3001
|
+
const plan = planSetup(sharedResolution, localResolution);
|
|
2380
3002
|
const previous = await readState(chain.project.dir);
|
|
2381
3003
|
const sync = await planSync(chain.project.dir, plan, previous);
|
|
2382
3004
|
const report = {
|
|
@@ -2400,6 +3022,7 @@ function describeResolution(resolution) {
|
|
|
2400
3022
|
describeSection("Skills", resolution.skills, projectDir),
|
|
2401
3023
|
describeSection("Commands", resolution.commands, projectDir),
|
|
2402
3024
|
describeSection("MCP servers", resolution.mcp, projectDir),
|
|
3025
|
+
describeSection("Subagents", resolution.subagents, projectDir),
|
|
2403
3026
|
describeSection("Environment", resolution.env, projectDir),
|
|
2404
3027
|
describeSection("Permissions", permissions, projectDir)
|
|
2405
3028
|
].filter((section) => section.length > 0).join("\n\n");
|
|
@@ -2497,25 +3120,26 @@ async function removeGitignoreBlockFile(projectDir) {
|
|
|
2497
3120
|
async function runUninstall(projectDir, options) {
|
|
2498
3121
|
const state = await readState(projectDir);
|
|
2499
3122
|
const checks = await mapSettled(Object.entries(state.outputs), ([path, record]) => checkStaleOutput(projectDir, path, record));
|
|
2500
|
-
const
|
|
2501
|
-
const
|
|
2502
|
-
|
|
3123
|
+
const removable = checks.filter((check) => check.isRemovable);
|
|
3124
|
+
const mergePlan = await planMerges(projectDir, [], state.merges);
|
|
3125
|
+
const removals = [...removable.map((check) => check.path), ...mergePlan.removals];
|
|
3126
|
+
const report = {
|
|
2503
3127
|
removals,
|
|
2504
|
-
|
|
3128
|
+
updates: mergePlan.writes.map((write) => write.path),
|
|
3129
|
+
warnings: checks.map((check) => check.warning).filter(isString)
|
|
2505
3130
|
};
|
|
3131
|
+
if (options.dryRun) return report;
|
|
2506
3132
|
const stateDir = join(projectDir, STATE_DIR);
|
|
2507
3133
|
const tunestraDir = join(projectDir, PROJECT_DIR);
|
|
2508
3134
|
await removeOutputs(projectDir, removals);
|
|
3135
|
+
await writeOutputs(projectDir, mergePlan.writes);
|
|
2509
3136
|
await removeGitignoreBlockFile(projectDir);
|
|
2510
3137
|
await rm(stateDir, {
|
|
2511
3138
|
recursive: true,
|
|
2512
3139
|
force: true
|
|
2513
3140
|
});
|
|
2514
3141
|
await removeDirIfEmpty(tunestraDir);
|
|
2515
|
-
return
|
|
2516
|
-
removals,
|
|
2517
|
-
warnings
|
|
2518
|
-
};
|
|
3142
|
+
return report;
|
|
2519
3143
|
}
|
|
2520
3144
|
//#endregion
|
|
2521
3145
|
//#region src/cli/commands/uninstall.ts
|
|
@@ -2526,12 +3150,14 @@ async function describeUninstall(projectDir, options) {
|
|
|
2526
3150
|
const count = report.removals.length;
|
|
2527
3151
|
if (options.dryRun) {
|
|
2528
3152
|
const removals = report.removals.map((path) => ` remove ${path}`);
|
|
2529
|
-
|
|
3153
|
+
const updates = report.updates.map((path) => ` update ${path}`);
|
|
3154
|
+
lines.push(...removals, ...updates, "", "Nothing was removed (--dry-run).");
|
|
2530
3155
|
return lines.join("\n");
|
|
2531
3156
|
}
|
|
2532
3157
|
const check = pc.green("✔");
|
|
2533
3158
|
lines.push(`${check} Removed ${count} generated files and the tunestra block in .gitignore.`);
|
|
2534
|
-
lines.push(
|
|
3159
|
+
for (const path of report.updates) lines.push(`Took the settings of tunestra out of ${path}; your settings stay.`);
|
|
3160
|
+
lines.push("tunestra.yaml, tunestra.local.yaml and their folders are kept: they are your sources.");
|
|
2535
3161
|
return lines.join("\n");
|
|
2536
3162
|
}
|
|
2537
3163
|
//#endregion
|
|
@@ -2554,9 +3180,12 @@ async function runCommand(action) {
|
|
|
2554
3180
|
//#endregion
|
|
2555
3181
|
//#region src/cli/index.ts
|
|
2556
3182
|
const program = new Command().name("tunestra").description("Tune your AI agents to play as one orchestra.").version(VERSION);
|
|
2557
|
-
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) => {
|
|
3183
|
+
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", []).option("--local", "create only your personal level: tunestra.local.yaml and .tunestra.local/", false).action(async (options) => {
|
|
2558
3184
|
const projectDir = process.cwd();
|
|
2559
|
-
const initOptions = {
|
|
3185
|
+
const initOptions = {
|
|
3186
|
+
presets: options.extends,
|
|
3187
|
+
local: options.local
|
|
3188
|
+
};
|
|
2560
3189
|
await runCommand(() => describeInit(projectDir, initOptions));
|
|
2561
3190
|
});
|
|
2562
3191
|
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) => {
|