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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/adapters/claude-code.d.ts +23 -0
- package/dist/adapters/claude-code.js +86 -0
- package/dist/adapters/codex.d.ts +27 -0
- package/dist/adapters/codex.js +119 -0
- package/dist/adapters/jsonMcp.d.ts +24 -0
- package/dist/adapters/jsonMcp.js +84 -0
- package/dist/adapters/kiro.d.ts +34 -0
- package/dist/adapters/kiro.js +175 -0
- package/dist/adapters/mcpPlan.d.ts +28 -0
- package/dist/adapters/mcpPlan.js +83 -0
- package/dist/adapters/pi.d.ts +23 -0
- package/dist/adapters/pi.js +108 -0
- package/dist/adapters/symlinkPlan.d.ts +33 -0
- package/dist/adapters/symlinkPlan.js +120 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +135 -0
- package/dist/commands/doctor.d.ts +88 -0
- package/dist/commands/doctor.js +269 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.js +150 -0
- package/dist/commands/mcp.d.ts +28 -0
- package/dist/commands/mcp.js +70 -0
- package/dist/commands/migrate.d.ts +38 -0
- package/dist/commands/migrate.js +132 -0
- package/dist/commands/onboard.d.ts +50 -0
- package/dist/commands/onboard.js +155 -0
- package/dist/commands/secretsAudit.d.ts +35 -0
- package/dist/commands/secretsAudit.js +115 -0
- package/dist/commands/sync.d.ts +40 -0
- package/dist/commands/sync.js +91 -0
- package/dist/core/adapter.d.ts +133 -0
- package/dist/core/adapter.js +16 -0
- package/dist/core/canonical.d.ts +16 -0
- package/dist/core/canonical.js +148 -0
- package/dist/core/types.d.ts +201 -0
- package/dist/core/types.js +15 -0
- package/dist/lib/dirEquals.d.ts +7 -0
- package/dist/lib/dirEquals.js +39 -0
- package/dist/lib/envVarNames.d.ts +35 -0
- package/dist/lib/envVarNames.js +79 -0
- package/dist/lib/fsIdentity.d.ts +16 -0
- package/dist/lib/fsIdentity.js +53 -0
- package/dist/lib/mcpProbe.d.ts +14 -0
- package/dist/lib/mcpProbe.js +96 -0
- package/dist/lib/probeCommon.d.ts +24 -0
- package/dist/lib/probeCommon.js +108 -0
- package/dist/lib/secretEnv.d.ts +19 -0
- package/dist/lib/secretEnv.js +46 -0
- package/dist/lib/skillFile.d.ts +12 -0
- package/dist/lib/skillFile.js +26 -0
- package/dist/lib/syncArgs.d.ts +16 -0
- package/dist/lib/syncArgs.js +17 -0
- package/dist/lib/tomlSection.d.ts +57 -0
- package/dist/lib/tomlSection.js +162 -0
- package/dist/pi-bridge/bundle.js +32074 -0
- package/dist/pi-bridge/index.d.ts +48 -0
- package/dist/pi-bridge/index.js +188 -0
- package/dist/pi-bridge/schemaTranslate.d.ts +55 -0
- package/dist/pi-bridge/schemaTranslate.js +40 -0
- package/dist/probes/claude-code.d.ts +13 -0
- package/dist/probes/claude-code.js +48 -0
- package/dist/probes/codex.d.ts +24 -0
- package/dist/probes/codex.js +78 -0
- package/dist/probes/kiro.d.ts +12 -0
- package/dist/probes/kiro.js +48 -0
- package/dist/probes/pi.d.ts +14 -0
- package/dist/probes/pi.js +53 -0
- package/dist/sdk.d.ts +14 -0
- package/dist/sdk.js +13 -0
- package/docs/architecture.md +367 -0
- package/docs/getting-started.md +235 -0
- package/docs/implementation-plan.md +341 -0
- package/docs/research.md +175 -0
- package/docs/roadmap.md +484 -0
- package/package.json +59 -0
- package/schema/scope.example.yaml +33 -0
- package/schema/secrets.policy.example.yaml +43 -0
- 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
|
+
}
|