tunestra 0.2.0 → 0.3.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 and environment variables, and generates each agent's native
17
+ 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`,
49
+ `.claude/settings.json`. Everything else that tunestra generates is listed in `.gitignore`
50
+ automatically.
49
51
 
50
52
  ## Principles
51
53
 
@@ -86,6 +88,8 @@ 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
89
93
  ```
90
94
 
91
95
  ### Project: `tunestra.yaml`
@@ -136,6 +140,7 @@ chain once, before both of them.
136
140
  | Skill | `name` from `SKILL.md`, otherwise the folder name | replaces it | `exclude.skills` |
137
141
  | Command | file name without `.md` | replaces it | `exclude.commands` |
138
142
  | MCP server | file name without `.yaml` | replaces it | `exclude.mcp` |
143
+ | Environment variable | name in `env` | replaces it | `exclude.env` |
139
144
 
140
145
  `overrides.mcp.<key>` patches an inherited server instead of replacing it: `env` and `headers`
141
146
  are merged by key (the specific value wins); `command`, `args` and `url` are replaced.
@@ -149,7 +154,10 @@ are merged by key (the specific value wins); `command`, `args` and `url` are rep
149
154
  - two levels have the same name;
150
155
  - a manifest has an unknown field or a missing required field;
151
156
  - `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.
157
+ - after a patch, an MCP server has both `command` and `url`, or neither;
158
+ - an `env` value refers to another variable, like `${TOKEN}`: agents do not expand it in
159
+ their settings, so write the value itself and keep secrets out of presets;
160
+ - `env` sets a variable that Claude Code ignores in project settings (see below).
153
161
 
154
162
  All problems are reported at once, in the order of the files, so you can fix them in one pass.
155
163
 
@@ -174,6 +182,20 @@ For every agent:
174
182
  | `.claude/skills/<key>` | Symlinks to `.agents/skills/<key>` | no |
175
183
  | `.claude/commands/<key>.md` | Commands | no |
176
184
  | `.mcp.json` | MCP servers | no |
185
+ | `.claude/settings.json` | `env`, when presets set it | yes |
186
+
187
+ `.claude/settings.json` is shared by the team, so tunestra writes it and you commit it. Keep
188
+ your personal settings in `.claude/settings.local.json` or `~/.claude/settings.json`. A
189
+ `.claude/settings.json` with other keys, like `hooks` or `model`, stops setup and asks you to
190
+ move them; a file with only `$schema` and `env` is replaced with a warning, for example in a
191
+ fresh clone where `.tunestra/state/` does not exist yet.
192
+
193
+ Claude Code applies project `env` after you trust the folder. It ignores some variables in
194
+ project settings: the ones that turn telemetry on or choose where it goes
195
+ (`CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*_EXPORTER`, `OTEL_EXPORTER_OTLP_*` and others), and
196
+ the ones that choose its own directories (`HOME`, `XDG_*`, `CLAUDE_CONFIG_DIR`). setup stops
197
+ on them, because they would not work; set them in managed settings, `~/.claude/settings.json`
198
+ or your shell. Values that turn telemetry off, like `OTEL_LOGS_EXPORTER: none`, are allowed.
177
199
 
178
200
  ### Codex
179
201
 
@@ -182,10 +204,12 @@ Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the
182
204
 
183
205
  | Path | Content | Committed |
184
206
  |---|---|---|
185
- | `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, and `[mcp_servers]` | no |
207
+ | `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, `[shell_environment_policy] set` with `env`, and `[mcp_servers]` | no |
186
208
  | `.agents/skills/<command>/` | Each command as a skill that runs only when you call it: `$release-notes` | no |
187
209
 
188
210
  Codex reads `.codex/config.toml` only in a trusted project: trust the project when Codex asks.
211
+ Codex gets `env` as the environment of the commands it runs; it does not change how Codex
212
+ itself works, and Codex ignores telemetry settings in a project.
189
213
  Codex has no `type` for MCP servers and does not support SSE: an SSE server is skipped with a
190
214
  note, and `headers` become `http_headers`. tunestra owns the whole `.codex/config.toml`; keep
191
215
  your personal Codex settings in `~/.codex/config.toml`.
@@ -240,7 +264,7 @@ and tells you where to move their text.
240
264
  |---|---|
241
265
  | v0.1 | Inheritance for Claude Code |
242
266
  | v0.2 | Codex (this document) |
243
- | v0.3 | Subagents, a personal `local` level, `env` and permissions |
267
+ | v0.3 | `env`, then permissions, subagents and a personal `local` level |
244
268
  | v0.4 | Spaces, npm sources, lockfile |
245
269
  | v0.5 | `required` / `locked` policies, CI check |
246
270
  | 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-BNeh64fK.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,48 @@ 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 skillName = z.string().regex(SKILL_NAME_PATTERN, "Use lowercase letters, digits and single hyphens").max(64);
307
342
  const excludeSchema = z.strictObject({
308
343
  rules: keys.optional(),
309
344
  skills: keys.optional(),
310
345
  commands: keys.optional(),
311
- mcp: keys.optional()
346
+ mcp: keys.optional(),
347
+ env: keys.optional()
312
348
  });
313
349
  const mcpPatchSchema = z.strictObject({
314
350
  command: z.string().optional(),
@@ -324,14 +360,16 @@ const presetManifestSchema = z.strictObject({
324
360
  description: z.string().optional(),
325
361
  extends: z.array(z.string().min(1)).readonly().default([]),
326
362
  exclude: excludeSchema.default({}),
327
- overrides: overridesSchema.default({})
363
+ overrides: overridesSchema.default({}),
364
+ env: envSchema.default({})
328
365
  });
329
366
  const projectConfigSchema = z.strictObject({
330
367
  name: z.string().min(1).optional(),
331
368
  extends: z.array(z.string().min(1)).readonly().default([]),
332
369
  agents: z.array(z.enum(Agent)).min(1).readonly().default([Agent.Claude]),
333
370
  exclude: excludeSchema.default({}),
334
- overrides: overridesSchema.default({})
371
+ overrides: overridesSchema.default({}),
372
+ env: envSchema.default({})
335
373
  });
336
374
  const mcpServerSchema = z.strictObject({
337
375
  description: z.string().optional(),
@@ -577,6 +615,26 @@ async function loadArtifacts(dir, presetName) {
577
615
  };
578
616
  }
579
617
  //#endregion
618
+ //#region src/shared/utils/paths.ts
619
+ /** Generated files always use `/`, so they are the same on every system. */
620
+ function toPosixPath(path) {
621
+ return path.split(sep).join("/");
622
+ }
623
+ function relativePosix(from, to) {
624
+ return toPosixPath(relative(from, to));
625
+ }
626
+ //#endregion
627
+ //#region src/presets/env.ts
628
+ /** The `env` map of a manifest as artifacts, in the order of the file. */
629
+ function createEnvVariables(env, manifestPath, artifactsDir) {
630
+ const source = relativePosix(artifactsDir, manifestPath);
631
+ return Object.entries(env).map(([key, value]) => ({
632
+ key,
633
+ source,
634
+ value
635
+ }));
636
+ }
637
+ //#endregion
580
638
  //#region src/presets/load-project.ts
581
639
  async function loadProject(dir) {
582
640
  const root = resolve(dir);
@@ -587,6 +645,7 @@ async function loadProject(dir) {
587
645
  const name = config.name ?? folderName;
588
646
  const artifactsDir = join(root, PROJECT_DIR);
589
647
  const artifacts = await loadArtifacts(artifactsDir, name);
648
+ const env = createEnvVariables(config.env, configPath, artifactsDir);
590
649
  return {
591
650
  ...config,
592
651
  name,
@@ -595,19 +654,11 @@ async function loadProject(dir) {
595
654
  dir: root,
596
655
  artifactsDir,
597
656
  remote: void 0,
598
- ...artifacts
657
+ ...artifacts,
658
+ env
599
659
  };
600
660
  }
601
661
  //#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
662
  //#region src/sources/parse-git-source.ts
612
663
  /**
613
664
  * A reference to a preset in a git repository.
@@ -809,13 +860,15 @@ async function loadPreset(dir) {
809
860
  if (await checkExists(manifestPath) === false) throw new TunestraError(`${root} is not a preset: ${PRESET_MANIFEST} is missing`);
810
861
  const manifest = await readYaml(manifestPath, presetManifestSchema, "preset manifest");
811
862
  const artifacts = await loadArtifacts(root, manifest.name);
863
+ const env = createEnvVariables(manifest.env, manifestPath, root);
812
864
  return {
813
865
  ...manifest,
814
866
  description: manifest.description,
815
867
  dir: root,
816
868
  artifactsDir: root,
817
869
  remote: void 0,
818
- ...artifacts
870
+ ...artifacts,
871
+ env
819
872
  };
820
873
  }
821
874
  //#endregion
@@ -896,6 +949,87 @@ function createFileOutput(path, content, source) {
896
949
  };
897
950
  }
898
951
  //#endregion
952
+ //#region src/agents/claude/ignored-env.ts
953
+ /**
954
+ * Variables that Claude Code drops from project settings, because a repository should not
955
+ * control them: https://code.claude.com/docs/en/settings-reference#variables-claude-code-ignores-in-env
956
+ */
957
+ const IGNORED_NAMES = /* @__PURE__ */ new Set([
958
+ "CLAUDE_CONFIG_DIR",
959
+ "CLAUDE_CODE_TMPDIR",
960
+ "HOME",
961
+ "TMPDIR",
962
+ "TMP",
963
+ "TEMP",
964
+ "SystemRoot",
965
+ "ComSpec",
966
+ "ProgramData",
967
+ "LOCALAPPDATA",
968
+ "PATHEXT",
969
+ "PSModulePath",
970
+ "OTEL_LOG_RAW_API_BODIES",
971
+ "ENABLE_BETA_TRACING_DETAILED",
972
+ "BETA_TRACING_ENDPOINT",
973
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
974
+ "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA",
975
+ "ENABLE_ENHANCED_TELEMETRY_BETA",
976
+ "OTEL_LOGS_EXPORTER",
977
+ "OTEL_METRICS_EXPORTER",
978
+ "OTEL_TRACES_EXPORTER",
979
+ "OTEL_LOG_USER_PROMPTS",
980
+ "OTEL_LOG_ASSISTANT_RESPONSES",
981
+ "OTEL_LOG_TOOL_CONTENT",
982
+ "OTEL_LOG_TOOL_DETAILS",
983
+ "OTEL_EXPORTER_PROMETHEUS_HOST",
984
+ "OTEL_EXPORTER_PROMETHEUS_PORT",
985
+ "CLAUDE_CODE_PROCESS_WRAPPER",
986
+ "CLAUDE_CODE_SYNC_SKILLS",
987
+ "CLAUDE_CODE_SYNC_PLUGINS",
988
+ "CLAUDE_CODE_PLUGIN_CACHE_DIR",
989
+ "CLAUDE_CODE_PLUGIN_SEED_DIR",
990
+ "CLAUDE_CODE_OAUTH_TOKEN",
991
+ "CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
992
+ "CLAUDE_CODE_OAUTH_SCOPES",
993
+ "CLAUDE_CODE_USER_DIALOG_TIMEOUT_MS",
994
+ "CLAUDE_CODE_DISABLE_ATTACHMENTS",
995
+ "CLAUDE_CODE_DISABLE_FILE_MENTIONS",
996
+ "CLAUDE_CODE_REMOTE",
997
+ "CLAUDE_CODE_ACCOUNT_UUID",
998
+ "CLAUDE_CODE_MESSAGING_SOCKET",
999
+ "CLAUDE_CODE_MESSAGING_TOKEN",
1000
+ "CLAUDE_CODE_PROJECT_DIR_NAME",
1001
+ "CLAUDE_CODE_RESTRICTED",
1002
+ "CLAUDE_CODE_DISABLE_POWERSHELL_CMD_RM_DENY",
1003
+ "CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT",
1004
+ "CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT",
1005
+ "CLAUDE_CODE_DISABLE_INLINE_SHELL_RM_PROMPT",
1006
+ "CLAUDE_CODE_DISABLE_MOUNTED_DRIVE_RM_PROMPT",
1007
+ "CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS",
1008
+ "CLAUDE_CODE_WARN_DISK_FREE_GIB",
1009
+ "CLAUDE_CODE_WARN_MEMORY_FREE_GIB"
1010
+ ]);
1011
+ /**
1012
+ * Families of ignored variables.
1013
+ *
1014
+ * Matches: "XDG_CONFIG_HOME", "ProgramFiles(x86)", "CLAUDE_AX_SCREEN_READER",
1015
+ * "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_HEADERS"
1016
+ * Does not match: "MCP_TIMEOUT", "OTEL_EXPORTER_OTLP_TIMEOUT", "OTEL_SERVICE_NAME"
1017
+ */
1018
+ const IGNORED_NAME_PATTERN = /^(XDG_|ProgramFiles|CLAUDE_AX_|OTEL_EXPORTER_OTLP_(\w+_)?(ENDPOINT|HEADERS|PROTOCOL|CERTIFICATE|CLIENT_KEY|INSECURE)$)/;
1019
+ /** Values that turn telemetry off: Claude Code applies them even from project settings. */
1020
+ const OFF_VALUES = {
1021
+ OTEL_LOGS_EXPORTER: ["none"],
1022
+ OTEL_METRICS_EXPORTER: ["none"],
1023
+ OTEL_TRACES_EXPORTER: ["none"],
1024
+ OTEL_LOG_USER_PROMPTS: ["0", "false"],
1025
+ OTEL_LOG_TOOL_CONTENT: ["0", "false"],
1026
+ OTEL_LOG_TOOL_DETAILS: ["0", "false"]
1027
+ };
1028
+ function isIgnoredByClaude(name, value) {
1029
+ if ((OFF_VALUES[name] ?? []).includes(value)) return false;
1030
+ return IGNORED_NAMES.has(name) || IGNORED_NAME_PATTERN.test(name);
1031
+ }
1032
+ //#endregion
899
1033
  //#region src/agents/claude/mcp-json.ts
900
1034
  /** `.mcp.json` in the shape Claude Code reads: `{ "mcpServers": { "<key>": config } }`. */
901
1035
  function renderMcpJson(servers) {
@@ -904,6 +1038,32 @@ function renderMcpJson(servers) {
904
1038
  return `${JSON.stringify(content, null, 2)}\n`;
905
1039
  }
906
1040
  //#endregion
1041
+ //#region src/shared/utils/env.ts
1042
+ /**
1043
+ * The variables as a plain map, sorted by name so that the generated file and its diff stay
1044
+ * the same on every machine.
1045
+ */
1046
+ function buildEnvMap(env) {
1047
+ const sortedEntries = env.map((variable) => [variable.artifact.key, variable.artifact.value]).toSorted(compareByName);
1048
+ return Object.fromEntries(sortedEntries);
1049
+ }
1050
+ /** By code points, not by locale; names are unique, so two entries are never equal. */
1051
+ function compareByName([a], [b]) {
1052
+ return a < b ? -1 : 1;
1053
+ }
1054
+ //#endregion
1055
+ //#region src/agents/claude/settings-json.ts
1056
+ const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
1057
+ /** The keys that tunestra writes; a file with only these keys is a generated one. */
1058
+ const SETTINGS_KEYS = ["$schema", "env"];
1059
+ function renderSettingsJson(env) {
1060
+ const variables = buildEnvMap(env);
1061
+ return `${JSON.stringify({
1062
+ $schema: SETTINGS_SCHEMA,
1063
+ env: variables
1064
+ }, null, 2)}\n`;
1065
+ }
1066
+ //#endregion
907
1067
  //#region src/agents/claude/claude-adapter.ts
908
1068
  const CLAUDE_FILE = "CLAUDE.md";
909
1069
  const RULES_DIR = ".claude/rules/tunestra";
@@ -911,10 +1071,15 @@ const SKILLS_DIR = ".claude/skills";
911
1071
  const COMMANDS_DIR = ".claude/commands";
912
1072
  const MCP_FILE = ".mcp.json";
913
1073
  const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
1074
+ const SETTINGS_FILE = ".claude/settings.json";
1075
+ const SETTINGS_SOURCE = "env in tunestra.yaml and its presets";
1076
+ const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
914
1077
  /**
915
1078
  * Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
916
1079
  * 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`.
1080
+ * would need an approval. Skills are symlinks to the shared `.agents/skills`. Environment
1081
+ * variables go to `.claude/settings.json`, which the team commits; `settings.local.json`
1082
+ * stays for personal settings.
918
1083
  */
919
1084
  const claudeAdapter = {
920
1085
  agent: Agent.Claude,
@@ -943,6 +1108,12 @@ function planClaude(context) {
943
1108
  const mcpFile = createFileOutput(MCP_FILE, mcpJson, MCP_SOURCE);
944
1109
  outputs.push(mcpFile);
945
1110
  }
1111
+ const protectedPaths = [CLAUDE_FILE];
1112
+ if (resolution.env.length > 0) {
1113
+ const settingsFile = planSettingsFile(resolution.env);
1114
+ outputs.push(settingsFile);
1115
+ protectedPaths.push(SETTINGS_FILE);
1116
+ }
946
1117
  return {
947
1118
  outputs,
948
1119
  ignoredPaths: [
@@ -951,10 +1122,25 @@ function planClaude(context) {
951
1122
  `${COMMANDS_DIR}/`,
952
1123
  MCP_FILE
953
1124
  ],
954
- protectedPaths: [CLAUDE_FILE],
1125
+ protectedPaths,
955
1126
  notes: []
956
1127
  };
957
1128
  }
1129
+ /** Claude Code drops some variables from project settings; setup says so instead. */
1130
+ function planSettingsFile(env) {
1131
+ 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`);
1132
+ if (problems.length > 0) throw createProblemsError(problems);
1133
+ const content = renderSettingsJson(env);
1134
+ const file = createFileOutput(SETTINGS_FILE, content, SETTINGS_SOURCE);
1135
+ const ownership = {
1136
+ keys: SETTINGS_KEYS,
1137
+ otherKeysTarget: `${LOCAL_SETTINGS_FILE} or ~/.claude/settings.json`
1138
+ };
1139
+ return {
1140
+ ...file,
1141
+ ownership
1142
+ };
1143
+ }
958
1144
  function copyRule(ruleFile) {
959
1145
  const fileName = posix.basename(ruleFile.path);
960
1146
  return createFileOutput(`${RULES_DIR}/${fileName}`, ruleFile.content, ruleFile.source);
@@ -1012,22 +1198,29 @@ const LITERAL_DELIMITER = "'''";
1012
1198
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
1013
1199
  /**
1014
1200
  * `.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.
1201
+ * session, the environment of the commands that Codex runs, and the MCP servers. The rules
1202
+ * are a literal string, so that people can read them.
1016
1203
  */
1017
- function renderConfigToml(instructions, servers) {
1204
+ function renderConfigToml(config) {
1018
1205
  const parts = [GENERATED_HASH_NOTICE];
1019
- if (typeof instructions === "string") {
1020
- const line = renderInstructions$1(instructions);
1206
+ if (typeof config.instructions === "string") {
1207
+ const line = renderInstructions$1(config.instructions);
1021
1208
  parts.push(line);
1022
1209
  }
1023
- if (servers.length > 0) {
1024
- const entries = servers.map((server) => {
1025
- const config = convertMcpConfig(server.artifact.config);
1026
- return [server.artifact.key, config];
1210
+ const tables = {};
1211
+ const hasEnv = config.env.length > 0;
1212
+ const hasServers = config.servers.length > 0;
1213
+ if (hasEnv) tables.shell_environment_policy = { set: buildEnvMap(config.env) };
1214
+ if (hasServers) {
1215
+ const entries = config.servers.map((server) => {
1216
+ const serverConfig = convertMcpConfig(server.artifact.config);
1217
+ return [server.artifact.key, serverConfig];
1027
1218
  });
1028
- const content = { mcp_servers: Object.fromEntries(entries) };
1029
- const tables = stringify$1(content).trimEnd();
1030
- parts.push(tables);
1219
+ tables.mcp_servers = Object.fromEntries(entries);
1220
+ }
1221
+ if (hasEnv || hasServers) {
1222
+ const text = stringify$1(tables).trimEnd();
1223
+ parts.push(text);
1031
1224
  }
1032
1225
  return `${parts.join("\n\n")}\n`;
1033
1226
  }
@@ -1049,9 +1242,9 @@ function convertMcpConfig(config) {
1049
1242
  //#endregion
1050
1243
  //#region src/agents/codex/codex-adapter.ts
1051
1244
  const CONFIG_FILE = ".codex/config.toml";
1052
- const CONFIG_SOURCE = "the rules/ and mcp/ folders of tunestra.yaml and its presets";
1245
+ const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
1053
1246
  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.";
1247
+ const TRUST_NOTE = "Codex reads .codex/config.toml only in a trusted project: trust the project when Codex asks.";
1055
1248
  /**
1056
1249
  * Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
1057
1250
  * `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
@@ -1078,7 +1271,7 @@ function planCodex(context) {
1078
1271
  return false;
1079
1272
  }
1080
1273
  return true;
1081
- }));
1274
+ }), resolution.env);
1082
1275
  if (configFile === void 0) return {
1083
1276
  outputs,
1084
1277
  ignoredPaths: [],
@@ -1094,11 +1287,16 @@ function planCodex(context) {
1094
1287
  notes
1095
1288
  };
1096
1289
  }
1097
- function planConfigFile(ruleFiles, servers) {
1290
+ function planConfigFile(ruleFiles, servers, env) {
1098
1291
  const hasRules = ruleFiles.length > 0;
1099
1292
  const hasServers = servers.length > 0;
1100
- if (hasRules === false && hasServers === false) return;
1101
- const content = renderConfigToml(hasRules ? renderInstructions(ruleFiles) : void 0, servers);
1293
+ const hasEnv = env.length > 0;
1294
+ if (hasRules === false && hasServers === false && hasEnv === false) return;
1295
+ const content = renderConfigToml({
1296
+ instructions: hasRules ? renderInstructions(ruleFiles) : void 0,
1297
+ servers,
1298
+ env
1299
+ });
1102
1300
  return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
1103
1301
  }
1104
1302
  /** The rule files as they are, with the header that names the source of each. */
@@ -1200,6 +1398,7 @@ function renderLevelLine(rule) {
1200
1398
  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
1399
  const RULES_INTRO = "Always follow these rules. Claude Code and Codex load them automatically; other agents should read them before starting work.";
1202
1400
  const SKILLS_INTRO = "Use a skill when the task matches its description.";
1401
+ const ENV_INTRO = "Agents set these variables in every session, also for the commands they run. The values are in the presets.";
1203
1402
  const CHANGING_SECTION = [
1204
1403
  "## Changing this setup",
1205
1404
  "",
@@ -1301,6 +1500,13 @@ function renderMcpServers(servers) {
1301
1500
  ];
1302
1501
  })));
1303
1502
  }
1503
+ function renderEnv(env) {
1504
+ const table = renderTable(["Variable", "Level"], env.map((variable) => {
1505
+ const origin = describeOrigin(variable);
1506
+ return [variable.artifact.key, origin];
1507
+ }));
1508
+ return renderSection("Environment", ENV_INTRO, table);
1509
+ }
1304
1510
  /** The project shows its config file; a preset shows its git reference or its path. */
1305
1511
  function describeLevelSource(chain, level) {
1306
1512
  if (level.dir === chain.project.dir) return PROJECT_CONFIG;
@@ -1340,6 +1546,7 @@ function renderAgentsIndex(resolution) {
1340
1546
  const hasSkills = resolution.skills.length > 0;
1341
1547
  const hasCommands = resolution.commands.length > 0;
1342
1548
  const hasMcpServers = resolution.mcp.length > 0;
1549
+ const hasEnv = resolution.env.length > 0;
1343
1550
  if (hasRules) {
1344
1551
  const rules = renderRules(resolution.rules);
1345
1552
  sections.push(rules);
@@ -1356,6 +1563,10 @@ function renderAgentsIndex(resolution) {
1356
1563
  const servers = renderMcpServers(resolution.mcp);
1357
1564
  sections.push(servers);
1358
1565
  }
1566
+ if (hasEnv) {
1567
+ const env = renderEnv(resolution.env);
1568
+ sections.push(env);
1569
+ }
1359
1570
  sections.push(CHANGING_SECTION);
1360
1571
  return `${sections.join("\n\n")}\n`;
1361
1572
  }
@@ -1432,12 +1643,16 @@ function findPathConflicts(outputs) {
1432
1643
  }
1433
1644
  //#endregion
1434
1645
  //#region src/shared/constants/artifacts.ts
1435
- /** Kinds of artifacts; the values match the folder-based fields of a preset and of `exclude`. */
1646
+ /**
1647
+ * Kinds of artifacts; the values match the fields of a preset and of `exclude`. Environment
1648
+ * variables come from the manifest, the other kinds from folders.
1649
+ */
1436
1650
  const ArtifactKind = {
1437
1651
  Rules: "rules",
1438
1652
  Skills: "skills",
1439
1653
  Commands: "commands",
1440
- Mcp: "mcp"
1654
+ Mcp: "mcp",
1655
+ Env: "env"
1441
1656
  };
1442
1657
  //#endregion
1443
1658
  //#region src/inheritance/inherit-artifacts.ts
@@ -1575,11 +1790,13 @@ function applyInheritance(chain) {
1575
1790
  const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
1576
1791
  const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
1577
1792
  const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
1793
+ const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
1578
1794
  const problems = [
1579
1795
  ...rules.problems,
1580
1796
  ...skills.problems,
1581
1797
  ...commands.problems,
1582
- ...mcp.problems
1798
+ ...mcp.problems,
1799
+ ...env.problems
1583
1800
  ];
1584
1801
  if (problems.length > 0) throw createProblemsError(problems);
1585
1802
  return {
@@ -1587,7 +1804,8 @@ function applyInheritance(chain) {
1587
1804
  rules: rules.artifacts,
1588
1805
  skills: skills.artifacts,
1589
1806
  commands: commands.artifacts,
1590
- mcp: mcp.artifacts
1807
+ mcp: mcp.artifacts,
1808
+ env: env.artifacts
1591
1809
  };
1592
1810
  }
1593
1811
  //#endregion
@@ -1605,7 +1823,7 @@ async function readState(projectDir) {
1605
1823
  const file = join(projectDir, STATE_FILE);
1606
1824
  const content = await readFileIfExists(file);
1607
1825
  if (content === void 0) return createEmptyState();
1608
- return validate(parseJson(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
1826
+ return validate(parseJson$1(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
1609
1827
  }
1610
1828
  function createEmptyState() {
1611
1829
  return {
@@ -1613,7 +1831,7 @@ function createEmptyState() {
1613
1831
  outputs: {}
1614
1832
  };
1615
1833
  }
1616
- function parseJson(content, file) {
1834
+ function parseJson$1(content, file) {
1617
1835
  try {
1618
1836
  return JSON.parse(content);
1619
1837
  } catch (error) {
@@ -1727,6 +1945,24 @@ async function createRecord(output) {
1727
1945
  }
1728
1946
  }
1729
1947
  //#endregion
1948
+ //#region src/sync/find-foreign-keys.ts
1949
+ /**
1950
+ * The top-level keys of a JSON file that are not in `ownedKeys`. A file that is not a JSON
1951
+ * object has no keys that tunestra could own, so the result is `undefined`.
1952
+ */
1953
+ async function findForeignKeys(path, ownedKeys) {
1954
+ const data = parseJson(await readFile(path, "utf8"));
1955
+ if (isKeyValueMap(data)) return Object.keys(data).filter((key) => ownedKeys.includes(key) === false);
1956
+ }
1957
+ function parseJson(text) {
1958
+ try {
1959
+ return JSON.parse(text);
1960
+ } catch (error) {
1961
+ if (error instanceof SyntaxError) return;
1962
+ throw error;
1963
+ }
1964
+ }
1965
+ //#endregion
1730
1966
  //#region src/sync/inspect-path.ts
1731
1967
  async function inspectPath(path) {
1732
1968
  const type = await getPathType(path);
@@ -1770,9 +2006,21 @@ async function checkOutput(output, context) {
1770
2006
  const isRecorded = typeof previousRecord === "object";
1771
2007
  const isReplaceable = disk.type === PathType.File || disk.type === PathType.Symlink;
1772
2008
  if (isRecorded && isReplaceable) return createCheck(output, record, `${output.path} was edited by hand; tunestra replaced it. Edit ${output.source} instead`);
2009
+ const ownership = output.kind === OutputKind.File ? output.ownership : void 0;
2010
+ if (typeof ownership === "object" && disk.type === PathType.File) return createCheck(output, record, await checkOwnedJson(path, output, ownership));
1773
2011
  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
2012
  throw new TunestraError(`${output.path} already exists, and tunestra did not create it. Move it away and run tunestra setup again`);
1775
2013
  }
2014
+ /** Takes over a JSON file with only the keys that tunestra writes; other keys stop setup. */
2015
+ async function checkOwnedJson(path, output, ownership) {
2016
+ const foreignKeys = await findForeignKeys(path, ownership.keys);
2017
+ 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`);
2018
+ if (foreignKeys.length > 0) {
2019
+ const keys = foreignKeys.join(", ");
2020
+ throw new TunestraError(`${output.path} has settings that tunestra does not write: ${keys}. Move them to ${ownership.otherKeysTarget}, then run tunestra setup again`);
2021
+ }
2022
+ return `${output.path} had only the settings that tunestra writes; tunestra replaced it. Edit ${output.source} instead`;
2023
+ }
1776
2024
  /**
1777
2025
  * Nothing to lose: the path is empty, already has the new content, still has what tunestra
1778
2026
  * wrote last time, or is a generated markdown file whose record was lost.
@@ -1913,7 +2161,8 @@ function describeResolution(resolution) {
1913
2161
  describeSection("Rules", resolution.rules, projectDir),
1914
2162
  describeSection("Skills", resolution.skills, projectDir),
1915
2163
  describeSection("Commands", resolution.commands, projectDir),
1916
- describeSection("MCP servers", resolution.mcp, projectDir)
2164
+ describeSection("MCP servers", resolution.mcp, projectDir),
2165
+ describeSection("Environment", resolution.env, projectDir)
1917
2166
  ].filter((section) => section.length > 0).join("\n\n");
1918
2167
  }
1919
2168
  function describeSection(title, artifacts, projectDir) {
@@ -1961,12 +2210,19 @@ function describeSync(plan, sync, options) {
1961
2210
  return lines.join("\n");
1962
2211
  }
1963
2212
  const check = pc.green("✔");
1964
- const committedFiles = plan.protectedPaths.join(" and ");
2213
+ const committedFiles = joinList(plan.protectedPaths);
1965
2214
  lines.push(`${check} Generated ${writeCount} files, removed ${removeCount} stale files.`);
1966
2215
  lines.push(...notes);
1967
2216
  lines.push(`Commit ${committedFiles}; the rest is in .gitignore.`);
1968
2217
  return lines.join("\n");
1969
2218
  }
2219
+ /** For example `AGENTS.md, CLAUDE.md and .claude/settings.json`. */
2220
+ function joinList(items) {
2221
+ const last = items.at(-1) ?? "";
2222
+ const rest = items.slice(0, -1);
2223
+ if (rest.length === 0) return last;
2224
+ return `${rest.join(", ")} and ${last}`;
2225
+ }
1970
2226
  //#endregion
1971
2227
  //#region src/uninstall/remove-gitignore-block.ts
1972
2228
  /** 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-BNeh64fK.mjs";
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/index.ts
3
- const VERSION = "0.2.0";
3
+ const VERSION = "0.3.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.3.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",