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 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 and MCP servers, and generates each agent's native configuration from it.
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` and `CLAUDE.md`. Everything else that
48
- tunestra generates is listed in `.gitignore` automatically.
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 | Subagents, a personal `local` level, `env` and permissions |
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-jfoAZbqo.mjs";
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
- const skillName = z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "Use lowercase letters, digits and single hyphens").max(64);
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: [CLAUDE_FILE],
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, and the MCP servers. The rules are a literal string, so that people can read them.
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(instructions, servers) {
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
- if (servers.length > 0) {
1024
- const entries = servers.map((server) => {
1025
- const config = convertMcpConfig(server.artifact.config);
1026
- return [server.artifact.key, config];
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
- const content = { mcp_servers: Object.fromEntries(entries) };
1029
- const tables = stringify$1(content).trimEnd();
1030
- parts.push(tables);
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 rules and MCP servers from .codex/config.toml only in a trusted project: trust the project when Codex asks.";
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
- if (configFile === void 0) return {
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
- outputs.push(configFile);
1089
- notes.push(TRUST_NOTE);
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
- outputs,
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
- if (hasRules === false && hasServers === false) return;
1101
- const content = renderConfigToml(hasRules ? renderInstructions(ruleFiles) : void 0, servers);
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
- /** Kinds of artifacts; the values match the folder-based fields of a preset and of `exclude`. */
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.join(" and ");
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-jfoAZbqo.mjs";
1
+ export { t as VERSION } from "./src-Bh7d7ERC.mjs";
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/index.ts
3
- const VERSION = "0.2.0";
3
+ const VERSION = "0.4.0";
4
4
  //#endregion
5
5
  export { VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunestra",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Tune your AI agents to play as one orchestra: one source of rules, skills, commands and MCP servers for Claude Code, Codex and more.",
5
5
  "keywords": [
6
6
  "ai",