@intentius/chant-lexicon-fountain 0.52.0 → 0.52.2

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Re-express a machine's local agent configuration as fountain resources.
3
+ *
4
+ * `chant audit --agents` answers "what is configured on this machine". This is
5
+ * the other half: taking that inventory and turning it into chant code, so a
6
+ * configuration that accumulated by hand over months becomes a reviewable,
7
+ * version-controlled, reproducible declaration.
8
+ *
9
+ * The mapping is close to 1:1 because fountain's `Agent` models the same four
10
+ * ideas the harnesses do:
11
+ *
12
+ * | local agent config | fountain |
13
+ * |-------------------------------|-----------------------|
14
+ * | CLAUDE.md / AGENTS.md | `Agent.system` |
15
+ * | `mcpServers` / `mcp_servers` | `Agent.mcp_servers` |
16
+ * | `skills/*​/SKILL.md` | `Agent.skills` |
17
+ * | settings `env` | `Environment.env_vars`|
18
+ *
19
+ * Three places where it is deliberately *not* a transcription:
20
+ *
21
+ * 1. **Egress is derived, not copied.** A local agent runs with the machine's
22
+ * full network access; a fountain sandbox must declare intent (FTN010). The
23
+ * hosts the config's own remote MCP servers use are exactly the egress it
24
+ * demonstrably needs, so those become the `allowed_hosts` allowlist and
25
+ * everything else is denied. Copying "unrestricted" would launder an
26
+ * implicit local permission into an explicit remote one.
27
+ * 2. **Secrets are not carried over.** A literal credential found in local
28
+ * config (AGT002) is rewritten to a `${VAR}` reference in the emitted code.
29
+ * Generated chant code gets committed; transcribing a live secret into it
30
+ * would turn a local mistake into a repository one.
31
+ * 3. **Skills are inlined by content where possible.** A local skill is text
32
+ * on this machine with no upstream, so `{name, content}` is the only form
33
+ * that reproduces it elsewhere. Remote skills keep `{source, ref}`.
34
+ *
35
+ * Cursor is discovered by the scanner but has no fountain `runtime` value, so
36
+ * its sites are reported as skipped rather than silently mapped onto a
37
+ * different agent runtime.
38
+ */
39
+ import type { AgentConfigSite, McpServerDecl, SkillDecl } from "@intentius/chant/agents";
40
+ import type { AgentImportOutcome } from "@intentius/chant/agents/importer";
41
+ /** Runtimes fountain's `Agent.runtime` accepts. `cursor` is absent by design. */
42
+ export declare const MAPPABLE_RUNTIMES: readonly ["claude", "codex", "gemini", "opencode"];
43
+ export type MappableRuntime = (typeof MAPPABLE_RUNTIMES)[number];
44
+ /**
45
+ * Default model per runtime, used when the local config pins none.
46
+ *
47
+ * `Agent.model` is required by fountain, and most local configs leave the model
48
+ * to the harness's own default — a value that isn't written down anywhere this
49
+ * scanner can read. Emitting a documented default that the user edits is more
50
+ * honest than inventing a pin and calling it discovered; `unmappedModel` in the
51
+ * result reports every site this applied to.
52
+ */
53
+ export declare const DEFAULT_MODEL: Record<MappableRuntime, string>;
54
+ /**
55
+ * Build the `system` prompt from the site's instruction files.
56
+ *
57
+ * Provenance is kept as a comment header per file. A user reading the generated
58
+ * code needs to know which of their three CLAUDE.md files a paragraph came
59
+ * from, and a single concatenated blob without headers makes that unrecoverable.
60
+ */
61
+ export declare function buildSystem(site: AgentConfigSite): string | undefined;
62
+ /** Project the normalized MCP declarations back into fountain's `mcp_servers` map. */
63
+ export declare function toMcpServers(servers: McpServerDecl[], onRedact: () => void): Record<string, unknown> | undefined;
64
+ /**
65
+ * Project skills into fountain's two accepted forms.
66
+ *
67
+ * fountain requires exactly one of `content` or `source` per entry. A local
68
+ * skill has no upstream to install from, so its text is inlined — that is what
69
+ * makes the emitted code reproduce the configuration on a machine that has
70
+ * never seen this one.
71
+ */
72
+ export declare function toSkills(skills: SkillDecl[]): Record<string, unknown>[] | undefined;
73
+ /**
74
+ * Hosts the config's own remote MCP servers reach.
75
+ *
76
+ * This is the evidence-based egress allowlist: every host here is one the
77
+ * configuration already talks to, so the sandbox stays functional while
78
+ * everything else stays denied.
79
+ */
80
+ export declare function derivedAllowedHosts(servers: McpServerDecl[]): string[];
81
+ /** Canonicalize a local model name into fountain's `provider/model_id` form. */
82
+ export declare function canonicalModel(local: string | undefined, runtime: MappableRuntime): {
83
+ model: string;
84
+ defaulted: boolean;
85
+ };
86
+ /**
87
+ * Convert discovered agent config sites into fountain import IR.
88
+ *
89
+ * Each mappable site yields an `Agent`, plus an `Environment` when it has
90
+ * anything environmental to declare (env vars, or remote MCP hosts to
91
+ * allowlist). Feed the result to `FountainGenerator` to get chant TypeScript.
92
+ */
93
+ export declare function sitesToTemplateIR(sites: AgentConfigSite[]): AgentImportOutcome;
94
+ //# sourceMappingURL=local-agents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-agents.d.ts","sourceRoot":"","sources":["../../src/import/local-agents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACzF,OAAO,KAAK,EAAE,kBAAkB,EAAe,MAAM,kCAAkC,CAAC;AAExF,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,oDAAqD,CAAC;AACpF,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,eAAe,EAAE,MAAM,CAKzD,CAAC;AA0BF;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAIrE;AAuGD,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,OAAO,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAsBhH;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,SAAS,CAanF;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,aAAa,EAAE,GAAG,MAAM,EAAE,CAYtE;AAED,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAKzH;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,kBAAkB,CA8E9E"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "23b006cbf650b74f712e3aab7820cf68cedd67632790ddb795d11694637c596f",
4
+ "manifest.json": "bace62b6de51e843f06dbc2de56ec34020ca13898a12102e2629971b91c599a8",
5
5
  "meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
6
6
  "types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
7
7
  "rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
@@ -17,5 +17,5 @@
17
17
  "skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
18
18
  "skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
19
19
  },
20
- "composite": "7d43b99109b6b29a2e9394893336f33bb7e92eb4089f341afb308ac3636594d0"
20
+ "composite": "4ab7aac826621cd338e16b85a2d8be23007185e49d3e71aef7ad3e533e9560ec"
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.52.0",
3
+ "version": "0.52.2",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fountain",
6
6
  "specVersion": "v0.3.0"
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAuB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aA0J5B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAwB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aA8J5B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.52.0",
3
+ "version": "0.52.2",
4
4
  "type": "module",
5
5
  "description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@
50
50
  "bundle": "tsx src/package-cli.ts"
51
51
  },
52
52
  "peerDependencies": {
53
- "@intentius/chant": "^0.52.0",
53
+ "@intentius/chant": "^0.52.2",
54
54
  "typescript": "^5.9.3"
55
55
  },
56
56
  "devDependencies": {
@@ -82,11 +82,27 @@ function formatNested(value: unknown, indent: number, spec: Nested, imports: Set
82
82
  return `[\n${items.join("\n")}\n${closePad}]`;
83
83
  }
84
84
 
85
+ /**
86
+ * A cross-resource reference in the IR: `{__ref: "<logicalId>"}` renders as the
87
+ * bare variable name that resource was declared under, so `agent.environment`
88
+ * comes out as a typed ref (`environment: userClaudeEnv`) rather than a copy of
89
+ * the environment's properties. Mirrors the `__intrinsic` sentinel the
90
+ * CloudFormation importer uses for `Ref`.
91
+ */
92
+ function asRef(value: unknown): string | undefined {
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
94
+ const ref = (value as Record<string, unknown>).__ref;
95
+ return typeof ref === "string" ? ref : undefined;
96
+ }
97
+
85
98
  function formatValue(value: unknown, indent: number): string {
86
99
  if (value === null || value === undefined) return "undefined";
87
100
  if (typeof value === "string") return JSON.stringify(value);
88
101
  if (typeof value === "number" || typeof value === "boolean") return String(value);
89
102
 
103
+ const ref = asRef(value);
104
+ if (ref !== undefined) return camelCase(ref);
105
+
90
106
  if (Array.isArray(value)) {
91
107
  if (value.length === 0) return "[]";
92
108
  const pad = " ".repeat(indent + 1);
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The mapper's contract is that generated code is safe to commit and faithful
3
+ * to the config it came from — in that order.
4
+ *
5
+ * The redaction tests are the load-bearing ones. Running the importer against a
6
+ * real machine produced `Authorization: "Bearer rnd_…"` in the output: a live
7
+ * token written into a file whose whole point is to be checked into version
8
+ * control. Every path that can carry a credential is covered here.
9
+ */
10
+
11
+ import { describe, test, expect } from "vitest";
12
+ import { sitesToTemplateIR, derivedAllowedHosts, canonicalModel, buildSystem, toSkills, DEFAULT_MODEL } from "./local-agents";
13
+ import { FountainGenerator } from "./generator";
14
+ import type { AgentConfigSite } from "@intentius/chant/agents";
15
+
16
+ function site(overrides: Partial<AgentConfigSite> = {}): AgentConfigSite {
17
+ return {
18
+ id: "user-claude",
19
+ scope: "user",
20
+ runtime: "claude",
21
+ root: "/home/u",
22
+ sources: ["/home/u/.claude/settings.json"],
23
+ instructions: [],
24
+ mcpServers: [],
25
+ skills: [],
26
+ subagents: [],
27
+ commands: [],
28
+ plugins: [],
29
+ env: {},
30
+ settings: {},
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ /** The generated `Agent` resource for a single-site conversion. */
36
+ function agentOf(s: AgentConfigSite) {
37
+ const { ir } = sitesToTemplateIR([s]);
38
+ const agent = ir.resources.find((r) => r.type === "Fountain::V1::Agent");
39
+ if (!agent) throw new Error("no Agent resource generated");
40
+ return agent;
41
+ }
42
+
43
+ describe("runtime mapping", () => {
44
+ test("maps the four runtimes fountain accepts", () => {
45
+ for (const runtime of ["claude", "codex", "gemini", "opencode"] as const) {
46
+ const { ir, skipped } = sitesToTemplateIR([site({ id: `user-${runtime}`, runtime })]);
47
+ expect(skipped).toEqual([]);
48
+ expect(ir.resources[0].properties.runtime).toBe(runtime);
49
+ }
50
+ });
51
+
52
+ test("skips cursor with a stated reason rather than mapping it onto another runtime", () => {
53
+ const { ir, skipped } = sitesToTemplateIR([site({ id: "user-cursor", runtime: "cursor" })]);
54
+ expect(ir.resources).toEqual([]);
55
+ expect(skipped[0].siteId).toBe("user-cursor");
56
+ expect(skipped[0].reason).toContain("cursor");
57
+ });
58
+ });
59
+
60
+ describe("model canonicalization", () => {
61
+ test("expands a harness alias to fountain's provider/model_id form", () => {
62
+ expect(canonicalModel("opus", "claude")).toEqual({ model: "anthropic/claude-opus-4-6", defaulted: false });
63
+ });
64
+
65
+ test("passes an already-qualified model through untouched", () => {
66
+ expect(canonicalModel("anthropic/claude-sonnet-4-6", "claude").model).toBe("anthropic/claude-sonnet-4-6");
67
+ });
68
+
69
+ test("defaults when the local config pins no model, and reports having done so", () => {
70
+ const { unmappedModel } = sitesToTemplateIR([site()]);
71
+ expect(unmappedModel).toEqual(["user-claude"]);
72
+ expect(agentOf(site()).properties.model).toBe(DEFAULT_MODEL.claude);
73
+ });
74
+ });
75
+
76
+ describe("secret redaction", () => {
77
+ test("replaces a literal token in env with an env reference", () => {
78
+ const s = site({ mcpServers: [{ name: "api", transport: "stdio", source: "/c", command: "x", env: { API_KEY: "sk-ant-abcdefghijklmnopqrstuvwxyz01" } }] });
79
+ const servers = agentOf(s).properties.mcp_servers as Record<string, { env: Record<string, string> }>;
80
+ expect(servers.api.env.API_KEY).toBe("${API_KEY}");
81
+ expect(sitesToTemplateIR([s]).redactedSecrets).toEqual(["user-claude"]);
82
+ });
83
+
84
+ test("replaces a bearer token in headers, which arrive via `extra`", () => {
85
+ // The regression that motivated this file: `extra` was copied verbatim, so
86
+ // a live token landed in code destined for version control.
87
+ const s = site({
88
+ mcpServers: [{ name: "nebula", transport: "http", source: "/c", url: "https://n/mcp", extra: { headers: { Authorization: "Bearer nbla_xbIEER2743B8Csrs" } } }],
89
+ });
90
+ const servers = agentOf(s).properties.mcp_servers as Record<string, { headers: Record<string, string> }>;
91
+ expect(servers.nebula.headers.Authorization).toBe("${NEBULA_AUTH_TOKEN}");
92
+ });
93
+
94
+ test("namespaces a redacted header by server, so two servers don't collide", () => {
95
+ const s = site({
96
+ mcpServers: [
97
+ { name: "render", transport: "http", source: "/c", url: "https://r/mcp", extra: { headers: { Authorization: "Bearer rnd_aaaaaaaaaaaaaaaaa" } } },
98
+ { name: "xhawk", transport: "http", source: "/c", url: "https://x/mcp", extra: { headers: { Authorization: "Bearer xhk_bbbbbbbbbbbbbbbbb" } } },
99
+ ],
100
+ });
101
+ const servers = agentOf(s).properties.mcp_servers as Record<string, { headers: Record<string, string> }>;
102
+ expect(servers.render.headers.Authorization).toBe("${RENDER_AUTH_TOKEN}");
103
+ expect(servers.xhawk.headers.Authorization).toBe("${XHAWK_AUTH_TOKEN}");
104
+ });
105
+
106
+ test("does not double-prefix a key that already names its server", () => {
107
+ const s = site({
108
+ mcpServers: [{ name: "cleanjobdata", transport: "stdio", source: "/c", command: "x", env: { CLEANJOBDATA_API_KEY: "0123456789abcdefghijklmnopqrstuvwxyz" } }],
109
+ });
110
+ const servers = agentOf(s).properties.mcp_servers as Record<string, { env: Record<string, string> }>;
111
+ expect(servers.cleanjobdata.env.CLEANJOBDATA_API_KEY).toBe("${CLEANJOBDATA_API_KEY}");
112
+ });
113
+
114
+ test("leaves non-secret configuration alone", () => {
115
+ const s = site({ mcpServers: [{ name: "api", transport: "stdio", source: "/c", command: "x", env: { LOG_LEVEL: "debug" } }] });
116
+ const servers = agentOf(s).properties.mcp_servers as Record<string, { env: Record<string, string> }>;
117
+ expect(servers.api.env.LOG_LEVEL).toBe("debug");
118
+ expect(sitesToTemplateIR([s]).redactedSecrets).toEqual([]);
119
+ });
120
+
121
+ test("no generated output contains a value that looked like a credential", () => {
122
+ const s = site({
123
+ env: { GLOBAL_TOKEN: "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
124
+ mcpServers: [{ name: "a", transport: "http", source: "/c", url: "https://a/mcp", extra: { headers: { Authorization: "Bearer secret-value-here-000" } } }],
125
+ });
126
+ const [file] = new FountainGenerator().generate(sitesToTemplateIR([s]).ir);
127
+ expect(file.content).not.toContain("ghp_");
128
+ expect(file.content).not.toContain("secret-value-here");
129
+ });
130
+ });
131
+
132
+ describe("environment derivation", () => {
133
+ test("derives the egress allowlist from the config's own remote MCP hosts", () => {
134
+ expect(
135
+ derivedAllowedHosts([
136
+ { name: "a", transport: "http", source: "/c", url: "https://mcp.posthog.com/mcp" },
137
+ { name: "b", transport: "http", source: "/c", url: "https://mcp.render.com/mcp" },
138
+ { name: "c", transport: "stdio", source: "/c", command: "local" },
139
+ ]),
140
+ ).toEqual(["mcp.posthog.com", "mcp.render.com"]);
141
+ });
142
+
143
+ test("emits an Environment with explicit limited networking (FTN010)", () => {
144
+ const s = site({ mcpServers: [{ name: "a", transport: "http", source: "/c", url: "https://x.dev/mcp" }] });
145
+ const { ir } = sitesToTemplateIR([s]);
146
+ const env = ir.resources.find((r) => r.type === "Fountain::V1::Environment");
147
+ expect(env?.properties.networking_type).toBe("limited");
148
+ expect(env?.properties.networking_config).toEqual({ allowed_hosts: ["x.dev"] });
149
+ });
150
+
151
+ test("omits the Environment when there is nothing environmental to declare", () => {
152
+ const { ir } = sitesToTemplateIR([site()]);
153
+ expect(ir.resources.filter((r) => r.type === "Fountain::V1::Environment")).toEqual([]);
154
+ expect(ir.resources[0].properties.environment).toBeUndefined();
155
+ });
156
+
157
+ test("links the Agent to its Environment by reference, not by copy", () => {
158
+ const s = site({ env: { A: "1" } });
159
+ const [file] = new FountainGenerator().generate(sitesToTemplateIR([s]).ir);
160
+ // A typed ref renders as the variable name the Environment was declared under.
161
+ expect(file.content).toContain("environment: userClaudeEnv");
162
+ });
163
+ });
164
+
165
+ describe("system prompt", () => {
166
+ test("uses the single instruction file verbatim", () => {
167
+ expect(buildSystem(site({ instructions: [{ path: "/a", content: "be brief\n", bytes: 9 }] }))).toBe("be brief");
168
+ });
169
+
170
+ test("keeps per-file provenance when several files merge", () => {
171
+ const system = buildSystem(
172
+ site({ instructions: [{ path: "/a", content: "one", bytes: 3 }, { path: "/b", content: "two", bytes: 3 }] }),
173
+ );
174
+ expect(system).toContain("# /a");
175
+ expect(system).toContain("# /b");
176
+ });
177
+
178
+ test("is undefined when there are no instructions", () => {
179
+ expect(buildSystem(site())).toBeUndefined();
180
+ });
181
+ });
182
+
183
+ describe("skills", () => {
184
+ test("inlines a local skill's content, the only form that reproduces it elsewhere", () => {
185
+ expect(toSkills([{ name: "s", origin: "local", path: "/p", content: "do the thing" }])).toEqual([
186
+ { name: "s", content: "do the thing" },
187
+ ]);
188
+ });
189
+
190
+ test("keeps a remote skill as a source, with its ref when pinned", () => {
191
+ expect(toSkills([{ name: "s", origin: "marketplace", source: "o/r", ref: "v1" }])).toEqual([
192
+ { source: "o/r", name: "s", ref: "v1" },
193
+ ]);
194
+ });
195
+
196
+ test("drops a skill with neither content nor source rather than emitting one fountain would reject", () => {
197
+ expect(toSkills([{ name: "s", origin: "local" }])).toBeUndefined();
198
+ });
199
+ });
200
+
201
+ describe("end-to-end", () => {
202
+ test("generates compilable-shaped chant code with the ownership marker", () => {
203
+ const s = site({
204
+ model: "opus",
205
+ instructions: [{ path: "/home/u/CLAUDE.md", content: "be brief", bytes: 8 }],
206
+ mcpServers: [{ name: "posthog", transport: "http", source: "/c", url: "https://mcp.posthog.com/mcp" }],
207
+ skills: [{ name: "deploy", origin: "local", path: "/p", content: "steps" }],
208
+ });
209
+ const [file] = new FountainGenerator().generate(sitesToTemplateIR([s]).ir);
210
+ expect(file.path).toBe("main.ts");
211
+ expect(file.content).toContain('import { Agent, Environment } from "@intentius/chant-lexicon-fountain";');
212
+ expect(file.content).toContain("export const userClaude = new Agent({");
213
+ expect(file.content).toContain('"managed-by": "chant"');
214
+ expect(file.content).toContain('runtime: "claude"');
215
+ });
216
+ });
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Re-express a machine's local agent configuration as fountain resources.
3
+ *
4
+ * `chant audit --agents` answers "what is configured on this machine". This is
5
+ * the other half: taking that inventory and turning it into chant code, so a
6
+ * configuration that accumulated by hand over months becomes a reviewable,
7
+ * version-controlled, reproducible declaration.
8
+ *
9
+ * The mapping is close to 1:1 because fountain's `Agent` models the same four
10
+ * ideas the harnesses do:
11
+ *
12
+ * | local agent config | fountain |
13
+ * |-------------------------------|-----------------------|
14
+ * | CLAUDE.md / AGENTS.md | `Agent.system` |
15
+ * | `mcpServers` / `mcp_servers` | `Agent.mcp_servers` |
16
+ * | `skills/*​/SKILL.md` | `Agent.skills` |
17
+ * | settings `env` | `Environment.env_vars`|
18
+ *
19
+ * Three places where it is deliberately *not* a transcription:
20
+ *
21
+ * 1. **Egress is derived, not copied.** A local agent runs with the machine's
22
+ * full network access; a fountain sandbox must declare intent (FTN010). The
23
+ * hosts the config's own remote MCP servers use are exactly the egress it
24
+ * demonstrably needs, so those become the `allowed_hosts` allowlist and
25
+ * everything else is denied. Copying "unrestricted" would launder an
26
+ * implicit local permission into an explicit remote one.
27
+ * 2. **Secrets are not carried over.** A literal credential found in local
28
+ * config (AGT002) is rewritten to a `${VAR}` reference in the emitted code.
29
+ * Generated chant code gets committed; transcribing a live secret into it
30
+ * would turn a local mistake into a repository one.
31
+ * 3. **Skills are inlined by content where possible.** A local skill is text
32
+ * on this machine with no upstream, so `{name, content}` is the only form
33
+ * that reproduces it elsewhere. Remote skills keep `{source, ref}`.
34
+ *
35
+ * Cursor is discovered by the scanner but has no fountain `runtime` value, so
36
+ * its sites are reported as skipped rather than silently mapped onto a
37
+ * different agent runtime.
38
+ */
39
+
40
+ import type { ResourceIR } from "@intentius/chant/import/parser";
41
+ import type { AgentConfigSite, McpServerDecl, SkillDecl } from "@intentius/chant/agents";
42
+ import type { AgentImportOutcome, SkippedSite } from "@intentius/chant/agents/importer";
43
+
44
+ /** Runtimes fountain's `Agent.runtime` accepts. `cursor` is absent by design. */
45
+ export const MAPPABLE_RUNTIMES = ["claude", "codex", "gemini", "opencode"] as const;
46
+ export type MappableRuntime = (typeof MAPPABLE_RUNTIMES)[number];
47
+
48
+ /**
49
+ * Default model per runtime, used when the local config pins none.
50
+ *
51
+ * `Agent.model` is required by fountain, and most local configs leave the model
52
+ * to the harness's own default — a value that isn't written down anywhere this
53
+ * scanner can read. Emitting a documented default that the user edits is more
54
+ * honest than inventing a pin and calling it discovered; `unmappedModel` in the
55
+ * result reports every site this applied to.
56
+ */
57
+ export const DEFAULT_MODEL: Record<MappableRuntime, string> = {
58
+ claude: "anthropic/claude-sonnet-4-6",
59
+ codex: "openai/gpt-5.1-codex",
60
+ gemini: "google/gemini-2.5-pro",
61
+ opencode: "anthropic/claude-sonnet-4-6",
62
+ };
63
+
64
+ /**
65
+ * Local model aliases → fountain's `provider/model_id` form.
66
+ *
67
+ * Harnesses accept short names (`opus`, `sonnet`); fountain wants the canonical
68
+ * id. An alias not listed here passes through untouched — a user who pinned an
69
+ * exact provider-qualified model already has the right shape.
70
+ */
71
+ const MODEL_ALIASES: Record<string, string> = {
72
+ opus: "anthropic/claude-opus-4-6",
73
+ sonnet: "anthropic/claude-sonnet-4-6",
74
+ haiku: "anthropic/claude-haiku-4-5",
75
+ "gpt-5.5": "openai/gpt-5.5",
76
+ "gpt-5.1-codex": "openai/gpt-5.1-codex",
77
+ };
78
+
79
+ function isMappable(runtime: string): runtime is MappableRuntime {
80
+ return (MAPPABLE_RUNTIMES as readonly string[]).includes(runtime);
81
+ }
82
+
83
+ /** A cross-resource reference the fountain generator renders as a bare variable. */
84
+ function ref(logicalId: string): { __ref: string } {
85
+ return { __ref: logicalId };
86
+ }
87
+
88
+ /**
89
+ * Build the `system` prompt from the site's instruction files.
90
+ *
91
+ * Provenance is kept as a comment header per file. A user reading the generated
92
+ * code needs to know which of their three CLAUDE.md files a paragraph came
93
+ * from, and a single concatenated blob without headers makes that unrecoverable.
94
+ */
95
+ export function buildSystem(site: AgentConfigSite): string | undefined {
96
+ if (site.instructions.length === 0) return undefined;
97
+ if (site.instructions.length === 1) return site.instructions[0].content.trim();
98
+ return site.instructions.map((f) => `# ${f.path}\n\n${f.content.trim()}`).join("\n\n---\n\n");
99
+ }
100
+
101
+ /** Env-var reference form for a value that must not be inlined. */
102
+ function envRef(key: string): string {
103
+ return `\${${key}}`;
104
+ }
105
+
106
+ /** Value shapes that are already indirection rather than a literal. */
107
+ const INDIRECT = /^\s*(?:\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|\$\(.+\))\s*$/;
108
+ const SECRET_KEY = /(?:^|_)(?:token|secret|password|passwd|api_?key|access_?key|credential|private_?key)s?(?:$|_)/i;
109
+ const DIGEST_KEY = /(?:sha\d*|checksum|digest|fingerprint|thumbprint|hash|etag)/i;
110
+
111
+ /**
112
+ * Header and field names that carry a credential without ever saying "token".
113
+ *
114
+ * `Authorization` is the important one: it is where remote MCP servers put
115
+ * their bearer tokens, it matches none of the "looks like a secret" key
116
+ * heuristics, and it is the single most likely thing in an agent config to be a
117
+ * live credential.
118
+ */
119
+ const AUTH_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|apikey|x-auth-token|auth)$/i;
120
+
121
+ /** An HTTP authorization value: a scheme followed by the credential itself. */
122
+ const AUTH_SCHEME_VALUE = /^\s*(?:Bearer|Basic|Token|ApiKey)\s+\S+/i;
123
+
124
+ /**
125
+ * The env-var name a redacted value is replaced with. Namespaced by the thing
126
+ * it belongs to, so two servers' `Authorization` headers don't collapse onto
127
+ * one variable that can only hold one of them.
128
+ */
129
+ function secretVarName(context: string, key: string): string {
130
+ const norm = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
131
+ const scope = norm(context);
132
+ const name = norm(key);
133
+ // A header like `Authorization` says nothing on its own, so it takes the
134
+ // server's name. A key that already carries it (`CLEANJOBDATA_API_KEY` on the
135
+ // `cleanjobdata` server) is left alone rather than doubled.
136
+ if (AUTH_KEY.test(key)) return `${scope}_AUTH_TOKEN`;
137
+ return name.includes(scope) || scope.includes(name) ? name : `${scope}_${name}`;
138
+ }
139
+
140
+ /**
141
+ * Decide whether a value is a literal credential that must not be written to
142
+ * disk, and if so, what to replace it with.
143
+ *
144
+ * Deliberately more eager than the audit check: this decides what gets written
145
+ * into a file destined for version control, so a false positive costs the user
146
+ * one edit while a false negative commits a live secret.
147
+ */
148
+ function redactedValue(key: string, value: string, context: string): string | undefined {
149
+ if (INDIRECT.test(value)) return undefined;
150
+ if (DIGEST_KEY.test(key)) return undefined;
151
+ if (AUTH_KEY.test(key) || AUTH_SCHEME_VALUE.test(value)) return envRef(secretVarName(context, key));
152
+ if (SECRET_KEY.test(key) || value.length >= 32) return envRef(secretVarName(context, key));
153
+ return undefined;
154
+ }
155
+
156
+ /** Replace literal credential values in a flat string map with `${VAR}` references. */
157
+ function redactEnv(
158
+ env: Record<string, string> | undefined,
159
+ context: string,
160
+ onRedact: () => void,
161
+ ): Record<string, string> | undefined {
162
+ if (!env) return undefined;
163
+ const out: Record<string, string> = {};
164
+ for (const [key, value] of Object.entries(env)) {
165
+ const replacement = redactedValue(key, value, context);
166
+ if (replacement !== undefined) {
167
+ out[key] = replacement;
168
+ onRedact();
169
+ } else {
170
+ out[key] = value;
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Redact credentials anywhere in a nested structure.
178
+ *
179
+ * The passthrough fields a harness accepts (`headers`, auth blocks, whatever a
180
+ * future version adds) are exactly the ones this model does not name, so
181
+ * redaction cannot be a fixed list of keys — it has to walk whatever is there.
182
+ * Everything non-credential is preserved verbatim.
183
+ */
184
+ function redactDeep(value: unknown, key: string, context: string, onRedact: () => void): unknown {
185
+ if (typeof value === "string") {
186
+ const replacement = redactedValue(key, value, context);
187
+ if (replacement === undefined) return value;
188
+ onRedact();
189
+ return replacement;
190
+ }
191
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, key, context, onRedact));
192
+ if (value && typeof value === "object") {
193
+ const out: Record<string, unknown> = {};
194
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
195
+ out[k] = redactDeep(v, k, context, onRedact);
196
+ }
197
+ return out;
198
+ }
199
+ return value;
200
+ }
201
+
202
+ /** Project the normalized MCP declarations back into fountain's `mcp_servers` map. */
203
+ export function toMcpServers(servers: McpServerDecl[], onRedact: () => void): Record<string, unknown> | undefined {
204
+ if (servers.length === 0) return undefined;
205
+ const out: Record<string, unknown> = {};
206
+ for (const server of servers) {
207
+ const entry: Record<string, unknown> = {};
208
+ if (server.command) entry.command = server.command;
209
+ if (server.args?.length) entry.args = server.args;
210
+ if (server.url) entry.url = server.url;
211
+ if (server.transport === "sse" || server.transport === "http") entry.type = server.transport;
212
+ const env = redactEnv(server.env, server.name, onRedact);
213
+ if (env && Object.keys(env).length > 0) entry.env = env;
214
+ // `extra` is where `headers` (and anything else the harness accepted that
215
+ // this model doesn't name) lands — so it gets the same redaction as `env`,
216
+ // not a verbatim copy.
217
+ if (server.extra) {
218
+ for (const [key, value] of Object.entries(server.extra)) {
219
+ entry[key] = redactDeep(value, key, server.name, onRedact);
220
+ }
221
+ }
222
+ out[server.name] = entry;
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * Project skills into fountain's two accepted forms.
229
+ *
230
+ * fountain requires exactly one of `content` or `source` per entry. A local
231
+ * skill has no upstream to install from, so its text is inlined — that is what
232
+ * makes the emitted code reproduce the configuration on a machine that has
233
+ * never seen this one.
234
+ */
235
+ export function toSkills(skills: SkillDecl[]): Record<string, unknown>[] | undefined {
236
+ if (skills.length === 0) return undefined;
237
+ const out: Record<string, unknown>[] = [];
238
+ for (const skill of skills) {
239
+ if (skill.source) {
240
+ out.push({ source: skill.source, name: skill.name, ...(skill.ref ? { ref: skill.ref } : {}) });
241
+ } else if (skill.content) {
242
+ out.push({ name: skill.name, content: skill.content });
243
+ }
244
+ // A skill with neither is unreproducible; it is left out rather than
245
+ // emitted as an entry fountain would reject at apply time.
246
+ }
247
+ return out.length > 0 ? out : undefined;
248
+ }
249
+
250
+ /**
251
+ * Hosts the config's own remote MCP servers reach.
252
+ *
253
+ * This is the evidence-based egress allowlist: every host here is one the
254
+ * configuration already talks to, so the sandbox stays functional while
255
+ * everything else stays denied.
256
+ */
257
+ export function derivedAllowedHosts(servers: McpServerDecl[]): string[] {
258
+ const hosts = new Set<string>();
259
+ for (const server of servers) {
260
+ if (!server.url) continue;
261
+ try {
262
+ hosts.add(new URL(server.url).hostname);
263
+ } catch {
264
+ // A malformed URL contributes no host — the server simply isn't reachable
265
+ // from the sandbox until someone adds the right one by hand.
266
+ }
267
+ }
268
+ return [...hosts].sort();
269
+ }
270
+
271
+ /** Canonicalize a local model name into fountain's `provider/model_id` form. */
272
+ export function canonicalModel(local: string | undefined, runtime: MappableRuntime): { model: string; defaulted: boolean } {
273
+ if (!local) return { model: DEFAULT_MODEL[runtime], defaulted: true };
274
+ const alias = MODEL_ALIASES[local.toLowerCase()];
275
+ if (alias) return { model: alias, defaulted: false };
276
+ return { model: local, defaulted: false };
277
+ }
278
+
279
+ /**
280
+ * Convert discovered agent config sites into fountain import IR.
281
+ *
282
+ * Each mappable site yields an `Agent`, plus an `Environment` when it has
283
+ * anything environmental to declare (env vars, or remote MCP hosts to
284
+ * allowlist). Feed the result to `FountainGenerator` to get chant TypeScript.
285
+ */
286
+ export function sitesToTemplateIR(sites: AgentConfigSite[]): AgentImportOutcome {
287
+ const resources: ResourceIR[] = [];
288
+ const skipped: SkippedSite[] = [];
289
+ const unmappedModel: string[] = [];
290
+ const redactedSecrets: string[] = [];
291
+
292
+ for (const site of sites) {
293
+ if (!isMappable(site.runtime)) {
294
+ skipped.push({
295
+ siteId: site.id,
296
+ reason: `fountain has no "${site.runtime}" runtime — its Agent.runtime accepts ${MAPPABLE_RUNTIMES.join(", ")}. The config was audited but not re-expressed.`,
297
+ });
298
+ continue;
299
+ }
300
+
301
+ let redacted = false;
302
+ const onRedact = () => {
303
+ redacted = true;
304
+ };
305
+
306
+ const { model, defaulted } = canonicalModel(site.model, site.runtime);
307
+ if (defaulted) unmappedModel.push(site.id);
308
+
309
+ const allowedHosts = derivedAllowedHosts(site.mcpServers);
310
+ const envVars = redactEnv(site.env, site.id, onRedact) ?? {};
311
+ const hasEnvironment = Object.keys(envVars).length > 0 || allowedHosts.length > 0;
312
+
313
+ const metadata: Record<string, unknown> = {
314
+ "managed-by": "chant",
315
+ "chant.io/imported-from": "local-agent-config",
316
+ "chant.io/scope": site.scope,
317
+ "chant.io/runtime": site.runtime,
318
+ "chant.io/root": site.root,
319
+ };
320
+
321
+ let environmentId: string | undefined;
322
+ if (hasEnvironment) {
323
+ environmentId = `${site.id}-env`;
324
+ resources.push({
325
+ logicalId: environmentId,
326
+ type: "Fountain::V1::Environment",
327
+ properties: {
328
+ name: environmentId,
329
+ // FTN010: intent must be explicit. `limited` with a derived
330
+ // allowlist — an empty list is deny-all, which is the right default
331
+ // for a config whose network needs we could not observe.
332
+ networking_type: "limited",
333
+ networking_config: { allowed_hosts: allowedHosts },
334
+ ...(Object.keys(envVars).length > 0 ? { env_vars: envVars } : {}),
335
+ metadata,
336
+ },
337
+ });
338
+ }
339
+
340
+ const mcpServers = toMcpServers(site.mcpServers, onRedact);
341
+ const skills = toSkills(site.skills);
342
+ const system = buildSystem(site);
343
+
344
+ resources.push({
345
+ logicalId: site.id,
346
+ type: "Fountain::V1::Agent",
347
+ properties: {
348
+ name: site.id,
349
+ model,
350
+ runtime: site.runtime,
351
+ ...(environmentId ? { environment: ref(environmentId) } : {}),
352
+ ...(system !== undefined ? { system } : {}),
353
+ ...(mcpServers ? { mcp_servers: mcpServers } : {}),
354
+ ...(skills ? { skills } : {}),
355
+ description: `Imported from ${site.scope}-scope ${site.runtime} configuration at ${site.root}`,
356
+ metadata,
357
+ },
358
+ });
359
+
360
+ if (redacted) redactedSecrets.push(site.id);
361
+ }
362
+
363
+ return { ir: { resources, parameters: [] }, skipped, unmappedModel, redactedSecrets };
364
+ }
package/src/plugin.ts CHANGED
@@ -18,6 +18,7 @@ import { detectFountainTemplate } from "./detect";
18
18
  import { fountainInitTemplates } from "./init-templates";
19
19
  import { FountainParser } from "./import/parser";
20
20
  import { FountainGenerator } from "./import/generator";
21
+ import { sitesToTemplateIR } from "./import/local-agents";
21
22
  import { completions } from "./lsp/completions";
22
23
  import { hover } from "./lsp/hover";
23
24
 
@@ -156,6 +157,10 @@ export const fountainPlugin: LexiconPlugin = {
156
157
  return new FountainGenerator();
157
158
  },
158
159
 
160
+ agentConfigImporter() {
161
+ return { toTemplateIR: sitesToTemplateIR };
162
+ },
163
+
159
164
  async exportResources(options) {
160
165
  const { exportResources } = await import("./export-resources");
161
166
  return exportResources(options);