@intentius/chant 0.52.1 → 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.
Files changed (65) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cli/commands/audit-agents.d.ts +83 -0
  17. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  18. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  19. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  20. package/dist/cli/commands/import-agents.d.ts +64 -0
  21. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  22. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  23. package/dist/cli/handlers/misc.d.ts.map +1 -1
  24. package/dist/cli/main.d.ts.map +1 -1
  25. package/dist/cli/registry.d.ts +15 -0
  26. package/dist/cli/registry.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +11 -0
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/terraform/adopt-state.d.ts +4 -1
  30. package/dist/terraform/adopt-state.d.ts.map +1 -1
  31. package/dist/terraform/bridge.d.ts.map +1 -1
  32. package/dist/terraform/tier-map.d.ts +17 -0
  33. package/dist/terraform/tier-map.d.ts.map +1 -1
  34. package/dist/yaml.d.ts.map +1 -1
  35. package/package.json +6 -1
  36. package/src/agents/checks.test.ts +228 -0
  37. package/src/agents/checks.ts +429 -0
  38. package/src/agents/discover.test.ts +310 -0
  39. package/src/agents/discover.ts +939 -0
  40. package/src/agents/importer.ts +49 -0
  41. package/src/agents/index.ts +29 -0
  42. package/src/agents/types.ts +207 -0
  43. package/src/audit/catalog.ts +90 -1
  44. package/src/audit/report.ts +9 -1
  45. package/src/audit/rules-doc.ts +6 -0
  46. package/src/cli/commands/audit-agents.test.ts +260 -0
  47. package/src/cli/commands/audit-agents.ts +387 -0
  48. package/src/cli/commands/carve-bridge.test.ts +30 -0
  49. package/src/cli/commands/carve-bridge.ts +19 -1
  50. package/src/cli/commands/carve-emit.test.ts +72 -1
  51. package/src/cli/commands/carve-emit.ts +43 -20
  52. package/src/cli/commands/import-agents.test.ts +208 -0
  53. package/src/cli/commands/import-agents.ts +196 -0
  54. package/src/cli/handlers/carve-emit.ts +2 -0
  55. package/src/cli/handlers/misc.ts +111 -0
  56. package/src/cli/main.ts +17 -0
  57. package/src/cli/registry.ts +15 -0
  58. package/src/lexicon.ts +12 -0
  59. package/src/terraform/adopt-state.ts +7 -4
  60. package/src/terraform/aws-resources.test.ts +24 -1
  61. package/src/terraform/bridge.test.ts +12 -0
  62. package/src/terraform/bridge.ts +4 -1
  63. package/src/terraform/tier-map.ts +28 -1
  64. package/src/yaml.test.ts +54 -0
  65. package/src/yaml.ts +24 -3
@@ -14,7 +14,7 @@ import { join, basename, relative, resolve } from "path";
14
14
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
15
15
  import { boundaryReport, type CarveReport } from "../../terraform/carve";
16
16
  import { generateBridge, type BridgePlan, type CarvedIdentity } from "../../terraform/bridge";
17
- import { IDENTITY_ATTR } from "../../terraform/tier-map";
17
+ import { IDENTITY_ATTR, canBridge } from "../../terraform/tier-map";
18
18
  import { resolveCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
19
19
  import { newFileDiff, unifiedDiff } from "../../terraform/unified-diff";
20
20
 
@@ -74,6 +74,24 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
74
74
  if (!found) return { ok: false, error: `${select} not found in ${opts.from}` };
75
75
  report = found;
76
76
 
77
+ // Refuse a carve set whose data source cannot be written as valid HCL: a
78
+ // dotted identity attribute is a path into nested blocks, and a data body
79
+ // is flat `attr = value`. `kubernetes_manifest` is the standing case — the
80
+ // provider has no `kubernetes_manifest` data source either; its generic
81
+ // read is `kubernetes_resource`, a different shape (#999).
82
+ const carvedTypes = report.carveSet.map((m) => m.type).filter((t): t is string => t !== undefined);
83
+ const unbridgeable = [...new Set(carvedTypes.filter((t) => !canBridge(t)))];
84
+ if (unbridgeable.length) {
85
+ return {
86
+ ok: false,
87
+ error:
88
+ `${select} cannot be bridged: ${unbridgeable
89
+ .map((t) => `${t} identifies itself by \`${IDENTITY_ATTR[t]}\``)
90
+ .join(", ")}, a path into nested blocks that a Terraform data source body cannot express. ` +
91
+ `Bridging these types needs a data-source mapping — see chant issue #999.`,
92
+ };
93
+ }
94
+
77
95
  // Physical identities for the carved resources, for the data sources.
78
96
  const identities = new Map<string, CarvedIdentity>();
79
97
  for (const node of graph.nodes) {
@@ -1,9 +1,10 @@
1
1
  import { describe, test, expect, vi } from "vitest";
2
- import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
2
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { carveEmit, formatCarveEmit } from "./carve-emit";
6
6
  import { loadHcl2json } from "../../terraform/parse";
7
+ import { listCarveManifests } from "../../terraform/manifest";
7
8
  import type { ImportResult, LiveImportOptions } from "./import";
8
9
  import type { LexiconPlugin } from "../../lexicon";
9
10
 
@@ -35,12 +36,30 @@ resource "aws_lambda_function" "api" {
35
36
  resource "random_pet" "suffix" {
36
37
  length = 2
37
38
  }
39
+ resource "kubernetes_config_map" "demo" {
40
+ metadata { name = "demo-config" }
41
+ }
38
42
  `;
39
43
 
44
+ // A state file for the bucket, so the --state path reaches the same gate the
45
+ // --env path does rather than stopping at "not found in state".
46
+ const TFSTATE = JSON.stringify({
47
+ version: 4,
48
+ resources: [
49
+ {
50
+ mode: "managed",
51
+ type: "kubernetes_config_map",
52
+ name: "demo",
53
+ instances: [{ attributes: { id: "default/demo-config" } }],
54
+ },
55
+ ],
56
+ });
57
+
40
58
  async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
41
59
  const dir = mkdtempSync(join(tmpdir(), "chant-carve-emit-"));
42
60
  try {
43
61
  writeFileSync(join(dir, "main.tf"), ESTATE);
62
+ writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
44
63
  return await fn(dir);
45
64
  } finally {
46
65
  rmSync(dir, { recursive: true, force: true });
@@ -142,6 +161,58 @@ describe("carveEmit — emit + boundary", () => {
142
161
  });
143
162
  });
144
163
 
164
+ test("a failed live import fails the emit and writes no carve manifest (#2015)", async () => {
165
+ if (!parserAvailable) return;
166
+ await withEstate(async (dir) => {
167
+ const out = join(dir, "carveout");
168
+ const li = vi.fn(
169
+ async (_p: LexiconPlugin[], _o: LiveImportOptions): Promise<ImportResult> => ({
170
+ success: false,
171
+ generatedFiles: [],
172
+ warnings: [],
173
+ error: "no stack in prod exports AWS::S3::Bucket",
174
+ }),
175
+ );
176
+ const res = await carveEmit(
177
+ { from: dir, select: "aws_s3_bucket.assets", env: "prod", output: out },
178
+ { plugins: noPlugins, liveImport: li },
179
+ );
180
+
181
+ expect(res.ok).toBe(false);
182
+ expect(res.error).toContain("no stack in prod exports AWS::S3::Bucket");
183
+ expect(res.manifestPath).toBeUndefined();
184
+ // Bridge/apply compose from the manifest; one written here would make
185
+ // bridge excise a `.tf` block for a resource that was never emitted.
186
+ expect(listCarveManifests(out)).toEqual([]);
187
+ expect(existsSync(out)).toBe(false);
188
+ // No false "Adopted live" line on the failure path.
189
+ expect(formatCarveEmit(res)).not.toContain("Adopted live");
190
+ });
191
+ });
192
+
193
+ test("a type emit cannot carve is refused identically on --state and --env (#2015)", async () => {
194
+ if (!parserAvailable) return;
195
+ await withEstate(async (dir) => {
196
+ const li = fakeImport();
197
+ const live = await carveEmit(
198
+ { from: dir, select: "kubernetes_config_map.demo", env: "prod" },
199
+ { plugins: noPlugins, liveImport: li },
200
+ );
201
+ const state = await carveEmit(
202
+ { from: dir, select: "kubernetes_config_map.demo", statePath: join(dir, "terraform.tfstate") },
203
+ { plugins: noPlugins, liveImport: li },
204
+ );
205
+
206
+ expect(live.ok).toBe(false);
207
+ expect(state.ok).toBe(false);
208
+ expect(live.error).toBe(state.error);
209
+ expect(live.error).toContain("kubernetes_config_map cannot be emitted yet");
210
+ // advise ranks it (tier 2), so it gets past resolveTier — the emit gate
211
+ // is what refuses it, before the AWS exporter is ever reached.
212
+ expect(li).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+
145
216
  test("--live-name narrows the live selector to a CFN logical id", async () => {
146
217
  if (!parserAvailable) return;
147
218
  await withEstate(async (dir) => {
@@ -16,20 +16,22 @@ import { existsSync, statSync, writeFileSync, mkdirSync } from "fs";
16
16
  import { basename, join, resolve } from "path";
17
17
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
18
18
  import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
19
- import { resolveTier } from "../../terraform/tier-map";
19
+ import { canCarveEmit, carveEmitTypes, resolveTier } from "../../terraform/tier-map";
20
20
  import { readStateResource, type StateResource } from "../../terraform/state";
21
21
  import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
22
- import {
23
- adoptFromState,
24
- canAdoptFromState,
25
- supportedStateAdoptionTypes,
26
- type DeferredParam,
27
- type FoldedContribution,
28
- } from "../../terraform/adopt-state";
22
+ import { adoptFromState, type DeferredParam, type FoldedContribution } from "../../terraform/adopt-state";
29
23
  import { getChantVersion } from "./init";
30
24
  import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
31
25
  import type { ImportResult, LiveImportOptions } from "./import";
32
26
 
27
+ /**
28
+ * The lexicon emit targets. `canCarveEmit` admits only AWS carve-table types,
29
+ * so both the scaffolded project and the live import are AWS. Widening this
30
+ * needs the lexicon-agnostic emit seam (#999, #2016) — deriving it from
31
+ * `tier.mapsTo` does not work: a `k8s:Namespace` mapping has no `::` to split.
32
+ */
33
+ const EMIT_LEXICON = "aws";
34
+
33
35
  export interface CarveEmitOptions {
34
36
  /** Terraform estate directory (`--from`). */
35
37
  from?: string;
@@ -112,19 +114,24 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
112
114
  };
113
115
  }
114
116
 
117
+ // `carve advise` ranks every type in the tier map; emit produces source only
118
+ // for the ones with a native constructor mapping. Both adoption paths refuse
119
+ // the rest here — before any file is written or any cloud call is made — so
120
+ // neither path can proceed on a type the other rejects.
121
+ if (!canCarveEmit(tfType!)) {
122
+ return {
123
+ ok: false,
124
+ error:
125
+ `${tfType} cannot be emitted yet (no native constructor mapping).\n` +
126
+ `Supported types: ${carveEmitTypes().join(", ")}. ` +
127
+ `Coverage is expanding — see chant issue #1001.`,
128
+ };
129
+ }
130
+
115
131
  if (opts.reportFile) writeFileSync(opts.reportFile, JSON.stringify(report, null, 2));
116
132
 
117
133
  // ── Adoption path 1: from .tfstate (offline, correct for TF-managed) ──
118
134
  if (opts.statePath) {
119
- if (!canAdoptFromState(tfType!)) {
120
- return {
121
- ok: false,
122
- error:
123
- `${tfType} cannot be adopted from state yet (no native constructor mapping).\n` +
124
- `Supported types: ${supportedStateAdoptionTypes().join(", ")}. ` +
125
- `Coverage is expanding — see chant issue #1001.`,
126
- };
127
- }
128
135
  const stateResource = readStateResource(opts.statePath, opts.select);
129
136
  if (!stateResource) {
130
137
  return { ok: false, error: `${opts.select} not found in state ${opts.statePath} (or is a data source / module-nested).` };
@@ -149,8 +156,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
149
156
  mkdirSync(srcDir, { recursive: true });
150
157
  const outPath = join(srcDir, adopted.fileName);
151
158
  writeFileSync(outPath, adopted.content);
152
- const lexicon = tier.mapsTo.split("::")[0]?.toLowerCase() ?? "aws";
153
- const scaffolded = scaffoldProject(outDir, lexicon, params);
159
+ const scaffolded = scaffoldProject(outDir, EMIT_LEXICON, params);
154
160
 
155
161
  const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
156
162
  return {
@@ -178,9 +184,26 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
178
184
  environment: opts.env!,
179
185
  selector,
180
186
  output: opts.output,
181
- lexicon: tier.mapsTo.split("::")[0]?.toLowerCase() === "aws" ? "aws" : undefined,
187
+ lexicon: EMIT_LEXICON,
182
188
  });
183
189
 
190
+ // A failed import emitted no source. Reporting success here would write a
191
+ // carve manifest that `carve bridge` composes from, and bridge excises the
192
+ // survivor's `.tf` block for the resource — so the estate would lose a
193
+ // declaration that was never adopted. Fail, and persist nothing.
194
+ if (!emit.success) {
195
+ return {
196
+ ok: false,
197
+ error:
198
+ `Live import of ${selector.type} from ${opts.env} failed: ${emit.error ?? "no error reported"}\n` +
199
+ `Nothing was emitted and no carve manifest was written.`,
200
+ report,
201
+ emit,
202
+ selector,
203
+ source: "live",
204
+ };
205
+ }
206
+
184
207
  const outDir = opts.output ?? join(opts.from, "carveout");
185
208
  const manifestPath = persistManifest(outDir, opts, report, tfType, "live", emit.generatedFiles ?? []);
186
209
  return { ok: true, report, emit, selector, source: "live", emittedFiles: emit.generatedFiles, manifestPath };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The import command's own contract, separate from the mapping it delegates to
3
+ * (covered by the fountain lexicon's `local-agents.test.ts`).
4
+ *
5
+ * What matters here is the parts a user hits when something is off: a lexicon
6
+ * that can't be loaded or can't express agents, an output directory that
7
+ * already has files in it, and — above all — that every lossy step the mapper
8
+ * reports actually reaches the user. Re-expression silently dropping a config
9
+ * or defaulting a required field is the failure mode that would make the
10
+ * generated code untrustworthy.
11
+ *
12
+ * The plugin is injected rather than loaded, so these run without a built
13
+ * lexicon on disk.
14
+ */
15
+
16
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
17
+ import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
18
+ import { join } from "path";
19
+ import { tmpdir } from "os";
20
+ import { importAgentsCommand, DEFAULT_AGENT_LEXICON, type PluginLoader } from "./import-agents";
21
+ import type { LexiconPlugin } from "../../lexicon";
22
+ import type { AgentImportOutcome } from "../../agents/importer";
23
+ import type { TemplateIR } from "../../import/parser";
24
+
25
+ let home: string;
26
+ let out: string;
27
+
28
+ function writeIn(root: string, rel: string, content: string): void {
29
+ const full = join(root, rel);
30
+ mkdirSync(join(full, ".."), { recursive: true });
31
+ writeFileSync(full, content, "utf-8");
32
+ }
33
+
34
+ beforeEach(() => {
35
+ home = join(tmpdir(), `chant-ia-home-${Math.random().toString(36).slice(2)}`);
36
+ out = join(tmpdir(), `chant-ia-out-${Math.random().toString(36).slice(2)}`);
37
+ mkdirSync(home, { recursive: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ rmSync(home, { recursive: true, force: true });
42
+ rmSync(out, { recursive: true, force: true });
43
+ });
44
+
45
+ /** A config with one MCP server, so the scan finds something to import. */
46
+ function seedConfig(): void {
47
+ writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { a: { command: "x" } } }));
48
+ }
49
+
50
+ const EMPTY_OUTCOME: AgentImportOutcome = { ir: { resources: [], parameters: [] }, skipped: [], unmappedModel: [], redactedSecrets: [] };
51
+
52
+ /** A plugin whose importer and generator are both stubbed. */
53
+ function fakePlugin(outcome: Partial<AgentImportOutcome> = {}, overrides: Partial<LexiconPlugin> = {}): LexiconPlugin {
54
+ const resolved: AgentImportOutcome = { ...EMPTY_OUTCOME, ...outcome };
55
+ return {
56
+ name: "fountain",
57
+ agentConfigImporter: () => ({ toTemplateIR: () => resolved }),
58
+ templateGenerator: () => ({
59
+ generate: (ir: TemplateIR) => [{ path: "main.ts", content: ir.resources.map((r) => `export const ${r.logicalId} = {};`).join("\n") }],
60
+ }),
61
+ ...overrides,
62
+ } as unknown as LexiconPlugin;
63
+ }
64
+
65
+ /** An outcome with one resource, so the command reaches the write step. */
66
+ const ONE_RESOURCE: Partial<AgentImportOutcome> = {
67
+ ir: { resources: [{ logicalId: "userClaude", type: "Fountain::V1::Agent", properties: {} }], parameters: [] },
68
+ };
69
+
70
+ const loaderFor = (plugin: LexiconPlugin): PluginLoader => async () => plugin;
71
+
72
+ const run = (opts: Parameters<typeof importAgentsCommand>[0] = {}) =>
73
+ importAgentsCommand({ home, platform: "linux", projectRoots: [], output: out, pluginLoader: loaderFor(fakePlugin(ONE_RESOURCE)), ...opts });
74
+
75
+ describe("nothing to import", () => {
76
+ test("fails with a clear message when no agent config exists", async () => {
77
+ const result = await run();
78
+ expect(result.success).toBe(false);
79
+ expect(result.error).toContain("No agent configuration found");
80
+ expect(result.generatedFiles).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe("lexicon resolution", () => {
85
+ test("defaults to the fountain lexicon", async () => {
86
+ seedConfig();
87
+ let asked: string | undefined;
88
+ await run({ pluginLoader: async (name) => { asked = name; return fakePlugin(ONE_RESOURCE); } });
89
+ expect(asked).toBe(DEFAULT_AGENT_LEXICON);
90
+ });
91
+
92
+ test("reports an install hint when the lexicon package is missing", async () => {
93
+ seedConfig();
94
+ const result = await run({
95
+ lexicon: "nope",
96
+ pluginLoader: async () => { throw new Error("Cannot find module"); },
97
+ });
98
+ expect(result.success).toBe(false);
99
+ expect(result.error).toContain("npm i @intentius/chant-lexicon-nope");
100
+ });
101
+
102
+ test("names the fallback when the lexicon cannot express agent config", async () => {
103
+ seedConfig();
104
+ const result = await run({ lexicon: "k8s", pluginLoader: loaderFor(fakePlugin({}, { agentConfigImporter: undefined })) });
105
+ expect(result.success).toBe(false);
106
+ expect(result.error).toContain("cannot express agent configuration");
107
+ expect(result.error).toContain(`--lexicon ${DEFAULT_AGENT_LEXICON}`);
108
+ });
109
+
110
+ test("fails when the lexicon has no generator to emit TypeScript", async () => {
111
+ seedConfig();
112
+ const result = await run({ pluginLoader: loaderFor(fakePlugin(ONE_RESOURCE, { templateGenerator: undefined })) });
113
+ expect(result.success).toBe(false);
114
+ expect(result.error).toContain("no templateGenerator");
115
+ });
116
+ });
117
+
118
+ describe("reporting what was lost", () => {
119
+ test("surfaces a skipped site with the mapper's reason", async () => {
120
+ seedConfig();
121
+ const result = await run({
122
+ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, skipped: [{ siteId: "user-cursor", reason: 'fountain has no "cursor" runtime' }] })),
123
+ });
124
+ expect(result.warnings.some((w) => w.includes("user-cursor") && w.includes("cursor"))).toBe(true);
125
+ });
126
+
127
+ test("warns that a defaulted model needs editing before it is applied", async () => {
128
+ seedConfig();
129
+ const result = await run({ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, unmappedModel: ["user-claude"] })) });
130
+ expect(result.warnings.some((w) => w.includes("user-claude") && w.includes("Edit it before applying"))).toBe(true);
131
+ });
132
+
133
+ test("states that redacted credentials were not copied into the generated code", async () => {
134
+ // The user has to know a secret was *removed*, not carried over — otherwise
135
+ // they'd assume the generated config works as-is.
136
+ seedConfig();
137
+ const result = await run({ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, redactedSecrets: ["user-claude"] })) });
138
+ const warning = result.warnings.find((w) => w.includes("Literal credentials"));
139
+ expect(warning).toContain("were NOT copied");
140
+ });
141
+
142
+ test("fails, rather than writing an empty file, when nothing could be mapped", async () => {
143
+ seedConfig();
144
+ const result = await run({ pluginLoader: loaderFor(fakePlugin({ skipped: [{ siteId: "user-cursor", reason: "no runtime" }] })) });
145
+ expect(result.success).toBe(false);
146
+ expect(result.error).toContain("none could be expressed");
147
+ expect(existsSync(join(out, "main.ts"))).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("writing output", () => {
152
+ test("writes the generated files and reports what it counted", async () => {
153
+ seedConfig();
154
+ const result = await run();
155
+ expect(result.success).toBe(true);
156
+ expect(result.generatedFiles).toEqual([join(out, "main.ts")]);
157
+ expect(readFileSync(join(out, "main.ts"), "utf-8")).toContain("export const userClaude");
158
+ expect(result.summary).toEqual({ discovered: 1, mapped: 1 });
159
+ });
160
+
161
+ test("refuses to overwrite an existing file without --force", async () => {
162
+ // Generated agent code gets hand-edited after the first run; silently
163
+ // reverting those edits would be the worst outcome for this command.
164
+ seedConfig();
165
+ mkdirSync(out, { recursive: true });
166
+ writeFileSync(join(out, "main.ts"), "// hand-edited", "utf-8");
167
+
168
+ const result = await run();
169
+ expect(result.success).toBe(false);
170
+ expect(result.error).toContain("--force");
171
+ expect(readFileSync(join(out, "main.ts"), "utf-8")).toBe("// hand-edited");
172
+ });
173
+
174
+ test("overwrites with --force", async () => {
175
+ seedConfig();
176
+ mkdirSync(out, { recursive: true });
177
+ writeFileSync(join(out, "main.ts"), "// hand-edited", "utf-8");
178
+
179
+ const result = await run({ force: true });
180
+ expect(result.success).toBe(true);
181
+ expect(readFileSync(join(out, "main.ts"), "utf-8")).toContain("export const userClaude");
182
+ });
183
+
184
+ test("creates the output directory when it does not exist", async () => {
185
+ seedConfig();
186
+ const nested = join(out, "deep", "nested");
187
+ const result = await run({ output: nested });
188
+ expect(result.success).toBe(true);
189
+ expect(existsSync(join(nested, "main.ts"))).toBe(true);
190
+ });
191
+ });
192
+
193
+ describe("scope and runtime filtering", () => {
194
+ test("passes the scope filter through to the scan", async () => {
195
+ seedConfig();
196
+ // user scope holds the config; restricting to project finds nothing.
197
+ const result = await run({ scopes: ["project"] });
198
+ expect(result.success).toBe(false);
199
+ expect(result.error).toContain("No agent configuration found");
200
+ });
201
+
202
+ test("passes the runtime filter through to the scan", async () => {
203
+ seedConfig();
204
+ const result = await run({ runtimes: ["codex"] });
205
+ expect(result.success).toBe(false);
206
+ expect(result.error).toContain("No agent configuration found");
207
+ });
208
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `chant import --agents` — re-express this machine's agent configuration as
3
+ * chant code.
4
+ *
5
+ * Shares its front half with `chant audit --agents` (the same scan, the same
6
+ * normalized sites) and its back half with every other import path (a lexicon's
7
+ * `templateGenerator()` turning IR into TypeScript). The only new step is the
8
+ * middle: `LexiconPlugin.agentConfigImporter()`, which maps sites onto the
9
+ * lexicon's own resource types.
10
+ *
11
+ * The command is loud about what it changed. Re-expression here is lossy in
12
+ * three specific ways — a skipped runtime, a defaulted model, a redacted
13
+ * secret — and each one is something the user would otherwise find only by
14
+ * diffing the generated code against their real config. Printing them is not
15
+ * politeness; it is the difference between generated code you can trust and
16
+ * generated code you have to re-verify by hand.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
20
+ import { homedir } from "os";
21
+ import { join, resolve } from "path";
22
+ import { scanAgentConfigs } from "../../agents/discover";
23
+ import type { AgentRuntime, AgentScope } from "../../agents/types";
24
+ import type { AgentImportOutcome } from "../../agents/importer";
25
+ import type { LexiconPlugin } from "../../lexicon";
26
+ import { loadPlugin } from "../plugins";
27
+
28
+ /** The lexicon used when `--lexicon` isn't given: the one that models agent workloads. */
29
+ export const DEFAULT_AGENT_LEXICON = "fountain";
30
+
31
+ /**
32
+ * Loads the lexicon plugin. Injectable so this command can be unit-tested
33
+ * without a built lexicon package on disk — the same seam `audit.ts` uses for
34
+ * its `ChecksProvider`.
35
+ */
36
+ export type PluginLoader = (name: string) => Promise<LexiconPlugin>;
37
+
38
+ export interface ImportAgentsOptions {
39
+ /** Lexicon to re-express into. Defaults to {@link DEFAULT_AGENT_LEXICON}. */
40
+ lexicon?: string;
41
+ /** Injectable plugin loader (testing). Defaults to the real one. */
42
+ pluginLoader?: PluginLoader;
43
+ /** Output directory. Defaults to `./infra/agents`. */
44
+ output?: string;
45
+ force?: boolean;
46
+ scopes?: readonly AgentScope[];
47
+ runtimes?: readonly AgentRuntime[];
48
+ projectRoots?: string[];
49
+ home?: string;
50
+ platform?: NodeJS.Platform;
51
+ }
52
+
53
+ export interface ImportAgentsResult {
54
+ success: boolean;
55
+ generatedFiles: string[];
56
+ /** Sites found, mapped, and deliberately not mapped. */
57
+ summary: { discovered: number; mapped: number };
58
+ outcome?: AgentImportOutcome;
59
+ warnings: string[];
60
+ error?: string;
61
+ }
62
+
63
+ /**
64
+ * Scan, re-express, and write.
65
+ *
66
+ * Refuses to overwrite existing files without `--force`, matching `chant
67
+ * import` — generated agent code is likely to be edited by hand after the
68
+ * first run, and silently reverting those edits would be the worst possible
69
+ * behavior for a command whose whole purpose is capturing hand-tuned config.
70
+ */
71
+ export async function importAgentsCommand(opts: ImportAgentsOptions = {}): Promise<ImportAgentsResult> {
72
+ const lexiconName = opts.lexicon ?? DEFAULT_AGENT_LEXICON;
73
+ const outputDir = resolve(opts.output ?? join("infra", "agents"));
74
+ const warnings: string[] = [];
75
+
76
+ const scan = scanAgentConfigs({
77
+ scopes: opts.scopes,
78
+ runtimes: opts.runtimes,
79
+ projectRoots: opts.projectRoots,
80
+ home: opts.home ?? homedir(),
81
+ platform: opts.platform,
82
+ });
83
+
84
+ if (scan.sites.length === 0) {
85
+ return {
86
+ success: false,
87
+ generatedFiles: [],
88
+ summary: { discovered: 0, mapped: 0 },
89
+ warnings,
90
+ error: "No agent configuration found to import.",
91
+ };
92
+ }
93
+
94
+ let plugin: LexiconPlugin;
95
+ try {
96
+ plugin = await (opts.pluginLoader ?? loadPlugin)(lexiconName);
97
+ } catch (err) {
98
+ return {
99
+ success: false,
100
+ generatedFiles: [],
101
+ summary: { discovered: scan.sites.length, mapped: 0 },
102
+ warnings,
103
+ error:
104
+ `Could not load the ${lexiconName} lexicon: ${err instanceof Error ? err.message : String(err)}\n` +
105
+ `Install it with: npm i @intentius/chant-lexicon-${lexiconName}`,
106
+ };
107
+ }
108
+
109
+ const importer = plugin.agentConfigImporter?.();
110
+ if (!importer) {
111
+ return {
112
+ success: false,
113
+ generatedFiles: [],
114
+ summary: { discovered: scan.sites.length, mapped: 0 },
115
+ warnings,
116
+ error: `The ${lexiconName} lexicon cannot express agent configuration (no agentConfigImporter). Try --lexicon ${DEFAULT_AGENT_LEXICON}.`,
117
+ };
118
+ }
119
+
120
+ const generator = plugin.templateGenerator?.();
121
+ if (!generator) {
122
+ return {
123
+ success: false,
124
+ generatedFiles: [],
125
+ summary: { discovered: scan.sites.length, mapped: 0 },
126
+ warnings,
127
+ error: `The ${lexiconName} lexicon has no templateGenerator, so it cannot emit TypeScript.`,
128
+ };
129
+ }
130
+
131
+ const outcome = importer.toTemplateIR(scan.sites);
132
+
133
+ for (const skip of outcome.skipped) warnings.push(`Skipped ${skip.siteId}: ${skip.reason}`);
134
+ if (outcome.unmappedModel.length > 0) {
135
+ warnings.push(
136
+ `No model was pinned in the local config for ${outcome.unmappedModel.join(", ")}; a default was written. Edit it before applying.`,
137
+ );
138
+ }
139
+ if (outcome.redactedSecrets.length > 0) {
140
+ warnings.push(
141
+ `Literal credentials in ${outcome.redactedSecrets.join(", ")} were replaced with \`\${VAR}\` references — the values were NOT copied into the generated code. Supply them via a Vault or the environment.`,
142
+ );
143
+ }
144
+
145
+ if (outcome.ir.resources.length === 0) {
146
+ return {
147
+ success: false,
148
+ generatedFiles: [],
149
+ summary: { discovered: scan.sites.length, mapped: 0 },
150
+ outcome,
151
+ warnings,
152
+ error: `Found ${scan.sites.length} agent config(s), but none could be expressed as ${lexiconName} resources.`,
153
+ };
154
+ }
155
+
156
+ const files = generator.generate(outcome.ir);
157
+
158
+ const existing = files.map((f) => join(outputDir, f.path)).filter((p) => existsSync(p));
159
+ if (existing.length > 0 && !opts.force) {
160
+ return {
161
+ success: false,
162
+ generatedFiles: [],
163
+ summary: { discovered: scan.sites.length, mapped: outcome.ir.resources.length },
164
+ outcome,
165
+ warnings,
166
+ error: `Refusing to overwrite ${existing.join(", ")}. Re-run with --force to replace.`,
167
+ };
168
+ }
169
+
170
+ const generatedFiles: string[] = [];
171
+ try {
172
+ mkdirSync(outputDir, { recursive: true });
173
+ for (const file of files) {
174
+ const full = join(outputDir, file.path);
175
+ writeFileSync(full, file.content, "utf-8");
176
+ generatedFiles.push(full);
177
+ }
178
+ } catch (err) {
179
+ return {
180
+ success: false,
181
+ generatedFiles,
182
+ summary: { discovered: scan.sites.length, mapped: outcome.ir.resources.length },
183
+ outcome,
184
+ warnings,
185
+ error: `Failed to write generated files: ${err instanceof Error ? err.message : String(err)}`,
186
+ };
187
+ }
188
+
189
+ return {
190
+ success: true,
191
+ generatedFiles,
192
+ summary: { discovered: scan.sites.length, mapped: outcome.ir.resources.length },
193
+ outcome,
194
+ warnings,
195
+ };
196
+ }
@@ -16,6 +16,8 @@ export async function runCarveEmit(ctx: CommandContext): Promise<number> {
16
16
  const { args } = ctx;
17
17
 
18
18
  // Load plugins only for the live path; the offline --state path needs none.
19
+ // aws is the only lexicon emit can adopt into (`canCarveEmit`), so it is the
20
+ // only one loaded here.
19
21
  let plugins = ctx.plugins;
20
22
  if (!args.statePath && args.env) {
21
23
  try {