@kuznai/inception-engine 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -271,6 +271,8 @@ Global targets where supported:
271
271
 
272
272
  For GitHub Copilot, deploy also records migration from the legacy `{repo}/.github/agents/{name}.agent.md` path so older installs can be cleaned up safely.
273
273
 
274
+ For GitHub Copilot, the `tools` YAML frontmatter field in agent definition files declares which MCP tools and built-in Copilot tools the agent has access to (agent-level tool mapping). Inception-engine validates that `tools`, when present, is an array of strings. Deploy agent-level tool mappings through `agentDefinitions` by including the `tools` field in your source file's frontmatter - no separate manifest section is needed.
275
+
274
276
  Revert removes the deployed agent-definition file.
275
277
 
276
278
  ## Creating Skills
@@ -472,6 +472,12 @@ export const AGENT_REGISTRY = [
472
472
  windows: ["{workspace}", "CLAUDE.md"],
473
473
  },
474
474
  },
475
+ mcpDevcontainerSupport: {
476
+ status: "planned",
477
+ schemaLabel: "devcontainer.json MCP surface",
478
+ plannedSurface: "devcontainer.json customizations.vscode.mcp.servers",
479
+ reason: 'devcontainer.json MCP support is planned — use scope: "repo" or scope: "workspace" to target .vscode/mcp.json in the meantime',
480
+ },
475
481
  permissionsSupport: {
476
482
  status: "unsupported",
477
483
  schemaLabel: "global permissions surface",
@@ -485,7 +491,27 @@ export const AGENT_REGISTRY = [
485
491
  windows: ["{repo}", ".github", "copilot", "agents", "{name}.md"],
486
492
  },
487
493
  },
494
+ // GitHub Copilot agent definitions declare MCP tool access via a top-level
495
+ // 'tools' array in YAML frontmatter. This surface is deployed through
496
+ // agentDefinitions - no separate deploy action is emitted.
497
+ mcpAgentFrontmatterSupport: {
498
+ status: "supported",
499
+ schemaLabel: "agent definition frontmatter tools field",
500
+ surfaceKind: { kind: "native" },
501
+ path: {
502
+ posix: ["{repo}", ".github", "copilot", "agents", "{name}.md"],
503
+ windows: ["{repo}", ".github", "copilot", "agents", "{name}.md"],
504
+ },
505
+ },
488
506
  policyNote: "Organization policies may override locally deployed configuration. Verify with your GitHub org admin if deployed skills or rules are not active.",
507
+ unsupportedSurfaces: [
508
+ {
509
+ status: "planned",
510
+ schemaLabel: "devcontainer.json MCP surface",
511
+ plannedSurface: "devcontainer.json customizations.vscode.mcp.servers",
512
+ reason: 'devcontainer.json MCP support is planned — use scope: "repo" or scope: "workspace" to target .vscode/mcp.json in the meantime',
513
+ },
514
+ ],
489
515
  instructionFrontmatterRequired: true,
490
516
  enterprisePolicyDetection: true,
491
517
  },
@@ -3,28 +3,23 @@ import { compileMcpServerActions, compileMcpServerReverts } from "./mcp.js";
3
3
  import { compilePermissionsActions, compilePermissionsReverts, } from "./permissions.js";
4
4
  import { compileAgentRuleActions, compileAgentRuleReverts } from "./rules.js";
5
5
  export { compileAgentDefinitionReverts, compileAgentRuleReverts, compileMcpServerReverts, compilePermissionsReverts, };
6
+ async function compileAll(entries, fn) {
7
+ const results = await Promise.all(entries.map(fn));
8
+ return {
9
+ actions: results.flatMap((r) => r.actions),
10
+ warnings: results.flatMap((r) => r.warnings),
11
+ };
12
+ }
6
13
  export async function compileAdapterActions(mcpServers, agentRules, permissions, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repo, agentDefinitions, workspace) {
7
14
  const actions = [];
8
15
  const warnings = [];
9
- for (const entry of mcpServers) {
10
- const r = compileMcpServerActions(entry, detectedAgents, home, repo, workspace);
11
- actions.push(...r.actions);
12
- warnings.push(...r.warnings);
13
- }
14
- for (const entry of agentRules) {
15
- const r = await compileAgentRuleActions(entry, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repo, workspace);
16
- actions.push(...r.actions);
17
- warnings.push(...r.warnings);
18
- }
19
- for (const entry of permissions) {
20
- const r = compilePermissionsActions(entry, detectedAgents, home);
21
- actions.push(...r.actions);
22
- warnings.push(...r.warnings);
23
- }
24
- for (const entry of agentDefinitions ?? []) {
25
- const r = await compileAgentDefinitionActions(entry, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repo, workspace);
26
- actions.push(...r.actions);
27
- warnings.push(...r.warnings);
28
- }
16
+ const [mcp, rules, perms, defs] = await Promise.all([
17
+ compileAll(mcpServers, (entry) => compileMcpServerActions(entry, detectedAgents, home, repo, workspace)),
18
+ compileAll(agentRules, (entry) => compileAgentRuleActions(entry, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repo, workspace)),
19
+ compileAll(permissions, (entry) => compilePermissionsActions(entry, detectedAgents, home)),
20
+ compileAll(agentDefinitions ?? [], (entry) => compileAgentDefinitionActions(entry, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repo, workspace)),
21
+ ]);
22
+ actions.push(...mcp.actions, ...rules.actions, ...perms.actions, ...defs.actions);
23
+ warnings.push(...mcp.warnings, ...rules.warnings, ...perms.warnings, ...defs.warnings);
29
24
  return { actions, warnings };
30
25
  }
@@ -4,7 +4,9 @@ import path from "node:path";
4
4
  import { AGENT_REGISTRY, AGENT_REGISTRY_BY_ID } from "../config/agents.js";
5
5
  import { UserError } from "../errors.js";
6
6
  import { logger } from "../logger.js";
7
+ import * as frontmatterAdapter from "./adapters/frontmatter.js";
7
8
  import { compileAdapterActions } from "./adapters/index.js";
9
+ import { applyTomlMcpPatch } from "./adapters/toml.js";
8
10
  import { planCapabilityForDeploy } from "./capabilities.js";
9
11
  import { applyMergePatch, computeUndoPatch, isPlainObject, } from "./merge-patch.js";
10
12
  import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
@@ -105,7 +107,7 @@ function checkPairAgentRuleAmbiguities(manifest, primary, rider) {
105
107
  JSON.stringify(primarySupport.path) === JSON.stringify(riderSupport.path)) {
106
108
  warnings.push({
107
109
  kind: "ambiguity",
108
- message: `Both "${primary}" and "${rider}" are listed in agentRules entry "${entry.name}". Both target the same surface listing both is redundant but harmless; deduplication ensures only one write action is emitted.`,
110
+ message: `Both "${primary}" and "${rider}" are listed in agentRules entry "${entry.name}". Both target the same surface - listing both is redundant but harmless; deduplication ensures only one write action is emitted.`,
109
111
  });
110
112
  }
111
113
  }
@@ -122,7 +124,7 @@ function checkPairMcpAmbiguities(manifest, primary, rider) {
122
124
  continue;
123
125
  warnings.push({
124
126
  kind: "ambiguity",
125
- message: `Both "${primary}" and "${rider}" are listed in mcpServers entry "${entry.name}". "${primary}" writes to a shared MCP surface verify that deploying to both does not produce conflicting MCP server behavior.`,
127
+ message: `Both "${primary}" and "${rider}" are listed in mcpServers entry "${entry.name}". "${primary}" writes to a shared MCP surface - verify that deploying to both does not produce conflicting MCP server behavior.`,
126
128
  });
127
129
  }
128
130
  return warnings;
@@ -134,7 +136,7 @@ function checkPairAgentDefinitionAmbiguities(manifest, primary, rider) {
134
136
  continue;
135
137
  warnings.push({
136
138
  kind: "ambiguity",
137
- message: `Both "${primary}" and "${rider}" are listed in agentDefinitions entry "${entry.name}". They write to distinct surfaces verify that this behavioral divergence is intended.`,
139
+ message: `Both "${primary}" and "${rider}" are listed in agentDefinitions entry "${entry.name}". They write to distinct surfaces - verify that this behavioral divergence is intended.`,
138
140
  });
139
141
  }
140
142
  return warnings;
@@ -162,7 +164,7 @@ function checkAntigravityPathCollisions(manifest) {
162
164
  if (defNames.has(entry.name)) {
163
165
  warnings.push({
164
166
  kind: "collision",
165
- message: `agentDefinitions entry "${entry.name}" and mcpServers entry "${entry.name}" for agent "antigravity" both resolve to {repo}/.agents/rules/${entry.name}.md one will silently overwrite the other; use different names or remove one entry`,
167
+ message: `agentDefinitions entry "${entry.name}" and mcpServers entry "${entry.name}" for agent "antigravity" both resolve to {repo}/.agents/rules/${entry.name}.md - one will silently overwrite the other; use different names or remove one entry`,
166
168
  });
167
169
  }
168
170
  }
@@ -212,17 +214,15 @@ async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realR
212
214
  });
213
215
  }
214
216
  }
215
- for (const skill of manifest.skills) {
216
- await planSkillEntry(skill);
217
- }
217
+ await Promise.all(manifest.skills.map(planSkillEntry));
218
218
  return { actions, warnings };
219
219
  }
220
220
  async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repo, workspace) {
221
221
  const actions = [];
222
- for (const fileEntry of manifest.files ?? []) {
222
+ await Promise.all((manifest.files ?? []).map(async (fileEntry) => {
223
223
  const targetAgents = fileEntry.agents.filter((agentId) => detectedAgents.includes(agentId));
224
224
  if (targetAgents.length === 0)
225
- continue;
225
+ return;
226
226
  const source = path.resolve(sourceDir, fileEntry.path);
227
227
  await validateSourcePath(source, fileEntry.path, resolvedSourceDir, realRoot);
228
228
  await validateSourceFile(source, fileEntry.path);
@@ -236,10 +236,10 @@ async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, real
236
236
  agent: agentId,
237
237
  source,
238
238
  target: resolveTargetTemplate(fileEntry.target, home, repo, workspace),
239
- confidence: agent.provenance.skills,
239
+ confidence: "implementation-only",
240
240
  });
241
241
  }
242
- }
242
+ }));
243
243
  return actions;
244
244
  }
245
245
  function planConfigPatchActions(manifest, detectedAgents, home, repo, workspace) {
@@ -370,7 +370,7 @@ async function backupManagedFileWriteTarget(action, home, deps) {
370
370
  agent: action.agent,
371
371
  }, deps.registry);
372
372
  if (!isOwned) {
373
- throw new Error(`Target "${action.target}" exists but is not managed by inception-engine refusing to overwrite`);
373
+ throw new Error(`Target "${action.target}" exists but is not managed by inception-engine - refusing to overwrite`);
374
374
  }
375
375
  const backupPath = `${action.target}.inception-backup`;
376
376
  await (deps.fileOps ?? defaultDeployFileOps).rm(backupPath, {
@@ -528,7 +528,7 @@ async function deployConfigPatch(action, dryRun, verbose, home, planned, deps) {
528
528
  if (existingEntry &&
529
529
  (existingEntry.skill !== action.skill ||
530
530
  existingEntry.agent !== action.agent)) {
531
- throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" refusing to double-patch`);
531
+ throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" - refusing to double-patch`);
532
532
  }
533
533
  const original = await readJsonConfigFile(action.target);
534
534
  const undoPatch = computeUndoPatch(original, patch);
@@ -576,7 +576,7 @@ async function deployTomlPatch(action, dryRun, verbose, home, planned, deps) {
576
576
  try {
577
577
  const { previousValue } = await (deps.registry
578
578
  ? Promise.resolve({ previousValue: null })
579
- : (await import("./adapters/toml.js")).applyTomlMcpPatch(action.target, action.skill, action.config));
579
+ : applyTomlMcpPatch(action.target, action.skill, action.config));
580
580
  // Note: To truly support custom deps here we'd need to refactor toml adapter to accept deps.
581
581
  // For now we assume standard adapter for TOML.
582
582
  await registerDeployment(home, action.target, {
@@ -622,9 +622,8 @@ async function deployFrontmatterEmit(action, dryRun, verbose, home, planned, dep
622
622
  (existingEntry.kind !== "frontmatter-emit" ||
623
623
  existingEntry.skill !== action.skill ||
624
624
  existingEntry.agent !== action.agent)) {
625
- throw new Error(`Frontmatter target "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" refusing to double-patch`);
625
+ throw new Error(`Frontmatter target "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" - refusing to double-patch`);
626
626
  }
627
- const frontmatterAdapter = await import("./adapters/frontmatter.js");
628
627
  const existing = await frontmatterAdapter.readFrontmatterDocumentFile(action.target);
629
628
  const undoPatch = computeUndoPatch(existing.attributes, action.frontmatter);
630
629
  const patchedFrontmatter = applyMergePatch(existing.attributes, action.frontmatter);
@@ -712,7 +711,7 @@ async function assertTargetAbsent(targetPath) {
712
711
  catch (err) {
713
712
  if (err instanceof Error && err.message.startsWith("Target path appeared"))
714
713
  throw err;
715
- // ENOENT is expected target should not exist after backup
714
+ // ENOENT is expected - target should not exist after backup
716
715
  }
717
716
  }
718
717
  async function createDeployTarget(action, home, deps) {
@@ -775,7 +774,7 @@ async function backupExisting(targetPath, verbose, home, expected, deps) {
775
774
  skill: expected.skill,
776
775
  agent: expected.agent,
777
776
  }, deps.registry))) {
778
- throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine refusing to overwrite`);
777
+ throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine - refusing to overwrite`);
779
778
  }
780
779
  const backupPath = `${targetPath}.inception-backup`;
781
780
  if (verbose) {
@@ -8,13 +8,8 @@ const defaultExecFn = async (cmd, args) => {
8
8
  await execFileAsync(cmd, args);
9
9
  };
10
10
  export async function detectInstalledAgents(home) {
11
- const detected = [];
12
- for (const agent of AGENT_REGISTRY) {
13
- if (await isAgentInstalled(agent, home)) {
14
- detected.push(agent.id);
15
- }
16
- }
17
- return detected;
11
+ const results = await Promise.all(AGENT_REGISTRY.map((agent) => isAgentInstalled(agent, home)));
12
+ return AGENT_REGISTRY.filter((_, i) => results[i]).map((a) => a.id);
18
13
  }
19
14
  async function isAgentInstalled(agent, home) {
20
15
  const detectPath = resolveAgentDetectPath(agent, home);
@@ -521,7 +521,8 @@ function logPathAgentEntries(label, entries) {
521
521
  return;
522
522
  logger.detail(`${label}:`);
523
523
  for (const e of entries) {
524
- logger.detail(` ${e.name} → ${e.path} [${e.agents.join(", ")}]`);
524
+ const entryAgents = e.agents ?? [];
525
+ logger.detail(` ${e.name} → ${e.path} [${entryAgents.join(", ")}]`);
525
526
  }
526
527
  }
527
528
  function logVerboseManifest(skills, agentRules, mcpServers, files, configs, agentDefinitions) {
@@ -155,12 +155,22 @@ async function detectInstructionBudgetRisk(detectedAgents, manifest, sourceDir)
155
155
  // will produce the proper UserError during action compilation.
156
156
  }
157
157
  }
158
+ const entries = [];
158
159
  for (const entry of manifest.agentRules ?? []) {
159
- await checkEntry(entry.path, "agentRules", entry.agents);
160
+ entries.push({
161
+ path: entry.path,
162
+ label: "agentRules",
163
+ agents: entry.agents,
164
+ });
160
165
  }
161
166
  for (const entry of manifest.agentDefinitions ?? []) {
162
- await checkEntry(entry.path, "agentDefinitions", entry.agents);
167
+ entries.push({
168
+ path: entry.path,
169
+ label: "agentDefinitions",
170
+ agents: entry.agents,
171
+ });
163
172
  }
173
+ await Promise.all(entries.map((e) => checkEntry(e.path, e.label, e.agents)));
164
174
  return warnings;
165
175
  }
166
176
  function pushCapabilityWarning(acc, kind, message) {
@@ -169,6 +179,21 @@ function pushCapabilityWarning(acc, kind, message) {
169
179
  acc.seen.add(`${kind}:${message}`);
170
180
  acc.warnings.push({ kind, message });
171
181
  }
182
+ function collectPlannedSurfaceWarnings(acc, agentId, capability) {
183
+ const agent = AGENT_REGISTRY_BY_ID[agentId];
184
+ if (!agent?.unsupportedSurfaces ||
185
+ (capability !== "mcpServers" && capability !== "permissions")) {
186
+ return;
187
+ }
188
+ for (const surface of agent.unsupportedSurfaces) {
189
+ if (surface.status === "planned") {
190
+ pushCapabilityWarning(acc, "info", `Agent "${agentId}": ${surface.reason ?? surface.plannedSurface}`);
191
+ }
192
+ else if (surface.status === "unsupported") {
193
+ pushCapabilityWarning(acc, "config-authority", `Agent "${agentId}": ${surface.reason ?? surface.schemaLabel}`);
194
+ }
195
+ }
196
+ }
172
197
  function collectCapabilityWarningsForAgent(acc, agentId, capability, entryName, targetAgents, scope) {
173
198
  const plan = planCapabilityForDeploy({
174
199
  agentId,
@@ -181,24 +206,13 @@ function collectCapabilityWarningsForAgent(acc, agentId, capability, entryName,
181
206
  pushCapabilityWarning(acc, "info", plan.warning.message);
182
207
  return;
183
208
  }
184
- // Evaluate undocumented/planned surfaces warning
185
- const agent = AGENT_REGISTRY_BY_ID[agentId];
186
- if (agent?.unsupportedSurfaces) {
187
- for (const surface of agent.unsupportedSurfaces) {
188
- if (surface.status === "planned") {
189
- pushCapabilityWarning(acc, "info", `Agent "${agentId}": ${surface.reason ?? surface.plannedSurface}`);
190
- }
191
- else if (surface.status === "unsupported") {
192
- pushCapabilityWarning(acc, "config-authority", `Agent "${agentId}": ${surface.reason ?? surface.schemaLabel}`);
193
- }
209
+ if (capability !== "skills") {
210
+ const confidence = describeCapabilityConfidence(agentId, capability, scope);
211
+ if (confidence.message) {
212
+ pushCapabilityWarning(acc, "config-authority", confidence.message);
194
213
  }
195
214
  }
196
- if (capability === "skills")
197
- return;
198
- const confidence = describeCapabilityConfidence(agentId, capability, scope);
199
- if (confidence.message) {
200
- pushCapabilityWarning(acc, "config-authority", confidence.message);
201
- }
215
+ collectPlannedSurfaceWarnings(acc, agentId, capability);
202
216
  }
203
217
  function collectCapabilityWarningsForTargets(acc, targetAgents, capability, entryName, scope) {
204
218
  for (const agentId of targetAgents) {
@@ -252,7 +266,9 @@ async function collectAgentWarnings(agentId, manifest, home) {
252
266
  });
253
267
  }
254
268
  else if (agent.policyNote &&
255
- !agent.policyNote.includes("Organization policies may override")) {
269
+ !agent.policyNote
270
+ .toLowerCase()
271
+ .includes("organization policies may override")) {
256
272
  warnings.push({
257
273
  kind: "policy",
258
274
  message: `Agent "${agentId}": ${agent.policyNote}`,
@@ -1,6 +1,7 @@
1
1
  import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
2
2
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
3
3
  import { logger } from "../logger.js";
4
+ import * as frontmatterAdapter from "./adapters/frontmatter.js";
4
5
  import { compileAgentDefinitionReverts, compileAgentRuleReverts, compileMcpServerReverts, compilePermissionsReverts, } from "./adapters/index.js";
5
6
  import { revertTomlMcpPatch } from "./adapters/toml.js";
6
7
  import { applyUndoPatch } from "./merge-patch.js";
@@ -177,7 +178,6 @@ function planFrontmatterRevert(action, frontmatterEntry, planned) {
177
178
  return { outcome: "ok" };
178
179
  }
179
180
  async function applyFrontmatterRevert(action, frontmatterEntry) {
180
- const frontmatterAdapter = await import("./adapters/frontmatter.js");
181
181
  const current = await frontmatterAdapter.readFrontmatterDocumentFile(action.target);
182
182
  const restoredFrontmatter = applyUndoPatch(current.attributes, frontmatterEntry.undoPatch ?? {});
183
183
  const shouldDeleteFile = frontmatterEntry.created === true &&
@@ -218,6 +218,17 @@ function validateGithubCopilotRequirements(attributes, manifestPath) {
218
218
  if (!(hasTools || hasInstructions)) {
219
219
  throw new UserError("DEPLOY_FAILED", `Instruction file "${manifestPath}" for agent "github-copilot" must define "tools" or "instructions" in frontmatter`);
220
220
  }
221
+ if (hasTools) {
222
+ const tools = attributes.tools;
223
+ if (!Array.isArray(tools)) {
224
+ throw new UserError("DEPLOY_FAILED", `Instruction file "${manifestPath}" for agent "github-copilot" has a "tools" field that must be an array`);
225
+ }
226
+ for (const tool of tools) {
227
+ if (typeof tool !== "string") {
228
+ throw new UserError("DEPLOY_FAILED", `Instruction file "${manifestPath}" for agent "github-copilot" has a "tools" entry that must be a string`);
229
+ }
230
+ }
231
+ }
221
232
  }
222
233
  function validateAntigravityRequirements(attributes, manifestPath) {
223
234
  const mcpServers = attributes["mcp-servers"] ?? attributes.mcpServers;
@@ -1,5 +1,7 @@
1
1
  export type ErrorCode = "INVALID_ARGS" | "MANIFEST_INVALID" | "DEPLOY_FAILED" | "RESOLVE_FAILED";
2
2
  export declare class UserError extends Error {
3
3
  readonly code: ErrorCode;
4
- constructor(code: ErrorCode, message: string);
4
+ constructor(code: ErrorCode, message: string, options?: {
5
+ cause?: unknown;
6
+ });
5
7
  }
@@ -1,7 +1,7 @@
1
1
  export class UserError extends Error {
2
2
  code;
3
- constructor(code, message) {
4
- super(message);
3
+ constructor(code, message, options) {
4
+ super(message, options);
5
5
  this.name = "UserError";
6
6
  this.code = code;
7
7
  }
@@ -102,6 +102,8 @@ export interface AgentConfig {
102
102
  agentDefinitionsWorkspaceSupport?: AgentSurfaceSupport;
103
103
  agentDefinitionsTomlSupport?: AgentSurfaceSupport;
104
104
  agentDefinitionsTomlRepoSupport?: AgentSurfaceSupport;
105
+ mcpDevcontainerSupport?: AgentSurfaceSupport;
106
+ mcpAgentFrontmatterSupport?: AgentSurfaceSupport;
105
107
  policyNote?: string;
106
108
  /**
107
109
  * A list of documented surfaces that are currently not safe to support directly.
@@ -0,0 +1,245 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { executeDeploy } from "../../../src/core/deploy.js";
6
+ import { lookupDeployment, registerDeployment, } from "../../../src/core/ownership.js";
7
+ import { executeRevert } from "../../../src/core/revert.js";
8
+ import { exists, makeTmpDir } from "../../helpers/fs.js";
9
+ describe("agentRules repo/workspace file-write (Windows)", {
10
+ skip: process.platform !== "win32",
11
+ }, () => {
12
+ it("deploys agentRules with scope repo and registers it", async () => {
13
+ const home = await makeTmpDir("ie-rules-home");
14
+ const repo = await makeTmpDir("ie-rules-repo");
15
+ const sourceDir = await makeTmpDir("ie-rules-source");
16
+ try {
17
+ const source = path.join(sourceDir, "CLAUDE.md");
18
+ await writeFile(source, "# My Rules\n");
19
+ const target = path.join(repo, "CLAUDE.md");
20
+ const action = {
21
+ kind: "file-write",
22
+ skill: "my-rules",
23
+ agent: "claude-code",
24
+ source,
25
+ target,
26
+ };
27
+ const { succeeded, failed } = await executeDeploy([action], false, false, home);
28
+ assert.equal(succeeded, 1);
29
+ assert.equal(failed.length, 0);
30
+ assert.ok(await exists(target));
31
+ const entry = await lookupDeployment(home, target);
32
+ assert.ok(entry !== null);
33
+ assert.equal(entry?.kind, "file-write");
34
+ }
35
+ finally {
36
+ await rm(home, { recursive: true, force: true });
37
+ await rm(repo, { recursive: true, force: true });
38
+ await rm(sourceDir, { recursive: true, force: true });
39
+ }
40
+ });
41
+ it("reverts agentRules with scope repo and unregisters it", async () => {
42
+ const home = await makeTmpDir("ie-rules-home");
43
+ const repo = await makeTmpDir("ie-rules-repo");
44
+ try {
45
+ const source = path.join(repo, "source-CLAUDE.md");
46
+ const target = path.join(repo, "CLAUDE.md");
47
+ await writeFile(target, "# My Rules\n");
48
+ await registerDeployment(home, target, {
49
+ kind: "file-write",
50
+ source,
51
+ skill: "my-rules",
52
+ agent: "claude-code",
53
+ });
54
+ const action = {
55
+ kind: "file-write",
56
+ skill: "my-rules",
57
+ agent: "claude-code",
58
+ target,
59
+ };
60
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
61
+ assert.equal(succeeded, 1);
62
+ assert.equal(failed.length, 0);
63
+ assert.ok(!(await exists(target)));
64
+ assert.equal(await lookupDeployment(home, target), null);
65
+ }
66
+ finally {
67
+ await rm(home, { recursive: true, force: true });
68
+ await rm(repo, { recursive: true, force: true });
69
+ }
70
+ });
71
+ it("deploys agentRules with scope workspace and registers it", async () => {
72
+ const home = await makeTmpDir("ie-rules-home");
73
+ const workspace = await makeTmpDir("ie-rules-workspace");
74
+ const sourceDir = await makeTmpDir("ie-rules-source");
75
+ try {
76
+ const source = path.join(sourceDir, "CLAUDE.md");
77
+ await writeFile(source, "# Workspace Rules\n");
78
+ const target = path.join(workspace, "CLAUDE.md");
79
+ const action = {
80
+ kind: "file-write",
81
+ skill: "my-rules",
82
+ agent: "claude-code",
83
+ source,
84
+ target,
85
+ };
86
+ const { succeeded, failed } = await executeDeploy([action], false, false, home);
87
+ assert.equal(succeeded, 1);
88
+ assert.equal(failed.length, 0);
89
+ assert.ok(await exists(target));
90
+ const entry = await lookupDeployment(home, target);
91
+ assert.ok(entry !== null);
92
+ assert.equal(entry?.kind, "file-write");
93
+ }
94
+ finally {
95
+ await rm(home, { recursive: true, force: true });
96
+ await rm(workspace, { recursive: true, force: true });
97
+ await rm(sourceDir, { recursive: true, force: true });
98
+ }
99
+ });
100
+ it("reverts agentRules with scope workspace and unregisters it", async () => {
101
+ const home = await makeTmpDir("ie-rules-home");
102
+ const workspace = await makeTmpDir("ie-rules-workspace");
103
+ try {
104
+ const source = path.join(workspace, "source-CLAUDE.md");
105
+ const target = path.join(workspace, "CLAUDE.md");
106
+ await writeFile(target, "# Workspace Rules\n");
107
+ await registerDeployment(home, target, {
108
+ kind: "file-write",
109
+ source,
110
+ skill: "my-rules",
111
+ agent: "claude-code",
112
+ });
113
+ const action = {
114
+ kind: "file-write",
115
+ skill: "my-rules",
116
+ agent: "claude-code",
117
+ target,
118
+ };
119
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
120
+ assert.equal(succeeded, 1);
121
+ assert.equal(failed.length, 0);
122
+ assert.ok(!(await exists(target)));
123
+ assert.equal(await lookupDeployment(home, target), null);
124
+ }
125
+ finally {
126
+ await rm(home, { recursive: true, force: true });
127
+ await rm(workspace, { recursive: true, force: true });
128
+ }
129
+ });
130
+ });
131
+ describe("Antigravity frontmatter-emit (Windows)", {
132
+ skip: process.platform !== "win32",
133
+ }, () => {
134
+ it("deploys frontmatter-emit for Antigravity and registers it", async () => {
135
+ const home = await makeTmpDir("ie-fm-home");
136
+ const repo = await makeTmpDir("ie-fm-repo");
137
+ try {
138
+ const target = path.join(repo, ".agents", "rules", "my-mcp.md");
139
+ const frontmatter = {
140
+ "mcp-servers": {
141
+ "my-mcp": { command: "npx", args: ["-y", "my-mcp-server"] },
142
+ },
143
+ };
144
+ const action = {
145
+ kind: "frontmatter-emit",
146
+ skill: "my-mcp",
147
+ agent: "antigravity",
148
+ target,
149
+ frontmatter,
150
+ };
151
+ const { succeeded, failed } = await executeDeploy([action], false, false, home);
152
+ assert.equal(succeeded, 1);
153
+ assert.equal(failed.length, 0);
154
+ assert.ok(await exists(target));
155
+ const content = await readFile(target, "utf-8");
156
+ assert.ok(content.includes("mcp-servers:"));
157
+ const entry = await lookupDeployment(home, target);
158
+ assert.ok(entry !== null);
159
+ assert.equal(entry?.kind, "frontmatter-emit");
160
+ assert.equal(entry.created, true);
161
+ }
162
+ finally {
163
+ await rm(home, { recursive: true, force: true });
164
+ await rm(repo, { recursive: true, force: true });
165
+ }
166
+ });
167
+ it("reverts frontmatter-emit for Antigravity and removes file when no body", async () => {
168
+ const home = await makeTmpDir("ie-fm-home");
169
+ const repo = await makeTmpDir("ie-fm-repo");
170
+ try {
171
+ const target = path.join(repo, ".agents", "rules", "my-mcp.md");
172
+ const frontmatter = {
173
+ "mcp-servers": {
174
+ "my-mcp": { command: "npx", args: ["-y", "my-mcp-server"] },
175
+ },
176
+ };
177
+ // Deploy first so the file and registry entry are created.
178
+ const deployAction = {
179
+ kind: "frontmatter-emit",
180
+ skill: "my-mcp",
181
+ agent: "antigravity",
182
+ target,
183
+ frontmatter,
184
+ };
185
+ await executeDeploy([deployAction], false, false, home);
186
+ const action = {
187
+ kind: "frontmatter-emit",
188
+ skill: "my-mcp",
189
+ agent: "antigravity",
190
+ target,
191
+ };
192
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
193
+ assert.equal(succeeded, 1);
194
+ assert.equal(failed.length, 0);
195
+ assert.ok(!(await exists(target)));
196
+ assert.equal(await lookupDeployment(home, target), null);
197
+ }
198
+ finally {
199
+ await rm(home, { recursive: true, force: true });
200
+ await rm(repo, { recursive: true, force: true });
201
+ }
202
+ });
203
+ it("reverts frontmatter-emit for Antigravity preserving body when file had prior content", async () => {
204
+ const home = await makeTmpDir("ie-fm-home");
205
+ const repo = await makeTmpDir("ie-fm-repo");
206
+ try {
207
+ const target = path.join(repo, ".agents", "rules", "my-mcp.md");
208
+ await mkdir(path.dirname(target), { recursive: true });
209
+ await writeFile(target, "# My Rules\n");
210
+ const undoPatch = { "mcp-servers": null };
211
+ await registerDeployment(home, target, {
212
+ kind: "frontmatter-emit",
213
+ patch: {
214
+ "mcp-servers": {
215
+ "my-mcp": { command: "npx", args: ["-y", "my-mcp-server"] },
216
+ },
217
+ },
218
+ undoPatch,
219
+ created: false,
220
+ hadFrontmatter: false,
221
+ skill: "my-mcp",
222
+ agent: "antigravity",
223
+ });
224
+ const action = {
225
+ kind: "frontmatter-emit",
226
+ skill: "my-mcp",
227
+ agent: "antigravity",
228
+ target,
229
+ };
230
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
231
+ assert.equal(succeeded, 1);
232
+ assert.equal(failed.length, 0);
233
+ // File should still exist because it had body content.
234
+ assert.ok(await exists(target));
235
+ const content = await readFile(target, "utf-8");
236
+ assert.ok(!content.includes("mcp-servers:"));
237
+ assert.ok(content.includes("My Rules"));
238
+ assert.equal(await lookupDeployment(home, target), null);
239
+ }
240
+ finally {
241
+ await rm(home, { recursive: true, force: true });
242
+ await rm(repo, { recursive: true, force: true });
243
+ }
244
+ });
245
+ });
@@ -910,6 +910,76 @@ describe("compileAgentDefinitionActions", () => {
910
910
  await rm(dir, { recursive: true });
911
911
  }
912
912
  });
913
+ it("accepts github-copilot tools as empty array", async () => {
914
+ const compile = await getAdapter();
915
+ const dir = await makeTmpDir();
916
+ try {
917
+ await writeFile(path.join(dir, "agent.md"), "---\nname: test\ndescription: test\ntools: []\n---\n# Agent");
918
+ const realRoot = await realpath(dir);
919
+ const result = await compile({
920
+ name: "agent",
921
+ agents: ["github-copilot"],
922
+ path: "agent.md",
923
+ scope: "repo",
924
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", "/repo/test");
925
+ assert.ok(result.actions.length > 0, "expected at least one action");
926
+ }
927
+ finally {
928
+ await rm(dir, { recursive: true });
929
+ }
930
+ });
931
+ it("accepts github-copilot tools as array of strings", async () => {
932
+ const compile = await getAdapter();
933
+ const dir = await makeTmpDir();
934
+ try {
935
+ await writeFile(path.join(dir, "agent.md"), "---\nname: test\ndescription: test\ntools:\n - github\n - codebase\n---\n# Agent");
936
+ const realRoot = await realpath(dir);
937
+ const result = await compile({
938
+ name: "agent",
939
+ agents: ["github-copilot"],
940
+ path: "agent.md",
941
+ scope: "repo",
942
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", "/repo/test");
943
+ assert.ok(result.actions.length > 0, "expected at least one action");
944
+ }
945
+ finally {
946
+ await rm(dir, { recursive: true });
947
+ }
948
+ });
949
+ it("throws for github-copilot when tools is not an array", async () => {
950
+ const compile = await getAdapter();
951
+ const dir = await makeTmpDir();
952
+ try {
953
+ await writeFile(path.join(dir, "bad-tools.md"), "---\nname: test\ndescription: test\ntools: not-an-array\n---\n# Bad");
954
+ const realRoot = await realpath(dir);
955
+ await assert.rejects(compile({
956
+ name: "bad",
957
+ agents: ["github-copilot"],
958
+ path: "bad-tools.md",
959
+ scope: "repo",
960
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", "/repo/test"), /"tools" field that must be an array/);
961
+ }
962
+ finally {
963
+ await rm(dir, { recursive: true });
964
+ }
965
+ });
966
+ it("throws for github-copilot when tools contains a non-string entry", async () => {
967
+ const compile = await getAdapter();
968
+ const dir = await makeTmpDir();
969
+ try {
970
+ await writeFile(path.join(dir, "bad-tools.md"), "---\nname: test\ndescription: test\ntools:\n - github\n - 123\n---\n# Bad");
971
+ const realRoot = await realpath(dir);
972
+ await assert.rejects(compile({
973
+ name: "bad",
974
+ agents: ["github-copilot"],
975
+ path: "bad-tools.md",
976
+ scope: "repo",
977
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", "/repo/test"), /"tools" entry that must be a string/);
978
+ }
979
+ finally {
980
+ await rm(dir, { recursive: true });
981
+ }
982
+ });
913
983
  it("throws for antigravity when mcp-servers is malformed", async () => {
914
984
  const compile = await getAdapter();
915
985
  const dir = await makeTmpDir();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import { cp, readFile, rm } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import path from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { makeTmpDir } from "../helpers/fs.js";
7
+ import { assertPathEndsWith, normalizeSlashes } from "../helpers/path.js";
8
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, "..", "..");
9
+ const FIXTURE_DIR = path.join(PROJECT_ROOT, "test", "fixtures", "readme-sample");
10
+ function run(args, env) {
11
+ return new Promise((resolve) => {
12
+ const child = spawn(process.execPath, [path.join(PROJECT_ROOT, "src", "index.ts"), ...args], {
13
+ cwd: PROJECT_ROOT,
14
+ env: { ...process.env, ...env },
15
+ });
16
+ let stdout = "";
17
+ let stderr = "";
18
+ child.stdout.on("data", (d) => {
19
+ stdout += d.toString();
20
+ });
21
+ child.stderr.on("data", (d) => {
22
+ stderr += d.toString();
23
+ });
24
+ child.on("close", (code) => {
25
+ resolve({ stdout, stderr, code: code ?? 1 });
26
+ });
27
+ });
28
+ }
29
+ /**
30
+ * Extract the JSON manifest object from `init --plan` stdout.
31
+ * The output format is:
32
+ * [plan] Would write /path/inception.json with N skill(s), ...:
33
+ * <blank line>
34
+ * { ...json... }
35
+ * The [plan] prefix may include ANSI escape codes; the first `{` is
36
+ * reliably the start of the JSON object.
37
+ */
38
+ function extractPlanJson(stdout) {
39
+ const jsonStart = stdout.indexOf("{");
40
+ assert.ok(jsonStart !== -1, `Could not find JSON object in --plan stdout:\n${stdout}`);
41
+ return JSON.parse(stdout.slice(jsonStart));
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Block 1: init --plan against the real limbo/ tree (read-only)
45
+ // ---------------------------------------------------------------------------
46
+ describe("init --plan against real limbo/ tree", () => {
47
+ it("emits the 4 limbo skills with all 5 agents and empty other sections", async () => {
48
+ const limboDir = path.join(PROJECT_ROOT, "limbo");
49
+ const { stdout, code } = await run(["init", limboDir, "--plan"]);
50
+ assert.equal(code, 0, `init --plan exited non-zero.\nstdout: ${stdout}`);
51
+ assert.ok(stdout.includes("[plan]"), `missing [plan] prefix:\n${stdout}`);
52
+ const manifest = extractPlanJson(stdout);
53
+ // Exactly 4 skills
54
+ assert.equal(manifest.skills.length, 4, `expected 4 skills, got ${manifest.skills.length}: ${JSON.stringify(manifest.skills.map((s) => s.name))}`);
55
+ // Names sorted alphabetically (sort before comparing - filesystem order not guaranteed)
56
+ const names = manifest.skills.map((s) => s.name).sort();
57
+ assert.deepEqual(names, [
58
+ "inception",
59
+ "interstellar",
60
+ "tenet",
61
+ "the-prestige",
62
+ ]);
63
+ // Each skill has exactly the 5 portability agents (github-copilot excluded)
64
+ const expectedAgents = [
65
+ "antigravity",
66
+ "claude-code",
67
+ "codex",
68
+ "gemini-cli",
69
+ "opencode",
70
+ ];
71
+ for (const skill of manifest.skills) {
72
+ assert.deepEqual(skill.agents.slice().sort(), expectedAgents, `skill "${skill.name}" agents mismatch: ${JSON.stringify(skill.agents)}`);
73
+ assertPathEndsWith(skill.path, `skills/${skill.name}`, `skill "${skill.name}" path should end with skills/${skill.name}`);
74
+ }
75
+ // All other sections are empty arrays
76
+ assert.deepEqual(manifest.mcpServers, [], "mcpServers should be []");
77
+ assert.deepEqual(manifest.agentRules, [], "agentRules should be []");
78
+ assert.deepEqual(manifest.agentDefinitions, [], "agentDefinitions should be []");
79
+ assert.deepEqual(manifest.files, [], "files should be []");
80
+ assert.deepEqual(manifest.configs, [], "configs should be []");
81
+ });
82
+ });
83
+ // ---------------------------------------------------------------------------
84
+ // Block 2: init against README-shaped fixture (copied to temp dir)
85
+ // ---------------------------------------------------------------------------
86
+ describe("init against readme-sample fixture", () => {
87
+ it("generates a manifest covering all README-documented init discovery paths", async () => {
88
+ const tmpDir = await makeTmpDir("ie-fixture-readme");
89
+ try {
90
+ // Copy the static fixture tree (including .claude/ hidden dir) into tmpDir
91
+ await cp(FIXTURE_DIR, tmpDir, { recursive: true });
92
+ const { stdout, code } = await run(["init", tmpDir]);
93
+ assert.equal(code, 0, `init exited non-zero.\nstdout: ${stdout}`);
94
+ const manifest = JSON.parse(await readFile(path.join(tmpDir, "inception.json"), "utf-8"));
95
+ // --- skills: 2 entries, github-copilot excluded per portability rules ---
96
+ assert.equal(manifest.skills.length, 2, `expected 2 skills, got ${manifest.skills.length}: ${JSON.stringify(manifest.skills.map((s) => s.name))}`);
97
+ const skillNames = manifest.skills.map((s) => s.name).sort();
98
+ assert.deepEqual(skillNames, ["my-skill", "other-skill"]);
99
+ const expectedSkillAgents = [
100
+ "antigravity",
101
+ "claude-code",
102
+ "codex",
103
+ "gemini-cli",
104
+ "opencode",
105
+ ];
106
+ for (const skill of manifest.skills) {
107
+ assert.deepEqual(skill.agents.slice().sort(), expectedSkillAgents, `skill "${skill.name}" should have all 5 agents (not github-copilot): ${JSON.stringify(skill.agents)}`);
108
+ assertPathEndsWith(skill.path, `skills/${skill.name}`, `skill "${skill.name}" path should end with skills/${skill.name}`);
109
+ }
110
+ // --- agentRules: 3 entries for CLAUDE.md, AGENTS.md, GEMINI.md ---
111
+ assert.equal(manifest.agentRules.length, 3, `expected 3 agentRules, got ${manifest.agentRules.length}: ${JSON.stringify(manifest.agentRules.map((r) => r.name))}`);
112
+ const claudeRule = manifest.agentRules.find((r) => normalizeSlashes(r.path).endsWith("CLAUDE.md"));
113
+ assert.ok(claudeRule, "should have an agentRules entry for CLAUDE.md");
114
+ assert.deepEqual(claudeRule?.agents.slice().sort(), ["claude-code"], `CLAUDE.md agents: ${JSON.stringify(claudeRule?.agents)}`);
115
+ assert.equal(claudeRule?.scope, "global");
116
+ const agentsRule = manifest.agentRules.find((r) => normalizeSlashes(r.path).endsWith("AGENTS.md"));
117
+ assert.ok(agentsRule, "should have an agentRules entry for AGENTS.md");
118
+ assert.deepEqual(agentsRule?.agents.slice().sort(), ["codex", "opencode"], `AGENTS.md agents: ${JSON.stringify(agentsRule?.agents)}`);
119
+ assert.equal(agentsRule?.scope, "global");
120
+ const geminiRule = manifest.agentRules.find((r) => normalizeSlashes(r.path).endsWith("GEMINI.md"));
121
+ assert.ok(geminiRule, "should have an agentRules entry for GEMINI.md");
122
+ assert.deepEqual(geminiRule?.agents.slice().sort(), ["gemini-cli"],
123
+ // antigravity is shared-via gemini-cli and excluded from init defaults
124
+ `GEMINI.md agents should be [gemini-cli] only: ${JSON.stringify(geminiRule?.agents)}`);
125
+ assert.equal(geminiRule?.scope, "global");
126
+ // --- mcpServers: 1 entry round-tripped from mcp-servers.json sidecar ---
127
+ assert.equal(manifest.mcpServers.length, 1, `expected 1 mcpServer, got ${manifest.mcpServers.length}`);
128
+ const mcp = manifest.mcpServers[0];
129
+ assert.equal(mcp.name, "my-mcp-server");
130
+ assert.deepEqual(mcp.agents.slice().sort(), [
131
+ "claude-code",
132
+ "codex",
133
+ "gemini-cli",
134
+ "opencode",
135
+ ]);
136
+ assert.equal(mcp.config.command, "npx");
137
+ assert.deepEqual(mcp.config.args, ["-y", "@example/mcp-server"]);
138
+ // --- agentDefinitions: 1 entry from .claude/agents/ -> claude-code ---
139
+ assert.equal(manifest.agentDefinitions.length, 1, `expected 1 agentDefinition, got ${manifest.agentDefinitions.length}`);
140
+ const def = manifest.agentDefinitions[0];
141
+ assert.equal(def.name, "code-reviewer");
142
+ assert.deepEqual(def.agents, ["claude-code"]);
143
+ assert.equal(def.scope, "repo");
144
+ assertPathEndsWith(def.path, ".claude/agents/code-reviewer.md", "code-reviewer path should end with .claude/agents/code-reviewer.md");
145
+ // --- files and configs stay empty (no sidecar manifests for them) ---
146
+ assert.deepEqual(manifest.files, [], "files should be []");
147
+ assert.deepEqual(manifest.configs, [], "configs should be []");
148
+ }
149
+ finally {
150
+ await rm(tmpDir, { recursive: true });
151
+ }
152
+ });
153
+ });
@@ -125,6 +125,26 @@ describe("runPreflight", () => {
125
125
  assert.equal(implementationOnlyWarning, undefined, `expected no implementation-only warning, got: ${implementationOnlyWarning?.message}`);
126
126
  });
127
127
  });
128
+ describe("github-copilot planned surfaces", () => {
129
+ it("emits a planned surface info notice for devcontainer MCP when Copilot is detected and MCP is targeted", async () => {
130
+ const manifestWithMcp = {
131
+ ...emptyManifest,
132
+ mcpServers: [
133
+ {
134
+ name: "test-mcp",
135
+ scope: "repo",
136
+ agents: ["github-copilot"],
137
+ config: { command: "node", args: ["server.js"] },
138
+ },
139
+ ],
140
+ };
141
+ const warnings = await runPreflight(baseOptions, manifestWithMcp, "/home/test", ["github-copilot"]);
142
+ const plannedWarning = warnings.find((w) => w.kind === "info" &&
143
+ /devcontainer\.json MCP support is planned/.test(w.message));
144
+ assert.ok(plannedWarning, "expected a planned surface warning for Copilot devcontainer when MCP is targeted");
145
+ assert.match(plannedWarning.message, /planned/);
146
+ });
147
+ });
128
148
  describe("instruction precedence warnings", () => {
129
149
  it("emits duplicate-content precedence warning when same source used in both global and repo scope for an agent", async () => {
130
150
  const sourceDir = await makeTmpDir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuznai/inception-engine",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Deploy AI agent skills from a git repo to user home directories",
5
5
  "license": "MIT",
6
6
  "author": "Damian Piątkowski",