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 +30 -6
- package/dist/cli.mjs +299 -43
- package/dist/index.mjs +1 -1
- package/dist/{src-jfoAZbqo.mjs → src-BNeh64fK.mjs} +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
> before v1.0.
|
|
14
14
|
|
|
15
15
|
tunestra gives Claude Code, Codex and other AI coding agents **one shared source** of rules,
|
|
16
|
-
skills, commands and
|
|
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
|
|
48
|
-
tunestra generates is listed in `.gitignore`
|
|
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 |
|
|
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-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
|
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
|
-
|
|
1101
|
-
|
|
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
|
-
/**
|
|
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
|
|
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-
|
|
1
|
+
export { t as VERSION } from "./src-BNeh64fK.mjs";
|
package/package.json
CHANGED