tunestra 0.2.0 → 0.4.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 +48 -6
- package/dist/cli.mjs +558 -51
- package/dist/index.mjs +1 -1
- package/dist/{src-jfoAZbqo.mjs → src-Bh7d7ERC.mjs} +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
> before v1.0.
|
|
14
14
|
|
|
15
15
|
tunestra gives Claude Code, Codex and other AI coding agents **one shared source** of rules,
|
|
16
|
-
skills, commands
|
|
16
|
+
skills, commands, MCP servers, environment variables and permissions, and generates each
|
|
17
|
+
agent's native configuration from it.
|
|
17
18
|
|
|
18
19
|
The source is a chain of **presets**, from general to specific:
|
|
19
20
|
|
|
@@ -44,8 +45,9 @@ extends:
|
|
|
44
45
|
agents: [claude] # add codex: [claude, codex]
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
Commit `tunestra.yaml`, `.tunestra/`, `AGENTS.md
|
|
48
|
-
tunestra generates is listed in `.gitignore`
|
|
48
|
+
Commit `tunestra.yaml`, `.tunestra/`, `AGENTS.md`, `CLAUDE.md` and, when presets set `env` or
|
|
49
|
+
`permissions`, `.claude/settings.json`. Everything else that tunestra generates is listed in `.gitignore`
|
|
50
|
+
automatically.
|
|
49
51
|
|
|
50
52
|
## Principles
|
|
51
53
|
|
|
@@ -86,6 +88,12 @@ overrides: # optional: patch inherited MCP servers
|
|
|
86
88
|
tracker:
|
|
87
89
|
env:
|
|
88
90
|
QUEUE: TEAM
|
|
91
|
+
env: # optional: environment variables of the agents
|
|
92
|
+
MCP_TIMEOUT: 120000
|
|
93
|
+
permissions: # optional: rules in the syntax of Claude Code
|
|
94
|
+
allow: [Bash(npm test *)]
|
|
95
|
+
ask: [Bash(git push *)]
|
|
96
|
+
deny: [Read(./.env), Bash(rm -rf *)]
|
|
89
97
|
```
|
|
90
98
|
|
|
91
99
|
### Project: `tunestra.yaml`
|
|
@@ -136,6 +144,11 @@ chain once, before both of them.
|
|
|
136
144
|
| Skill | `name` from `SKILL.md`, otherwise the folder name | replaces it | `exclude.skills` |
|
|
137
145
|
| Command | file name without `.md` | replaces it | `exclude.commands` |
|
|
138
146
|
| MCP server | file name without `.yaml` | replaces it | `exclude.mcp` |
|
|
147
|
+
| Environment variable | name in `env` | replaces it | `exclude.env` |
|
|
148
|
+
| Permission rule | the rule, like `Bash(npm test *)` | lists are joined: every level adds its rules | `exclude.permissions`, only for `allow` and `ask` |
|
|
149
|
+
|
|
150
|
+
A `deny` rule cannot be removed: the organization keeps a floor of what agents may never do.
|
|
151
|
+
Agents check `deny` first, so a `deny` rule on any level wins over an `allow` rule.
|
|
139
152
|
|
|
140
153
|
`overrides.mcp.<key>` patches an inherited server instead of replacing it: `env` and `headers`
|
|
141
154
|
are merged by key (the specific value wins); `command`, `args` and `url` are replaced.
|
|
@@ -149,7 +162,11 @@ are merged by key (the specific value wins); `command`, `args` and `url` are rep
|
|
|
149
162
|
- two levels have the same name;
|
|
150
163
|
- a manifest has an unknown field or a missing required field;
|
|
151
164
|
- `exclude` or `overrides` refers to a key that is not inherited;
|
|
152
|
-
- after a patch, an MCP server has both `command` and `url`, or neither
|
|
165
|
+
- after a patch, an MCP server has both `command` and `url`, or neither;
|
|
166
|
+
- an `env` value refers to another variable, like `${TOKEN}`: agents do not expand it in
|
|
167
|
+
their settings, so write the value itself and keep secrets out of presets;
|
|
168
|
+
- `env` sets a variable that Claude Code ignores in project settings (see below);
|
|
169
|
+
- `exclude.permissions` tries to remove a `deny` rule.
|
|
153
170
|
|
|
154
171
|
All problems are reported at once, in the order of the files, so you can fix them in one pass.
|
|
155
172
|
|
|
@@ -174,6 +191,21 @@ For every agent:
|
|
|
174
191
|
| `.claude/skills/<key>` | Symlinks to `.agents/skills/<key>` | no |
|
|
175
192
|
| `.claude/commands/<key>.md` | Commands | no |
|
|
176
193
|
| `.mcp.json` | MCP servers | no |
|
|
194
|
+
| `.claude/settings.json` | `env` and `permissions`, when presets set them | yes |
|
|
195
|
+
|
|
196
|
+
`.claude/settings.json` is shared by the team, so tunestra writes it and you commit it. Keep
|
|
197
|
+
your personal settings in `.claude/settings.local.json` or `~/.claude/settings.json`. A
|
|
198
|
+
`.claude/settings.json` with other keys, like `hooks` or `model`, stops setup and asks you to
|
|
199
|
+
move them; a file with only `$schema`, `env` and `permissions` is replaced with a warning, for example in a
|
|
200
|
+
fresh clone where `.tunestra/state/` does not exist yet.
|
|
201
|
+
|
|
202
|
+
Claude Code applies project `env` and `allow` rules after you trust the folder; `deny` and
|
|
203
|
+
`ask` rules apply right away. It ignores some variables in
|
|
204
|
+
project settings: the ones that turn telemetry on or choose where it goes
|
|
205
|
+
(`CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*_EXPORTER`, `OTEL_EXPORTER_OTLP_*` and others), and
|
|
206
|
+
the ones that choose its own directories (`HOME`, `XDG_*`, `CLAUDE_CONFIG_DIR`). setup stops
|
|
207
|
+
on them, because they would not work; set them in managed settings, `~/.claude/settings.json`
|
|
208
|
+
or your shell. Values that turn telemetry off, like `OTEL_LOGS_EXPORTER: none`, are allowed.
|
|
177
209
|
|
|
178
210
|
### Codex
|
|
179
211
|
|
|
@@ -182,10 +214,20 @@ Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the
|
|
|
182
214
|
|
|
183
215
|
| Path | Content | Committed |
|
|
184
216
|
|---|---|---|
|
|
185
|
-
| `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, and `[mcp_servers]` | no |
|
|
217
|
+
| `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, `[shell_environment_policy] set` with `env`, and `[mcp_servers]` | no |
|
|
218
|
+
| `.codex/rules/tunestra.rules` | `deny` and `ask` rules for shell commands as `prefix_rule`: `forbidden` and `prompt` | no |
|
|
186
219
|
| `.agents/skills/<command>/` | Each command as a skill that runs only when you call it: `$release-notes` | no |
|
|
187
220
|
|
|
188
221
|
Codex reads `.codex/config.toml` only in a trusted project: trust the project when Codex asks.
|
|
222
|
+
Codex gets `env` as the environment of the commands it runs; it does not change how Codex
|
|
223
|
+
itself works, and Codex ignores telemetry settings in a project.
|
|
224
|
+
|
|
225
|
+
Codex rules match only the start of a shell command, so tunestra converts only `Bash` rules
|
|
226
|
+
like `Bash(git push *)` or `Bash(rm -rf)`. A rule without a wildcard also becomes a prefix,
|
|
227
|
+
which is stricter for `deny` and `ask`. Other rules (`Read`, `Edit`, `WebFetch`, MCP tools, and
|
|
228
|
+
`Bash` rules with wildcards in the middle or shell syntax) are skipped with a note. `allow`
|
|
229
|
+
rules are skipped too: in Codex, an allowed command runs outside the sandbox without asking,
|
|
230
|
+
which is more than an `allow` rule of Claude Code means.
|
|
189
231
|
Codex has no `type` for MCP servers and does not support SSE: an SSE server is skipped with a
|
|
190
232
|
note, and `headers` become `http_headers`. tunestra owns the whole `.codex/config.toml`; keep
|
|
191
233
|
your personal Codex settings in `~/.codex/config.toml`.
|
|
@@ -240,7 +282,7 @@ and tells you where to move their text.
|
|
|
240
282
|
|---|---|
|
|
241
283
|
| v0.1 | Inheritance for Claude Code |
|
|
242
284
|
| v0.2 | Codex (this document) |
|
|
243
|
-
| v0.3 |
|
|
285
|
+
| v0.3 | `env` and permissions, then subagents and a personal `local` level |
|
|
244
286
|
| v0.4 | Spaces, npm sources, lockfile |
|
|
245
287
|
| v0.5 | `required` / `locked` policies, CI check |
|
|
246
288
|
| v0.6 | Hooks |
|
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-Bh7d7ERC.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";
|
|
@@ -303,12 +303,56 @@ function isKeyValueMap(value) {
|
|
|
303
303
|
//#region src/shared/schemas/preset.ts
|
|
304
304
|
const keys = z.array(z.string().min(1)).readonly();
|
|
305
305
|
const stringMap = z.record(z.string(), z.string());
|
|
306
|
-
|
|
306
|
+
/**
|
|
307
|
+
* Agent Skills naming rule; the name also becomes a directory name.
|
|
308
|
+
*
|
|
309
|
+
* Valid: "code-review", "storybook", "v2"
|
|
310
|
+
* Invalid: "Code-Review" (uppercase), "code--review" or "-review" (wrong hyphens), "../../etc"
|
|
311
|
+
*/
|
|
312
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
313
|
+
/**
|
|
314
|
+
* A name of an environment variable.
|
|
315
|
+
*
|
|
316
|
+
* Valid: "MCP_TIMEOUT", "http_proxy", "_PRIVATE"
|
|
317
|
+
* Invalid: "1PASSWORD" (starts with a digit), "MY-VAR" (hyphen), "" (empty)
|
|
318
|
+
*/
|
|
319
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
320
|
+
/**
|
|
321
|
+
* A reference to another variable, which agents do not expand in their settings.
|
|
322
|
+
*
|
|
323
|
+
* Matches: "${TOKEN}", "Bearer ${TOKEN}"
|
|
324
|
+
* Does not match: "$TOKEN", "price: 5$", "{TOKEN}"
|
|
325
|
+
*/
|
|
326
|
+
const ENV_REFERENCE_PATTERN = /\$\{[^}]*\}/;
|
|
327
|
+
/** YAML reads `MCP_TIMEOUT: 120000` as a number; agents need every value as text. */
|
|
328
|
+
const envValue = z.union([
|
|
329
|
+
z.string(),
|
|
330
|
+
z.number(),
|
|
331
|
+
z.boolean()
|
|
332
|
+
]).transform(String).refine((value) => ENV_REFERENCE_PATTERN.test(value) === false, { message: `Agents do not expand \${…} in their settings: write the value itself, and keep secrets out of presets` });
|
|
333
|
+
const envSchema = z.record(z.string(), envValue).superRefine((env, context) => {
|
|
334
|
+
const names = Object.keys(env);
|
|
335
|
+
for (const name of names) if (ENV_NAME_PATTERN.test(name) === false) context.addIssue({
|
|
336
|
+
code: "custom",
|
|
337
|
+
message: `"${name}" is not a variable name: use letters, digits and "_", and do not start with a digit`,
|
|
338
|
+
path: [name]
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
const permissionRules = z.array(z.string().regex(/^[A-Za-z][\w*-]*(\(.+\))?$/, "Write a rule as Tool or Tool(specifier), like Bash(npm test *)")).readonly();
|
|
342
|
+
const permissionsSchema = z.strictObject({
|
|
343
|
+
allow: permissionRules.optional(),
|
|
344
|
+
ask: permissionRules.optional(),
|
|
345
|
+
deny: permissionRules.optional()
|
|
346
|
+
});
|
|
347
|
+
const skillName = z.string().regex(SKILL_NAME_PATTERN, "Use lowercase letters, digits and single hyphens").max(64);
|
|
307
348
|
const excludeSchema = z.strictObject({
|
|
308
349
|
rules: keys.optional(),
|
|
309
350
|
skills: keys.optional(),
|
|
310
351
|
commands: keys.optional(),
|
|
311
|
-
mcp: keys.optional()
|
|
352
|
+
mcp: keys.optional(),
|
|
353
|
+
env: keys.optional(),
|
|
354
|
+
/** Inherited `allow` and `ask` rules; a `deny` rule cannot be removed. */
|
|
355
|
+
permissions: keys.optional()
|
|
312
356
|
});
|
|
313
357
|
const mcpPatchSchema = z.strictObject({
|
|
314
358
|
command: z.string().optional(),
|
|
@@ -324,14 +368,18 @@ const presetManifestSchema = z.strictObject({
|
|
|
324
368
|
description: z.string().optional(),
|
|
325
369
|
extends: z.array(z.string().min(1)).readonly().default([]),
|
|
326
370
|
exclude: excludeSchema.default({}),
|
|
327
|
-
overrides: overridesSchema.default({})
|
|
371
|
+
overrides: overridesSchema.default({}),
|
|
372
|
+
env: envSchema.default({}),
|
|
373
|
+
permissions: permissionsSchema.default({})
|
|
328
374
|
});
|
|
329
375
|
const projectConfigSchema = z.strictObject({
|
|
330
376
|
name: z.string().min(1).optional(),
|
|
331
377
|
extends: z.array(z.string().min(1)).readonly().default([]),
|
|
332
378
|
agents: z.array(z.enum(Agent)).min(1).readonly().default([Agent.Claude]),
|
|
333
379
|
exclude: excludeSchema.default({}),
|
|
334
|
-
overrides: overridesSchema.default({})
|
|
380
|
+
overrides: overridesSchema.default({}),
|
|
381
|
+
env: envSchema.default({}),
|
|
382
|
+
permissions: permissionsSchema.default({})
|
|
335
383
|
});
|
|
336
384
|
const mcpServerSchema = z.strictObject({
|
|
337
385
|
description: z.string().optional(),
|
|
@@ -577,6 +625,54 @@ async function loadArtifacts(dir, presetName) {
|
|
|
577
625
|
};
|
|
578
626
|
}
|
|
579
627
|
//#endregion
|
|
628
|
+
//#region src/shared/utils/paths.ts
|
|
629
|
+
/** Generated files always use `/`, so they are the same on every system. */
|
|
630
|
+
function toPosixPath(path) {
|
|
631
|
+
return path.split(sep).join("/");
|
|
632
|
+
}
|
|
633
|
+
function relativePosix(from, to) {
|
|
634
|
+
return toPosixPath(relative(from, to));
|
|
635
|
+
}
|
|
636
|
+
//#endregion
|
|
637
|
+
//#region src/presets/env.ts
|
|
638
|
+
/** The `env` map of a manifest as artifacts, in the order of the file. */
|
|
639
|
+
function createEnvVariables(env, manifestPath, artifactsDir) {
|
|
640
|
+
const source = relativePosix(artifactsDir, manifestPath);
|
|
641
|
+
return Object.entries(env).map(([key, value]) => ({
|
|
642
|
+
key,
|
|
643
|
+
source,
|
|
644
|
+
value
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region src/shared/constants/permissions.ts
|
|
649
|
+
/** What an agent does with a tool call that matches a permission rule. */
|
|
650
|
+
const PermissionDecision = {
|
|
651
|
+
/** Run without asking. */
|
|
652
|
+
Allow: "allow",
|
|
653
|
+
/** Ask every time. */
|
|
654
|
+
Ask: "ask",
|
|
655
|
+
/** Never run. Agents check deny first, so a deny rule always wins. */
|
|
656
|
+
Deny: "deny"
|
|
657
|
+
};
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/presets/permissions.ts
|
|
660
|
+
const DECISIONS$1 = Object.values(PermissionDecision);
|
|
661
|
+
/** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
|
|
662
|
+
function createPermissionRules(permissions, manifestPath, artifactsDir) {
|
|
663
|
+
const source = relativePosix(artifactsDir, manifestPath);
|
|
664
|
+
const rules = [];
|
|
665
|
+
for (const decision of DECISIONS$1) {
|
|
666
|
+
const keys = permissions[decision] ?? [];
|
|
667
|
+
for (const key of keys) rules.push({
|
|
668
|
+
key,
|
|
669
|
+
decision,
|
|
670
|
+
source
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return rules;
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
580
676
|
//#region src/presets/load-project.ts
|
|
581
677
|
async function loadProject(dir) {
|
|
582
678
|
const root = resolve(dir);
|
|
@@ -587,6 +683,8 @@ async function loadProject(dir) {
|
|
|
587
683
|
const name = config.name ?? folderName;
|
|
588
684
|
const artifactsDir = join(root, PROJECT_DIR);
|
|
589
685
|
const artifacts = await loadArtifacts(artifactsDir, name);
|
|
686
|
+
const env = createEnvVariables(config.env, configPath, artifactsDir);
|
|
687
|
+
const permissions = createPermissionRules(config.permissions, configPath, artifactsDir);
|
|
590
688
|
return {
|
|
591
689
|
...config,
|
|
592
690
|
name,
|
|
@@ -595,19 +693,12 @@ async function loadProject(dir) {
|
|
|
595
693
|
dir: root,
|
|
596
694
|
artifactsDir,
|
|
597
695
|
remote: void 0,
|
|
598
|
-
...artifacts
|
|
696
|
+
...artifacts,
|
|
697
|
+
env,
|
|
698
|
+
permissions
|
|
599
699
|
};
|
|
600
700
|
}
|
|
601
701
|
//#endregion
|
|
602
|
-
//#region src/shared/utils/paths.ts
|
|
603
|
-
/** Generated files always use `/`, so they are the same on every system. */
|
|
604
|
-
function toPosixPath(path) {
|
|
605
|
-
return path.split(sep).join("/");
|
|
606
|
-
}
|
|
607
|
-
function relativePosix(from, to) {
|
|
608
|
-
return toPosixPath(relative(from, to));
|
|
609
|
-
}
|
|
610
|
-
//#endregion
|
|
611
702
|
//#region src/sources/parse-git-source.ts
|
|
612
703
|
/**
|
|
613
704
|
* A reference to a preset in a git repository.
|
|
@@ -809,13 +900,17 @@ async function loadPreset(dir) {
|
|
|
809
900
|
if (await checkExists(manifestPath) === false) throw new TunestraError(`${root} is not a preset: ${PRESET_MANIFEST} is missing`);
|
|
810
901
|
const manifest = await readYaml(manifestPath, presetManifestSchema, "preset manifest");
|
|
811
902
|
const artifacts = await loadArtifacts(root, manifest.name);
|
|
903
|
+
const env = createEnvVariables(manifest.env, manifestPath, root);
|
|
904
|
+
const permissions = createPermissionRules(manifest.permissions, manifestPath, root);
|
|
812
905
|
return {
|
|
813
906
|
...manifest,
|
|
814
907
|
description: manifest.description,
|
|
815
908
|
dir: root,
|
|
816
909
|
artifactsDir: root,
|
|
817
910
|
remote: void 0,
|
|
818
|
-
...artifacts
|
|
911
|
+
...artifacts,
|
|
912
|
+
env,
|
|
913
|
+
permissions
|
|
819
914
|
};
|
|
820
915
|
}
|
|
821
916
|
//#endregion
|
|
@@ -896,6 +991,87 @@ function createFileOutput(path, content, source) {
|
|
|
896
991
|
};
|
|
897
992
|
}
|
|
898
993
|
//#endregion
|
|
994
|
+
//#region src/agents/claude/ignored-env.ts
|
|
995
|
+
/**
|
|
996
|
+
* Variables that Claude Code drops from project settings, because a repository should not
|
|
997
|
+
* control them: https://code.claude.com/docs/en/settings-reference#variables-claude-code-ignores-in-env
|
|
998
|
+
*/
|
|
999
|
+
const IGNORED_NAMES = /* @__PURE__ */ new Set([
|
|
1000
|
+
"CLAUDE_CONFIG_DIR",
|
|
1001
|
+
"CLAUDE_CODE_TMPDIR",
|
|
1002
|
+
"HOME",
|
|
1003
|
+
"TMPDIR",
|
|
1004
|
+
"TMP",
|
|
1005
|
+
"TEMP",
|
|
1006
|
+
"SystemRoot",
|
|
1007
|
+
"ComSpec",
|
|
1008
|
+
"ProgramData",
|
|
1009
|
+
"LOCALAPPDATA",
|
|
1010
|
+
"PATHEXT",
|
|
1011
|
+
"PSModulePath",
|
|
1012
|
+
"OTEL_LOG_RAW_API_BODIES",
|
|
1013
|
+
"ENABLE_BETA_TRACING_DETAILED",
|
|
1014
|
+
"BETA_TRACING_ENDPOINT",
|
|
1015
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
1016
|
+
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA",
|
|
1017
|
+
"ENABLE_ENHANCED_TELEMETRY_BETA",
|
|
1018
|
+
"OTEL_LOGS_EXPORTER",
|
|
1019
|
+
"OTEL_METRICS_EXPORTER",
|
|
1020
|
+
"OTEL_TRACES_EXPORTER",
|
|
1021
|
+
"OTEL_LOG_USER_PROMPTS",
|
|
1022
|
+
"OTEL_LOG_ASSISTANT_RESPONSES",
|
|
1023
|
+
"OTEL_LOG_TOOL_CONTENT",
|
|
1024
|
+
"OTEL_LOG_TOOL_DETAILS",
|
|
1025
|
+
"OTEL_EXPORTER_PROMETHEUS_HOST",
|
|
1026
|
+
"OTEL_EXPORTER_PROMETHEUS_PORT",
|
|
1027
|
+
"CLAUDE_CODE_PROCESS_WRAPPER",
|
|
1028
|
+
"CLAUDE_CODE_SYNC_SKILLS",
|
|
1029
|
+
"CLAUDE_CODE_SYNC_PLUGINS",
|
|
1030
|
+
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
|
|
1031
|
+
"CLAUDE_CODE_PLUGIN_SEED_DIR",
|
|
1032
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
1033
|
+
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
|
1034
|
+
"CLAUDE_CODE_OAUTH_SCOPES",
|
|
1035
|
+
"CLAUDE_CODE_USER_DIALOG_TIMEOUT_MS",
|
|
1036
|
+
"CLAUDE_CODE_DISABLE_ATTACHMENTS",
|
|
1037
|
+
"CLAUDE_CODE_DISABLE_FILE_MENTIONS",
|
|
1038
|
+
"CLAUDE_CODE_REMOTE",
|
|
1039
|
+
"CLAUDE_CODE_ACCOUNT_UUID",
|
|
1040
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
1041
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
1042
|
+
"CLAUDE_CODE_PROJECT_DIR_NAME",
|
|
1043
|
+
"CLAUDE_CODE_RESTRICTED",
|
|
1044
|
+
"CLAUDE_CODE_DISABLE_POWERSHELL_CMD_RM_DENY",
|
|
1045
|
+
"CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT",
|
|
1046
|
+
"CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT",
|
|
1047
|
+
"CLAUDE_CODE_DISABLE_INLINE_SHELL_RM_PROMPT",
|
|
1048
|
+
"CLAUDE_CODE_DISABLE_MOUNTED_DRIVE_RM_PROMPT",
|
|
1049
|
+
"CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS",
|
|
1050
|
+
"CLAUDE_CODE_WARN_DISK_FREE_GIB",
|
|
1051
|
+
"CLAUDE_CODE_WARN_MEMORY_FREE_GIB"
|
|
1052
|
+
]);
|
|
1053
|
+
/**
|
|
1054
|
+
* Families of ignored variables.
|
|
1055
|
+
*
|
|
1056
|
+
* Matches: "XDG_CONFIG_HOME", "ProgramFiles(x86)", "CLAUDE_AX_SCREEN_READER",
|
|
1057
|
+
* "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_HEADERS"
|
|
1058
|
+
* Does not match: "MCP_TIMEOUT", "OTEL_EXPORTER_OTLP_TIMEOUT", "OTEL_SERVICE_NAME"
|
|
1059
|
+
*/
|
|
1060
|
+
const IGNORED_NAME_PATTERN = /^(XDG_|ProgramFiles|CLAUDE_AX_|OTEL_EXPORTER_OTLP_(\w+_)?(ENDPOINT|HEADERS|PROTOCOL|CERTIFICATE|CLIENT_KEY|INSECURE)$)/;
|
|
1061
|
+
/** Values that turn telemetry off: Claude Code applies them even from project settings. */
|
|
1062
|
+
const OFF_VALUES = {
|
|
1063
|
+
OTEL_LOGS_EXPORTER: ["none"],
|
|
1064
|
+
OTEL_METRICS_EXPORTER: ["none"],
|
|
1065
|
+
OTEL_TRACES_EXPORTER: ["none"],
|
|
1066
|
+
OTEL_LOG_USER_PROMPTS: ["0", "false"],
|
|
1067
|
+
OTEL_LOG_TOOL_CONTENT: ["0", "false"],
|
|
1068
|
+
OTEL_LOG_TOOL_DETAILS: ["0", "false"]
|
|
1069
|
+
};
|
|
1070
|
+
function isIgnoredByClaude(name, value) {
|
|
1071
|
+
if ((OFF_VALUES[name] ?? []).includes(value)) return false;
|
|
1072
|
+
return IGNORED_NAMES.has(name) || IGNORED_NAME_PATTERN.test(name);
|
|
1073
|
+
}
|
|
1074
|
+
//#endregion
|
|
899
1075
|
//#region src/agents/claude/mcp-json.ts
|
|
900
1076
|
/** `.mcp.json` in the shape Claude Code reads: `{ "mcpServers": { "<key>": config } }`. */
|
|
901
1077
|
function renderMcpJson(servers) {
|
|
@@ -904,6 +1080,49 @@ function renderMcpJson(servers) {
|
|
|
904
1080
|
return `${JSON.stringify(content, null, 2)}\n`;
|
|
905
1081
|
}
|
|
906
1082
|
//#endregion
|
|
1083
|
+
//#region src/shared/utils/env.ts
|
|
1084
|
+
/**
|
|
1085
|
+
* The variables as a plain map, sorted by name so that the generated file and its diff stay
|
|
1086
|
+
* the same on every machine.
|
|
1087
|
+
*/
|
|
1088
|
+
function buildEnvMap(env) {
|
|
1089
|
+
const sortedEntries = env.map((variable) => [variable.artifact.key, variable.artifact.value]).toSorted(compareByName);
|
|
1090
|
+
return Object.fromEntries(sortedEntries);
|
|
1091
|
+
}
|
|
1092
|
+
/** By code points, not by locale; names are unique, so two entries are never equal. */
|
|
1093
|
+
function compareByName([a], [b]) {
|
|
1094
|
+
return a < b ? -1 : 1;
|
|
1095
|
+
}
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region src/agents/claude/settings-json.ts
|
|
1098
|
+
const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
|
|
1099
|
+
const DECISIONS = Object.values(PermissionDecision);
|
|
1100
|
+
/** The keys that tunestra writes; a file with only these keys is a generated one. */
|
|
1101
|
+
const SETTINGS_KEYS = [
|
|
1102
|
+
"$schema",
|
|
1103
|
+
"env",
|
|
1104
|
+
"permissions"
|
|
1105
|
+
];
|
|
1106
|
+
/** `.claude/settings.json` with sorted keys and lists, so that its diff stays small. */
|
|
1107
|
+
function renderSettingsJson(settings) {
|
|
1108
|
+
const content = { $schema: SETTINGS_SCHEMA };
|
|
1109
|
+
const hasEnv = settings.env.length > 0;
|
|
1110
|
+
const hasPermissions = settings.permissions.length > 0;
|
|
1111
|
+
if (hasEnv) content.env = buildEnvMap(settings.env);
|
|
1112
|
+
if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
|
|
1113
|
+
return `${JSON.stringify(content, null, 2)}\n`;
|
|
1114
|
+
}
|
|
1115
|
+
/** Only the lists that have rules; a rule repeated on several levels is written once. */
|
|
1116
|
+
function buildPermissionLists(permissions) {
|
|
1117
|
+
const lists = {};
|
|
1118
|
+
for (const decision of DECISIONS) {
|
|
1119
|
+
const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
|
|
1120
|
+
const sortedKeys = [...new Set(keys)].toSorted();
|
|
1121
|
+
if (sortedKeys.length > 0) lists[decision] = sortedKeys;
|
|
1122
|
+
}
|
|
1123
|
+
return lists;
|
|
1124
|
+
}
|
|
1125
|
+
//#endregion
|
|
907
1126
|
//#region src/agents/claude/claude-adapter.ts
|
|
908
1127
|
const CLAUDE_FILE = "CLAUDE.md";
|
|
909
1128
|
const RULES_DIR = ".claude/rules/tunestra";
|
|
@@ -911,10 +1130,15 @@ const SKILLS_DIR = ".claude/skills";
|
|
|
911
1130
|
const COMMANDS_DIR = ".claude/commands";
|
|
912
1131
|
const MCP_FILE = ".mcp.json";
|
|
913
1132
|
const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
|
|
1133
|
+
const SETTINGS_FILE = ".claude/settings.json";
|
|
1134
|
+
const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
|
|
1135
|
+
const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
|
|
914
1136
|
/**
|
|
915
1137
|
* Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
|
|
916
1138
|
* which Claude Code loads in every session; a symlink to a directory outside `.claude`
|
|
917
|
-
* would need an approval. Skills are symlinks to the shared `.agents/skills`.
|
|
1139
|
+
* would need an approval. Skills are symlinks to the shared `.agents/skills`. Environment
|
|
1140
|
+
* variables and permissions go to `.claude/settings.json`, which the team commits;
|
|
1141
|
+
* `settings.local.json` stays for personal settings.
|
|
918
1142
|
*/
|
|
919
1143
|
const claudeAdapter = {
|
|
920
1144
|
agent: Agent.Claude,
|
|
@@ -943,6 +1167,12 @@ function planClaude(context) {
|
|
|
943
1167
|
const mcpFile = createFileOutput(MCP_FILE, mcpJson, MCP_SOURCE);
|
|
944
1168
|
outputs.push(mcpFile);
|
|
945
1169
|
}
|
|
1170
|
+
const protectedPaths = [CLAUDE_FILE];
|
|
1171
|
+
if (resolution.env.length > 0 || resolution.permissions.length > 0) {
|
|
1172
|
+
const settingsFile = planSettingsFile(resolution.env, resolution.permissions);
|
|
1173
|
+
outputs.push(settingsFile);
|
|
1174
|
+
protectedPaths.push(SETTINGS_FILE);
|
|
1175
|
+
}
|
|
946
1176
|
return {
|
|
947
1177
|
outputs,
|
|
948
1178
|
ignoredPaths: [
|
|
@@ -951,10 +1181,28 @@ function planClaude(context) {
|
|
|
951
1181
|
`${COMMANDS_DIR}/`,
|
|
952
1182
|
MCP_FILE
|
|
953
1183
|
],
|
|
954
|
-
protectedPaths
|
|
1184
|
+
protectedPaths,
|
|
955
1185
|
notes: []
|
|
956
1186
|
};
|
|
957
1187
|
}
|
|
1188
|
+
/** Claude Code drops some variables from project settings; setup says so instead. */
|
|
1189
|
+
function planSettingsFile(env, permissions) {
|
|
1190
|
+
const problems = env.filter((variable) => isIgnoredByClaude(variable.artifact.key, variable.artifact.value)).map((variable) => `${variable.origin.name}: env sets ${variable.artifact.key}, but Claude Code ignores it in project settings. Set it in managed settings, ~/.claude/settings.json or your shell`);
|
|
1191
|
+
if (problems.length > 0) throw createProblemsError(problems);
|
|
1192
|
+
const content = renderSettingsJson({
|
|
1193
|
+
env,
|
|
1194
|
+
permissions
|
|
1195
|
+
});
|
|
1196
|
+
const file = createFileOutput(SETTINGS_FILE, content, SETTINGS_SOURCE);
|
|
1197
|
+
const ownership = {
|
|
1198
|
+
keys: SETTINGS_KEYS,
|
|
1199
|
+
otherKeysTarget: `${LOCAL_SETTINGS_FILE} or ~/.claude/settings.json`
|
|
1200
|
+
};
|
|
1201
|
+
return {
|
|
1202
|
+
...file,
|
|
1203
|
+
ownership
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
958
1206
|
function copyRule(ruleFile) {
|
|
959
1207
|
const fileName = posix.basename(ruleFile.path);
|
|
960
1208
|
return createFileOutput(`${RULES_DIR}/${fileName}`, ruleFile.content, ruleFile.source);
|
|
@@ -1012,22 +1260,29 @@ const LITERAL_DELIMITER = "'''";
|
|
|
1012
1260
|
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
|
|
1013
1261
|
/**
|
|
1014
1262
|
* `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
|
|
1015
|
-
* session,
|
|
1263
|
+
* session, the environment of the commands that Codex runs, and the MCP servers. The rules
|
|
1264
|
+
* are a literal string, so that people can read them.
|
|
1016
1265
|
*/
|
|
1017
|
-
function renderConfigToml(
|
|
1266
|
+
function renderConfigToml(config) {
|
|
1018
1267
|
const parts = [GENERATED_HASH_NOTICE];
|
|
1019
|
-
if (typeof instructions === "string") {
|
|
1020
|
-
const line = renderInstructions$1(instructions);
|
|
1268
|
+
if (typeof config.instructions === "string") {
|
|
1269
|
+
const line = renderInstructions$1(config.instructions);
|
|
1021
1270
|
parts.push(line);
|
|
1022
1271
|
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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];
|
|
1027
1280
|
});
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1281
|
+
tables.mcp_servers = Object.fromEntries(entries);
|
|
1282
|
+
}
|
|
1283
|
+
if (hasEnv || hasServers) {
|
|
1284
|
+
const text = stringify$1(tables).trimEnd();
|
|
1285
|
+
parts.push(text);
|
|
1031
1286
|
}
|
|
1032
1287
|
return `${parts.join("\n\n")}\n`;
|
|
1033
1288
|
}
|
|
@@ -1047,15 +1302,82 @@ function convertMcpConfig(config) {
|
|
|
1047
1302
|
};
|
|
1048
1303
|
}
|
|
1049
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
|
|
1050
1369
|
//#region src/agents/codex/codex-adapter.ts
|
|
1051
1370
|
const CONFIG_FILE = ".codex/config.toml";
|
|
1052
|
-
const CONFIG_SOURCE = "the rules/ and mcp/ folders of tunestra.yaml and its presets";
|
|
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";
|
|
1053
1374
|
const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
|
|
1054
|
-
const TRUST_NOTE = "Codex reads
|
|
1375
|
+
const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
|
|
1055
1376
|
/**
|
|
1056
1377
|
* Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
|
|
1057
1378
|
* `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
|
|
1058
|
-
* in AGENTS.md; MCP servers go to the same file. Commands become skills
|
|
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`.
|
|
1059
1381
|
*/
|
|
1060
1382
|
const codexAdapter = {
|
|
1061
1383
|
agent: Agent.Codex,
|
|
@@ -1078,27 +1400,81 @@ function planCodex(context) {
|
|
|
1078
1400
|
return false;
|
|
1079
1401
|
}
|
|
1080
1402
|
return true;
|
|
1081
|
-
}));
|
|
1082
|
-
|
|
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 {
|
|
1083
1417
|
outputs,
|
|
1084
|
-
ignoredPaths
|
|
1418
|
+
ignoredPaths,
|
|
1085
1419
|
protectedPaths: [],
|
|
1086
1420
|
notes
|
|
1087
1421
|
};
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
+
}
|
|
1090
1463
|
return {
|
|
1091
|
-
|
|
1092
|
-
ignoredPaths: [CONFIG_FILE],
|
|
1093
|
-
protectedPaths: [],
|
|
1464
|
+
file: void 0,
|
|
1094
1465
|
notes
|
|
1095
1466
|
};
|
|
1096
1467
|
}
|
|
1097
|
-
function planConfigFile(ruleFiles, servers) {
|
|
1468
|
+
function planConfigFile(ruleFiles, servers, env) {
|
|
1098
1469
|
const hasRules = ruleFiles.length > 0;
|
|
1099
1470
|
const hasServers = servers.length > 0;
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
+
});
|
|
1102
1478
|
return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
|
|
1103
1479
|
}
|
|
1104
1480
|
/** The rule files as they are, with the header that names the source of each. */
|
|
@@ -1200,6 +1576,7 @@ function renderLevelLine(rule) {
|
|
|
1200
1576
|
const HIERARCHY_INTRO = "Instructions are ordered from specific to general. When two instructions conflict, follow the one from the higher level in this list.";
|
|
1201
1577
|
const RULES_INTRO = "Always follow these rules. Claude Code and Codex load them automatically; other agents should read them before starting work.";
|
|
1202
1578
|
const SKILLS_INTRO = "Use a skill when the task matches its description.";
|
|
1579
|
+
const ENV_INTRO = "Agents set these variables in every session, also for the commands they run. The values are in the presets.";
|
|
1203
1580
|
const CHANGING_SECTION = [
|
|
1204
1581
|
"## Changing this setup",
|
|
1205
1582
|
"",
|
|
@@ -1301,6 +1678,13 @@ function renderMcpServers(servers) {
|
|
|
1301
1678
|
];
|
|
1302
1679
|
})));
|
|
1303
1680
|
}
|
|
1681
|
+
function renderEnv(env) {
|
|
1682
|
+
const table = renderTable(["Variable", "Level"], env.map((variable) => {
|
|
1683
|
+
const origin = describeOrigin(variable);
|
|
1684
|
+
return [variable.artifact.key, origin];
|
|
1685
|
+
}));
|
|
1686
|
+
return renderSection("Environment", ENV_INTRO, table);
|
|
1687
|
+
}
|
|
1304
1688
|
/** The project shows its config file; a preset shows its git reference or its path. */
|
|
1305
1689
|
function describeLevelSource(chain, level) {
|
|
1306
1690
|
if (level.dir === chain.project.dir) return PROJECT_CONFIG;
|
|
@@ -1340,6 +1724,7 @@ function renderAgentsIndex(resolution) {
|
|
|
1340
1724
|
const hasSkills = resolution.skills.length > 0;
|
|
1341
1725
|
const hasCommands = resolution.commands.length > 0;
|
|
1342
1726
|
const hasMcpServers = resolution.mcp.length > 0;
|
|
1727
|
+
const hasEnv = resolution.env.length > 0;
|
|
1343
1728
|
if (hasRules) {
|
|
1344
1729
|
const rules = renderRules(resolution.rules);
|
|
1345
1730
|
sections.push(rules);
|
|
@@ -1356,6 +1741,10 @@ function renderAgentsIndex(resolution) {
|
|
|
1356
1741
|
const servers = renderMcpServers(resolution.mcp);
|
|
1357
1742
|
sections.push(servers);
|
|
1358
1743
|
}
|
|
1744
|
+
if (hasEnv) {
|
|
1745
|
+
const env = renderEnv(resolution.env);
|
|
1746
|
+
sections.push(env);
|
|
1747
|
+
}
|
|
1359
1748
|
sections.push(CHANGING_SECTION);
|
|
1360
1749
|
return `${sections.join("\n\n")}\n`;
|
|
1361
1750
|
}
|
|
@@ -1432,12 +1821,16 @@ function findPathConflicts(outputs) {
|
|
|
1432
1821
|
}
|
|
1433
1822
|
//#endregion
|
|
1434
1823
|
//#region src/shared/constants/artifacts.ts
|
|
1435
|
-
/**
|
|
1824
|
+
/**
|
|
1825
|
+
* Kinds of artifacts; the values match the fields of a preset and of `exclude`. Environment
|
|
1826
|
+
* variables come from the manifest, the other kinds from folders.
|
|
1827
|
+
*/
|
|
1436
1828
|
const ArtifactKind = {
|
|
1437
1829
|
Rules: "rules",
|
|
1438
1830
|
Skills: "skills",
|
|
1439
1831
|
Commands: "commands",
|
|
1440
|
-
Mcp: "mcp"
|
|
1832
|
+
Mcp: "mcp",
|
|
1833
|
+
Env: "env"
|
|
1441
1834
|
};
|
|
1442
1835
|
//#endregion
|
|
1443
1836
|
//#region src/inheritance/inherit-artifacts.ts
|
|
@@ -1507,6 +1900,62 @@ function orderFromSpecific(levels, inherited, selectArtifacts) {
|
|
|
1507
1900
|
return ordered;
|
|
1508
1901
|
}
|
|
1509
1902
|
//#endregion
|
|
1903
|
+
//#region src/inheritance/inherit-permissions.ts
|
|
1904
|
+
/**
|
|
1905
|
+
* Permission lists are joined along the chain: every level adds its rules. A more specific
|
|
1906
|
+
* level can remove inherited `allow` and `ask` rules with `exclude.permissions`, but not
|
|
1907
|
+
* `deny` rules, so that a general level keeps a floor of what is never allowed.
|
|
1908
|
+
*/
|
|
1909
|
+
function inheritPermissions(levels) {
|
|
1910
|
+
const inherited = /* @__PURE__ */ new Map();
|
|
1911
|
+
const problems = [];
|
|
1912
|
+
for (const level of levels) {
|
|
1913
|
+
const excludeProblems = excludeRules(inherited, level);
|
|
1914
|
+
const origin = createOrigin(level);
|
|
1915
|
+
problems.push(...excludeProblems);
|
|
1916
|
+
for (const rule of level.permissions) {
|
|
1917
|
+
const id = buildRuleId(rule);
|
|
1918
|
+
const previous = inherited.get(id);
|
|
1919
|
+
const overrides = previous === void 0 ? [] : [...previous.overrides, previous.origin];
|
|
1920
|
+
inherited.set(id, {
|
|
1921
|
+
artifact: rule,
|
|
1922
|
+
origin,
|
|
1923
|
+
overrides,
|
|
1924
|
+
patchedBy: []
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
return {
|
|
1929
|
+
artifacts: [...inherited.values()],
|
|
1930
|
+
problems
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
/** The same rule can be in two lists, for example in `allow` on one level and `deny` on another. */
|
|
1934
|
+
function buildRuleId(rule) {
|
|
1935
|
+
return `${rule.decision} ${rule.key}`;
|
|
1936
|
+
}
|
|
1937
|
+
function excludeRules(inherited, level) {
|
|
1938
|
+
const keys = level.exclude.permissions ?? [];
|
|
1939
|
+
const problems = [];
|
|
1940
|
+
for (const key of keys) {
|
|
1941
|
+
const matches = [...inherited.values()].filter((resolved) => resolved.artifact.key === key);
|
|
1942
|
+
const deny = matches.find((resolved) => resolved.artifact.decision === PermissionDecision.Deny);
|
|
1943
|
+
if (typeof deny === "object") {
|
|
1944
|
+
problems.push(`${level.name}: exclude.permissions lists "${key}", but it is a deny rule of ${deny.origin.name}, and a deny rule cannot be removed`);
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
if (matches.length === 0) {
|
|
1948
|
+
problems.push(`${level.name}: exclude.permissions lists "${key}", but no more general level has it`);
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
for (const match of matches) {
|
|
1952
|
+
const id = buildRuleId(match.artifact);
|
|
1953
|
+
inherited.delete(id);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return problems;
|
|
1957
|
+
}
|
|
1958
|
+
//#endregion
|
|
1510
1959
|
//#region src/shared/utils/records.ts
|
|
1511
1960
|
/** Merges two string maps; keys of `patch` win. Missing on both sides stays missing. */
|
|
1512
1961
|
function mergeRecords(base, patch) {
|
|
@@ -1575,11 +2024,15 @@ function applyInheritance(chain) {
|
|
|
1575
2024
|
const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
|
|
1576
2025
|
const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
|
|
1577
2026
|
const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
|
|
2027
|
+
const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
|
|
2028
|
+
const permissions = inheritPermissions(chain.levels);
|
|
1578
2029
|
const problems = [
|
|
1579
2030
|
...rules.problems,
|
|
1580
2031
|
...skills.problems,
|
|
1581
2032
|
...commands.problems,
|
|
1582
|
-
...mcp.problems
|
|
2033
|
+
...mcp.problems,
|
|
2034
|
+
...env.problems,
|
|
2035
|
+
...permissions.problems
|
|
1583
2036
|
];
|
|
1584
2037
|
if (problems.length > 0) throw createProblemsError(problems);
|
|
1585
2038
|
return {
|
|
@@ -1587,7 +2040,9 @@ function applyInheritance(chain) {
|
|
|
1587
2040
|
rules: rules.artifacts,
|
|
1588
2041
|
skills: skills.artifacts,
|
|
1589
2042
|
commands: commands.artifacts,
|
|
1590
|
-
mcp: mcp.artifacts
|
|
2043
|
+
mcp: mcp.artifacts,
|
|
2044
|
+
env: env.artifacts,
|
|
2045
|
+
permissions: permissions.artifacts
|
|
1591
2046
|
};
|
|
1592
2047
|
}
|
|
1593
2048
|
//#endregion
|
|
@@ -1605,7 +2060,7 @@ async function readState(projectDir) {
|
|
|
1605
2060
|
const file = join(projectDir, STATE_FILE);
|
|
1606
2061
|
const content = await readFileIfExists(file);
|
|
1607
2062
|
if (content === void 0) return createEmptyState();
|
|
1608
|
-
return validate(parseJson(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
|
|
2063
|
+
return validate(parseJson$1(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
|
|
1609
2064
|
}
|
|
1610
2065
|
function createEmptyState() {
|
|
1611
2066
|
return {
|
|
@@ -1613,7 +2068,7 @@ function createEmptyState() {
|
|
|
1613
2068
|
outputs: {}
|
|
1614
2069
|
};
|
|
1615
2070
|
}
|
|
1616
|
-
function parseJson(content, file) {
|
|
2071
|
+
function parseJson$1(content, file) {
|
|
1617
2072
|
try {
|
|
1618
2073
|
return JSON.parse(content);
|
|
1619
2074
|
} catch (error) {
|
|
@@ -1727,6 +2182,24 @@ async function createRecord(output) {
|
|
|
1727
2182
|
}
|
|
1728
2183
|
}
|
|
1729
2184
|
//#endregion
|
|
2185
|
+
//#region src/sync/find-foreign-keys.ts
|
|
2186
|
+
/**
|
|
2187
|
+
* The top-level keys of a JSON file that are not in `ownedKeys`. A file that is not a JSON
|
|
2188
|
+
* object has no keys that tunestra could own, so the result is `undefined`.
|
|
2189
|
+
*/
|
|
2190
|
+
async function findForeignKeys(path, ownedKeys) {
|
|
2191
|
+
const data = parseJson(await readFile(path, "utf8"));
|
|
2192
|
+
if (isKeyValueMap(data)) return Object.keys(data).filter((key) => ownedKeys.includes(key) === false);
|
|
2193
|
+
}
|
|
2194
|
+
function parseJson(text) {
|
|
2195
|
+
try {
|
|
2196
|
+
return JSON.parse(text);
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
if (error instanceof SyntaxError) return;
|
|
2199
|
+
throw error;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
//#endregion
|
|
1730
2203
|
//#region src/sync/inspect-path.ts
|
|
1731
2204
|
async function inspectPath(path) {
|
|
1732
2205
|
const type = await getPathType(path);
|
|
@@ -1770,9 +2243,21 @@ async function checkOutput(output, context) {
|
|
|
1770
2243
|
const isRecorded = typeof previousRecord === "object";
|
|
1771
2244
|
const isReplaceable = disk.type === PathType.File || disk.type === PathType.Symlink;
|
|
1772
2245
|
if (isRecorded && isReplaceable) return createCheck(output, record, `${output.path} was edited by hand; tunestra replaced it. Edit ${output.source} instead`);
|
|
2246
|
+
const ownership = output.kind === OutputKind.File ? output.ownership : void 0;
|
|
2247
|
+
if (typeof ownership === "object" && disk.type === PathType.File) return createCheck(output, record, await checkOwnedJson(path, output, ownership));
|
|
1773
2248
|
if (context.protectedPaths.includes(output.path)) throw new TunestraError(`${output.path} was written by hand, and setup would overwrite it. Move its text to .tunestra/AGENTS.md or to a rule in .tunestra/rules/, then delete ${output.path} and run tunestra setup again`);
|
|
1774
2249
|
throw new TunestraError(`${output.path} already exists, and tunestra did not create it. Move it away and run tunestra setup again`);
|
|
1775
2250
|
}
|
|
2251
|
+
/** Takes over a JSON file with only the keys that tunestra writes; other keys stop setup. */
|
|
2252
|
+
async function checkOwnedJson(path, output, ownership) {
|
|
2253
|
+
const foreignKeys = await findForeignKeys(path, ownership.keys);
|
|
2254
|
+
if (foreignKeys === void 0) throw new TunestraError(`${output.path} is not a JSON object, and setup would overwrite it. Fix or delete it and run tunestra setup again`);
|
|
2255
|
+
if (foreignKeys.length > 0) {
|
|
2256
|
+
const keys = foreignKeys.join(", ");
|
|
2257
|
+
throw new TunestraError(`${output.path} has settings that tunestra does not write: ${keys}. Move them to ${ownership.otherKeysTarget}, then run tunestra setup again`);
|
|
2258
|
+
}
|
|
2259
|
+
return `${output.path} had only the settings that tunestra writes; tunestra replaced it. Edit ${output.source} instead`;
|
|
2260
|
+
}
|
|
1776
2261
|
/**
|
|
1777
2262
|
* Nothing to lose: the path is empty, already has the new content, still has what tunestra
|
|
1778
2263
|
* wrote last time, or is a generated markdown file whose record was lost.
|
|
@@ -1909,13 +2394,28 @@ async function runSetup(projectDir, options) {
|
|
|
1909
2394
|
/** For --verbose: every artifact, the level it comes from and the file to edit. */
|
|
1910
2395
|
function describeResolution(resolution) {
|
|
1911
2396
|
const projectDir = resolution.chain.project.dir;
|
|
2397
|
+
const permissions = resolution.permissions.map(labelPermission);
|
|
1912
2398
|
return [
|
|
1913
2399
|
describeSection("Rules", resolution.rules, projectDir),
|
|
1914
2400
|
describeSection("Skills", resolution.skills, projectDir),
|
|
1915
2401
|
describeSection("Commands", resolution.commands, projectDir),
|
|
1916
|
-
describeSection("MCP servers", resolution.mcp, projectDir)
|
|
2402
|
+
describeSection("MCP servers", resolution.mcp, projectDir),
|
|
2403
|
+
describeSection("Environment", resolution.env, projectDir),
|
|
2404
|
+
describeSection("Permissions", permissions, projectDir)
|
|
1917
2405
|
].filter((section) => section.length > 0).join("\n\n");
|
|
1918
2406
|
}
|
|
2407
|
+
/** For example `deny Read(./.env)`: the same rule can be in two lists. */
|
|
2408
|
+
function labelPermission(resolved) {
|
|
2409
|
+
const { decision, key } = resolved.artifact;
|
|
2410
|
+
const artifact = {
|
|
2411
|
+
...resolved.artifact,
|
|
2412
|
+
key: `${decision} ${key}`
|
|
2413
|
+
};
|
|
2414
|
+
return {
|
|
2415
|
+
...resolved,
|
|
2416
|
+
artifact
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
1919
2419
|
function describeSection(title, artifacts, projectDir) {
|
|
1920
2420
|
if (artifacts.length === 0) return "";
|
|
1921
2421
|
const rows = artifacts.map((resolved) => {
|
|
@@ -1961,12 +2461,19 @@ function describeSync(plan, sync, options) {
|
|
|
1961
2461
|
return lines.join("\n");
|
|
1962
2462
|
}
|
|
1963
2463
|
const check = pc.green("✔");
|
|
1964
|
-
const committedFiles = plan.protectedPaths
|
|
2464
|
+
const committedFiles = joinList(plan.protectedPaths);
|
|
1965
2465
|
lines.push(`${check} Generated ${writeCount} files, removed ${removeCount} stale files.`);
|
|
1966
2466
|
lines.push(...notes);
|
|
1967
2467
|
lines.push(`Commit ${committedFiles}; the rest is in .gitignore.`);
|
|
1968
2468
|
return lines.join("\n");
|
|
1969
2469
|
}
|
|
2470
|
+
/** For example `AGENTS.md, CLAUDE.md and .claude/settings.json`. */
|
|
2471
|
+
function joinList(items) {
|
|
2472
|
+
const last = items.at(-1) ?? "";
|
|
2473
|
+
const rest = items.slice(0, -1);
|
|
2474
|
+
if (rest.length === 0) return last;
|
|
2475
|
+
return `${rest.join(", ")} and ${last}`;
|
|
2476
|
+
}
|
|
1970
2477
|
//#endregion
|
|
1971
2478
|
//#region src/uninstall/remove-gitignore-block.ts
|
|
1972
2479
|
/** A .gitignore that had only the tunestra block is removed, because tunestra created it. */
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as VERSION } from "./src-
|
|
1
|
+
export { t as VERSION } from "./src-Bh7d7ERC.mjs";
|
package/package.json
CHANGED