tunestra 0.3.0 → 0.5.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 +58 -10
- package/dist/cli.mjs +443 -47
- package/dist/index.mjs +1 -1
- package/dist/{src-BNeh64fK.mjs → src-CZK2Ixbt.mjs} +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,8 +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, MCP servers
|
|
17
|
-
configuration from it.
|
|
16
|
+
skills, commands, MCP servers, environment variables and permissions, and generates each
|
|
17
|
+
agent's native configuration from it.
|
|
18
18
|
|
|
19
19
|
The source is a chain of **presets**, from general to specific:
|
|
20
20
|
|
|
@@ -45,8 +45,8 @@ extends:
|
|
|
45
45
|
agents: [claude] # add codex: [claude, codex]
|
|
46
46
|
```
|
|
47
47
|
|
|
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`
|
|
48
|
+
Commit `tunestra.yaml`, `.tunestra/`, `AGENTS.md`, `CLAUDE.md` and, when presets set `env` or
|
|
49
|
+
`permissions`, `.claude/settings.json`. Everything else that tunestra generates is listed in `.gitignore`
|
|
50
50
|
automatically.
|
|
51
51
|
|
|
52
52
|
## Principles
|
|
@@ -70,9 +70,26 @@ the manifest only holds metadata and overrides.
|
|
|
70
70
|
rules/<key>.md # rules, one topic per file
|
|
71
71
|
skills/<key>/SKILL.md # skills (Agent Skills format)
|
|
72
72
|
commands/<key>.md # slash commands
|
|
73
|
+
agents/<key>.md # subagents
|
|
73
74
|
mcp/<key>.yaml # MCP servers
|
|
74
75
|
```
|
|
75
76
|
|
|
77
|
+
A subagent is a markdown file with frontmatter; the text after it is the instructions:
|
|
78
|
+
|
|
79
|
+
```md
|
|
80
|
+
---
|
|
81
|
+
description: Reviews a diff for bugs and missing tests # required: when to use it
|
|
82
|
+
model: # optional: the model for each agent
|
|
83
|
+
claude: sonnet # otherwise the agent uses the model of the main session
|
|
84
|
+
codex: gpt-5.5
|
|
85
|
+
effort: medium # optional: low | medium | high | xhigh
|
|
86
|
+
readOnly: true # optional: the subagent must not change files
|
|
87
|
+
---
|
|
88
|
+
Review the diff. Report each finding with a file and a line.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The file name is the name of the subagent: lowercase letters, digits and single hyphens.
|
|
92
|
+
|
|
76
93
|
### Manifest: `tunestra.preset.yaml`
|
|
77
94
|
|
|
78
95
|
```yaml
|
|
@@ -90,6 +107,10 @@ overrides: # optional: patch inherited MCP servers
|
|
|
90
107
|
QUEUE: TEAM
|
|
91
108
|
env: # optional: environment variables of the agents
|
|
92
109
|
MCP_TIMEOUT: 120000
|
|
110
|
+
permissions: # optional: rules in the syntax of Claude Code
|
|
111
|
+
allow: [Bash(npm test *)]
|
|
112
|
+
ask: [Bash(git push *)]
|
|
113
|
+
deny: [Read(./.env), Bash(rm -rf *)]
|
|
93
114
|
```
|
|
94
115
|
|
|
95
116
|
### Project: `tunestra.yaml`
|
|
@@ -139,8 +160,13 @@ chain once, before both of them.
|
|
|
139
160
|
| Level instructions (`AGENTS.md`) | `instructions@<preset name>` | never collides: every level keeps its own | `exclude.rules` |
|
|
140
161
|
| Skill | `name` from `SKILL.md`, otherwise the folder name | replaces it | `exclude.skills` |
|
|
141
162
|
| Command | file name without `.md` | replaces it | `exclude.commands` |
|
|
163
|
+
| Subagent | file name without `.md` | replaces it | `exclude.subagents` |
|
|
142
164
|
| MCP server | file name without `.yaml` | replaces it | `exclude.mcp` |
|
|
143
165
|
| Environment variable | name in `env` | replaces it | `exclude.env` |
|
|
166
|
+
| Permission rule | the rule, like `Bash(npm test *)` | lists are joined: every level adds its rules | `exclude.permissions`, only for `allow` and `ask` |
|
|
167
|
+
|
|
168
|
+
A `deny` rule cannot be removed: the organization keeps a floor of what agents may never do.
|
|
169
|
+
Agents check `deny` first, so a `deny` rule on any level wins over an `allow` rule.
|
|
144
170
|
|
|
145
171
|
`overrides.mcp.<key>` patches an inherited server instead of replacing it: `env` and `headers`
|
|
146
172
|
are merged by key (the specific value wins); `command`, `args` and `url` are replaced.
|
|
@@ -157,7 +183,10 @@ are merged by key (the specific value wins); `command`, `args` and `url` are rep
|
|
|
157
183
|
- after a patch, an MCP server has both `command` and `url`, or neither;
|
|
158
184
|
- an `env` value refers to another variable, like `${TOKEN}`: agents do not expand it in
|
|
159
185
|
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)
|
|
186
|
+
- `env` sets a variable that Claude Code ignores in project settings (see below);
|
|
187
|
+
- `exclude.permissions` tries to remove a `deny` rule;
|
|
188
|
+
- a subagent has no `description` or no instructions, an unknown field, or a file name that
|
|
189
|
+
cannot be a subagent name.
|
|
161
190
|
|
|
162
191
|
All problems are reported at once, in the order of the files, so you can fix them in one pass.
|
|
163
192
|
|
|
@@ -167,7 +196,7 @@ For every agent:
|
|
|
167
196
|
|
|
168
197
|
| Path | Content | Committed |
|
|
169
198
|
|---|---|---|
|
|
170
|
-
| `AGENTS.md` | Index: hierarchy, rules, skills, commands, MCP servers | yes |
|
|
199
|
+
| `AGENTS.md` | Index: hierarchy, rules, skills, commands, MCP servers, subagents | yes |
|
|
171
200
|
| `.agents/rules/<NN>-<level>-<key>.md` | Rule texts, ordered from specific to general | no |
|
|
172
201
|
| `.agents/skills/<key>/` | Skills | no |
|
|
173
202
|
| `.tunestra/state/` | What tunestra generated last time | no |
|
|
@@ -181,22 +210,27 @@ For every agent:
|
|
|
181
210
|
| `.claude/rules/tunestra/<NN>-<level>-<key>.md` | Copies of the rules; Claude Code loads them in every session | no |
|
|
182
211
|
| `.claude/skills/<key>` | Symlinks to `.agents/skills/<key>` | no |
|
|
183
212
|
| `.claude/commands/<key>.md` | Commands | no |
|
|
213
|
+
| `.claude/agents/<key>.md` | Subagents | no |
|
|
184
214
|
| `.mcp.json` | MCP servers | no |
|
|
185
|
-
| `.claude/settings.json` | `env`, when presets set
|
|
215
|
+
| `.claude/settings.json` | `env` and `permissions`, when presets set them | yes |
|
|
186
216
|
|
|
187
217
|
`.claude/settings.json` is shared by the team, so tunestra writes it and you commit it. Keep
|
|
188
218
|
your personal settings in `.claude/settings.local.json` or `~/.claude/settings.json`. A
|
|
189
219
|
`.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 `
|
|
220
|
+
move them; a file with only `$schema`, `env` and `permissions` is replaced with a warning, for example in a
|
|
191
221
|
fresh clone where `.tunestra/state/` does not exist yet.
|
|
192
222
|
|
|
193
|
-
Claude Code applies project `env` after you trust the folder
|
|
223
|
+
Claude Code applies project `env` and `allow` rules after you trust the folder; `deny` and
|
|
224
|
+
`ask` rules apply right away. It ignores some variables in
|
|
194
225
|
project settings: the ones that turn telemetry on or choose where it goes
|
|
195
226
|
(`CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*_EXPORTER`, `OTEL_EXPORTER_OTLP_*` and others), and
|
|
196
227
|
the ones that choose its own directories (`HOME`, `XDG_*`, `CLAUDE_CONFIG_DIR`). setup stops
|
|
197
228
|
on them, because they would not work; set them in managed settings, `~/.claude/settings.json`
|
|
198
229
|
or your shell. Values that turn telemetry off, like `OTEL_LOGS_EXPORTER: none`, are allowed.
|
|
199
230
|
|
|
231
|
+
A read-only subagent of Claude Code loses the tools that change files: `Write`, `Edit`,
|
|
232
|
+
`NotebookEdit` and `Bash`. It keeps the tools that read and search, and MCP tools.
|
|
233
|
+
|
|
200
234
|
### Codex
|
|
201
235
|
|
|
202
236
|
Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the links in
|
|
@@ -205,15 +239,29 @@ Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the
|
|
|
205
239
|
| Path | Content | Committed |
|
|
206
240
|
|---|---|---|
|
|
207
241
|
| `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, `[shell_environment_policy] set` with `env`, and `[mcp_servers]` | no |
|
|
242
|
+
| `.codex/rules/tunestra.rules` | `deny` and `ask` rules for shell commands as `prefix_rule`: `forbidden` and `prompt` | no |
|
|
208
243
|
| `.agents/skills/<command>/` | Each command as a skill that runs only when you call it: `$release-notes` | no |
|
|
244
|
+
| `.codex/agents/<key>.toml` | Subagents | no |
|
|
209
245
|
|
|
210
246
|
Codex reads `.codex/config.toml` only in a trusted project: trust the project when Codex asks.
|
|
211
247
|
Codex gets `env` as the environment of the commands it runs; it does not change how Codex
|
|
212
248
|
itself works, and Codex ignores telemetry settings in a project.
|
|
249
|
+
|
|
250
|
+
Codex rules match only the start of a shell command, so tunestra converts only `Bash` rules
|
|
251
|
+
like `Bash(git push *)` or `Bash(rm -rf)`. A rule without a wildcard also becomes a prefix,
|
|
252
|
+
which is stricter for `deny` and `ask`. Other rules (`Read`, `Edit`, `WebFetch`, MCP tools, and
|
|
253
|
+
`Bash` rules with wildcards in the middle or shell syntax) are skipped with a note. `allow`
|
|
254
|
+
rules are skipped too: in Codex, an allowed command runs outside the sandbox without asking,
|
|
255
|
+
which is more than an `allow` rule of Claude Code means.
|
|
213
256
|
Codex has no `type` for MCP servers and does not support SSE: an SSE server is skipped with a
|
|
214
257
|
note, and `headers` become `http_headers`. tunestra owns the whole `.codex/config.toml`; keep
|
|
215
258
|
your personal Codex settings in `~/.codex/config.toml`.
|
|
216
259
|
|
|
260
|
+
A Codex subagent gets the sandbox, the permissions and the MCP servers of the main session; its
|
|
261
|
+
file can change only the model and the instructions. So Codex cannot make a subagent
|
|
262
|
+
read-only: tunestra turns off its shell (`features.shell_tool = false`) and asks it not to
|
|
263
|
+
edit files in the instructions, and setup says so in a note.
|
|
264
|
+
|
|
217
265
|
`<NN>` orders rules from specific to general: `10` project, `20` team, `30` domain, `40` base.
|
|
218
266
|
Level instructions get the key `instructions`, and the preset name when it differs from the
|
|
219
267
|
level: `20-team-instructions.md`, but `20-team-instructions-frontend.md` for a `team` preset
|
|
@@ -264,7 +312,7 @@ and tells you where to move their text.
|
|
|
264
312
|
|---|---|
|
|
265
313
|
| v0.1 | Inheritance for Claude Code |
|
|
266
314
|
| v0.2 | Codex (this document) |
|
|
267
|
-
| v0.3 | `env
|
|
315
|
+
| v0.3 | `env` and permissions, then subagents and a personal `local` level |
|
|
268
316
|
| v0.4 | Spaces, npm sources, lockfile |
|
|
269
317
|
| v0.5 | `required` / `locked` policies, CI check |
|
|
270
318
|
| 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-CZK2Ixbt.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";
|
|
@@ -338,13 +338,22 @@ const envSchema = z.record(z.string(), envValue).superRefine((env, context) => {
|
|
|
338
338
|
path: [name]
|
|
339
339
|
});
|
|
340
340
|
});
|
|
341
|
+
const permissionRules = z.array(z.string().regex(/^[A-Za-z][\w*-]*(\(.+\))?$/, "Write a rule as Tool or Tool(specifier), like Bash(npm test *)")).readonly();
|
|
342
|
+
const permissionsSchema = z.strictObject({
|
|
343
|
+
allow: permissionRules.optional(),
|
|
344
|
+
ask: permissionRules.optional(),
|
|
345
|
+
deny: permissionRules.optional()
|
|
346
|
+
});
|
|
341
347
|
const skillName = z.string().regex(SKILL_NAME_PATTERN, "Use lowercase letters, digits and single hyphens").max(64);
|
|
342
348
|
const excludeSchema = z.strictObject({
|
|
343
349
|
rules: keys.optional(),
|
|
344
350
|
skills: keys.optional(),
|
|
345
351
|
commands: keys.optional(),
|
|
346
352
|
mcp: keys.optional(),
|
|
347
|
-
|
|
353
|
+
subagents: keys.optional(),
|
|
354
|
+
env: keys.optional(),
|
|
355
|
+
/** Inherited `allow` and `ask` rules; a `deny` rule cannot be removed. */
|
|
356
|
+
permissions: keys.optional()
|
|
348
357
|
});
|
|
349
358
|
const mcpPatchSchema = z.strictObject({
|
|
350
359
|
command: z.string().optional(),
|
|
@@ -361,7 +370,8 @@ const presetManifestSchema = z.strictObject({
|
|
|
361
370
|
extends: z.array(z.string().min(1)).readonly().default([]),
|
|
362
371
|
exclude: excludeSchema.default({}),
|
|
363
372
|
overrides: overridesSchema.default({}),
|
|
364
|
-
env: envSchema.default({})
|
|
373
|
+
env: envSchema.default({}),
|
|
374
|
+
permissions: permissionsSchema.default({})
|
|
365
375
|
});
|
|
366
376
|
const projectConfigSchema = z.strictObject({
|
|
367
377
|
name: z.string().min(1).optional(),
|
|
@@ -369,7 +379,8 @@ const projectConfigSchema = z.strictObject({
|
|
|
369
379
|
agents: z.array(z.enum(Agent)).min(1).readonly().default([Agent.Claude]),
|
|
370
380
|
exclude: excludeSchema.default({}),
|
|
371
381
|
overrides: overridesSchema.default({}),
|
|
372
|
-
env: envSchema.default({})
|
|
382
|
+
env: envSchema.default({}),
|
|
383
|
+
permissions: permissionsSchema.default({})
|
|
373
384
|
});
|
|
374
385
|
const mcpServerSchema = z.strictObject({
|
|
375
386
|
description: z.string().optional(),
|
|
@@ -453,6 +464,15 @@ async function mapSettled(items, map) {
|
|
|
453
464
|
throw buildResultsError(results);
|
|
454
465
|
}
|
|
455
466
|
//#endregion
|
|
467
|
+
//#region src/shared/constants/subagents.ts
|
|
468
|
+
/** Reasoning effort of a subagent: the levels that both Claude Code and Codex accept. */
|
|
469
|
+
const SubagentEffort = {
|
|
470
|
+
Low: "low",
|
|
471
|
+
Medium: "medium",
|
|
472
|
+
High: "high",
|
|
473
|
+
ExtraHigh: "xhigh"
|
|
474
|
+
};
|
|
475
|
+
//#endregion
|
|
456
476
|
//#region src/shared/schemas/frontmatter.ts
|
|
457
477
|
const ruleFrontmatterSchema = z.looseObject({ description: z.string().optional() });
|
|
458
478
|
const skillFrontmatterSchema = z.looseObject({
|
|
@@ -460,6 +480,15 @@ const skillFrontmatterSchema = z.looseObject({
|
|
|
460
480
|
description: z.string().min(1)
|
|
461
481
|
});
|
|
462
482
|
const commandFrontmatterSchema = z.looseObject({ description: z.string().optional() });
|
|
483
|
+
/** The model names differ between agents, so a subagent sets one for each agent. */
|
|
484
|
+
const subagentModelSchema = z.partialRecord(z.enum(Agent), z.string().min(1));
|
|
485
|
+
/** tunestra writes the files of the agents itself, so it knows every field here. */
|
|
486
|
+
const subagentFrontmatterSchema = z.strictObject({
|
|
487
|
+
description: z.string().min(1),
|
|
488
|
+
model: subagentModelSchema.default({}),
|
|
489
|
+
effort: z.enum(SubagentEffort).optional(),
|
|
490
|
+
readOnly: z.boolean().default(false)
|
|
491
|
+
});
|
|
463
492
|
//#endregion
|
|
464
493
|
//#region src/presets/frontmatter.ts
|
|
465
494
|
/**
|
|
@@ -592,6 +621,28 @@ function findDuplicateSkills(skills, dir) {
|
|
|
592
621
|
return problems;
|
|
593
622
|
}
|
|
594
623
|
//#endregion
|
|
624
|
+
//#region src/presets/artifacts/subagents.ts
|
|
625
|
+
/** Subagents live in `agents/`, like in Claude Code and Codex. */
|
|
626
|
+
async function loadSubagents(dir) {
|
|
627
|
+
return mapSettled(await listFiles(dir, "agents/*.md"), (source) => loadSubagent(dir, source));
|
|
628
|
+
}
|
|
629
|
+
async function loadSubagent(dir, source) {
|
|
630
|
+
const file = join(dir, source);
|
|
631
|
+
const { data, body } = parseFrontmatter(await readFile(file, "utf8"), file);
|
|
632
|
+
const frontmatter = validate(data, subagentFrontmatterSchema, `frontmatter in ${file}`);
|
|
633
|
+
const key = validate(basename(source, ".md"), skillName, `subagent file ${source}`);
|
|
634
|
+
if (body.trim().length > 0 === false) throw new TunestraError(`${file} has no instructions: write them after the frontmatter`);
|
|
635
|
+
return {
|
|
636
|
+
key,
|
|
637
|
+
source,
|
|
638
|
+
description: frontmatter.description,
|
|
639
|
+
model: frontmatter.model,
|
|
640
|
+
effort: frontmatter.effort,
|
|
641
|
+
readOnly: frontmatter.readOnly,
|
|
642
|
+
body
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
//#endregion
|
|
595
646
|
//#region src/presets/artifacts/load-artifacts.ts
|
|
596
647
|
/** Loads every kind of artifact at the same time and reports the problems of all of them. */
|
|
597
648
|
async function loadArtifacts(dir, presetName) {
|
|
@@ -599,19 +650,22 @@ async function loadArtifacts(dir, presetName) {
|
|
|
599
650
|
const skillsLoading = loadSkills(dir);
|
|
600
651
|
const commandsLoading = loadCommands(dir);
|
|
601
652
|
const mcpLoading = loadMcpServers(dir);
|
|
653
|
+
const subagentsLoading = loadSubagents(dir);
|
|
602
654
|
const results = await Promise.allSettled([
|
|
603
655
|
rulesLoading,
|
|
604
656
|
skillsLoading,
|
|
605
657
|
commandsLoading,
|
|
606
|
-
mcpLoading
|
|
658
|
+
mcpLoading,
|
|
659
|
+
subagentsLoading
|
|
607
660
|
]);
|
|
608
661
|
if (isEveryFulfilled(results) === false) throw buildResultsError(results);
|
|
609
|
-
const [rulesResult, skillsResult, commandsResult, mcpResult] = results;
|
|
662
|
+
const [rulesResult, skillsResult, commandsResult, mcpResult, subagentsResult] = results;
|
|
610
663
|
return {
|
|
611
664
|
rules: rulesResult.value,
|
|
612
665
|
skills: skillsResult.value,
|
|
613
666
|
commands: commandsResult.value,
|
|
614
|
-
mcp: mcpResult.value
|
|
667
|
+
mcp: mcpResult.value,
|
|
668
|
+
subagents: subagentsResult.value
|
|
615
669
|
};
|
|
616
670
|
}
|
|
617
671
|
//#endregion
|
|
@@ -635,6 +689,34 @@ function createEnvVariables(env, manifestPath, artifactsDir) {
|
|
|
635
689
|
}));
|
|
636
690
|
}
|
|
637
691
|
//#endregion
|
|
692
|
+
//#region src/shared/constants/permissions.ts
|
|
693
|
+
/** What an agent does with a tool call that matches a permission rule. */
|
|
694
|
+
const PermissionDecision = {
|
|
695
|
+
/** Run without asking. */
|
|
696
|
+
Allow: "allow",
|
|
697
|
+
/** Ask every time. */
|
|
698
|
+
Ask: "ask",
|
|
699
|
+
/** Never run. Agents check deny first, so a deny rule always wins. */
|
|
700
|
+
Deny: "deny"
|
|
701
|
+
};
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/presets/permissions.ts
|
|
704
|
+
const DECISIONS$1 = Object.values(PermissionDecision);
|
|
705
|
+
/** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
|
|
706
|
+
function createPermissionRules(permissions, manifestPath, artifactsDir) {
|
|
707
|
+
const source = relativePosix(artifactsDir, manifestPath);
|
|
708
|
+
const rules = [];
|
|
709
|
+
for (const decision of DECISIONS$1) {
|
|
710
|
+
const keys = permissions[decision] ?? [];
|
|
711
|
+
for (const key of keys) rules.push({
|
|
712
|
+
key,
|
|
713
|
+
decision,
|
|
714
|
+
source
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return rules;
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
638
720
|
//#region src/presets/load-project.ts
|
|
639
721
|
async function loadProject(dir) {
|
|
640
722
|
const root = resolve(dir);
|
|
@@ -646,6 +728,7 @@ async function loadProject(dir) {
|
|
|
646
728
|
const artifactsDir = join(root, PROJECT_DIR);
|
|
647
729
|
const artifacts = await loadArtifacts(artifactsDir, name);
|
|
648
730
|
const env = createEnvVariables(config.env, configPath, artifactsDir);
|
|
731
|
+
const permissions = createPermissionRules(config.permissions, configPath, artifactsDir);
|
|
649
732
|
return {
|
|
650
733
|
...config,
|
|
651
734
|
name,
|
|
@@ -655,7 +738,8 @@ async function loadProject(dir) {
|
|
|
655
738
|
artifactsDir,
|
|
656
739
|
remote: void 0,
|
|
657
740
|
...artifacts,
|
|
658
|
-
env
|
|
741
|
+
env,
|
|
742
|
+
permissions
|
|
659
743
|
};
|
|
660
744
|
}
|
|
661
745
|
//#endregion
|
|
@@ -861,6 +945,7 @@ async function loadPreset(dir) {
|
|
|
861
945
|
const manifest = await readYaml(manifestPath, presetManifestSchema, "preset manifest");
|
|
862
946
|
const artifacts = await loadArtifacts(root, manifest.name);
|
|
863
947
|
const env = createEnvVariables(manifest.env, manifestPath, root);
|
|
948
|
+
const permissions = createPermissionRules(manifest.permissions, manifestPath, root);
|
|
864
949
|
return {
|
|
865
950
|
...manifest,
|
|
866
951
|
description: manifest.description,
|
|
@@ -868,7 +953,8 @@ async function loadPreset(dir) {
|
|
|
868
953
|
artifactsDir: root,
|
|
869
954
|
remote: void 0,
|
|
870
955
|
...artifacts,
|
|
871
|
-
env
|
|
956
|
+
env,
|
|
957
|
+
permissions
|
|
872
958
|
};
|
|
873
959
|
}
|
|
874
960
|
//#endregion
|
|
@@ -1054,14 +1140,52 @@ function compareByName([a], [b]) {
|
|
|
1054
1140
|
//#endregion
|
|
1055
1141
|
//#region src/agents/claude/settings-json.ts
|
|
1056
1142
|
const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
|
|
1143
|
+
const DECISIONS = Object.values(PermissionDecision);
|
|
1057
1144
|
/** The keys that tunestra writes; a file with only these keys is a generated one. */
|
|
1058
|
-
const SETTINGS_KEYS = [
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1145
|
+
const SETTINGS_KEYS = [
|
|
1146
|
+
"$schema",
|
|
1147
|
+
"env",
|
|
1148
|
+
"permissions"
|
|
1149
|
+
];
|
|
1150
|
+
/** `.claude/settings.json` with sorted keys and lists, so that its diff stays small. */
|
|
1151
|
+
function renderSettingsJson(settings) {
|
|
1152
|
+
const content = { $schema: SETTINGS_SCHEMA };
|
|
1153
|
+
const hasEnv = settings.env.length > 0;
|
|
1154
|
+
const hasPermissions = settings.permissions.length > 0;
|
|
1155
|
+
if (hasEnv) content.env = buildEnvMap(settings.env);
|
|
1156
|
+
if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
|
|
1157
|
+
return `${JSON.stringify(content, null, 2)}\n`;
|
|
1158
|
+
}
|
|
1159
|
+
/** Only the lists that have rules; a rule repeated on several levels is written once. */
|
|
1160
|
+
function buildPermissionLists(permissions) {
|
|
1161
|
+
const lists = {};
|
|
1162
|
+
for (const decision of DECISIONS) {
|
|
1163
|
+
const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
|
|
1164
|
+
const sortedKeys = [...new Set(keys)].toSorted();
|
|
1165
|
+
if (sortedKeys.length > 0) lists[decision] = sortedKeys;
|
|
1166
|
+
}
|
|
1167
|
+
return lists;
|
|
1168
|
+
}
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/agents/claude/subagent-md.ts
|
|
1171
|
+
/** Tools that change files; a read-only subagent keeps the tools that read and search. */
|
|
1172
|
+
const WRITING_TOOLS = [
|
|
1173
|
+
"Write",
|
|
1174
|
+
"Edit",
|
|
1175
|
+
"NotebookEdit",
|
|
1176
|
+
"Bash"
|
|
1177
|
+
];
|
|
1178
|
+
/** `.claude/agents/<name>.md`: the frontmatter of Claude Code and the instructions. */
|
|
1179
|
+
function renderSubagentMd(subagent) {
|
|
1180
|
+
const { key, description, model, effort, readOnly, body } = subagent;
|
|
1181
|
+
const frontmatter = {
|
|
1182
|
+
name: key,
|
|
1183
|
+
description
|
|
1184
|
+
};
|
|
1185
|
+
if (typeof model.claude === "string") frontmatter.model = model.claude;
|
|
1186
|
+
if (typeof effort === "string") frontmatter.effort = effort;
|
|
1187
|
+
if (readOnly) frontmatter.disallowedTools = WRITING_TOOLS;
|
|
1188
|
+
return `---\n${stringify(frontmatter)}---\n${body}`;
|
|
1065
1189
|
}
|
|
1066
1190
|
//#endregion
|
|
1067
1191
|
//#region src/agents/claude/claude-adapter.ts
|
|
@@ -1069,17 +1193,18 @@ const CLAUDE_FILE = "CLAUDE.md";
|
|
|
1069
1193
|
const RULES_DIR = ".claude/rules/tunestra";
|
|
1070
1194
|
const SKILLS_DIR = ".claude/skills";
|
|
1071
1195
|
const COMMANDS_DIR = ".claude/commands";
|
|
1196
|
+
const SUBAGENTS_DIR$1 = ".claude/agents";
|
|
1072
1197
|
const MCP_FILE = ".mcp.json";
|
|
1073
1198
|
const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
|
|
1074
1199
|
const SETTINGS_FILE = ".claude/settings.json";
|
|
1075
|
-
const SETTINGS_SOURCE = "env in tunestra.yaml and its presets";
|
|
1200
|
+
const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
|
|
1076
1201
|
const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
|
|
1077
1202
|
/**
|
|
1078
1203
|
* Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
|
|
1079
1204
|
* which Claude Code loads in every session; a symlink to a directory outside `.claude`
|
|
1080
1205
|
* would need an approval. Skills are symlinks to the shared `.agents/skills`. Environment
|
|
1081
|
-
* variables go to `.claude/settings.json`, which the team commits;
|
|
1082
|
-
* stays for personal settings.
|
|
1206
|
+
* variables and permissions go to `.claude/settings.json`, which the team commits;
|
|
1207
|
+
* `settings.local.json` stays for personal settings.
|
|
1083
1208
|
*/
|
|
1084
1209
|
const claudeAdapter = {
|
|
1085
1210
|
agent: Agent.Claude,
|
|
@@ -1097,11 +1222,15 @@ function planClaude(context) {
|
|
|
1097
1222
|
const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
|
|
1098
1223
|
return createFileOutput(path, command.artifact.content, source);
|
|
1099
1224
|
});
|
|
1225
|
+
const subagentFiles = resolution.subagents.map((subagent) => {
|
|
1226
|
+
return createFileOutput(`${SUBAGENTS_DIR$1}/${subagent.artifact.key}.md`, renderSubagentMd(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
|
|
1227
|
+
});
|
|
1100
1228
|
const outputs = [
|
|
1101
1229
|
claudeFile,
|
|
1102
1230
|
...ruleCopies,
|
|
1103
1231
|
...skillLinks,
|
|
1104
|
-
...commandFiles
|
|
1232
|
+
...commandFiles,
|
|
1233
|
+
...subagentFiles
|
|
1105
1234
|
];
|
|
1106
1235
|
if (resolution.mcp.length > 0) {
|
|
1107
1236
|
const mcpJson = renderMcpJson(resolution.mcp);
|
|
@@ -1109,8 +1238,8 @@ function planClaude(context) {
|
|
|
1109
1238
|
outputs.push(mcpFile);
|
|
1110
1239
|
}
|
|
1111
1240
|
const protectedPaths = [CLAUDE_FILE];
|
|
1112
|
-
if (resolution.env.length > 0) {
|
|
1113
|
-
const settingsFile = planSettingsFile(resolution.env);
|
|
1241
|
+
if (resolution.env.length > 0 || resolution.permissions.length > 0) {
|
|
1242
|
+
const settingsFile = planSettingsFile(resolution.env, resolution.permissions);
|
|
1114
1243
|
outputs.push(settingsFile);
|
|
1115
1244
|
protectedPaths.push(SETTINGS_FILE);
|
|
1116
1245
|
}
|
|
@@ -1120,6 +1249,7 @@ function planClaude(context) {
|
|
|
1120
1249
|
`${RULES_DIR}/`,
|
|
1121
1250
|
`${SKILLS_DIR}/`,
|
|
1122
1251
|
`${COMMANDS_DIR}/`,
|
|
1252
|
+
`${SUBAGENTS_DIR$1}/`,
|
|
1123
1253
|
MCP_FILE
|
|
1124
1254
|
],
|
|
1125
1255
|
protectedPaths,
|
|
@@ -1127,10 +1257,13 @@ function planClaude(context) {
|
|
|
1127
1257
|
};
|
|
1128
1258
|
}
|
|
1129
1259
|
/** Claude Code drops some variables from project settings; setup says so instead. */
|
|
1130
|
-
function planSettingsFile(env) {
|
|
1260
|
+
function planSettingsFile(env, permissions) {
|
|
1131
1261
|
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
1262
|
if (problems.length > 0) throw createProblemsError(problems);
|
|
1133
|
-
const content = renderSettingsJson(
|
|
1263
|
+
const content = renderSettingsJson({
|
|
1264
|
+
env,
|
|
1265
|
+
permissions
|
|
1266
|
+
});
|
|
1134
1267
|
const file = createFileOutput(SETTINGS_FILE, content, SETTINGS_SOURCE);
|
|
1135
1268
|
const ownership = {
|
|
1136
1269
|
keys: SETTINGS_KEYS,
|
|
@@ -1185,7 +1318,7 @@ function planCommandSkill(projectDir, command) {
|
|
|
1185
1318
|
return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
|
|
1186
1319
|
}
|
|
1187
1320
|
//#endregion
|
|
1188
|
-
//#region src/agents/codex/
|
|
1321
|
+
//#region src/agents/codex/toml-text.ts
|
|
1189
1322
|
/** Starts and ends a multi-line literal string, where TOML escapes nothing. */
|
|
1190
1323
|
const LITERAL_DELIMITER = "'''";
|
|
1191
1324
|
/**
|
|
@@ -1197,6 +1330,18 @@ const LITERAL_DELIMITER = "'''";
|
|
|
1197
1330
|
*/
|
|
1198
1331
|
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
|
|
1199
1332
|
/**
|
|
1333
|
+
* A key with a long text, like `developer_instructions`. The text is a literal string, so
|
|
1334
|
+
* that people can read it; a text that a literal string cannot hold is escaped.
|
|
1335
|
+
*/
|
|
1336
|
+
function renderTomlText(key, text) {
|
|
1337
|
+
const hasDelimiter = text.includes(LITERAL_DELIMITER);
|
|
1338
|
+
const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
|
|
1339
|
+
if (hasDelimiter || hasControlCharacter) return stringify$1({ [key]: text });
|
|
1340
|
+
return `${key} = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
|
|
1341
|
+
}
|
|
1342
|
+
//#endregion
|
|
1343
|
+
//#region src/agents/codex/config-toml.ts
|
|
1344
|
+
/**
|
|
1200
1345
|
* `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
|
|
1201
1346
|
* session, the environment of the commands that Codex runs, and the MCP servers. The rules
|
|
1202
1347
|
* are a literal string, so that people can read them.
|
|
@@ -1204,7 +1349,7 @@ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
|
|
|
1204
1349
|
function renderConfigToml(config) {
|
|
1205
1350
|
const parts = [GENERATED_HASH_NOTICE];
|
|
1206
1351
|
if (typeof config.instructions === "string") {
|
|
1207
|
-
const line =
|
|
1352
|
+
const line = renderTomlText("developer_instructions", config.instructions);
|
|
1208
1353
|
parts.push(line);
|
|
1209
1354
|
}
|
|
1210
1355
|
const tables = {};
|
|
@@ -1224,12 +1369,6 @@ function renderConfigToml(config) {
|
|
|
1224
1369
|
}
|
|
1225
1370
|
return `${parts.join("\n\n")}\n`;
|
|
1226
1371
|
}
|
|
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
1372
|
/** Codex picks the transport by `command` or `url` and has no `type`. */
|
|
1234
1373
|
function convertMcpConfig(config) {
|
|
1235
1374
|
const { type: _type, headers, ...server } = config;
|
|
@@ -1240,15 +1379,112 @@ function convertMcpConfig(config) {
|
|
|
1240
1379
|
};
|
|
1241
1380
|
}
|
|
1242
1381
|
//#endregion
|
|
1382
|
+
//#region src/agents/codex/exec-rules.ts
|
|
1383
|
+
/** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
|
|
1384
|
+
const BASH_RULE_START = "Bash(";
|
|
1385
|
+
const BASH_RULE_END = ")";
|
|
1386
|
+
/**
|
|
1387
|
+
* Shell syntax and wildcards that a Codex prefix rule cannot express.
|
|
1388
|
+
*
|
|
1389
|
+
* Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
|
|
1390
|
+
* Does not match: "npm test", "git push --force", "rm -rf"
|
|
1391
|
+
*/
|
|
1392
|
+
const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
|
|
1393
|
+
const WHITESPACE_PATTERN = /\s+/;
|
|
1394
|
+
/** A trailing ` *` or the older `:*` means "this command with any arguments". */
|
|
1395
|
+
const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
|
|
1396
|
+
/**
|
|
1397
|
+
* Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
|
|
1398
|
+
* outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
|
|
1399
|
+
*/
|
|
1400
|
+
const CODEX_DECISIONS = {
|
|
1401
|
+
[PermissionDecision.Deny]: "forbidden",
|
|
1402
|
+
[PermissionDecision.Ask]: "prompt"
|
|
1403
|
+
};
|
|
1404
|
+
/**
|
|
1405
|
+
* The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
|
|
1406
|
+
* A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
|
|
1407
|
+
*/
|
|
1408
|
+
function convertBashRule(rule) {
|
|
1409
|
+
if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
|
|
1410
|
+
const specifier = rule.slice(5, -1);
|
|
1411
|
+
const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
|
|
1412
|
+
const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
|
|
1413
|
+
if (SHELL_SYNTAX_PATTERN.test(command)) return;
|
|
1414
|
+
const words = command.trim().split(WHITESPACE_PATTERN);
|
|
1415
|
+
if (words.every((word) => word.length > 0)) return words;
|
|
1416
|
+
}
|
|
1417
|
+
function hasCodexDecision(decision) {
|
|
1418
|
+
return decision in CODEX_DECISIONS;
|
|
1419
|
+
}
|
|
1420
|
+
function createExecRule(resolved, decision, pattern) {
|
|
1421
|
+
return {
|
|
1422
|
+
pattern,
|
|
1423
|
+
decision: CODEX_DECISIONS[decision],
|
|
1424
|
+
justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
/** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
|
|
1428
|
+
function renderExecRules(rules) {
|
|
1429
|
+
const blocks = rules.map(renderExecRule);
|
|
1430
|
+
return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
|
|
1431
|
+
}
|
|
1432
|
+
/** JSON strings are valid Starlark strings. */
|
|
1433
|
+
function renderExecRule(rule) {
|
|
1434
|
+
const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
|
|
1435
|
+
const decision = JSON.stringify(rule.decision);
|
|
1436
|
+
const justification = JSON.stringify(rule.justification);
|
|
1437
|
+
return [
|
|
1438
|
+
"prefix_rule(",
|
|
1439
|
+
` pattern = [${pattern}],`,
|
|
1440
|
+
` decision = ${decision},`,
|
|
1441
|
+
` justification = ${justification},`,
|
|
1442
|
+
")"
|
|
1443
|
+
].join("\n");
|
|
1444
|
+
}
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region src/agents/codex/subagent-toml.ts
|
|
1447
|
+
/**
|
|
1448
|
+
* Codex has no read-only mode for a subagent: it can turn off the shell, but the subagent
|
|
1449
|
+
* still edits files with patches. So the instructions ask it not to.
|
|
1450
|
+
*/
|
|
1451
|
+
const READ_ONLY_INSTRUCTION = "Do not create, edit or delete files.";
|
|
1452
|
+
/**
|
|
1453
|
+
* `.codex/agents/<name>.toml`. Codex gives a subagent the sandbox, the permissions and the
|
|
1454
|
+
* MCP servers of the main session, so the file has only the model and the instructions.
|
|
1455
|
+
*/
|
|
1456
|
+
function renderSubagentToml(subagent) {
|
|
1457
|
+
const { key, description, model, effort, readOnly, body } = subagent;
|
|
1458
|
+
const fields = {
|
|
1459
|
+
name: key,
|
|
1460
|
+
description
|
|
1461
|
+
};
|
|
1462
|
+
if (typeof model.codex === "string") fields.model = model.codex;
|
|
1463
|
+
if (typeof effort === "string") fields.model_reasoning_effort = effort;
|
|
1464
|
+
const instructions = readOnly ? `${READ_ONLY_INSTRUCTION}\n\n${body}` : body;
|
|
1465
|
+
const parts = [
|
|
1466
|
+
GENERATED_HASH_NOTICE,
|
|
1467
|
+
stringify$1(fields).trimEnd(),
|
|
1468
|
+
renderTomlText("developer_instructions", instructions)
|
|
1469
|
+
];
|
|
1470
|
+
if (readOnly) parts.push(stringify$1({ features: { shell_tool: false } }).trimEnd());
|
|
1471
|
+
return `${parts.join("\n\n")}\n`;
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
1243
1474
|
//#region src/agents/codex/codex-adapter.ts
|
|
1244
1475
|
const CONFIG_FILE = ".codex/config.toml";
|
|
1245
1476
|
const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
|
|
1477
|
+
const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
|
|
1478
|
+
const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
|
|
1479
|
+
const SUBAGENTS_DIR = ".codex/agents";
|
|
1246
1480
|
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/
|
|
1481
|
+
const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
|
|
1248
1482
|
/**
|
|
1249
1483
|
* Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
|
|
1250
1484
|
* `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
|
|
1485
|
+
* in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
|
|
1486
|
+
* permission rules for shell commands become `.codex/rules/tunestra.rules`. Subagents go
|
|
1487
|
+
* to `.codex/agents`.
|
|
1252
1488
|
*/
|
|
1253
1489
|
const codexAdapter = {
|
|
1254
1490
|
agent: Agent.Codex,
|
|
@@ -1265,25 +1501,86 @@ function planCodex(context) {
|
|
|
1265
1501
|
return false;
|
|
1266
1502
|
}).flatMap((command) => planCommandSkill(projectDir, command));
|
|
1267
1503
|
outputs.push(...commandSkills);
|
|
1268
|
-
const
|
|
1504
|
+
const servers = resolution.mcp.filter((server) => {
|
|
1269
1505
|
if (server.artifact.config.type === "sse") {
|
|
1270
1506
|
notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
|
|
1271
1507
|
return false;
|
|
1272
1508
|
}
|
|
1273
1509
|
return true;
|
|
1274
|
-
})
|
|
1275
|
-
|
|
1510
|
+
});
|
|
1511
|
+
const subagentFiles = resolution.subagents.map((subagent) => {
|
|
1512
|
+
return createFileOutput(`${SUBAGENTS_DIR}/${subagent.artifact.key}.toml`, renderSubagentToml(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
|
|
1513
|
+
});
|
|
1514
|
+
const readOnlySubagents = resolution.subagents.filter((subagent) => subagent.artifact.readOnly).map((subagent) => subagent.artifact.key);
|
|
1515
|
+
const hasReadOnlySubagents = readOnlySubagents.length > 0;
|
|
1516
|
+
outputs.push(...subagentFiles);
|
|
1517
|
+
if (hasReadOnlySubagents) {
|
|
1518
|
+
const names = readOnlySubagents.join(", ");
|
|
1519
|
+
notes.push(`Codex cannot make subagents read-only: ${names}. It turns off their shell and asks them not to edit files.`);
|
|
1520
|
+
}
|
|
1521
|
+
const configFile = planConfigFile(ruleFiles, servers, resolution.env);
|
|
1522
|
+
const execRules = planExecRules(resolution.permissions);
|
|
1523
|
+
const ignoredPaths = [];
|
|
1524
|
+
notes.push(...execRules.notes);
|
|
1525
|
+
if (typeof configFile === "object") {
|
|
1526
|
+
outputs.push(configFile);
|
|
1527
|
+
ignoredPaths.push(CONFIG_FILE);
|
|
1528
|
+
}
|
|
1529
|
+
if (typeof execRules.file === "object") {
|
|
1530
|
+
outputs.push(execRules.file);
|
|
1531
|
+
ignoredPaths.push(EXEC_RULES_FILE);
|
|
1532
|
+
}
|
|
1533
|
+
if (subagentFiles.length > 0) ignoredPaths.push(`${SUBAGENTS_DIR}/`);
|
|
1534
|
+
if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
|
|
1535
|
+
return {
|
|
1276
1536
|
outputs,
|
|
1277
|
-
ignoredPaths
|
|
1537
|
+
ignoredPaths,
|
|
1278
1538
|
protectedPaths: [],
|
|
1279
1539
|
notes
|
|
1280
1540
|
};
|
|
1281
|
-
|
|
1282
|
-
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
|
|
1544
|
+
* for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
|
|
1545
|
+
*/
|
|
1546
|
+
function planExecRules(permissions) {
|
|
1547
|
+
const execRules = [];
|
|
1548
|
+
const skippedRules = [];
|
|
1549
|
+
const allowRules = [];
|
|
1550
|
+
for (const resolved of permissions) {
|
|
1551
|
+
const { key, decision } = resolved.artifact;
|
|
1552
|
+
if (hasCodexDecision(decision) === false) {
|
|
1553
|
+
allowRules.push(key);
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
const pattern = convertBashRule(key);
|
|
1557
|
+
if (pattern === void 0) {
|
|
1558
|
+
skippedRules.push(key);
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
const execRule = createExecRule(resolved, decision, pattern);
|
|
1562
|
+
execRules.push(execRule);
|
|
1563
|
+
}
|
|
1564
|
+
const notes = [];
|
|
1565
|
+
const hasSkippedRules = skippedRules.length > 0;
|
|
1566
|
+
const hasAllowRules = allowRules.length > 0;
|
|
1567
|
+
if (hasSkippedRules) {
|
|
1568
|
+
const rules = skippedRules.join(", ");
|
|
1569
|
+
notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
|
|
1570
|
+
}
|
|
1571
|
+
if (hasAllowRules) {
|
|
1572
|
+
const rules = allowRules.join(", ");
|
|
1573
|
+
notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
|
|
1574
|
+
}
|
|
1575
|
+
if (execRules.length > 0) {
|
|
1576
|
+
const content = renderExecRules(execRules);
|
|
1577
|
+
return {
|
|
1578
|
+
file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
|
|
1579
|
+
notes
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1283
1582
|
return {
|
|
1284
|
-
|
|
1285
|
-
ignoredPaths: [CONFIG_FILE],
|
|
1286
|
-
protectedPaths: [],
|
|
1583
|
+
file: void 0,
|
|
1287
1584
|
notes
|
|
1288
1585
|
};
|
|
1289
1586
|
}
|
|
@@ -1398,6 +1695,7 @@ function renderLevelLine(rule) {
|
|
|
1398
1695
|
const HIERARCHY_INTRO = "Instructions are ordered from specific to general. When two instructions conflict, follow the one from the higher level in this list.";
|
|
1399
1696
|
const RULES_INTRO = "Always follow these rules. Claude Code and Codex load them automatically; other agents should read them before starting work.";
|
|
1400
1697
|
const SKILLS_INTRO = "Use a skill when the task matches its description.";
|
|
1698
|
+
const SUBAGENTS_INTRO = "Hand a task to a subagent when the task matches its description.";
|
|
1401
1699
|
const ENV_INTRO = "Agents set these variables in every session, also for the commands they run. The values are in the presets.";
|
|
1402
1700
|
const CHANGING_SECTION = [
|
|
1403
1701
|
"## Changing this setup",
|
|
@@ -1500,6 +1798,21 @@ function renderMcpServers(servers) {
|
|
|
1500
1798
|
];
|
|
1501
1799
|
})));
|
|
1502
1800
|
}
|
|
1801
|
+
function renderSubagents(subagents) {
|
|
1802
|
+
const table = renderTable([
|
|
1803
|
+
"Subagent",
|
|
1804
|
+
"Level",
|
|
1805
|
+
"When to use"
|
|
1806
|
+
], subagents.map((subagent) => {
|
|
1807
|
+
const origin = describeOrigin(subagent);
|
|
1808
|
+
return [
|
|
1809
|
+
subagent.artifact.key,
|
|
1810
|
+
origin,
|
|
1811
|
+
subagent.artifact.description
|
|
1812
|
+
];
|
|
1813
|
+
}));
|
|
1814
|
+
return renderSection("Subagents", SUBAGENTS_INTRO, table);
|
|
1815
|
+
}
|
|
1503
1816
|
function renderEnv(env) {
|
|
1504
1817
|
const table = renderTable(["Variable", "Level"], env.map((variable) => {
|
|
1505
1818
|
const origin = describeOrigin(variable);
|
|
@@ -1532,7 +1845,7 @@ function renderSection(title, intro, table) {
|
|
|
1532
1845
|
//#region src/generate/agents-index.ts
|
|
1533
1846
|
/**
|
|
1534
1847
|
* AGENTS.md: an index of the setup, not the rules themselves. It lists the levels, every
|
|
1535
|
-
* rule, skill, command
|
|
1848
|
+
* rule, skill, command, MCP server and subagent, where each comes from and where its file is.
|
|
1536
1849
|
*/
|
|
1537
1850
|
function renderAgentsIndex(resolution) {
|
|
1538
1851
|
const title = `# AI agent context: ${resolution.chain.project.name}`;
|
|
@@ -1546,6 +1859,7 @@ function renderAgentsIndex(resolution) {
|
|
|
1546
1859
|
const hasSkills = resolution.skills.length > 0;
|
|
1547
1860
|
const hasCommands = resolution.commands.length > 0;
|
|
1548
1861
|
const hasMcpServers = resolution.mcp.length > 0;
|
|
1862
|
+
const hasSubagents = resolution.subagents.length > 0;
|
|
1549
1863
|
const hasEnv = resolution.env.length > 0;
|
|
1550
1864
|
if (hasRules) {
|
|
1551
1865
|
const rules = renderRules(resolution.rules);
|
|
@@ -1563,6 +1877,10 @@ function renderAgentsIndex(resolution) {
|
|
|
1563
1877
|
const servers = renderMcpServers(resolution.mcp);
|
|
1564
1878
|
sections.push(servers);
|
|
1565
1879
|
}
|
|
1880
|
+
if (hasSubagents) {
|
|
1881
|
+
const subagents = renderSubagents(resolution.subagents);
|
|
1882
|
+
sections.push(subagents);
|
|
1883
|
+
}
|
|
1566
1884
|
if (hasEnv) {
|
|
1567
1885
|
const env = renderEnv(resolution.env);
|
|
1568
1886
|
sections.push(env);
|
|
@@ -1652,6 +1970,7 @@ const ArtifactKind = {
|
|
|
1652
1970
|
Skills: "skills",
|
|
1653
1971
|
Commands: "commands",
|
|
1654
1972
|
Mcp: "mcp",
|
|
1973
|
+
Subagents: "subagents",
|
|
1655
1974
|
Env: "env"
|
|
1656
1975
|
};
|
|
1657
1976
|
//#endregion
|
|
@@ -1722,6 +2041,62 @@ function orderFromSpecific(levels, inherited, selectArtifacts) {
|
|
|
1722
2041
|
return ordered;
|
|
1723
2042
|
}
|
|
1724
2043
|
//#endregion
|
|
2044
|
+
//#region src/inheritance/inherit-permissions.ts
|
|
2045
|
+
/**
|
|
2046
|
+
* Permission lists are joined along the chain: every level adds its rules. A more specific
|
|
2047
|
+
* level can remove inherited `allow` and `ask` rules with `exclude.permissions`, but not
|
|
2048
|
+
* `deny` rules, so that a general level keeps a floor of what is never allowed.
|
|
2049
|
+
*/
|
|
2050
|
+
function inheritPermissions(levels) {
|
|
2051
|
+
const inherited = /* @__PURE__ */ new Map();
|
|
2052
|
+
const problems = [];
|
|
2053
|
+
for (const level of levels) {
|
|
2054
|
+
const excludeProblems = excludeRules(inherited, level);
|
|
2055
|
+
const origin = createOrigin(level);
|
|
2056
|
+
problems.push(...excludeProblems);
|
|
2057
|
+
for (const rule of level.permissions) {
|
|
2058
|
+
const id = buildRuleId(rule);
|
|
2059
|
+
const previous = inherited.get(id);
|
|
2060
|
+
const overrides = previous === void 0 ? [] : [...previous.overrides, previous.origin];
|
|
2061
|
+
inherited.set(id, {
|
|
2062
|
+
artifact: rule,
|
|
2063
|
+
origin,
|
|
2064
|
+
overrides,
|
|
2065
|
+
patchedBy: []
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return {
|
|
2070
|
+
artifacts: [...inherited.values()],
|
|
2071
|
+
problems
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
/** The same rule can be in two lists, for example in `allow` on one level and `deny` on another. */
|
|
2075
|
+
function buildRuleId(rule) {
|
|
2076
|
+
return `${rule.decision} ${rule.key}`;
|
|
2077
|
+
}
|
|
2078
|
+
function excludeRules(inherited, level) {
|
|
2079
|
+
const keys = level.exclude.permissions ?? [];
|
|
2080
|
+
const problems = [];
|
|
2081
|
+
for (const key of keys) {
|
|
2082
|
+
const matches = [...inherited.values()].filter((resolved) => resolved.artifact.key === key);
|
|
2083
|
+
const deny = matches.find((resolved) => resolved.artifact.decision === PermissionDecision.Deny);
|
|
2084
|
+
if (typeof deny === "object") {
|
|
2085
|
+
problems.push(`${level.name}: exclude.permissions lists "${key}", but it is a deny rule of ${deny.origin.name}, and a deny rule cannot be removed`);
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
if (matches.length === 0) {
|
|
2089
|
+
problems.push(`${level.name}: exclude.permissions lists "${key}", but no more general level has it`);
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
2092
|
+
for (const match of matches) {
|
|
2093
|
+
const id = buildRuleId(match.artifact);
|
|
2094
|
+
inherited.delete(id);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return problems;
|
|
2098
|
+
}
|
|
2099
|
+
//#endregion
|
|
1725
2100
|
//#region src/shared/utils/records.ts
|
|
1726
2101
|
/** Merges two string maps; keys of `patch` win. Missing on both sides stays missing. */
|
|
1727
2102
|
function mergeRecords(base, patch) {
|
|
@@ -1790,13 +2165,17 @@ function applyInheritance(chain) {
|
|
|
1790
2165
|
const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
|
|
1791
2166
|
const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
|
|
1792
2167
|
const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
|
|
2168
|
+
const subagents = inheritArtifacts(chain.levels, ArtifactKind.Subagents, (level) => level.subagents);
|
|
1793
2169
|
const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
|
|
2170
|
+
const permissions = inheritPermissions(chain.levels);
|
|
1794
2171
|
const problems = [
|
|
1795
2172
|
...rules.problems,
|
|
1796
2173
|
...skills.problems,
|
|
1797
2174
|
...commands.problems,
|
|
1798
2175
|
...mcp.problems,
|
|
1799
|
-
...
|
|
2176
|
+
...subagents.problems,
|
|
2177
|
+
...env.problems,
|
|
2178
|
+
...permissions.problems
|
|
1800
2179
|
];
|
|
1801
2180
|
if (problems.length > 0) throw createProblemsError(problems);
|
|
1802
2181
|
return {
|
|
@@ -1805,7 +2184,9 @@ function applyInheritance(chain) {
|
|
|
1805
2184
|
skills: skills.artifacts,
|
|
1806
2185
|
commands: commands.artifacts,
|
|
1807
2186
|
mcp: mcp.artifacts,
|
|
1808
|
-
|
|
2187
|
+
subagents: subagents.artifacts,
|
|
2188
|
+
env: env.artifacts,
|
|
2189
|
+
permissions: permissions.artifacts
|
|
1809
2190
|
};
|
|
1810
2191
|
}
|
|
1811
2192
|
//#endregion
|
|
@@ -2157,14 +2538,29 @@ async function runSetup(projectDir, options) {
|
|
|
2157
2538
|
/** For --verbose: every artifact, the level it comes from and the file to edit. */
|
|
2158
2539
|
function describeResolution(resolution) {
|
|
2159
2540
|
const projectDir = resolution.chain.project.dir;
|
|
2541
|
+
const permissions = resolution.permissions.map(labelPermission);
|
|
2160
2542
|
return [
|
|
2161
2543
|
describeSection("Rules", resolution.rules, projectDir),
|
|
2162
2544
|
describeSection("Skills", resolution.skills, projectDir),
|
|
2163
2545
|
describeSection("Commands", resolution.commands, projectDir),
|
|
2164
2546
|
describeSection("MCP servers", resolution.mcp, projectDir),
|
|
2165
|
-
describeSection("
|
|
2547
|
+
describeSection("Subagents", resolution.subagents, projectDir),
|
|
2548
|
+
describeSection("Environment", resolution.env, projectDir),
|
|
2549
|
+
describeSection("Permissions", permissions, projectDir)
|
|
2166
2550
|
].filter((section) => section.length > 0).join("\n\n");
|
|
2167
2551
|
}
|
|
2552
|
+
/** For example `deny Read(./.env)`: the same rule can be in two lists. */
|
|
2553
|
+
function labelPermission(resolved) {
|
|
2554
|
+
const { decision, key } = resolved.artifact;
|
|
2555
|
+
const artifact = {
|
|
2556
|
+
...resolved.artifact,
|
|
2557
|
+
key: `${decision} ${key}`
|
|
2558
|
+
};
|
|
2559
|
+
return {
|
|
2560
|
+
...resolved,
|
|
2561
|
+
artifact
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2168
2564
|
function describeSection(title, artifacts, projectDir) {
|
|
2169
2565
|
if (artifacts.length === 0) return "";
|
|
2170
2566
|
const rows = artifacts.map((resolved) => {
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as VERSION } from "./src-
|
|
1
|
+
export { t as VERSION } from "./src-CZK2Ixbt.mjs";
|
package/package.json
CHANGED