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 +72 -23
- package/dist/cli.mjs +471 -42
- package/dist/index.mjs +1 -1
- package/dist/{src-Dmt0_5_W.mjs → src-BNeh64fK.mjs} +1 -1
- package/package.json +10 -2
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.**
|
|
13
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
@@ -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]
|
|
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.
|
|
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
|
|
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
|
-
| `.
|
|
169
|
-
|
|
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
|
|
218
|
-
| v0.2 | Codex |
|
|
219
|
-
| v0.3 |
|
|
220
|
-
| v0.4 |
|
|
221
|
-
| v0.5 |
|
|
222
|
-
| v0.6 |
|
|
223
|
-
| v0.7 |
|
|
224
|
-
| v0.8 |
|
|
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-
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
966
|
-
const AGENT_ADAPTERS = {
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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-
|
|
1
|
+
export { t as VERSION } from "./src-BNeh64fK.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunestra",
|
|
3
|
-
"version": "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",
|
|
@@ -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",
|