tunestra 0.1.1 → 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
@@ -9,11 +9,12 @@
9
9
  > Tune your AI agents to play as one orchestra.
10
10
 
11
11
  > [!WARNING]
12
- > **Early version.** v0.1 supports Claude Code; Codex comes in v0.2. The formats may still
13
- > change before v1.0.
12
+ > **Early version.** tunestra supports Claude Code and Codex. The formats may still change
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
 
@@ -33,7 +34,7 @@ specific one wins.
33
34
 
34
35
  ```sh
35
36
  npx tunestra init --extends github:<organisation_name>/<presets_repo>/team#v1
36
- npx tunestra setup # generates AGENTS.md, CLAUDE.md, .claude/, .agents/, .mcp.json
37
+ npx tunestra setup # generates AGENTS.md and the configuration of every agent
37
38
  ```
38
39
 
39
40
  `init` creates `tunestra.yaml`:
@@ -41,11 +42,12 @@ npx tunestra setup # generates AGENTS.md, CLAUDE.md, .claude/, .agents/, .mcp
41
42
  ```yaml
42
43
  extends:
43
44
  - github:<organisation_name>/<presets_repo>/team#v1
44
- agents: [claude]
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`
@@ -97,7 +101,7 @@ as a preset.
97
101
  name: my-service # optional, defaults to the directory name
98
102
  extends:
99
103
  - ../presets/team
100
- agents: [claude] # v0.1 supports claude
104
+ agents: [claude, codex] # claude, codex; defaults to [claude]
101
105
  exclude: {}
102
106
  overrides: {}
103
107
  ```
@@ -116,7 +120,7 @@ repository with a relative path, like `../base`. A relative path cannot leave th
116
120
  Git is not needed: repositories are downloaded as archives over HTTPS.
117
121
 
118
122
  For a private repository, set the `TUNESTRA_GIT_TOKEN` environment variable to a token with
119
- read access. Lockfiles and npm sources are planned for v0.3.
123
+ read access. Lockfiles and npm sources are planned for v0.4.
120
124
 
121
125
  ## Inheritance
122
126
 
@@ -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,24 +154,65 @@ 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
 
156
- ## What gets generated (Claude Code)
164
+ ## What gets generated
165
+
166
+ For every agent:
157
167
 
158
168
  | Path | Content | Committed |
159
169
  |---|---|---|
160
170
  | `AGENTS.md` | Index: hierarchy, rules, skills, commands, MCP servers | yes |
161
- | `CLAUDE.md` | `@AGENTS.md` | yes |
162
171
  | `.agents/rules/<NN>-<level>-<key>.md` | Rule texts, ordered from specific to general | no |
163
172
  | `.agents/skills/<key>/` | Skills | no |
173
+ | `.tunestra/state/` | What tunestra generated last time | no |
174
+ | `.gitignore` | A block between `# >>> tunestra` and `# <<< tunestra` | yes |
175
+
176
+ ### Claude Code
177
+
178
+ | Path | Content | Committed |
179
+ |---|---|---|
180
+ | `CLAUDE.md` | `@AGENTS.md` | yes |
164
181
  | `.claude/rules/tunestra/<NN>-<level>-<key>.md` | Copies of the rules; Claude Code loads them in every session | no |
165
182
  | `.claude/skills/<key>` | Symlinks to `.agents/skills/<key>` | no |
166
183
  | `.claude/commands/<key>.md` | Commands | no |
167
184
  | `.mcp.json` | MCP servers | no |
168
- | `.tunestra/state/` | What tunestra generated last time | no |
169
- | `.gitignore` | A block between `# >>> tunestra` and `# <<< tunestra` | yes |
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.
199
+
200
+ ### Codex
201
+
202
+ Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the links in
203
+ `AGENTS.md`, so tunestra gives it the rule texts in its config.
204
+
205
+ | Path | Content | Committed |
206
+ |---|---|---|
207
+ | `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, `[shell_environment_policy] set` with `env`, and `[mcp_servers]` | no |
208
+ | `.agents/skills/<command>/` | Each command as a skill that runs only when you call it: `$release-notes` | no |
209
+
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.
213
+ Codex has no `type` for MCP servers and does not support SSE: an SSE server is skipped with a
214
+ note, and `headers` become `http_headers`. tunestra owns the whole `.codex/config.toml`; keep
215
+ your personal Codex settings in `~/.codex/config.toml`.
170
216
 
171
217
  `<NN>` orders rules from specific to general: `10` project, `20` team, `30` domain, `40` base.
172
218
  Level instructions get the key `instructions`, and the preset name when it differs from the
@@ -180,7 +226,9 @@ Every generated rule starts with its origin:
180
226
  > Level: team, preset: team. Overrides: base.
181
227
  ```
182
228
 
183
- Skills and commands are copied as is: their frontmatter must stay on the first line.
229
+ Skills and commands are copied as is: their frontmatter must stay on the first line. For
230
+ Codex, a command gets only `name` and `description` in its frontmatter; other fields, like
231
+ `argument-hint`, are for Claude Code.
184
232
 
185
233
  ## Running setup again
186
234
 
@@ -214,14 +262,15 @@ and tells you where to move their text.
214
262
 
215
263
  | Release | Scope |
216
264
  |---|---|
217
- | v0.1 | Inheritance for Claude Code (this document) |
218
- | v0.2 | Codex |
219
- | v0.3 | Spaces, npm sources, lockfile |
220
- | v0.4 | `required` / `locked` policies, CI check |
221
- | v0.5 | Hooks |
222
- | v0.6 | LLM gateway, permissions |
223
- | v0.7 | Local plugins |
224
- | v0.8 | Isolated remote plugins |
265
+ | v0.1 | Inheritance for Claude Code |
266
+ | v0.2 | Codex (this document) |
267
+ | v0.3 | `env`, then permissions, subagents and a personal `local` level |
268
+ | v0.4 | Spaces, npm sources, lockfile |
269
+ | v0.5 | `required` / `locked` policies, CI check |
270
+ | v0.6 | Hooks |
271
+ | v0.7 | LLM gateway |
272
+ | v0.8 | Local plugins |
273
+ | v0.9 | Isolated remote plugins |
225
274
 
226
275
  ## License
227
276
 
package/dist/cli.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { t as VERSION } from "./src-Dmt0_5_W.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";
6
6
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
7
7
  import { glob } from "tinyglobby";
8
8
  import { prettifyError, z } from "zod";
9
- import { parse } from "yaml";
9
+ import { parse, stringify } from "yaml";
10
10
  import { downloadTemplate } from "giget";
11
+ import { stringify as stringify$1 } from "smol-toml";
11
12
  import { createHash } from "node:crypto";
12
13
  //#region src/shared/constants/files.ts
13
14
  /** Preset manifest, in the root of every preset. */
@@ -32,6 +33,11 @@ const OutputKind = {
32
33
  /** First line of every generated markdown file; tunestra recognizes its files by it. */
33
34
  const GENERATED_MARKER = "<!-- Generated by tunestra";
34
35
  const GENERATED_NOTICE = `${GENERATED_MARKER}. Do not edit: change the sources and run \`tunestra setup\`. -->`;
36
+ /** The same marker for generated TOML and YAML files, where a comment starts with `#`. */
37
+ const GENERATED_HASH_MARKER = "# Generated by tunestra";
38
+ const GENERATED_HASH_NOTICE = `${GENERATED_HASH_MARKER}. Do not edit: change the sources and run \`tunestra setup\`.`;
39
+ /** Every marker that tunestra puts on the first line of a generated file. */
40
+ const GENERATED_MARKERS = [GENERATED_MARKER, GENERATED_HASH_MARKER];
35
41
  /** Shared output that any agent can read. */
36
42
  const AGENTS_DIR = ".agents";
37
43
  /** Rules for every agent, ordered from specific to general by their file names. */
@@ -155,7 +161,10 @@ async function removeDirIfEmpty(dir) {
155
161
  //#endregion
156
162
  //#region src/shared/constants/agents.ts
157
163
  /** Agents that tunestra generates configuration for. */
158
- const Agent = { Claude: "claude" };
164
+ const Agent = {
165
+ Claude: "claude",
166
+ Codex: "codex"
167
+ };
159
168
  //#endregion
160
169
  //#region src/init/render-config.ts
161
170
  const EXTENDS_EXAMPLE = "# - github:<owner>/<repo>/<path>#<ref>";
@@ -294,12 +303,48 @@ function isKeyValueMap(value) {
294
303
  //#region src/shared/schemas/preset.ts
295
304
  const keys = z.array(z.string().min(1)).readonly();
296
305
  const stringMap = z.record(z.string(), z.string());
297
- 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);
298
342
  const excludeSchema = z.strictObject({
299
343
  rules: keys.optional(),
300
344
  skills: keys.optional(),
301
345
  commands: keys.optional(),
302
- mcp: keys.optional()
346
+ mcp: keys.optional(),
347
+ env: keys.optional()
303
348
  });
304
349
  const mcpPatchSchema = z.strictObject({
305
350
  command: z.string().optional(),
@@ -315,14 +360,16 @@ const presetManifestSchema = z.strictObject({
315
360
  description: z.string().optional(),
316
361
  extends: z.array(z.string().min(1)).readonly().default([]),
317
362
  exclude: excludeSchema.default({}),
318
- overrides: overridesSchema.default({})
363
+ overrides: overridesSchema.default({}),
364
+ env: envSchema.default({})
319
365
  });
320
366
  const projectConfigSchema = z.strictObject({
321
367
  name: z.string().min(1).optional(),
322
368
  extends: z.array(z.string().min(1)).readonly().default([]),
323
369
  agents: z.array(z.enum(Agent)).min(1).readonly().default([Agent.Claude]),
324
370
  exclude: excludeSchema.default({}),
325
- overrides: overridesSchema.default({})
371
+ overrides: overridesSchema.default({}),
372
+ env: envSchema.default({})
326
373
  });
327
374
  const mcpServerSchema = z.strictObject({
328
375
  description: z.string().optional(),
@@ -446,13 +493,14 @@ async function loadCommands(dir) {
446
493
  async function loadCommand(dir, source) {
447
494
  const file = join(dir, source);
448
495
  const content = await readFile(file, "utf8");
449
- const { data } = parseFrontmatter(content, file);
496
+ const { data, body } = parseFrontmatter(content, file);
450
497
  const frontmatter = validate(data, commandFrontmatterSchema, `frontmatter in ${file}`);
451
498
  return {
452
499
  key: basename(source, ".md"),
453
500
  source,
454
501
  description: frontmatter.description,
455
- content
502
+ content,
503
+ body
456
504
  };
457
505
  }
458
506
  //#endregion
@@ -567,6 +615,26 @@ async function loadArtifacts(dir, presetName) {
567
615
  };
568
616
  }
569
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
570
638
  //#region src/presets/load-project.ts
571
639
  async function loadProject(dir) {
572
640
  const root = resolve(dir);
@@ -577,6 +645,7 @@ async function loadProject(dir) {
577
645
  const name = config.name ?? folderName;
578
646
  const artifactsDir = join(root, PROJECT_DIR);
579
647
  const artifacts = await loadArtifacts(artifactsDir, name);
648
+ const env = createEnvVariables(config.env, configPath, artifactsDir);
580
649
  return {
581
650
  ...config,
582
651
  name,
@@ -585,19 +654,11 @@ async function loadProject(dir) {
585
654
  dir: root,
586
655
  artifactsDir,
587
656
  remote: void 0,
588
- ...artifacts
657
+ ...artifacts,
658
+ env
589
659
  };
590
660
  }
591
661
  //#endregion
592
- //#region src/shared/utils/paths.ts
593
- /** Generated files always use `/`, so they are the same on every system. */
594
- function toPosixPath(path) {
595
- return path.split(sep).join("/");
596
- }
597
- function relativePosix(from, to) {
598
- return toPosixPath(relative(from, to));
599
- }
600
- //#endregion
601
662
  //#region src/sources/parse-git-source.ts
602
663
  /**
603
664
  * A reference to a preset in a git repository.
@@ -799,13 +860,15 @@ async function loadPreset(dir) {
799
860
  if (await checkExists(manifestPath) === false) throw new TunestraError(`${root} is not a preset: ${PRESET_MANIFEST} is missing`);
800
861
  const manifest = await readYaml(manifestPath, presetManifestSchema, "preset manifest");
801
862
  const artifacts = await loadArtifacts(root, manifest.name);
863
+ const env = createEnvVariables(manifest.env, manifestPath, root);
802
864
  return {
803
865
  ...manifest,
804
866
  description: manifest.description,
805
867
  dir: root,
806
868
  artifactsDir: root,
807
869
  remote: void 0,
808
- ...artifacts
870
+ ...artifacts,
871
+ env
809
872
  };
810
873
  }
811
874
  //#endregion
@@ -886,6 +949,87 @@ function createFileOutput(path, content, source) {
886
949
  };
887
950
  }
888
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
889
1033
  //#region src/agents/claude/mcp-json.ts
890
1034
  /** `.mcp.json` in the shape Claude Code reads: `{ "mcpServers": { "<key>": config } }`. */
891
1035
  function renderMcpJson(servers) {
@@ -894,6 +1038,32 @@ function renderMcpJson(servers) {
894
1038
  return `${JSON.stringify(content, null, 2)}\n`;
895
1039
  }
896
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
897
1067
  //#region src/agents/claude/claude-adapter.ts
898
1068
  const CLAUDE_FILE = "CLAUDE.md";
899
1069
  const RULES_DIR = ".claude/rules/tunestra";
@@ -901,10 +1071,15 @@ const SKILLS_DIR = ".claude/skills";
901
1071
  const COMMANDS_DIR = ".claude/commands";
902
1072
  const MCP_FILE = ".mcp.json";
903
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";
904
1077
  /**
905
1078
  * Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
906
1079
  * which Claude Code loads in every session; a symlink to a directory outside `.claude`
907
- * 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.
908
1083
  */
909
1084
  const claudeAdapter = {
910
1085
  agent: Agent.Claude,
@@ -933,6 +1108,12 @@ function planClaude(context) {
933
1108
  const mcpFile = createFileOutput(MCP_FILE, mcpJson, MCP_SOURCE);
934
1109
  outputs.push(mcpFile);
935
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
+ }
936
1117
  return {
937
1118
  outputs,
938
1119
  ignoredPaths: [
@@ -941,7 +1122,23 @@ function planClaude(context) {
941
1122
  `${COMMANDS_DIR}/`,
942
1123
  MCP_FILE
943
1124
  ],
944
- protectedPaths: [CLAUDE_FILE]
1125
+ protectedPaths,
1126
+ notes: []
1127
+ };
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
945
1142
  };
946
1143
  }
947
1144
  function copyRule(ruleFile) {
@@ -961,9 +1158,159 @@ function linkSkill(projectDir, skill) {
961
1158
  };
962
1159
  }
963
1160
  //#endregion
1161
+ //#region src/agents/codex/command-skill.ts
1162
+ /** Codex starts the skill only when people call it, like a command. */
1163
+ const OPENAI_YAML = [
1164
+ GENERATED_HASH_NOTICE,
1165
+ "policy:",
1166
+ " allow_implicit_invocation: false",
1167
+ ""
1168
+ ].join("\n");
1169
+ /**
1170
+ * Codex has no project commands, so a command becomes a skill with the same name that runs
1171
+ * only on `$<name>`. The skill gets `name` and `description`; other frontmatter of the
1172
+ * command belongs to Claude Code.
1173
+ */
1174
+ function planCommandSkill(projectDir, command) {
1175
+ const { key, description, body } = command.artifact;
1176
+ const dir = `${AGENTS_SKILLS_DIR}/${key}`;
1177
+ const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
1178
+ const frontmatter = {
1179
+ name: key,
1180
+ description: description ?? `The /${key} command`
1181
+ };
1182
+ const skill = `---\n${stringify(frontmatter)}---\n${body}`;
1183
+ const skillPath = `${dir}/SKILL.md`;
1184
+ const policyPath = `${dir}/agents/openai.yaml`;
1185
+ return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
1186
+ }
1187
+ //#endregion
1188
+ //#region src/agents/codex/config-toml.ts
1189
+ /** Starts and ends a multi-line literal string, where TOML escapes nothing. */
1190
+ const LITERAL_DELIMITER = "'''";
1191
+ /**
1192
+ * Control characters that a TOML literal string cannot hold. Tab and line feed are allowed;
1193
+ * a carriage return is in the list, because only `\r\n` would be valid.
1194
+ *
1195
+ * Matches: "\u0000", "\r", "\u001B" (escape), "\u007F" (delete)
1196
+ * Does not match: "\t", "\n", "é", "→"
1197
+ */
1198
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
1199
+ /**
1200
+ * `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
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.
1203
+ */
1204
+ function renderConfigToml(config) {
1205
+ const parts = [GENERATED_HASH_NOTICE];
1206
+ if (typeof config.instructions === "string") {
1207
+ const line = renderInstructions$1(config.instructions);
1208
+ parts.push(line);
1209
+ }
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];
1218
+ });
1219
+ tables.mcp_servers = Object.fromEntries(entries);
1220
+ }
1221
+ if (hasEnv || hasServers) {
1222
+ const text = stringify$1(tables).trimEnd();
1223
+ parts.push(text);
1224
+ }
1225
+ return `${parts.join("\n\n")}\n`;
1226
+ }
1227
+ function renderInstructions$1(text) {
1228
+ const hasDelimiter = text.includes(LITERAL_DELIMITER);
1229
+ const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
1230
+ if (hasDelimiter || hasControlCharacter) return stringify$1({ developer_instructions: text });
1231
+ return `developer_instructions = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
1232
+ }
1233
+ /** Codex picks the transport by `command` or `url` and has no `type`. */
1234
+ function convertMcpConfig(config) {
1235
+ const { type: _type, headers, ...server } = config;
1236
+ if (headers === void 0) return server;
1237
+ return {
1238
+ ...server,
1239
+ http_headers: headers
1240
+ };
1241
+ }
1242
+ //#endregion
1243
+ //#region src/agents/codex/codex-adapter.ts
1244
+ const CONFIG_FILE = ".codex/config.toml";
1245
+ const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
1246
+ const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
1247
+ const TRUST_NOTE = "Codex reads .codex/config.toml only in a trusted project: trust the project when Codex asks.";
1248
+ /**
1249
+ * Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
1250
+ * `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
1251
+ * in AGENTS.md; MCP servers go to the same file. Commands become skills.
1252
+ */
1253
+ const codexAdapter = {
1254
+ agent: Agent.Codex,
1255
+ plan: planCodex
1256
+ };
1257
+ function planCodex(context) {
1258
+ const { resolution, ruleFiles } = context;
1259
+ const projectDir = resolution.chain.project.dir;
1260
+ const notes = [];
1261
+ const outputs = [];
1262
+ const commandSkills = resolution.commands.filter((command) => {
1263
+ if (skillName.safeParse(command.artifact.key).success) return true;
1264
+ notes.push(`Codex skips the command "${command.artifact.key}": a skill name needs lowercase letters, digits and single hyphens.`);
1265
+ return false;
1266
+ }).flatMap((command) => planCommandSkill(projectDir, command));
1267
+ outputs.push(...commandSkills);
1268
+ const configFile = planConfigFile(ruleFiles, resolution.mcp.filter((server) => {
1269
+ if (server.artifact.config.type === "sse") {
1270
+ notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
1271
+ return false;
1272
+ }
1273
+ return true;
1274
+ }), resolution.env);
1275
+ if (configFile === void 0) return {
1276
+ outputs,
1277
+ ignoredPaths: [],
1278
+ protectedPaths: [],
1279
+ notes
1280
+ };
1281
+ outputs.push(configFile);
1282
+ notes.push(TRUST_NOTE);
1283
+ return {
1284
+ outputs,
1285
+ ignoredPaths: [CONFIG_FILE],
1286
+ protectedPaths: [],
1287
+ notes
1288
+ };
1289
+ }
1290
+ function planConfigFile(ruleFiles, servers, env) {
1291
+ const hasRules = ruleFiles.length > 0;
1292
+ const hasServers = servers.length > 0;
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
+ });
1300
+ return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
1301
+ }
1302
+ /** The rule files as they are, with the header that names the source of each. */
1303
+ function renderInstructions(ruleFiles) {
1304
+ const rules = ruleFiles.map((file) => file.content.trimEnd());
1305
+ return `${[INSTRUCTIONS_INTRO, ...rules].join("\n\n")}\n`;
1306
+ }
1307
+ //#endregion
964
1308
  //#region src/agents/adapters.ts
965
- /** Every supported agent; Codex comes in v0.2. */
966
- const AGENT_ADAPTERS = { [Agent.Claude]: claudeAdapter };
1309
+ /** Every supported agent. */
1310
+ const AGENT_ADAPTERS = {
1311
+ [Agent.Claude]: claudeAdapter,
1312
+ [Agent.Codex]: codexAdapter
1313
+ };
967
1314
  //#endregion
968
1315
  //#region src/shared/utils/markdown.ts
969
1316
  /**
@@ -1049,8 +1396,9 @@ function renderLevelLine(rule) {
1049
1396
  //#endregion
1050
1397
  //#region src/generate/index-sections.ts
1051
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.";
1052
- const RULES_INTRO = "Always follow these rules. Claude Code loads them automatically; other agents should read them before starting work.";
1399
+ const RULES_INTRO = "Always follow these rules. Claude Code and Codex load them automatically; other agents should read them before starting work.";
1053
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.";
1054
1402
  const CHANGING_SECTION = [
1055
1403
  "## Changing this setup",
1056
1404
  "",
@@ -1111,8 +1459,8 @@ function renderSkills(skills) {
1111
1459
  }));
1112
1460
  return renderSection("Skills", SKILLS_INTRO, table);
1113
1461
  }
1114
- function renderCommands(commands) {
1115
- return renderSection("Commands", void 0, renderTable([
1462
+ function renderCommands(commands, agents) {
1463
+ const table = renderTable([
1116
1464
  "Command",
1117
1465
  "Level",
1118
1466
  "Description"
@@ -1124,7 +1472,18 @@ function renderCommands(commands) {
1124
1472
  origin,
1125
1473
  description
1126
1474
  ];
1127
- })));
1475
+ }));
1476
+ const hints = agents.map(describeCommandCall).filter(isString);
1477
+ if (hints.length > 0) return renderSection("Commands", hints.join(" "), table);
1478
+ return renderSection("Commands", void 0, table);
1479
+ }
1480
+ /** How to call a command in the agent, when it is not the usual `/<name>`. */
1481
+ function describeCommandCall(agent) {
1482
+ switch (agent) {
1483
+ case Agent.Claude: return;
1484
+ case Agent.Codex: return "In Codex, a command is a skill with the same name: call it as `$<name>`.";
1485
+ default: throw new TunestraError(`Unknown agent: ${agent}`);
1486
+ }
1128
1487
  }
1129
1488
  function renderMcpServers(servers) {
1130
1489
  return renderSection("MCP servers", void 0, renderTable([
@@ -1141,6 +1500,13 @@ function renderMcpServers(servers) {
1141
1500
  ];
1142
1501
  })));
1143
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
+ }
1144
1510
  /** The project shows its config file; a preset shows its git reference or its path. */
1145
1511
  function describeLevelSource(chain, level) {
1146
1512
  if (level.dir === chain.project.dir) return PROJECT_CONFIG;
@@ -1180,6 +1546,7 @@ function renderAgentsIndex(resolution) {
1180
1546
  const hasSkills = resolution.skills.length > 0;
1181
1547
  const hasCommands = resolution.commands.length > 0;
1182
1548
  const hasMcpServers = resolution.mcp.length > 0;
1549
+ const hasEnv = resolution.env.length > 0;
1183
1550
  if (hasRules) {
1184
1551
  const rules = renderRules(resolution.rules);
1185
1552
  sections.push(rules);
@@ -1189,13 +1556,17 @@ function renderAgentsIndex(resolution) {
1189
1556
  sections.push(skills);
1190
1557
  }
1191
1558
  if (hasCommands) {
1192
- const commands = renderCommands(resolution.commands);
1559
+ const commands = renderCommands(resolution.commands, resolution.chain.project.agents);
1193
1560
  sections.push(commands);
1194
1561
  }
1195
1562
  if (hasMcpServers) {
1196
1563
  const servers = renderMcpServers(resolution.mcp);
1197
1564
  sections.push(servers);
1198
1565
  }
1566
+ if (hasEnv) {
1567
+ const env = renderEnv(resolution.env);
1568
+ sections.push(env);
1569
+ }
1199
1570
  sections.push(CHANGING_SECTION);
1200
1571
  return `${sections.join("\n\n")}\n`;
1201
1572
  }
@@ -1218,6 +1589,7 @@ function planSetup(resolution) {
1218
1589
  ];
1219
1590
  const ignoredPaths = [`${AGENTS_DIR}/`];
1220
1591
  const protectedPaths = [INSTRUCTIONS_FILE];
1592
+ const notes = [];
1221
1593
  const context = {
1222
1594
  resolution,
1223
1595
  ruleFiles
@@ -1227,6 +1599,7 @@ function planSetup(resolution) {
1227
1599
  outputs.push(...agentPlan.outputs);
1228
1600
  ignoredPaths.push(...agentPlan.ignoredPaths);
1229
1601
  protectedPaths.push(...agentPlan.protectedPaths);
1602
+ notes.push(...agentPlan.notes);
1230
1603
  }
1231
1604
  ignoredPaths.push(`${STATE_DIR}/`);
1232
1605
  const problems = findPathConflicts(outputs);
@@ -1234,7 +1607,8 @@ function planSetup(resolution) {
1234
1607
  return {
1235
1608
  outputs,
1236
1609
  ignoredPaths,
1237
- protectedPaths
1610
+ protectedPaths,
1611
+ notes
1238
1612
  };
1239
1613
  }
1240
1614
  function planRuleFile(projectDir, rule) {
@@ -1269,12 +1643,16 @@ function findPathConflicts(outputs) {
1269
1643
  }
1270
1644
  //#endregion
1271
1645
  //#region src/shared/constants/artifacts.ts
1272
- /** 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
+ */
1273
1650
  const ArtifactKind = {
1274
1651
  Rules: "rules",
1275
1652
  Skills: "skills",
1276
1653
  Commands: "commands",
1277
- Mcp: "mcp"
1654
+ Mcp: "mcp",
1655
+ Env: "env"
1278
1656
  };
1279
1657
  //#endregion
1280
1658
  //#region src/inheritance/inherit-artifacts.ts
@@ -1412,11 +1790,13 @@ function applyInheritance(chain) {
1412
1790
  const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
1413
1791
  const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
1414
1792
  const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
1793
+ const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
1415
1794
  const problems = [
1416
1795
  ...rules.problems,
1417
1796
  ...skills.problems,
1418
1797
  ...commands.problems,
1419
- ...mcp.problems
1798
+ ...mcp.problems,
1799
+ ...env.problems
1420
1800
  ];
1421
1801
  if (problems.length > 0) throw createProblemsError(problems);
1422
1802
  return {
@@ -1424,7 +1804,8 @@ function applyInheritance(chain) {
1424
1804
  rules: rules.artifacts,
1425
1805
  skills: skills.artifacts,
1426
1806
  commands: commands.artifacts,
1427
- mcp: mcp.artifacts
1807
+ mcp: mcp.artifacts,
1808
+ env: env.artifacts
1428
1809
  };
1429
1810
  }
1430
1811
  //#endregion
@@ -1442,7 +1823,7 @@ async function readState(projectDir) {
1442
1823
  const file = join(projectDir, STATE_FILE);
1443
1824
  const content = await readFileIfExists(file);
1444
1825
  if (content === void 0) return createEmptyState();
1445
- 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`);
1446
1827
  }
1447
1828
  function createEmptyState() {
1448
1829
  return {
@@ -1450,7 +1831,7 @@ function createEmptyState() {
1450
1831
  outputs: {}
1451
1832
  };
1452
1833
  }
1453
- function parseJson(content, file) {
1834
+ function parseJson$1(content, file) {
1454
1835
  try {
1455
1836
  return JSON.parse(content);
1456
1837
  } catch (error) {
@@ -1564,6 +1945,24 @@ async function createRecord(output) {
1564
1945
  }
1565
1946
  }
1566
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
1567
1966
  //#region src/sync/inspect-path.ts
1568
1967
  async function inspectPath(path) {
1569
1968
  const type = await getPathType(path);
@@ -1576,11 +1975,16 @@ async function inspectPath(path) {
1576
1975
  return {
1577
1976
  type,
1578
1977
  hash: hashContent(content),
1579
- isMarked: content.subarray(0, 26).toString("utf8") === GENERATED_MARKER
1978
+ isMarked: hasGeneratedMarker(content)
1580
1979
  };
1581
1980
  }
1582
1981
  return { type };
1583
1982
  }
1983
+ function hasGeneratedMarker(content) {
1984
+ return GENERATED_MARKERS.some((marker) => {
1985
+ return content.subarray(0, marker.length).toString("utf8") === marker;
1986
+ });
1987
+ }
1584
1988
  /** A file matches a record by its hash, a symlink by its target. */
1585
1989
  function matchesRecord(entry, record) {
1586
1990
  if (entry.type === PathType.File && "hash" in record) return entry.hash === record.hash;
@@ -1602,9 +2006,21 @@ async function checkOutput(output, context) {
1602
2006
  const isRecorded = typeof previousRecord === "object";
1603
2007
  const isReplaceable = disk.type === PathType.File || disk.type === PathType.Symlink;
1604
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));
1605
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`);
1606
2012
  throw new TunestraError(`${output.path} already exists, and tunestra did not create it. Move it away and run tunestra setup again`);
1607
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
+ }
1608
2024
  /**
1609
2025
  * Nothing to lose: the path is empty, already has the new content, still has what tunestra
1610
2026
  * wrote last time, or is a generated markdown file whose record was lost.
@@ -1728,6 +2144,7 @@ async function runSetup(projectDir, options) {
1728
2144
  const sync = await planSync(chain.project.dir, plan, previous);
1729
2145
  const report = {
1730
2146
  resolution,
2147
+ plan,
1731
2148
  sync
1732
2149
  };
1733
2150
  if (options.dryRun) return report;
@@ -1744,7 +2161,8 @@ function describeResolution(resolution) {
1744
2161
  describeSection("Rules", resolution.rules, projectDir),
1745
2162
  describeSection("Skills", resolution.skills, projectDir),
1746
2163
  describeSection("Commands", resolution.commands, projectDir),
1747
- describeSection("MCP servers", resolution.mcp, projectDir)
2164
+ describeSection("MCP servers", resolution.mcp, projectDir),
2165
+ describeSection("Environment", resolution.env, projectDir)
1748
2166
  ].filter((section) => section.length > 0).join("\n\n");
1749
2167
  }
1750
2168
  function describeSection(title, artifacts, projectDir) {
@@ -1772,28 +2190,39 @@ function measureColumn(rows, column) {
1772
2190
  //#region src/cli/commands/setup.ts
1773
2191
  async function describeSetup(projectDir, options) {
1774
2192
  const report = await runSetup(projectDir, options);
1775
- const summary = describeSync(report.sync, options);
2193
+ const summary = describeSync(report.plan, report.sync, options);
1776
2194
  if (options.verbose) return `${describeResolution(report.resolution)}
1777
2195
 
1778
2196
  ${summary}`;
1779
2197
  return summary;
1780
2198
  }
1781
- function describeSync(sync, options) {
2199
+ function describeSync(plan, sync, options) {
1782
2200
  const warningIcon = pc.yellow("⚠");
1783
2201
  const lines = sync.warnings.map((warning) => `${warningIcon} ${warning}`);
2202
+ const noteIcon = pc.cyan("ℹ");
2203
+ const notes = plan.notes.map((note) => `${noteIcon} ${note}`);
1784
2204
  const writeCount = sync.outputs.length;
1785
2205
  const removeCount = sync.removals.length;
1786
2206
  if (options.dryRun) {
1787
2207
  const writes = sync.outputs.map((output) => ` write ${output.path}`);
1788
2208
  const removals = sync.removals.map((path) => ` remove ${path}`);
1789
- lines.push(...writes, ...removals, "", "Nothing was written (--dry-run).");
2209
+ lines.push(...writes, ...removals, ...notes, "", "Nothing was written (--dry-run).");
1790
2210
  return lines.join("\n");
1791
2211
  }
1792
2212
  const check = pc.green("✔");
2213
+ const committedFiles = joinList(plan.protectedPaths);
1793
2214
  lines.push(`${check} Generated ${writeCount} files, removed ${removeCount} stale files.`);
1794
- lines.push("Commit AGENTS.md and CLAUDE.md; the rest is in .gitignore.");
2215
+ lines.push(...notes);
2216
+ lines.push(`Commit ${committedFiles}; the rest is in .gitignore.`);
1795
2217
  return lines.join("\n");
1796
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
+ }
1797
2226
  //#endregion
1798
2227
  //#region src/uninstall/remove-gitignore-block.ts
1799
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-Dmt0_5_W.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.1.1";
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.1.1",
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",
@@ -44,7 +44,13 @@
44
44
  "test:watch": "vitest",
45
45
  "coverage": "vitest run --coverage",
46
46
  "check": "npm run typecheck && npm run lint && npm run test && npm run build",
47
- "prepublishOnly": "npm run check"
47
+ "prepublishOnly": "npm run check",
48
+ "changeset": "changeset",
49
+ "version-packages": "changeset version && npm install --package-lock-only",
50
+ "release": "changeset publish"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
48
54
  },
49
55
  "engines": {
50
56
  "node": ">=22.18"
@@ -54,12 +60,14 @@
54
60
  "commander": "^15.0.0",
55
61
  "giget": "^3.3.1",
56
62
  "picocolors": "^1.1.1",
63
+ "smol-toml": "^1.9.1",
57
64
  "tinyglobby": "^0.2.17",
58
65
  "yaml": "^2.9.1",
59
66
  "zod": "^4.6.7"
60
67
  },
61
68
  "devDependencies": {
62
69
  "@biomejs/biome": "^2.5.15",
70
+ "@changesets/cli": "^3.0.3",
63
71
  "@types/node": "^22.20.5",
64
72
  "@vitest/coverage-v8": "^5.0.5",
65
73
  "tsdown": "^0.23.1",