agent-trellis 0.1.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/dist/adapters/claude-code.d.ts +23 -0
  4. package/dist/adapters/claude-code.js +86 -0
  5. package/dist/adapters/codex.d.ts +27 -0
  6. package/dist/adapters/codex.js +119 -0
  7. package/dist/adapters/jsonMcp.d.ts +24 -0
  8. package/dist/adapters/jsonMcp.js +84 -0
  9. package/dist/adapters/kiro.d.ts +34 -0
  10. package/dist/adapters/kiro.js +175 -0
  11. package/dist/adapters/mcpPlan.d.ts +28 -0
  12. package/dist/adapters/mcpPlan.js +83 -0
  13. package/dist/adapters/pi.d.ts +23 -0
  14. package/dist/adapters/pi.js +108 -0
  15. package/dist/adapters/symlinkPlan.d.ts +33 -0
  16. package/dist/adapters/symlinkPlan.js +120 -0
  17. package/dist/cli.d.ts +7 -0
  18. package/dist/cli.js +135 -0
  19. package/dist/commands/doctor.d.ts +88 -0
  20. package/dist/commands/doctor.js +269 -0
  21. package/dist/commands/init.d.ts +44 -0
  22. package/dist/commands/init.js +150 -0
  23. package/dist/commands/mcp.d.ts +28 -0
  24. package/dist/commands/mcp.js +70 -0
  25. package/dist/commands/migrate.d.ts +38 -0
  26. package/dist/commands/migrate.js +132 -0
  27. package/dist/commands/onboard.d.ts +50 -0
  28. package/dist/commands/onboard.js +155 -0
  29. package/dist/commands/secretsAudit.d.ts +35 -0
  30. package/dist/commands/secretsAudit.js +115 -0
  31. package/dist/commands/sync.d.ts +40 -0
  32. package/dist/commands/sync.js +91 -0
  33. package/dist/core/adapter.d.ts +133 -0
  34. package/dist/core/adapter.js +16 -0
  35. package/dist/core/canonical.d.ts +16 -0
  36. package/dist/core/canonical.js +148 -0
  37. package/dist/core/types.d.ts +201 -0
  38. package/dist/core/types.js +15 -0
  39. package/dist/lib/dirEquals.d.ts +7 -0
  40. package/dist/lib/dirEquals.js +39 -0
  41. package/dist/lib/envVarNames.d.ts +35 -0
  42. package/dist/lib/envVarNames.js +79 -0
  43. package/dist/lib/fsIdentity.d.ts +16 -0
  44. package/dist/lib/fsIdentity.js +53 -0
  45. package/dist/lib/mcpProbe.d.ts +14 -0
  46. package/dist/lib/mcpProbe.js +96 -0
  47. package/dist/lib/probeCommon.d.ts +24 -0
  48. package/dist/lib/probeCommon.js +108 -0
  49. package/dist/lib/secretEnv.d.ts +19 -0
  50. package/dist/lib/secretEnv.js +46 -0
  51. package/dist/lib/skillFile.d.ts +12 -0
  52. package/dist/lib/skillFile.js +26 -0
  53. package/dist/lib/syncArgs.d.ts +16 -0
  54. package/dist/lib/syncArgs.js +17 -0
  55. package/dist/lib/tomlSection.d.ts +57 -0
  56. package/dist/lib/tomlSection.js +162 -0
  57. package/dist/pi-bridge/bundle.js +32074 -0
  58. package/dist/pi-bridge/index.d.ts +48 -0
  59. package/dist/pi-bridge/index.js +188 -0
  60. package/dist/pi-bridge/schemaTranslate.d.ts +55 -0
  61. package/dist/pi-bridge/schemaTranslate.js +40 -0
  62. package/dist/probes/claude-code.d.ts +13 -0
  63. package/dist/probes/claude-code.js +48 -0
  64. package/dist/probes/codex.d.ts +24 -0
  65. package/dist/probes/codex.js +78 -0
  66. package/dist/probes/kiro.d.ts +12 -0
  67. package/dist/probes/kiro.js +48 -0
  68. package/dist/probes/pi.d.ts +14 -0
  69. package/dist/probes/pi.js +53 -0
  70. package/dist/sdk.d.ts +14 -0
  71. package/dist/sdk.js +13 -0
  72. package/docs/architecture.md +367 -0
  73. package/docs/getting-started.md +235 -0
  74. package/docs/implementation-plan.md +341 -0
  75. package/docs/research.md +175 -0
  76. package/docs/roadmap.md +484 -0
  77. package/package.json +59 -0
  78. package/schema/scope.example.yaml +33 -0
  79. package/schema/secrets.policy.example.yaml +43 -0
  80. package/schema/servers.example.yaml +87 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared filesystem/JSON reading used by every per-agent probe
3
+ * (src/probes/*.ts) — kept here so all four agree on what "symlink status,"
4
+ * "skill root," and "resolve env var references" mean, rather than each
5
+ * probe reimplementing it slightly differently.
6
+ */
7
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { findSkillFile } from "./skillFile.js";
10
+ export function pathRef(path) {
11
+ if (!existsSync(path)) {
12
+ return undefined;
13
+ }
14
+ if (!lstatSync(path).isSymbolicLink()) {
15
+ return { path, isSymlink: false };
16
+ }
17
+ try {
18
+ return { path, isSymlink: true, target: realpathSync(path) };
19
+ }
20
+ catch {
21
+ return { path, isSymlink: true };
22
+ }
23
+ }
24
+ /**
25
+ * One skill root = a directory of skill subdirectories, each expected to
26
+ * hold `SKILL.md`. Every agent as of design.md D5 uses this shape.
27
+ */
28
+ export function scanSkillRoot(rootPath) {
29
+ const ref = pathRef(rootPath);
30
+ if (!ref) {
31
+ return undefined;
32
+ }
33
+ const skills = [];
34
+ let entries = [];
35
+ try {
36
+ entries = readdirSync(rootPath);
37
+ }
38
+ catch {
39
+ entries = [];
40
+ }
41
+ for (const name of entries) {
42
+ const dir = join(rootPath, name);
43
+ let isDirectory;
44
+ try {
45
+ isDirectory = statSync(dir).isDirectory();
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ if (!isDirectory) {
51
+ continue;
52
+ }
53
+ const skillFile = findSkillFile(dir);
54
+ if (!skillFile) {
55
+ continue;
56
+ }
57
+ let realDir;
58
+ try {
59
+ realDir = realpathSync(dir);
60
+ }
61
+ catch {
62
+ realDir = dir;
63
+ }
64
+ skills.push({
65
+ name,
66
+ dir,
67
+ realDir,
68
+ isSymlink: lstatSync(dir).isSymbolicLink(),
69
+ caseCorrect: skillFile.caseCorrect,
70
+ });
71
+ }
72
+ return { path: rootPath, isSymlink: ref.isSymlink, target: ref.target, skills };
73
+ }
74
+ export function readJsonFile(path) {
75
+ try {
76
+ return JSON.parse(readFileSync(path, "utf-8"));
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ export function countDirEntries(dir) {
83
+ try {
84
+ return readdirSync(dir).length;
85
+ }
86
+ catch {
87
+ return 0;
88
+ }
89
+ }
90
+ /**
91
+ * Substitutes `${VAR}` against `base` (normally `process.env`) so a
92
+ * probe's handshake spawn gets the real value — configs only ever hold
93
+ * the variable NAME (docs/research.md "Secrets"), never a literal.
94
+ * Values given as a bare array (Codex's `env_vars` style: just names) need
95
+ * no substitution — the named var must already be in `base` for the
96
+ * spawned process to inherit it, which it will via object spread.
97
+ */
98
+ export function resolveEnvRefs(envSpec, base) {
99
+ const out = { ...base };
100
+ if (!envSpec || Array.isArray(envSpec)) {
101
+ return out;
102
+ }
103
+ for (const [key, raw] of Object.entries(envSpec)) {
104
+ const match = /^\$\{(.+)\}$/.exec(raw);
105
+ out[key] = match ? base[match[1]] : raw;
106
+ }
107
+ return out;
108
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared secret-value resolver (trellis-secrets-env-management). Both
3
+ * the pi bridge and `secrets audit` call this — never their own
4
+ * `process.env[name]` lookup — so they can never disagree about where a
5
+ * declared name's value comes from (design.md D1).
6
+ *
7
+ * Dependency-free by design, same as src/lib/envVarNames.ts: a full
8
+ * dotenv library's quoting/escaping/interpolation rules are more than
9
+ * this narrow need requires (design.md D4).
10
+ */
11
+ import type { SecretsPolicy } from "../core/types.js";
12
+ export declare function parseDotenv(content: string): Record<string, string>;
13
+ /**
14
+ * `policy.envFile` set: it is the SOLE source — a name absent from it
15
+ * resolves to `undefined`, `process.env` is never consulted (a silent
16
+ * fallback would defeat the isolation this exists to offer). Unset:
17
+ * reads `process.env` directly, identical to every pre-existing caller.
18
+ */
19
+ export declare function resolveSecretEnv(names: string[], policy: SecretsPolicy): Record<string, string | undefined>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared secret-value resolver (trellis-secrets-env-management). Both
3
+ * the pi bridge and `secrets audit` call this — never their own
4
+ * `process.env[name]` lookup — so they can never disagree about where a
5
+ * declared name's value comes from (design.md D1).
6
+ *
7
+ * Dependency-free by design, same as src/lib/envVarNames.ts: a full
8
+ * dotenv library's quoting/escaping/interpolation rules are more than
9
+ * this narrow need requires (design.md D4).
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ const LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
13
+ export function parseDotenv(content) {
14
+ const result = {};
15
+ for (const rawLine of content.split("\n")) {
16
+ const line = rawLine.trim();
17
+ if (line === "" || line.startsWith("#"))
18
+ continue;
19
+ const match = LINE_RE.exec(line);
20
+ if (match)
21
+ result[match[1]] = match[2];
22
+ }
23
+ return result;
24
+ }
25
+ function loadEnvFile(path) {
26
+ return existsSync(path) ? parseDotenv(readFileSync(path, "utf-8")) : {};
27
+ }
28
+ /**
29
+ * `policy.envFile` set: it is the SOLE source — a name absent from it
30
+ * resolves to `undefined`, `process.env` is never consulted (a silent
31
+ * fallback would defeat the isolation this exists to offer). Unset:
32
+ * reads `process.env` directly, identical to every pre-existing caller.
33
+ */
34
+ export function resolveSecretEnv(names, policy) {
35
+ const result = {};
36
+ if (policy.envFile) {
37
+ const map = loadEnvFile(policy.envFile);
38
+ for (const name of names)
39
+ result[name] = map[name];
40
+ }
41
+ else {
42
+ for (const name of names)
43
+ result[name] = process.env[name];
44
+ }
45
+ return result;
46
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Case-sensitive SKILL.md detection. A file saved as `skill.md` is silently
3
+ * dropped from discovery on at least one target agent (Codex — see
4
+ * docs/research.md) with no warning; `doctor` exists in part to catch this
5
+ * before an agent does, so the wrong-case case must be reported, not
6
+ * treated the same as "no skill file present at all."
7
+ */
8
+ export interface SkillFileResult {
9
+ path: string;
10
+ caseCorrect: boolean;
11
+ }
12
+ export declare function findSkillFile(dir: string): SkillFileResult | null;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Case-sensitive SKILL.md detection. A file saved as `skill.md` is silently
3
+ * dropped from discovery on at least one target agent (Codex — see
4
+ * docs/research.md) with no warning; `doctor` exists in part to catch this
5
+ * before an agent does, so the wrong-case case must be reported, not
6
+ * treated the same as "no skill file present at all."
7
+ */
8
+ import { readdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ export function findSkillFile(dir) {
11
+ let entries;
12
+ try {
13
+ entries = readdirSync(dir);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ if (entries.includes("SKILL.md")) {
19
+ return { path: join(dir, "SKILL.md"), caseCorrect: true };
20
+ }
21
+ const wrongCase = entries.find((name) => name.toLowerCase() === "skill.md");
22
+ if (wrongCase) {
23
+ return { path: join(dir, wrongCase), caseCorrect: false };
24
+ }
25
+ return null;
26
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pulled out of `src/cli.ts`'s `sync` dispatch branch specifically so it's
3
+ * directly unit-testable (test/unit/syncArgs.test.ts) without importing
4
+ * `cli.ts` itself — that file runs `main()` as a top-level side effect
5
+ * when it's the real entrypoint, which a symlink-safe "is this actually
6
+ * the entrypoint" check turned out not to be worth building (it broke the
7
+ * real npm-installed `trellis` bin, whose symlink target's realpath never
8
+ * equals `process.argv[1]`'s). A zero-side-effect module is the simpler
9
+ * fix. This exact class of bug (only `rest[0]` was ever checked, so a
10
+ * flag placed first was mistaken for an unknown target) had no test
11
+ * coverage before it was found by manual review, not a failing test.
12
+ */
13
+ export declare function parseSyncArgs(rest: string[]): {
14
+ target?: "skills" | "instructions";
15
+ unknownArg?: string;
16
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Pulled out of `src/cli.ts`'s `sync` dispatch branch specifically so it's
3
+ * directly unit-testable (test/unit/syncArgs.test.ts) without importing
4
+ * `cli.ts` itself — that file runs `main()` as a top-level side effect
5
+ * when it's the real entrypoint, which a symlink-safe "is this actually
6
+ * the entrypoint" check turned out not to be worth building (it broke the
7
+ * real npm-installed `trellis` bin, whose symlink target's realpath never
8
+ * equals `process.argv[1]`'s). A zero-side-effect module is the simpler
9
+ * fix. This exact class of bug (only `rest[0]` was ever checked, so a
10
+ * flag placed first was mistaken for an unknown target) had no test
11
+ * coverage before it was found by manual review, not a failing test.
12
+ */
13
+ export function parseSyncArgs(rest) {
14
+ const target = rest.find((arg) => arg === "skills" || arg === "instructions");
15
+ const unknownArg = rest.find((arg) => !arg.startsWith("--") && arg !== "skills" && arg !== "instructions");
16
+ return { target, unknownArg };
17
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Locate and splice a single `[mcp_servers.<name>]` section in Codex's
3
+ * `config.toml` — never a general TOML parser. Both realistic library
4
+ * candidates (`@iarna/toml`, `smol-toml`) were tested directly against a
5
+ * real fixture and silently drop comments + reformat arrays on a bare
6
+ * parse→stringify round-trip (design.md D2, trellis-mcp-sync-p2) — not
7
+ * safe for "touch nothing outside the target section." This module only
8
+ * ever needs to recognize table headers and render a bounded, known
9
+ * shape (`McpServerDef`), never general TOML.
10
+ */
11
+ import type { McpServerDef } from "../core/types.js";
12
+ export interface SectionRange {
13
+ /** Line index (0-based) of the `[header]` line itself. */
14
+ start: number;
15
+ /** Line index (0-based, inclusive) of the section's last line. */
16
+ end: number;
17
+ }
18
+ /**
19
+ * Finds an existing `[header]` table's exact line range: `start` is the
20
+ * header line itself, `end` is the line before the next table header (any
21
+ * `[...]`/`[[...]]`, not just another mcp_servers one) or EOF, whichever
22
+ * comes first. Returns `null` if no such header exists.
23
+ */
24
+ export declare function findSection(content: string, header: string): SectionRange | null;
25
+ /**
26
+ * Returns the current stored text of `[mcp_servers.<name>]` (or `null` if
27
+ * it doesn't exist), for comparing against `renderServerSection`'s output
28
+ * to decide create/repair vs. no-op — exact text equality is enough here,
29
+ * no parsing needed on either side.
30
+ */
31
+ export declare function currentServerSectionText(content: string, name: string): string | null;
32
+ /**
33
+ * The only `headers` shape Codex's own real schema can express — it has
34
+ * no generic headers concept, only this one purpose-built field
35
+ * (trellis-mcp-transport-auth design.md D4, matching what Codex's own
36
+ * `mcp add --bearer-token-env-var` CLI generates): exactly one entry,
37
+ * key `Authorization`, value exactly `Bearer ${VAR}`. Returns the var
38
+ * name, or `undefined` if `headers` is absent or any other shape —
39
+ * callers (mcpPlan.ts) refuse-and-conflict on "any other shape" rather
40
+ * than this function silently rendering nothing for it.
41
+ */
42
+ export declare function codexBearerTokenEnvVar(def: McpServerDef): string | undefined;
43
+ /** Renders a `[mcp_servers.<name>]` block for a bounded, known shape —
44
+ * this is templating, not general TOML serialization. */
45
+ export declare function renderServerSection(name: string, def: McpServerDef): string;
46
+ /**
47
+ * Replaces an existing section in place, or appends a new one at EOF
48
+ * (with a leading blank-line separator) if none exists yet. Never touches
49
+ * any line outside the section it locates or the single appended block.
50
+ */
51
+ export declare function upsertSection(content: string, name: string, def: McpServerDef): string;
52
+ /**
53
+ * Removes an existing section (and one immediately-preceding blank line,
54
+ * if any, so repeated add/remove doesn't accumulate blank separators).
55
+ * No-op if the section doesn't exist — idempotent.
56
+ */
57
+ export declare function removeSection(content: string, name: string): string;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Locate and splice a single `[mcp_servers.<name>]` section in Codex's
3
+ * `config.toml` — never a general TOML parser. Both realistic library
4
+ * candidates (`@iarna/toml`, `smol-toml`) were tested directly against a
5
+ * real fixture and silently drop comments + reformat arrays on a bare
6
+ * parse→stringify round-trip (design.md D2, trellis-mcp-sync-p2) — not
7
+ * safe for "touch nothing outside the target section." This module only
8
+ * ever needs to recognize table headers and render a bounded, known
9
+ * shape (`McpServerDef`), never general TOML.
10
+ */
11
+ /**
12
+ * Matches a TOML table header line, and *only* a table header line — the
13
+ * entire trimmed line must be exactly `[key.path]` or `[[key.path]]`,
14
+ * each path segment a bare key or a quoted string, nothing else on the
15
+ * line. Deliberately strict: a multi-line array literal elsewhere in the
16
+ * file (e.g. `[1, 2],` as a continuation line) must never be mistaken for
17
+ * a new table boundary — that line has trailing content (`,`) and its
18
+ * bracket contents aren't a valid key path, so this regex correctly
19
+ * rejects it. See tasks.md 2.8.
20
+ */
21
+ const KEY_SEGMENT = String.raw `(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
22
+ const TABLE_HEADER_RE = new RegExp(`^\\[{1,2}${KEY_SEGMENT}(?:\\.${KEY_SEGMENT})*\\]{1,2}$`);
23
+ function isTableHeaderLine(line) {
24
+ return TABLE_HEADER_RE.test(line.trim());
25
+ }
26
+ /** Quotes a key segment if it isn't a valid bare TOML key. */
27
+ function tomlKeySegment(name) {
28
+ return /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
29
+ }
30
+ function tomlString(value) {
31
+ return JSON.stringify(value);
32
+ }
33
+ function tomlStringArray(values) {
34
+ return `[${values.map(tomlString).join(", ")}]`;
35
+ }
36
+ function serverHeader(name) {
37
+ return `mcp_servers.${tomlKeySegment(name)}`;
38
+ }
39
+ /**
40
+ * Finds an existing `[header]` table's exact line range: `start` is the
41
+ * header line itself, `end` is the line before the next table header (any
42
+ * `[...]`/`[[...]]`, not just another mcp_servers one) or EOF, whichever
43
+ * comes first. Returns `null` if no such header exists.
44
+ */
45
+ export function findSection(content, header) {
46
+ const lines = content.split("\n");
47
+ const headerLine = `[${header}]`;
48
+ const start = lines.findIndex((line) => line.trim() === headerLine);
49
+ if (start === -1) {
50
+ return null;
51
+ }
52
+ let end = lines.length - 1;
53
+ for (let i = start + 1; i < lines.length; i++) {
54
+ if (isTableHeaderLine(lines[i])) {
55
+ end = i - 1;
56
+ break;
57
+ }
58
+ }
59
+ // Don't include a trailing blank separator (or, at EOF, the empty
60
+ // sentinel element split() leaves after a file's final newline), and
61
+ // don't include a trailing comment-only block either — a comment
62
+ // immediately before the NEXT `[header]` documents that section, not
63
+ // this one (found via the sandbox fixture: a multi-line comment
64
+ // introducing `[mcp_servers.sentry]` was getting swallowed into the
65
+ // *previous* section's range and would have been deleted by an
66
+ // unrelated update to that prior section).
67
+ while (end > start && (lines[end].trim() === "" || lines[end].trim().startsWith("#"))) {
68
+ end -= 1;
69
+ }
70
+ return { start, end };
71
+ }
72
+ /**
73
+ * Returns the current stored text of `[mcp_servers.<name>]` (or `null` if
74
+ * it doesn't exist), for comparing against `renderServerSection`'s output
75
+ * to decide create/repair vs. no-op — exact text equality is enough here,
76
+ * no parsing needed on either side.
77
+ */
78
+ export function currentServerSectionText(content, name) {
79
+ const header = serverHeader(name);
80
+ const range = findSection(content, header);
81
+ if (!range) {
82
+ return null;
83
+ }
84
+ return content.split("\n").slice(range.start, range.end + 1).join("\n");
85
+ }
86
+ const BEARER_TOKEN_VALUE_RE = /^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
87
+ /**
88
+ * The only `headers` shape Codex's own real schema can express — it has
89
+ * no generic headers concept, only this one purpose-built field
90
+ * (trellis-mcp-transport-auth design.md D4, matching what Codex's own
91
+ * `mcp add --bearer-token-env-var` CLI generates): exactly one entry,
92
+ * key `Authorization`, value exactly `Bearer ${VAR}`. Returns the var
93
+ * name, or `undefined` if `headers` is absent or any other shape —
94
+ * callers (mcpPlan.ts) refuse-and-conflict on "any other shape" rather
95
+ * than this function silently rendering nothing for it.
96
+ */
97
+ export function codexBearerTokenEnvVar(def) {
98
+ const entries = Object.entries(def.headers ?? {});
99
+ if (entries.length !== 1)
100
+ return undefined;
101
+ const [key, value] = entries[0];
102
+ if (key !== "Authorization")
103
+ return undefined;
104
+ return BEARER_TOKEN_VALUE_RE.exec(value)?.[1];
105
+ }
106
+ /** Renders a `[mcp_servers.<name>]` block for a bounded, known shape —
107
+ * this is templating, not general TOML serialization. */
108
+ export function renderServerSection(name, def) {
109
+ const lines = [`[${serverHeader(name)}]`];
110
+ if (def.transport === "stdio") {
111
+ if (def.command)
112
+ lines.push(`command = ${tomlString(def.command)}`);
113
+ if (def.args && def.args.length > 0)
114
+ lines.push(`args = ${tomlStringArray(def.args)}`);
115
+ if (def.env && def.env.length > 0)
116
+ lines.push(`env_vars = ${tomlStringArray(def.env)}`);
117
+ }
118
+ else {
119
+ if (def.url)
120
+ lines.push(`url = ${tomlString(def.url)}`);
121
+ const bearerEnvVar = codexBearerTokenEnvVar(def);
122
+ if (bearerEnvVar)
123
+ lines.push(`bearer_token_env_var = ${tomlString(bearerEnvVar)}`);
124
+ }
125
+ return lines.join("\n");
126
+ }
127
+ /**
128
+ * Replaces an existing section in place, or appends a new one at EOF
129
+ * (with a leading blank-line separator) if none exists yet. Never touches
130
+ * any line outside the section it locates or the single appended block.
131
+ */
132
+ export function upsertSection(content, name, def) {
133
+ const header = serverHeader(name);
134
+ const existing = findSection(content, header);
135
+ const newLines = renderServerSection(name, def).split("\n");
136
+ if (existing) {
137
+ const lines = content.split("\n");
138
+ lines.splice(existing.start, existing.end - existing.start + 1, ...newLines);
139
+ return lines.join("\n");
140
+ }
141
+ const withoutTrailingNewlines = content.replace(/\n+$/, "");
142
+ return `${withoutTrailingNewlines}\n\n${newLines.join("\n")}\n`;
143
+ }
144
+ /**
145
+ * Removes an existing section (and one immediately-preceding blank line,
146
+ * if any, so repeated add/remove doesn't accumulate blank separators).
147
+ * No-op if the section doesn't exist — idempotent.
148
+ */
149
+ export function removeSection(content, name) {
150
+ const header = serverHeader(name);
151
+ const existing = findSection(content, header);
152
+ if (!existing) {
153
+ return content;
154
+ }
155
+ const lines = content.split("\n");
156
+ let deleteStart = existing.start;
157
+ if (deleteStart > 0 && lines[deleteStart - 1].trim() === "") {
158
+ deleteStart -= 1;
159
+ }
160
+ lines.splice(deleteStart, existing.end - deleteStart + 1);
161
+ return lines.join("\n");
162
+ }