@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.
- package/dist/agents/checks.d.ts +35 -0
- package/dist/agents/checks.d.ts.map +1 -0
- package/dist/agents/discover.d.ts +86 -0
- package/dist/agents/discover.d.ts.map +1 -0
- package/dist/agents/importer.d.ts +46 -0
- package/dist/agents/importer.d.ts.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/types.d.ts +196 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/audit/catalog.d.ts +4 -1
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/report.d.ts +8 -0
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts +64 -0
- package/dist/cli/commands/import-agents.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +15 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lexicon.d.ts +11 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +4 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +17 -0
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/agents/checks.test.ts +228 -0
- package/src/agents/checks.ts +429 -0
- package/src/agents/discover.test.ts +310 -0
- package/src/agents/discover.ts +939 -0
- package/src/agents/importer.ts +49 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/types.ts +207 -0
- package/src/audit/catalog.ts +90 -1
- package/src/audit/report.ts +9 -1
- package/src/audit/rules-doc.ts +6 -0
- package/src/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +19 -1
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +43 -20
- package/src/cli/commands/import-agents.test.ts +208 -0
- package/src/cli/commands/import-agents.ts +196 -0
- package/src/cli/handlers/carve-emit.ts +2 -0
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +17 -0
- package/src/cli/registry.ts +15 -0
- package/src/lexicon.ts +12 -0
- package/src/terraform/adopt-state.ts +7 -4
- package/src/terraform/aws-resources.test.ts +24 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/tier-map.ts +28 -1
- package/src/yaml.test.ts +54 -0
- 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
|
|
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:
|
|
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 {
|