tunestra 0.5.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 +44 -4
- package/dist/cli.mjs +976 -492
- package/dist/index.mjs +1 -1
- package/dist/{src-CZK2Ixbt.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,
|
|
@@ -382,6 +446,15 @@ const projectConfigSchema = z.strictObject({
|
|
|
382
446
|
env: envSchema.default({}),
|
|
383
447
|
permissions: permissionsSchema.default({})
|
|
384
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
|
+
});
|
|
385
458
|
const mcpServerSchema = z.strictObject({
|
|
386
459
|
description: z.string().optional(),
|
|
387
460
|
type: z.enum([
|
|
@@ -701,12 +774,12 @@ const PermissionDecision = {
|
|
|
701
774
|
};
|
|
702
775
|
//#endregion
|
|
703
776
|
//#region src/presets/permissions.ts
|
|
704
|
-
const DECISIONS$
|
|
777
|
+
const DECISIONS$2 = Object.values(PermissionDecision);
|
|
705
778
|
/** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
|
|
706
779
|
function createPermissionRules(permissions, manifestPath, artifactsDir) {
|
|
707
780
|
const source = relativePosix(artifactsDir, manifestPath);
|
|
708
781
|
const rules = [];
|
|
709
|
-
for (const decision of DECISIONS$
|
|
782
|
+
for (const decision of DECISIONS$2) {
|
|
710
783
|
const keys = permissions[decision] ?? [];
|
|
711
784
|
for (const key of keys) rules.push({
|
|
712
785
|
key,
|
|
@@ -808,6 +881,7 @@ function createDownloadOptions(dir, token) {
|
|
|
808
881
|
}
|
|
809
882
|
//#endregion
|
|
810
883
|
//#region src/sources/create-source-resolver.ts
|
|
884
|
+
const HOME_PREFIX = "~/";
|
|
811
885
|
function createSourceResolver(projectDir, download = downloadRepository) {
|
|
812
886
|
const downloads = /* @__PURE__ */ new Map();
|
|
813
887
|
const repositories = [];
|
|
@@ -837,7 +911,7 @@ function createSourceResolver(projectDir, download = downloadRepository) {
|
|
|
837
911
|
const repository = await fetchRepository(gitSource);
|
|
838
912
|
return checkInside(repository, join(repository.root, gitSource.path), ref);
|
|
839
913
|
}
|
|
840
|
-
const dir =
|
|
914
|
+
const dir = resolveLocalPath(ref, fromDir);
|
|
841
915
|
const repository = findRepository(fromDir);
|
|
842
916
|
if (repository === void 0) return dir;
|
|
843
917
|
return checkInside(repository, dir, ref);
|
|
@@ -867,6 +941,17 @@ function isInside(root, dir) {
|
|
|
867
941
|
const isOtherDrive = isAbsolute(path);
|
|
868
942
|
return isParent === false && isOtherDrive === false;
|
|
869
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
|
+
}
|
|
870
955
|
//#endregion
|
|
871
956
|
//#region src/chain/check-chain.ts
|
|
872
957
|
/** A level can extend the same or a more general level, never a more specific one. */
|
|
@@ -898,7 +983,7 @@ function findDuplicateNames(levels) {
|
|
|
898
983
|
//#endregion
|
|
899
984
|
//#region src/chain/linearize-chain.ts
|
|
900
985
|
/**
|
|
901
|
-
* 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
|
|
902
987
|
* order of `extends`, then the level itself. A level that is reached twice is added once.
|
|
903
988
|
*/
|
|
904
989
|
function linearizeChain(graph) {
|
|
@@ -920,7 +1005,7 @@ function linearizeChain(graph) {
|
|
|
920
1005
|
if (level === void 0) return;
|
|
921
1006
|
levels.push(level);
|
|
922
1007
|
};
|
|
923
|
-
visit(graph.
|
|
1008
|
+
visit(graph.root.dir, []);
|
|
924
1009
|
return {
|
|
925
1010
|
levels,
|
|
926
1011
|
problems: [...problems]
|
|
@@ -960,14 +1045,14 @@ async function loadPreset(dir) {
|
|
|
960
1045
|
//#endregion
|
|
961
1046
|
//#region src/chain/load-preset-graph.ts
|
|
962
1047
|
/**
|
|
963
|
-
* Loads the presets of the
|
|
1048
|
+
* Loads the presets of the root, then the presets they extend, and so on. Each step loads
|
|
964
1049
|
* its presets in parallel; a preset that several levels extend is loaded once.
|
|
965
1050
|
*/
|
|
966
|
-
async function loadPresetGraph(
|
|
1051
|
+
async function loadPresetGraph(root, resolver) {
|
|
967
1052
|
const levels = /* @__PURE__ */ new Map();
|
|
968
1053
|
const parents = /* @__PURE__ */ new Map();
|
|
969
|
-
let pending = [
|
|
970
|
-
levels.set(
|
|
1054
|
+
let pending = [root];
|
|
1055
|
+
levels.set(root.dir, root);
|
|
971
1056
|
while (pending.length > 0) {
|
|
972
1057
|
const dirsToLoad = /* @__PURE__ */ new Set();
|
|
973
1058
|
for (const preset of pending) {
|
|
@@ -979,7 +1064,7 @@ async function loadPresetGraph(project, resolver) {
|
|
|
979
1064
|
for (const preset of pending) levels.set(preset.dir, preset);
|
|
980
1065
|
}
|
|
981
1066
|
return {
|
|
982
|
-
|
|
1067
|
+
root,
|
|
983
1068
|
levels,
|
|
984
1069
|
parents
|
|
985
1070
|
};
|
|
@@ -1013,6 +1098,67 @@ async function loadChain(projectDir, options = {}) {
|
|
|
1013
1098
|
};
|
|
1014
1099
|
}
|
|
1015
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
|
|
1016
1162
|
//#region src/generate/describe-source.ts
|
|
1017
1163
|
/** Files built from the whole chain, like AGENTS.md: there is no single file to edit. */
|
|
1018
1164
|
const SETUP_SOURCE = "tunestra.yaml and its presets";
|
|
@@ -1025,6 +1171,139 @@ function describeArtifactSource(projectDir, origin, source) {
|
|
|
1025
1171
|
return relativePosix(projectDir, join(origin.artifactsDir, source));
|
|
1026
1172
|
}
|
|
1027
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
|
|
1028
1307
|
//#region src/shared/utils/outputs.ts
|
|
1029
1308
|
function createFileOutput(path, content, source) {
|
|
1030
1309
|
return {
|
|
@@ -1140,7 +1419,7 @@ function compareByName([a], [b]) {
|
|
|
1140
1419
|
//#endregion
|
|
1141
1420
|
//#region src/agents/claude/settings-json.ts
|
|
1142
1421
|
const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
|
|
1143
|
-
const DECISIONS = Object.values(PermissionDecision);
|
|
1422
|
+
const DECISIONS$1 = Object.values(PermissionDecision);
|
|
1144
1423
|
/** The keys that tunestra writes; a file with only these keys is a generated one. */
|
|
1145
1424
|
const SETTINGS_KEYS = [
|
|
1146
1425
|
"$schema",
|
|
@@ -1156,10 +1435,23 @@ function renderSettingsJson(settings) {
|
|
|
1156
1435
|
if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
|
|
1157
1436
|
return `${JSON.stringify(content, null, 2)}\n`;
|
|
1158
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
|
+
}
|
|
1159
1451
|
/** Only the lists that have rules; a rule repeated on several levels is written once. */
|
|
1160
1452
|
function buildPermissionLists(permissions) {
|
|
1161
1453
|
const lists = {};
|
|
1162
|
-
for (const decision of DECISIONS) {
|
|
1454
|
+
for (const decision of DECISIONS$1) {
|
|
1163
1455
|
const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
|
|
1164
1456
|
const sortedKeys = [...new Set(keys)].toSorted();
|
|
1165
1457
|
if (sortedKeys.length > 0) lists[decision] = sortedKeys;
|
|
@@ -1199,6 +1491,8 @@ const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
|
|
|
1199
1491
|
const SETTINGS_FILE = ".claude/settings.json";
|
|
1200
1492
|
const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
|
|
1201
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}/`;
|
|
1202
1496
|
/**
|
|
1203
1497
|
* Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
|
|
1204
1498
|
* which Claude Code loads in every session; a symlink to a directory outside `.claude`
|
|
@@ -1208,8 +1502,40 @@ const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
|
|
|
1208
1502
|
*/
|
|
1209
1503
|
const claudeAdapter = {
|
|
1210
1504
|
agent: Agent.Claude,
|
|
1211
|
-
plan: planClaude
|
|
1505
|
+
plan: planClaude,
|
|
1506
|
+
planLocal: planClaudeLocal
|
|
1212
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
|
+
}
|
|
1213
1539
|
function planClaude(context) {
|
|
1214
1540
|
const { resolution, ruleFiles } = context;
|
|
1215
1541
|
const projectDir = resolution.chain.project.dir;
|
|
@@ -1253,7 +1579,8 @@ function planClaude(context) {
|
|
|
1253
1579
|
MCP_FILE
|
|
1254
1580
|
],
|
|
1255
1581
|
protectedPaths,
|
|
1256
|
-
notes: []
|
|
1582
|
+
notes: [],
|
|
1583
|
+
merges: []
|
|
1257
1584
|
};
|
|
1258
1585
|
}
|
|
1259
1586
|
/** Claude Code drops some variables from project settings; setup says so instead. */
|
|
@@ -1291,404 +1618,41 @@ function linkSkill(projectDir, skill) {
|
|
|
1291
1618
|
};
|
|
1292
1619
|
}
|
|
1293
1620
|
//#endregion
|
|
1294
|
-
//#region src/
|
|
1295
|
-
/** Codex starts the skill only when people call it, like a command. */
|
|
1296
|
-
const OPENAI_YAML = [
|
|
1297
|
-
GENERATED_HASH_NOTICE,
|
|
1298
|
-
"policy:",
|
|
1299
|
-
" allow_implicit_invocation: false",
|
|
1300
|
-
""
|
|
1301
|
-
].join("\n");
|
|
1621
|
+
//#region src/generate/rule-file.ts
|
|
1302
1622
|
/**
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
* 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.
|
|
1306
1625
|
*/
|
|
1307
|
-
function
|
|
1308
|
-
const
|
|
1309
|
-
const
|
|
1310
|
-
|
|
1311
|
-
const frontmatter = {
|
|
1312
|
-
name: key,
|
|
1313
|
-
description: description ?? `The /${key} command`
|
|
1314
|
-
};
|
|
1315
|
-
const skill = `---\n${stringify(frontmatter)}---\n${body}`;
|
|
1316
|
-
const skillPath = `${dir}/SKILL.md`;
|
|
1317
|
-
const policyPath = `${dir}/agents/openai.yaml`;
|
|
1318
|
-
return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
|
|
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`;
|
|
1319
1630
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
const
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
* `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
|
|
1346
|
-
* session, the environment of the commands that Codex runs, and the MCP servers. The rules
|
|
1347
|
-
* are a literal string, so that people can read them.
|
|
1348
|
-
*/
|
|
1349
|
-
function renderConfigToml(config) {
|
|
1350
|
-
const parts = [GENERATED_HASH_NOTICE];
|
|
1351
|
-
if (typeof config.instructions === "string") {
|
|
1352
|
-
const line = renderTomlText("developer_instructions", config.instructions);
|
|
1353
|
-
parts.push(line);
|
|
1354
|
-
}
|
|
1355
|
-
const tables = {};
|
|
1356
|
-
const hasEnv = config.env.length > 0;
|
|
1357
|
-
const hasServers = config.servers.length > 0;
|
|
1358
|
-
if (hasEnv) tables.shell_environment_policy = { set: buildEnvMap(config.env) };
|
|
1359
|
-
if (hasServers) {
|
|
1360
|
-
const entries = config.servers.map((server) => {
|
|
1361
|
-
const serverConfig = convertMcpConfig(server.artifact.config);
|
|
1362
|
-
return [server.artifact.key, serverConfig];
|
|
1363
|
-
});
|
|
1364
|
-
tables.mcp_servers = Object.fromEntries(entries);
|
|
1365
|
-
}
|
|
1366
|
-
if (hasEnv || hasServers) {
|
|
1367
|
-
const text = stringify$1(tables).trimEnd();
|
|
1368
|
-
parts.push(text);
|
|
1369
|
-
}
|
|
1370
|
-
return `${parts.join("\n\n")}\n`;
|
|
1371
|
-
}
|
|
1372
|
-
/** Codex picks the transport by `command` or `url` and has no `type`. */
|
|
1373
|
-
function convertMcpConfig(config) {
|
|
1374
|
-
const { type: _type, headers, ...server } = config;
|
|
1375
|
-
if (headers === void 0) return server;
|
|
1376
|
-
return {
|
|
1377
|
-
...server,
|
|
1378
|
-
http_headers: headers
|
|
1379
|
-
};
|
|
1380
|
-
}
|
|
1381
|
-
//#endregion
|
|
1382
|
-
//#region src/agents/codex/exec-rules.ts
|
|
1383
|
-
/** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
|
|
1384
|
-
const BASH_RULE_START = "Bash(";
|
|
1385
|
-
const BASH_RULE_END = ")";
|
|
1386
|
-
/**
|
|
1387
|
-
* Shell syntax and wildcards that a Codex prefix rule cannot express.
|
|
1388
|
-
*
|
|
1389
|
-
* Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
|
|
1390
|
-
* Does not match: "npm test", "git push --force", "rm -rf"
|
|
1391
|
-
*/
|
|
1392
|
-
const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
|
|
1393
|
-
const WHITESPACE_PATTERN = /\s+/;
|
|
1394
|
-
/** A trailing ` *` or the older `:*` means "this command with any arguments". */
|
|
1395
|
-
const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
|
|
1396
|
-
/**
|
|
1397
|
-
* Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
|
|
1398
|
-
* outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
|
|
1399
|
-
*/
|
|
1400
|
-
const CODEX_DECISIONS = {
|
|
1401
|
-
[PermissionDecision.Deny]: "forbidden",
|
|
1402
|
-
[PermissionDecision.Ask]: "prompt"
|
|
1403
|
-
};
|
|
1404
|
-
/**
|
|
1405
|
-
* The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
|
|
1406
|
-
* A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
|
|
1407
|
-
*/
|
|
1408
|
-
function convertBashRule(rule) {
|
|
1409
|
-
if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
|
|
1410
|
-
const specifier = rule.slice(5, -1);
|
|
1411
|
-
const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
|
|
1412
|
-
const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
|
|
1413
|
-
if (SHELL_SYNTAX_PATTERN.test(command)) return;
|
|
1414
|
-
const words = command.trim().split(WHITESPACE_PATTERN);
|
|
1415
|
-
if (words.every((word) => word.length > 0)) return words;
|
|
1416
|
-
}
|
|
1417
|
-
function hasCodexDecision(decision) {
|
|
1418
|
-
return decision in CODEX_DECISIONS;
|
|
1419
|
-
}
|
|
1420
|
-
function createExecRule(resolved, decision, pattern) {
|
|
1421
|
-
return {
|
|
1422
|
-
pattern,
|
|
1423
|
-
decision: CODEX_DECISIONS[decision],
|
|
1424
|
-
justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
|
|
1425
|
-
};
|
|
1426
|
-
}
|
|
1427
|
-
/** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
|
|
1428
|
-
function renderExecRules(rules) {
|
|
1429
|
-
const blocks = rules.map(renderExecRule);
|
|
1430
|
-
return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
|
|
1431
|
-
}
|
|
1432
|
-
/** JSON strings are valid Starlark strings. */
|
|
1433
|
-
function renderExecRule(rule) {
|
|
1434
|
-
const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
|
|
1435
|
-
const decision = JSON.stringify(rule.decision);
|
|
1436
|
-
const justification = JSON.stringify(rule.justification);
|
|
1437
|
-
return [
|
|
1438
|
-
"prefix_rule(",
|
|
1439
|
-
` pattern = [${pattern}],`,
|
|
1440
|
-
` decision = ${decision},`,
|
|
1441
|
-
` justification = ${justification},`,
|
|
1442
|
-
")"
|
|
1443
|
-
].join("\n");
|
|
1444
|
-
}
|
|
1445
|
-
//#endregion
|
|
1446
|
-
//#region src/agents/codex/subagent-toml.ts
|
|
1447
|
-
/**
|
|
1448
|
-
* Codex has no read-only mode for a subagent: it can turn off the shell, but the subagent
|
|
1449
|
-
* still edits files with patches. So the instructions ask it not to.
|
|
1450
|
-
*/
|
|
1451
|
-
const READ_ONLY_INSTRUCTION = "Do not create, edit or delete files.";
|
|
1452
|
-
/**
|
|
1453
|
-
* `.codex/agents/<name>.toml`. Codex gives a subagent the sandbox, the permissions and the
|
|
1454
|
-
* MCP servers of the main session, so the file has only the model and the instructions.
|
|
1455
|
-
*/
|
|
1456
|
-
function renderSubagentToml(subagent) {
|
|
1457
|
-
const { key, description, model, effort, readOnly, body } = subagent;
|
|
1458
|
-
const fields = {
|
|
1459
|
-
name: key,
|
|
1460
|
-
description
|
|
1461
|
-
};
|
|
1462
|
-
if (typeof model.codex === "string") fields.model = model.codex;
|
|
1463
|
-
if (typeof effort === "string") fields.model_reasoning_effort = effort;
|
|
1464
|
-
const instructions = readOnly ? `${READ_ONLY_INSTRUCTION}\n\n${body}` : body;
|
|
1465
|
-
const parts = [
|
|
1466
|
-
GENERATED_HASH_NOTICE,
|
|
1467
|
-
stringify$1(fields).trimEnd(),
|
|
1468
|
-
renderTomlText("developer_instructions", instructions)
|
|
1469
|
-
];
|
|
1470
|
-
if (readOnly) parts.push(stringify$1({ features: { shell_tool: false } }).trimEnd());
|
|
1471
|
-
return `${parts.join("\n\n")}\n`;
|
|
1472
|
-
}
|
|
1473
|
-
//#endregion
|
|
1474
|
-
//#region src/agents/codex/codex-adapter.ts
|
|
1475
|
-
const CONFIG_FILE = ".codex/config.toml";
|
|
1476
|
-
const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
|
|
1477
|
-
const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
|
|
1478
|
-
const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
|
|
1479
|
-
const SUBAGENTS_DIR = ".codex/agents";
|
|
1480
|
-
const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
|
|
1481
|
-
const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
|
|
1482
|
-
/**
|
|
1483
|
-
* Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
|
|
1484
|
-
* `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
|
|
1485
|
-
* in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
|
|
1486
|
-
* permission rules for shell commands become `.codex/rules/tunestra.rules`. Subagents go
|
|
1487
|
-
* to `.codex/agents`.
|
|
1488
|
-
*/
|
|
1489
|
-
const codexAdapter = {
|
|
1490
|
-
agent: Agent.Codex,
|
|
1491
|
-
plan: planCodex
|
|
1492
|
-
};
|
|
1493
|
-
function planCodex(context) {
|
|
1494
|
-
const { resolution, ruleFiles } = context;
|
|
1495
|
-
const projectDir = resolution.chain.project.dir;
|
|
1496
|
-
const notes = [];
|
|
1497
|
-
const outputs = [];
|
|
1498
|
-
const commandSkills = resolution.commands.filter((command) => {
|
|
1499
|
-
if (skillName.safeParse(command.artifact.key).success) return true;
|
|
1500
|
-
notes.push(`Codex skips the command "${command.artifact.key}": a skill name needs lowercase letters, digits and single hyphens.`);
|
|
1501
|
-
return false;
|
|
1502
|
-
}).flatMap((command) => planCommandSkill(projectDir, command));
|
|
1503
|
-
outputs.push(...commandSkills);
|
|
1504
|
-
const servers = resolution.mcp.filter((server) => {
|
|
1505
|
-
if (server.artifact.config.type === "sse") {
|
|
1506
|
-
notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
|
|
1507
|
-
return false;
|
|
1508
|
-
}
|
|
1509
|
-
return true;
|
|
1510
|
-
});
|
|
1511
|
-
const subagentFiles = resolution.subagents.map((subagent) => {
|
|
1512
|
-
return createFileOutput(`${SUBAGENTS_DIR}/${subagent.artifact.key}.toml`, renderSubagentToml(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
|
|
1513
|
-
});
|
|
1514
|
-
const readOnlySubagents = resolution.subagents.filter((subagent) => subagent.artifact.readOnly).map((subagent) => subagent.artifact.key);
|
|
1515
|
-
const hasReadOnlySubagents = readOnlySubagents.length > 0;
|
|
1516
|
-
outputs.push(...subagentFiles);
|
|
1517
|
-
if (hasReadOnlySubagents) {
|
|
1518
|
-
const names = readOnlySubagents.join(", ");
|
|
1519
|
-
notes.push(`Codex cannot make subagents read-only: ${names}. It turns off their shell and asks them not to edit files.`);
|
|
1520
|
-
}
|
|
1521
|
-
const configFile = planConfigFile(ruleFiles, servers, resolution.env);
|
|
1522
|
-
const execRules = planExecRules(resolution.permissions);
|
|
1523
|
-
const ignoredPaths = [];
|
|
1524
|
-
notes.push(...execRules.notes);
|
|
1525
|
-
if (typeof configFile === "object") {
|
|
1526
|
-
outputs.push(configFile);
|
|
1527
|
-
ignoredPaths.push(CONFIG_FILE);
|
|
1528
|
-
}
|
|
1529
|
-
if (typeof execRules.file === "object") {
|
|
1530
|
-
outputs.push(execRules.file);
|
|
1531
|
-
ignoredPaths.push(EXEC_RULES_FILE);
|
|
1532
|
-
}
|
|
1533
|
-
if (subagentFiles.length > 0) ignoredPaths.push(`${SUBAGENTS_DIR}/`);
|
|
1534
|
-
if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
|
|
1535
|
-
return {
|
|
1536
|
-
outputs,
|
|
1537
|
-
ignoredPaths,
|
|
1538
|
-
protectedPaths: [],
|
|
1539
|
-
notes
|
|
1540
|
-
};
|
|
1541
|
-
}
|
|
1542
|
-
/**
|
|
1543
|
-
* `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
|
|
1544
|
-
* for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
|
|
1545
|
-
*/
|
|
1546
|
-
function planExecRules(permissions) {
|
|
1547
|
-
const execRules = [];
|
|
1548
|
-
const skippedRules = [];
|
|
1549
|
-
const allowRules = [];
|
|
1550
|
-
for (const resolved of permissions) {
|
|
1551
|
-
const { key, decision } = resolved.artifact;
|
|
1552
|
-
if (hasCodexDecision(decision) === false) {
|
|
1553
|
-
allowRules.push(key);
|
|
1554
|
-
continue;
|
|
1555
|
-
}
|
|
1556
|
-
const pattern = convertBashRule(key);
|
|
1557
|
-
if (pattern === void 0) {
|
|
1558
|
-
skippedRules.push(key);
|
|
1559
|
-
continue;
|
|
1560
|
-
}
|
|
1561
|
-
const execRule = createExecRule(resolved, decision, pattern);
|
|
1562
|
-
execRules.push(execRule);
|
|
1563
|
-
}
|
|
1564
|
-
const notes = [];
|
|
1565
|
-
const hasSkippedRules = skippedRules.length > 0;
|
|
1566
|
-
const hasAllowRules = allowRules.length > 0;
|
|
1567
|
-
if (hasSkippedRules) {
|
|
1568
|
-
const rules = skippedRules.join(", ");
|
|
1569
|
-
notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
|
|
1570
|
-
}
|
|
1571
|
-
if (hasAllowRules) {
|
|
1572
|
-
const rules = allowRules.join(", ");
|
|
1573
|
-
notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
|
|
1574
|
-
}
|
|
1575
|
-
if (execRules.length > 0) {
|
|
1576
|
-
const content = renderExecRules(execRules);
|
|
1577
|
-
return {
|
|
1578
|
-
file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
|
|
1579
|
-
notes
|
|
1580
|
-
};
|
|
1581
|
-
}
|
|
1582
|
-
return {
|
|
1583
|
-
file: void 0,
|
|
1584
|
-
notes
|
|
1585
|
-
};
|
|
1586
|
-
}
|
|
1587
|
-
function planConfigFile(ruleFiles, servers, env) {
|
|
1588
|
-
const hasRules = ruleFiles.length > 0;
|
|
1589
|
-
const hasServers = servers.length > 0;
|
|
1590
|
-
const hasEnv = env.length > 0;
|
|
1591
|
-
if (hasRules === false && hasServers === false && hasEnv === false) return;
|
|
1592
|
-
const content = renderConfigToml({
|
|
1593
|
-
instructions: hasRules ? renderInstructions(ruleFiles) : void 0,
|
|
1594
|
-
servers,
|
|
1595
|
-
env
|
|
1596
|
-
});
|
|
1597
|
-
return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
|
|
1598
|
-
}
|
|
1599
|
-
/** The rule files as they are, with the header that names the source of each. */
|
|
1600
|
-
function renderInstructions(ruleFiles) {
|
|
1601
|
-
const rules = ruleFiles.map((file) => file.content.trimEnd());
|
|
1602
|
-
return `${[INSTRUCTIONS_INTRO, ...rules].join("\n\n")}\n`;
|
|
1603
|
-
}
|
|
1604
|
-
//#endregion
|
|
1605
|
-
//#region src/agents/adapters.ts
|
|
1606
|
-
/** Every supported agent. */
|
|
1607
|
-
const AGENT_ADAPTERS = {
|
|
1608
|
-
[Agent.Claude]: claudeAdapter,
|
|
1609
|
-
[Agent.Codex]: codexAdapter
|
|
1610
|
-
};
|
|
1611
|
-
//#endregion
|
|
1612
|
-
//#region src/shared/utils/markdown.ts
|
|
1613
|
-
/**
|
|
1614
|
-
* A pipe or a line break inside a cell would break the table.
|
|
1615
|
-
*
|
|
1616
|
-
* Matches: "|" in "a | b", "\n" and "\r\n" in "line one\nline two"
|
|
1617
|
-
*/
|
|
1618
|
-
const TABLE_BREAKING_PATTERN = /\||\r?\n/g;
|
|
1619
|
-
function renderTable(header, rows) {
|
|
1620
|
-
return [
|
|
1621
|
-
renderRow(header),
|
|
1622
|
-
`|${header.map(() => "---").join("|")}|`,
|
|
1623
|
-
...rows.map(renderRow)
|
|
1624
|
-
].join("\n");
|
|
1625
|
-
}
|
|
1626
|
-
function renderRow(cells) {
|
|
1627
|
-
return `| ${cells.map(escapeCell).join(" | ")} |`;
|
|
1628
|
-
}
|
|
1629
|
-
function escapeCell(cell) {
|
|
1630
|
-
return cell.replace(TABLE_BREAKING_PATTERN, replaceTableBreaking);
|
|
1631
|
-
}
|
|
1632
|
-
function replaceTableBreaking(match) {
|
|
1633
|
-
if (match === "|") return "\\|";
|
|
1634
|
-
return " ";
|
|
1635
|
-
}
|
|
1636
|
-
//#endregion
|
|
1637
|
-
//#region src/generate/describe-origin.ts
|
|
1638
|
-
/** For example `team, overrides base` or `base, patched by team`. */
|
|
1639
|
-
function describeOrigin(resolved) {
|
|
1640
|
-
const parts = [resolved.origin.level];
|
|
1641
|
-
const hasOverrides = resolved.overrides.length > 0;
|
|
1642
|
-
const isPatched = resolved.patchedBy.length > 0;
|
|
1643
|
-
if (hasOverrides) {
|
|
1644
|
-
const names = joinNames(resolved.overrides);
|
|
1645
|
-
parts.push(`overrides ${names}`);
|
|
1646
|
-
}
|
|
1647
|
-
if (isPatched) {
|
|
1648
|
-
const names = joinNames(resolved.patchedBy);
|
|
1649
|
-
parts.push(`patched by ${names}`);
|
|
1650
|
-
}
|
|
1651
|
-
return parts.join(", ");
|
|
1652
|
-
}
|
|
1653
|
-
function joinNames(origins) {
|
|
1654
|
-
return origins.map((origin) => origin.name).join(", ");
|
|
1655
|
-
}
|
|
1656
|
-
//#endregion
|
|
1657
|
-
//#region src/generate/rule-file.ts
|
|
1658
|
-
/**
|
|
1659
|
-
* `<rank>-<level>-<key>.md`, so that a plain sort puts specific rules first. Level
|
|
1660
|
-
* instructions become `instructions`, with the preset name when it differs from the level.
|
|
1661
|
-
*/
|
|
1662
|
-
function buildRuleFileName(rule) {
|
|
1663
|
-
const rank = LEVEL_RANK[rule.origin.level];
|
|
1664
|
-
const slug = buildRuleSlug(rule);
|
|
1665
|
-
return `${rank}-${rule.origin.level}-${slug}.md`;
|
|
1666
|
-
}
|
|
1667
|
-
/** The rule text with a header that tells where it comes from and what it overrides. */
|
|
1668
|
-
function renderRuleFile(rule) {
|
|
1669
|
-
const sourceFile = join(rule.origin.artifactsDir, rule.artifact.source);
|
|
1670
|
-
const sourcePath = relativePosix(rule.origin.dir, sourceFile);
|
|
1671
|
-
const levelLine = renderLevelLine(rule);
|
|
1672
|
-
return [
|
|
1673
|
-
`${GENERATED_MARKER} from ${rule.origin.name}:${sourcePath}. Do not edit. -->`,
|
|
1674
|
-
levelLine,
|
|
1675
|
-
"",
|
|
1676
|
-
rule.artifact.body
|
|
1677
|
-
].join("\n");
|
|
1678
|
-
}
|
|
1679
|
-
function buildRuleSlug(rule) {
|
|
1680
|
-
const instructionsKey = buildInstructionsKey(rule.origin.name);
|
|
1681
|
-
const isInstructions = rule.artifact.key === instructionsKey;
|
|
1682
|
-
const isNamedAsLevel = rule.origin.name === rule.origin.level;
|
|
1683
|
-
if (isInstructions === false) return rule.artifact.key;
|
|
1684
|
-
if (isNamedAsLevel) return "instructions";
|
|
1685
|
-
return `instructions-${rule.origin.name}`;
|
|
1686
|
-
}
|
|
1687
|
-
/** For example `> Level: team, preset: team. Overrides: base.` */
|
|
1688
|
-
function renderLevelLine(rule) {
|
|
1689
|
-
const line = `> Level: ${rule.origin.level}, preset: ${rule.origin.name}.`;
|
|
1690
|
-
if (rule.overrides.length > 0) return `${line} Overrides: ${joinNames(rule.overrides)}.`;
|
|
1691
|
-
return line;
|
|
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;
|
|
1692
1656
|
}
|
|
1693
1657
|
//#endregion
|
|
1694
1658
|
//#region src/generate/index-sections.ts
|
|
@@ -1820,9 +1784,9 @@ function renderEnv(env) {
|
|
|
1820
1784
|
}));
|
|
1821
1785
|
return renderSection("Environment", ENV_INTRO, table);
|
|
1822
1786
|
}
|
|
1823
|
-
/** The project
|
|
1787
|
+
/** The project and the personal level show their config files; a preset shows its git reference or its path. */
|
|
1824
1788
|
function describeLevelSource(chain, level) {
|
|
1825
|
-
if (level.dir === chain.project.dir) return PROJECT_CONFIG;
|
|
1789
|
+
if (level.dir === chain.project.dir) return level.level === Level.Local ? LOCAL_CONFIG : PROJECT_CONFIG;
|
|
1826
1790
|
if (typeof level.remote === "string") return level.remote;
|
|
1827
1791
|
return relativePosix(chain.project.dir, level.dir);
|
|
1828
1792
|
}
|
|
@@ -1842,59 +1806,424 @@ function renderSection(title, intro, table) {
|
|
|
1842
1806
|
].join("\n");
|
|
1843
1807
|
}
|
|
1844
1808
|
//#endregion
|
|
1845
|
-
//#region src/generate/agents-index.ts
|
|
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`;
|
|
1993
|
+
}
|
|
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);
|
|
1999
|
+
return [
|
|
2000
|
+
"prefix_rule(",
|
|
2001
|
+
` pattern = [${pattern}],`,
|
|
2002
|
+
` decision = ${decision},`,
|
|
2003
|
+
` justification = ${justification},`,
|
|
2004
|
+
")"
|
|
2005
|
+
].join("\n");
|
|
2006
|
+
}
|
|
2007
|
+
//#endregion
|
|
2008
|
+
//#region src/agents/codex/subagent-toml.ts
|
|
2009
|
+
/**
|
|
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.
|
|
2012
|
+
*/
|
|
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)
|
|
2031
|
+
];
|
|
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.";
|
|
1846
2046
|
/**
|
|
1847
|
-
* AGENTS.md
|
|
1848
|
-
*
|
|
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`.
|
|
1849
2052
|
*/
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
const
|
|
1859
|
-
const
|
|
1860
|
-
const
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
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.`);
|
|
1867
2085
|
}
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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);
|
|
1871
2093
|
}
|
|
1872
|
-
if (
|
|
1873
|
-
|
|
1874
|
-
|
|
2094
|
+
if (typeof execRules.file === "object") {
|
|
2095
|
+
outputs.push(execRules.file);
|
|
2096
|
+
ignoredPaths.push(EXEC_RULES_FILE);
|
|
1875
2097
|
}
|
|
1876
|
-
if (
|
|
1877
|
-
|
|
1878
|
-
|
|
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);
|
|
1879
2141
|
}
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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.`);
|
|
1883
2148
|
}
|
|
1884
|
-
if (
|
|
1885
|
-
const
|
|
1886
|
-
|
|
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.`);
|
|
1887
2152
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
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`;
|
|
1890
2181
|
}
|
|
1891
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
|
|
1892
2190
|
//#region src/generate/plan-setup.ts
|
|
1893
2191
|
/**
|
|
1894
|
-
* Builds every output without touching the disk
|
|
1895
|
-
*
|
|
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.
|
|
1896
2196
|
*/
|
|
1897
|
-
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) {
|
|
1898
2227
|
const projectDir = resolution.chain.project.dir;
|
|
1899
2228
|
const index = renderAgentsIndex(resolution);
|
|
1900
2229
|
const indexFile = createFileOutput(INSTRUCTIONS_FILE, index, SETUP_SOURCE);
|
|
@@ -1919,14 +2248,30 @@ function planSetup(resolution) {
|
|
|
1919
2248
|
protectedPaths.push(...agentPlan.protectedPaths);
|
|
1920
2249
|
notes.push(...agentPlan.notes);
|
|
1921
2250
|
}
|
|
1922
|
-
ignoredPaths.push(`${STATE_DIR}/`);
|
|
1923
|
-
const problems = findPathConflicts(outputs);
|
|
1924
|
-
if (problems.length > 0) throw createProblemsError(problems);
|
|
1925
2251
|
return {
|
|
1926
2252
|
outputs,
|
|
1927
2253
|
ignoredPaths,
|
|
1928
2254
|
protectedPaths,
|
|
1929
|
-
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
|
|
1930
2275
|
};
|
|
1931
2276
|
}
|
|
1932
2277
|
function planRuleFile(projectDir, rule) {
|
|
@@ -2193,9 +2538,20 @@ function applyInheritance(chain) {
|
|
|
2193
2538
|
//#region src/shared/schemas/state.ts
|
|
2194
2539
|
const fileRecordSchema = z.strictObject({ hash: z.string() });
|
|
2195
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
|
+
});
|
|
2196
2550
|
const stateSchema = z.strictObject({
|
|
2197
2551
|
version: z.literal(1),
|
|
2198
|
-
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({})
|
|
2199
2555
|
});
|
|
2200
2556
|
//#endregion
|
|
2201
2557
|
//#region src/state/read-state.ts
|
|
@@ -2204,15 +2560,16 @@ async function readState(projectDir) {
|
|
|
2204
2560
|
const file = join(projectDir, STATE_FILE);
|
|
2205
2561
|
const content = await readFileIfExists(file);
|
|
2206
2562
|
if (content === void 0) return createEmptyState();
|
|
2207
|
-
return validate(parseJson$
|
|
2563
|
+
return validate(parseJson$2(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
|
|
2208
2564
|
}
|
|
2209
2565
|
function createEmptyState() {
|
|
2210
2566
|
return {
|
|
2211
2567
|
version: 1,
|
|
2212
|
-
outputs: {}
|
|
2568
|
+
outputs: {},
|
|
2569
|
+
merges: {}
|
|
2213
2570
|
};
|
|
2214
2571
|
}
|
|
2215
|
-
function parseJson$
|
|
2572
|
+
function parseJson$2(content, file) {
|
|
2216
2573
|
try {
|
|
2217
2574
|
return JSON.parse(content);
|
|
2218
2575
|
} catch (error) {
|
|
@@ -2260,16 +2617,21 @@ async function removeSymlink(path) {
|
|
|
2260
2617
|
async function writeState(projectDir, state) {
|
|
2261
2618
|
const file = join(projectDir, STATE_FILE);
|
|
2262
2619
|
const dir = dirname(file);
|
|
2263
|
-
const
|
|
2264
|
-
const
|
|
2620
|
+
const outputs = sortByKey(state.outputs);
|
|
2621
|
+
const merges = sortByKey(state.merges);
|
|
2265
2622
|
const content = {
|
|
2266
2623
|
version: state.version,
|
|
2267
|
-
outputs
|
|
2624
|
+
outputs,
|
|
2625
|
+
merges
|
|
2268
2626
|
};
|
|
2269
2627
|
const text = `${JSON.stringify(content, null, 2)}\n`;
|
|
2270
2628
|
await mkdir(dir, { recursive: true });
|
|
2271
2629
|
await writeFile(file, text);
|
|
2272
2630
|
}
|
|
2631
|
+
function sortByKey(record) {
|
|
2632
|
+
const sorted = Object.entries(record).toSorted(([left], [right]) => left < right ? -1 : 1);
|
|
2633
|
+
return Object.fromEntries(sorted);
|
|
2634
|
+
}
|
|
2273
2635
|
//#endregion
|
|
2274
2636
|
//#region src/sync/remove-outputs.ts
|
|
2275
2637
|
/** Removes stale outputs, then the directories that they leave empty. */
|
|
@@ -2332,10 +2694,10 @@ async function createRecord(output) {
|
|
|
2332
2694
|
* object has no keys that tunestra could own, so the result is `undefined`.
|
|
2333
2695
|
*/
|
|
2334
2696
|
async function findForeignKeys(path, ownedKeys) {
|
|
2335
|
-
const data = parseJson(await readFile(path, "utf8"));
|
|
2697
|
+
const data = parseJson$1(await readFile(path, "utf8"));
|
|
2336
2698
|
if (isKeyValueMap(data)) return Object.keys(data).filter((key) => ownedKeys.includes(key) === false);
|
|
2337
2699
|
}
|
|
2338
|
-
function parseJson(text) {
|
|
2700
|
+
function parseJson$1(text) {
|
|
2339
2701
|
try {
|
|
2340
2702
|
return JSON.parse(text);
|
|
2341
2703
|
} catch (error) {
|
|
@@ -2444,6 +2806,117 @@ function createStaleCheck(path, isRemovable, warning) {
|
|
|
2444
2806
|
};
|
|
2445
2807
|
}
|
|
2446
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
|
|
2447
2920
|
//#region src/sync/plan-sync.ts
|
|
2448
2921
|
/** Reads the disk, but writes nothing; problems of every output are reported together. */
|
|
2449
2922
|
async function planSync(projectDir, plan, previous) {
|
|
@@ -2456,16 +2929,18 @@ async function planSync(projectDir, plan, previous) {
|
|
|
2456
2929
|
const outputPaths = new Set(paths);
|
|
2457
2930
|
const staleEntries = Object.entries(previous.outputs).filter(([path]) => outputPaths.has(path) === false);
|
|
2458
2931
|
const outputChecks = await mapSettled(plan.outputs, (output) => checkOutput(output, context));
|
|
2932
|
+
const mergePlan = await planMerges(projectDir, plan.merges, previous.merges);
|
|
2459
2933
|
const staleChecks = await mapSettled(staleEntries, ([path, record]) => checkStaleOutput(projectDir, path, record));
|
|
2460
|
-
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];
|
|
2461
2935
|
const warnings = [...outputChecks, ...staleChecks].map((check) => check.warning).filter(isString);
|
|
2462
2936
|
const recordEntries = outputChecks.map((check) => [check.path, check.record]);
|
|
2463
2937
|
const state = {
|
|
2464
2938
|
version: 1,
|
|
2465
|
-
outputs: Object.fromEntries(recordEntries)
|
|
2939
|
+
outputs: Object.fromEntries(recordEntries),
|
|
2940
|
+
merges: mergePlan.records
|
|
2466
2941
|
};
|
|
2467
2942
|
return {
|
|
2468
|
-
outputs: plan.outputs,
|
|
2943
|
+
outputs: [...plan.outputs, ...mergePlan.writes],
|
|
2469
2944
|
removals,
|
|
2470
2945
|
warnings,
|
|
2471
2946
|
state
|
|
@@ -2519,8 +2994,11 @@ async function updateGitignoreFile(projectDir, paths) {
|
|
|
2519
2994
|
*/
|
|
2520
2995
|
async function runSetup(projectDir, options) {
|
|
2521
2996
|
const chain = await loadChain(projectDir);
|
|
2522
|
-
const
|
|
2523
|
-
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);
|
|
2524
3002
|
const previous = await readState(chain.project.dir);
|
|
2525
3003
|
const sync = await planSync(chain.project.dir, plan, previous);
|
|
2526
3004
|
const report = {
|
|
@@ -2642,25 +3120,26 @@ async function removeGitignoreBlockFile(projectDir) {
|
|
|
2642
3120
|
async function runUninstall(projectDir, options) {
|
|
2643
3121
|
const state = await readState(projectDir);
|
|
2644
3122
|
const checks = await mapSettled(Object.entries(state.outputs), ([path, record]) => checkStaleOutput(projectDir, path, record));
|
|
2645
|
-
const
|
|
2646
|
-
const
|
|
2647
|
-
|
|
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 = {
|
|
2648
3127
|
removals,
|
|
2649
|
-
|
|
3128
|
+
updates: mergePlan.writes.map((write) => write.path),
|
|
3129
|
+
warnings: checks.map((check) => check.warning).filter(isString)
|
|
2650
3130
|
};
|
|
3131
|
+
if (options.dryRun) return report;
|
|
2651
3132
|
const stateDir = join(projectDir, STATE_DIR);
|
|
2652
3133
|
const tunestraDir = join(projectDir, PROJECT_DIR);
|
|
2653
3134
|
await removeOutputs(projectDir, removals);
|
|
3135
|
+
await writeOutputs(projectDir, mergePlan.writes);
|
|
2654
3136
|
await removeGitignoreBlockFile(projectDir);
|
|
2655
3137
|
await rm(stateDir, {
|
|
2656
3138
|
recursive: true,
|
|
2657
3139
|
force: true
|
|
2658
3140
|
});
|
|
2659
3141
|
await removeDirIfEmpty(tunestraDir);
|
|
2660
|
-
return
|
|
2661
|
-
removals,
|
|
2662
|
-
warnings
|
|
2663
|
-
};
|
|
3142
|
+
return report;
|
|
2664
3143
|
}
|
|
2665
3144
|
//#endregion
|
|
2666
3145
|
//#region src/cli/commands/uninstall.ts
|
|
@@ -2671,12 +3150,14 @@ async function describeUninstall(projectDir, options) {
|
|
|
2671
3150
|
const count = report.removals.length;
|
|
2672
3151
|
if (options.dryRun) {
|
|
2673
3152
|
const removals = report.removals.map((path) => ` remove ${path}`);
|
|
2674
|
-
|
|
3153
|
+
const updates = report.updates.map((path) => ` update ${path}`);
|
|
3154
|
+
lines.push(...removals, ...updates, "", "Nothing was removed (--dry-run).");
|
|
2675
3155
|
return lines.join("\n");
|
|
2676
3156
|
}
|
|
2677
3157
|
const check = pc.green("✔");
|
|
2678
3158
|
lines.push(`${check} Removed ${count} generated files and the tunestra block in .gitignore.`);
|
|
2679
|
-
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.");
|
|
2680
3161
|
return lines.join("\n");
|
|
2681
3162
|
}
|
|
2682
3163
|
//#endregion
|
|
@@ -2699,9 +3180,12 @@ async function runCommand(action) {
|
|
|
2699
3180
|
//#endregion
|
|
2700
3181
|
//#region src/cli/index.ts
|
|
2701
3182
|
const program = new Command().name("tunestra").description("Tune your AI agents to play as one orchestra.").version(VERSION);
|
|
2702
|
-
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) => {
|
|
2703
3184
|
const projectDir = process.cwd();
|
|
2704
|
-
const initOptions = {
|
|
3185
|
+
const initOptions = {
|
|
3186
|
+
presets: options.extends,
|
|
3187
|
+
local: options.local
|
|
3188
|
+
};
|
|
2705
3189
|
await runCommand(() => describeInit(projectDir, initOptions));
|
|
2706
3190
|
});
|
|
2707
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) => {
|