@kuznai/inception-engine 0.21.0 → 0.23.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.
@@ -88,9 +88,12 @@ export const McpServerEntrySchema = z.object({
88
88
  // config files for agents that support them (e.g. GitHub Copilot's
89
89
  // .vscode/mcp.json). For agents without scope-specific surfaces the
90
90
  // adapter falls back to the global surface or emits a warning.
91
- scope: z.enum(["global", "repo", "workspace"]).default("global"),
91
+ scope: z
92
+ .enum(["global", "repo", "workspace", "devcontainer"])
93
+ .default("global"),
92
94
  });
93
- export const AgentRuleEntrySchema = z.object({
95
+ export const AgentRuleEntrySchema = z
96
+ .object({
94
97
  name: nameField,
95
98
  agents: agentsField,
96
99
  // Relative path to the rules/instruction file within the source bundle.
@@ -101,7 +104,33 @@ export const AgentRuleEntrySchema = z.object({
101
104
  // file (default), "repo" targets the project-root instruction file within the
102
105
  // deployed repository (e.g. {repo}/CLAUDE.md for claude-code), and "workspace"
103
106
  // targets the agent's workspace-local instruction surface.
104
- scope: z.enum(["global", "repo", "workspace"]).default("global"),
107
+ // "copilot-repo" targets GitHub Copilot's native repo-level instruction file
108
+ // at {repo}/.github/copilot-instructions.md (github-copilot only).
109
+ // "copilot-scoped" targets {repo}/.github/instructions/{name}.instructions.md
110
+ // where {name} is the manifest entry name (github-copilot only).
111
+ scope: z
112
+ .enum(["global", "repo", "workspace", "copilot-repo", "copilot-scoped"])
113
+ .default("global"),
114
+ // Optional relative directory within the repo/workspace where the rule
115
+ // should be deployed. Only supported for scope: "repo" and scope: "workspace".
116
+ targetDir: z
117
+ .string()
118
+ .optional()
119
+ .refine((p) => !(p && nodePath.isAbsolute(p)), {
120
+ message: "targetDir must be a relative path",
121
+ })
122
+ .refine((p) => !(p && nodePath.normalize(p).startsWith("..")), {
123
+ message: "targetDir must not escape the target root",
124
+ }),
125
+ })
126
+ .superRefine((data, ctx) => {
127
+ if (data.targetDir && data.scope !== "repo" && data.scope !== "workspace") {
128
+ ctx.addIssue({
129
+ code: "custom",
130
+ path: ["targetDir"],
131
+ message: 'targetDir is only supported for scope "repo" or "workspace"',
132
+ });
133
+ }
105
134
  });
106
135
  export const PermissionsEntrySchema = z.object({
107
136
  name: nameField,
@@ -112,6 +141,13 @@ export const PermissionsEntrySchema = z.object({
112
141
  // For opencode: { permissions: { allow?: string[], ask?: string[], deny?: string[] } }
113
142
  config: z.record(z.string(), z.unknown()),
114
143
  });
144
+ export const ExecutionConfigEntrySchema = z.object({
145
+ name: nameField,
146
+ agents: agentsField,
147
+ // Raw execution config payload validated per agent by the execution-config adapter.
148
+ // For gemini-cli: { safeMode?: boolean, ... }
149
+ config: z.record(z.string(), z.unknown()),
150
+ });
115
151
  export const AgentDefinitionEntrySchema = z.object({
116
152
  name: nameField,
117
153
  agents: agentsField,
@@ -124,6 +160,14 @@ export const AgentDefinitionEntrySchema = z.object({
124
160
  // within the deployed repository (default).
125
161
  scope: z.enum(["global", "repo", "workspace"]).default("repo"),
126
162
  });
163
+ export const HookEntrySchema = z.object({
164
+ name: nameField,
165
+ agents: agentsField,
166
+ // Raw hook config payload validated per agent by the hooks adapter.
167
+ // For claude-code: { hooks: { "<EventName>": [{ matcher?: string, hooks: [{ type: "command", command: string }] }] } }
168
+ // Event names follow Claude Code's settings.json hooks surface (e.g. PreToolUse, PostToolUse, Notification, Stop, SubagentStop).
169
+ config: z.record(z.string(), z.unknown()),
170
+ });
127
171
  export const ManifestSchema = z.object({
128
172
  skills: z.array(SkillEntrySchema).superRefine((skills, ctx) => {
129
173
  const seen = new Set();
@@ -144,6 +188,8 @@ export const ManifestSchema = z.object({
144
188
  agentRules: z.array(AgentRuleEntrySchema).default([]),
145
189
  permissions: z.array(PermissionsEntrySchema).default([]),
146
190
  agentDefinitions: z.array(AgentDefinitionEntrySchema).default([]),
191
+ hooks: z.array(HookEntrySchema).optional(),
192
+ executionConfigs: z.array(ExecutionConfigEntrySchema).optional(),
147
193
  });
148
194
  // Parses the --agents CLI flag: comma-separated agent IDs → AgentId[]
149
195
  export const AgentListSchema = z
@@ -1,11 +1,11 @@
1
1
  import type { AgentId } from "./schemas/manifest.ts";
2
- export type { AgentDefinitionEntry, AgentId, ConfigEntry, FileEntry, Manifest, SkillEntry, } from "./schemas/manifest.ts";
2
+ export type { AgentDefinitionEntry, AgentId, ConfigEntry, ExecutionConfigEntry, FileEntry, HookEntry, Manifest, SkillEntry, } from "./schemas/manifest.ts";
3
3
  export interface AgentPaths {
4
4
  posix: string[];
5
5
  windows: string[];
6
6
  }
7
7
  export type Confidence = "documented" | "implementation-only" | "provisional";
8
- export type CapabilityKind = "skills" | "mcpServers" | "agentRules" | "permissions" | "agentDefinitions";
8
+ export type CapabilityKind = "skills" | "mcpServers" | "agentRules" | "permissions" | "hooks" | "executionConfigs" | "agentDefinitions";
9
9
  export interface SupportedAgentSurface {
10
10
  status: "supported";
11
11
  /**
@@ -67,6 +67,8 @@ export interface AgentProvenance {
67
67
  mcpConfig?: Confidence;
68
68
  agentRules?: Confidence;
69
69
  permissions?: Confidence;
70
+ hooks?: Confidence;
71
+ executionConfig?: Confidence;
70
72
  agentDefinitions?: Confidence;
71
73
  }
72
74
  export interface AgentConfig {
@@ -96,12 +98,18 @@ export interface AgentConfig {
96
98
  agentRulesSupport?: AgentSurfaceSupport;
97
99
  agentRulesRepoSupport?: AgentSurfaceSupport;
98
100
  agentRulesWorkspaceSupport?: AgentSurfaceSupport;
101
+ agentRulesCopilotRepoSupport?: AgentSurfaceSupport;
102
+ agentRulesCopilotScopedSupport?: AgentSurfaceSupport;
99
103
  permissionsSupport?: AgentSurfaceSupport;
104
+ hooksSupport?: AgentSurfaceSupport;
105
+ hooksRepoSupport?: AgentSurfaceSupport;
106
+ hooksWorkspaceSupport?: AgentSurfaceSupport;
100
107
  agentDefinitionsSupport?: AgentSurfaceSupport;
101
108
  agentDefinitionsRepoSupport?: AgentSurfaceSupport;
102
109
  agentDefinitionsWorkspaceSupport?: AgentSurfaceSupport;
103
110
  agentDefinitionsTomlSupport?: AgentSurfaceSupport;
104
111
  agentDefinitionsTomlRepoSupport?: AgentSurfaceSupport;
112
+ executionConfigSupport?: AgentSurfaceSupport;
105
113
  mcpDevcontainerSupport?: AgentSurfaceSupport;
106
114
  mcpAgentFrontmatterSupport?: AgentSurfaceSupport;
107
115
  policyNote?: string;
@@ -3,8 +3,9 @@ import { realpath, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
5
  import { compileMcpServerActions } from "../../src/core/adapters/mcp.js";
6
+ import { compileHookActions } from "../../src/core/adapters/hooks.js";
6
7
  import { compilePermissionsActions } from "../../src/core/adapters/permissions.js";
7
- import { compileAgentRuleActions } from "../../src/core/adapters/rules.js";
8
+ import { compileAgentRuleActions, compileAgentRuleReverts, } from "../../src/core/adapters/rules.js";
8
9
  import { makeTmpDir } from "../helpers/fs.js";
9
10
  import { assertPathEndsWith, normalizeSlashes } from "../helpers/path.js";
10
11
  describe("compileMcpServerActions", () => {
@@ -137,13 +138,13 @@ describe("compileMcpServerActions", () => {
137
138
  servers: { "my-mcp": { command: "s" } },
138
139
  });
139
140
  });
140
- it("returns a frontmatter-emit action for antigravity MCP", () => {
141
+ it("returns a frontmatter-emit action for antigravity MCP with scope: repo", () => {
141
142
  const home = "/home/test";
142
143
  const { actions, warnings } = compileMcpServerActions({
143
144
  name: "my-mcp",
144
145
  agents: ["antigravity"],
145
146
  config: { command: "s" },
146
- scope: "global",
147
+ scope: "repo",
147
148
  }, ["antigravity"], home, "/repo/test");
148
149
  assert.equal(actions.length, 1);
149
150
  assert.equal(warnings.length, 0);
@@ -151,6 +152,20 @@ describe("compileMcpServerActions", () => {
151
152
  assert.equal(actions[0]?.agent, "antigravity");
152
153
  assertPathEndsWith(actions[0]?.target ?? "", ".agents/rules/my-mcp.md", `expected target to end with .agents/rules/my-mcp.md, got ${actions[0]?.target}`);
153
154
  });
155
+ it("returns a config-patch action for antigravity MCP with scope: global", () => {
156
+ const home = "/home/test";
157
+ const { actions, warnings } = compileMcpServerActions({
158
+ name: "my-mcp",
159
+ agents: ["antigravity"],
160
+ config: { command: "s" },
161
+ scope: "global",
162
+ }, ["antigravity"], home);
163
+ assert.equal(actions.length, 1);
164
+ assert.equal(warnings.length, 0);
165
+ assert.equal(actions[0]?.kind, "config-patch");
166
+ assert.equal(actions[0]?.agent, "antigravity");
167
+ assertPathEndsWith(actions[0]?.target ?? "", ".gemini/antigravity/mcp_config.json", `expected target to end with .gemini/antigravity/mcp_config.json, got ${actions[0]?.target}`);
168
+ });
154
169
  it("throws when a supported MCP target is missing both command and url", () => {
155
170
  assert.throws(() => compileMcpServerActions({
156
171
  name: "my-mcp",
@@ -340,6 +355,115 @@ describe("compileAgentRuleActions", () => {
340
355
  await rm(dir, { recursive: true });
341
356
  }
342
357
  });
358
+ it("scope copilot-repo: returns file-write action targeting {repo}/.github/copilot-instructions.md", async () => {
359
+ const dir = await makeTmpDir();
360
+ try {
361
+ await writeFile(path.join(dir, "copilot.md"), "# Copilot rules");
362
+ const repo = "/repo/myproject";
363
+ const realRoot = await realpath(dir);
364
+ const { actions, warnings } = await compileAgentRuleActions({
365
+ name: "copilot-main",
366
+ agents: ["github-copilot"],
367
+ path: "copilot.md",
368
+ scope: "copilot-repo",
369
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", repo);
370
+ assert.equal(actions.length, 1);
371
+ assert.equal(warnings.length, 0);
372
+ const action = actions[0];
373
+ assert.equal(action.kind, "file-write");
374
+ assert.equal(action.agent, "github-copilot");
375
+ assert.equal(normalizeSlashes(action.target), `${repo}/.github/copilot-instructions.md`, `expected target at {repo}/.github/copilot-instructions.md, got: ${action.target}`);
376
+ assert.equal(action.confidence, "documented");
377
+ }
378
+ finally {
379
+ await rm(dir, { recursive: true });
380
+ }
381
+ });
382
+ it("scope copilot-scoped: returns file-write action with {name} substituted in path", async () => {
383
+ const dir = await makeTmpDir();
384
+ try {
385
+ await writeFile(path.join(dir, "typescript.md"), "# TypeScript rules");
386
+ const repo = "/repo/myproject";
387
+ const realRoot = await realpath(dir);
388
+ const { actions, warnings } = await compileAgentRuleActions({
389
+ name: "typescript",
390
+ agents: ["github-copilot"],
391
+ path: "typescript.md",
392
+ scope: "copilot-scoped",
393
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", repo);
394
+ assert.equal(actions.length, 1);
395
+ assert.equal(warnings.length, 0);
396
+ const action = actions[0];
397
+ assert.equal(action.kind, "file-write");
398
+ assert.equal(action.agent, "github-copilot");
399
+ assert.equal(normalizeSlashes(action.target), `${repo}/.github/instructions/typescript.instructions.md`, `expected target at {repo}/.github/instructions/typescript.instructions.md, got: ${action.target}`);
400
+ assert.equal(action.confidence, "documented");
401
+ }
402
+ finally {
403
+ await rm(dir, { recursive: true });
404
+ }
405
+ });
406
+ it("scope copilot-repo: returns warning when no repo path provided", async () => {
407
+ const dir = await makeTmpDir();
408
+ try {
409
+ await writeFile(path.join(dir, "copilot.md"), "# Copilot rules");
410
+ const realRoot = await realpath(dir);
411
+ const { actions, warnings } = await compileAgentRuleActions({
412
+ name: "copilot-main",
413
+ agents: ["github-copilot"],
414
+ path: "copilot.md",
415
+ scope: "copilot-repo",
416
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test");
417
+ assert.equal(actions.length, 0);
418
+ assert.equal(warnings.length, 1);
419
+ assert.equal(warnings[0]?.kind, "confidence");
420
+ assert.match(warnings[0]?.message ?? "", /copilot-repo/);
421
+ assert.match(warnings[0]?.message ?? "", /repository path/);
422
+ }
423
+ finally {
424
+ await rm(dir, { recursive: true });
425
+ }
426
+ });
427
+ it("scope copilot-repo: non-github-copilot agents get unsupported warning", async () => {
428
+ const dir = await makeTmpDir();
429
+ try {
430
+ await writeFile(path.join(dir, "copilot.md"), "# Copilot rules");
431
+ const realRoot = await realpath(dir);
432
+ const { actions, warnings } = await compileAgentRuleActions({
433
+ name: "copilot-main",
434
+ agents: ["claude-code"],
435
+ path: "copilot.md",
436
+ scope: "copilot-repo",
437
+ }, dir, dir, realRoot, ["claude-code"], "/home/test", "/repo/test");
438
+ assert.equal(actions.length, 0);
439
+ assert.equal(warnings.length, 1);
440
+ assert.equal(warnings[0]?.kind, "confidence");
441
+ }
442
+ finally {
443
+ await rm(dir, { recursive: true });
444
+ }
445
+ });
446
+ it("scope copilot-repo: plain markdown without frontmatter succeeds (no instructionFrontmatterRequired check)", async () => {
447
+ const dir = await makeTmpDir();
448
+ try {
449
+ // Plain markdown with no frontmatter — should NOT throw despite github-copilot
450
+ // having instructionFrontmatterRequired: true (native agentRules scopes skip that check)
451
+ await writeFile(path.join(dir, "plain.md"), "# Plain rules\n\nNo frontmatter.");
452
+ const repo = "/repo/myproject";
453
+ const realRoot = await realpath(dir);
454
+ const { actions, warnings } = await compileAgentRuleActions({
455
+ name: "plain-rules",
456
+ agents: ["github-copilot"],
457
+ path: "plain.md",
458
+ scope: "copilot-repo",
459
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", repo);
460
+ assert.equal(actions.length, 1);
461
+ assert.equal(warnings.length, 0);
462
+ }
463
+ finally {
464
+ await rm(dir, { recursive: true });
465
+ }
466
+ });
343
467
  it("throws when rules source file does not exist", async () => {
344
468
  const dir = await makeTmpDir();
345
469
  try {
@@ -455,6 +579,29 @@ describe("compileAgentRuleActions", () => {
455
579
  await rm(dir, { recursive: true });
456
580
  }
457
581
  });
582
+ it("scope repo with targetDir: injects targetDir into path for claude-code", async () => {
583
+ const dir = await makeTmpDir();
584
+ try {
585
+ const rulesFile = path.join(dir, "CLAUDE.md");
586
+ await writeFile(rulesFile, "# Rules");
587
+ const repo = "/repo/myproject";
588
+ const realRoot = await realpath(dir);
589
+ const { actions, warnings } = await compileAgentRuleActions({
590
+ name: "my-rule",
591
+ agents: ["claude-code"],
592
+ path: "CLAUDE.md",
593
+ scope: "repo",
594
+ targetDir: "apps/frontend",
595
+ }, dir, dir, realRoot, ["claude-code"], "/home/test", repo);
596
+ assert.equal(actions.length, 1);
597
+ assert.equal(warnings.length, 0);
598
+ const action = actions[0];
599
+ assert.equal(normalizeSlashes(action.target), `${repo}/apps/frontend/CLAUDE.md`, `expected target at {repo}/apps/frontend/CLAUDE.md, got: ${action.target}`);
600
+ }
601
+ finally {
602
+ await rm(dir, { recursive: true });
603
+ }
604
+ });
458
605
  it("scope repo: emits a warning and skips when repo path is not provided", async () => {
459
606
  const dir = await makeTmpDir();
460
607
  try {
@@ -538,6 +685,21 @@ describe("compileAgentRuleActions", () => {
538
685
  }
539
686
  });
540
687
  });
688
+ describe("compileAgentRuleReverts", () => {
689
+ it("injects targetDir into revert path", () => {
690
+ const home = "/home/test";
691
+ const repo = "/repo/test";
692
+ const actions = compileAgentRuleReverts({
693
+ name: "my-rule",
694
+ agents: ["claude-code"],
695
+ path: "CLAUDE.md",
696
+ scope: "repo",
697
+ targetDir: "apps/frontend",
698
+ }, ["claude-code"], home, repo);
699
+ assert.equal(actions.length, 1);
700
+ assert.equal(normalizeSlashes(actions[0]?.target ?? ""), `${repo}/apps/frontend/CLAUDE.md`);
701
+ });
702
+ });
541
703
  describe("compilePermissionsActions", () => {
542
704
  it("returns zero actions and warnings when no detected agents overlap", () => {
543
705
  const { actions, warnings } = compilePermissionsActions({
@@ -669,6 +831,121 @@ describe("compilePermissionsActions", () => {
669
831
  assert.ok(agents.includes("codex"));
670
832
  });
671
833
  });
834
+ describe("compileHookActions", () => {
835
+ const validClaudeHookConfig = {
836
+ hooks: {
837
+ PreToolUse: [
838
+ {
839
+ matcher: "Bash",
840
+ hooks: [{ type: "command", command: "scripts/check.sh" }],
841
+ },
842
+ ],
843
+ },
844
+ };
845
+ it("returns zero actions and warnings when no detected agents overlap", () => {
846
+ const { actions, warnings } = compileHookActions({ name: "check", agents: ["claude-code"], config: validClaudeHookConfig }, ["codex"], "/home/test");
847
+ assert.equal(actions.length, 0);
848
+ assert.equal(warnings.length, 0);
849
+ });
850
+ it("returns a config-patch action for claude-code targeting settings.json", () => {
851
+ const home = "/home/test";
852
+ const { actions, warnings } = compileHookActions({ name: "check", agents: ["claude-code"], config: validClaudeHookConfig }, ["claude-code"], home);
853
+ assert.equal(warnings.length, 0);
854
+ assert.equal(actions.length, 1);
855
+ const action = actions[0];
856
+ assert.equal(action.kind, "config-patch");
857
+ assert.ok(action.target.endsWith(".claude/settings.json".replace("/", path.sep)));
858
+ assert.equal(action.agent, "claude-code");
859
+ assert.equal(action.confidence, "documented");
860
+ });
861
+ it("accepts a hooks entry with no matcher property on the matcher object", () => {
862
+ const { actions, warnings } = compileHookActions({
863
+ name: "check",
864
+ agents: ["claude-code"],
865
+ config: {
866
+ hooks: {
867
+ Stop: [{ hooks: [{ type: "command", command: "notify.sh" }] }],
868
+ },
869
+ },
870
+ }, ["claude-code"], "/home/test");
871
+ assert.equal(warnings.length, 0);
872
+ assert.equal(actions.length, 1);
873
+ });
874
+ it("emits a warning and skips for agents without a hooks surface", () => {
875
+ const { actions, warnings } = compileHookActions({ name: "check", agents: ["gemini-cli"], config: {} }, ["gemini-cli"], "/home/test");
876
+ assert.equal(actions.length, 0);
877
+ assert.equal(warnings.length, 1);
878
+ });
879
+ it("throws on unknown top-level key in config for claude-code", () => {
880
+ assert.throws(() => compileHookActions({ name: "bad", agents: ["claude-code"], config: { bad_key: {} } }, ["claude-code"], "/home/test"), /unrecognized keys/);
881
+ });
882
+ it("throws when hooks value is not an object for claude-code", () => {
883
+ assert.throws(() => compileHookActions({ name: "bad", agents: ["claude-code"], config: { hooks: "string" } }, ["claude-code"], "/home/test"), /must define "hooks" as an object/);
884
+ });
885
+ it("throws when an event value is not an array", () => {
886
+ assert.throws(() => compileHookActions({
887
+ name: "bad",
888
+ agents: ["claude-code"],
889
+ config: { hooks: { PreToolUse: "not-array" } },
890
+ }, ["claude-code"], "/home/test"), /must be an array/);
891
+ });
892
+ it("throws when a matcher element is not an object", () => {
893
+ assert.throws(() => compileHookActions({
894
+ name: "bad",
895
+ agents: ["claude-code"],
896
+ config: { hooks: { PreToolUse: ["string"] } },
897
+ }, ["claude-code"], "/home/test"), /must be an object/);
898
+ });
899
+ it("throws when matcher.hooks is missing", () => {
900
+ assert.throws(() => compileHookActions({
901
+ name: "bad",
902
+ agents: ["claude-code"],
903
+ config: { hooks: { PreToolUse: [{ matcher: "Bash" }] } },
904
+ }, ["claude-code"], "/home/test"), /must be an array/);
905
+ });
906
+ it("throws when a hook command type is not 'command'", () => {
907
+ assert.throws(() => compileHookActions({
908
+ name: "bad",
909
+ agents: ["claude-code"],
910
+ config: {
911
+ hooks: {
912
+ PreToolUse: [
913
+ {
914
+ hooks: [{ type: "script", command: "check.sh" }],
915
+ },
916
+ ],
917
+ },
918
+ },
919
+ }, ["claude-code"], "/home/test"), /must be "command"/);
920
+ });
921
+ it("throws when a hook command is not a string", () => {
922
+ assert.throws(() => compileHookActions({
923
+ name: "bad",
924
+ agents: ["claude-code"],
925
+ config: {
926
+ hooks: {
927
+ PreToolUse: [{ hooks: [{ type: "command", command: 123 }] }],
928
+ },
929
+ },
930
+ }, ["claude-code"], "/home/test"), /must be a string/);
931
+ });
932
+ it("throws when matcher.matcher is not a string", () => {
933
+ assert.throws(() => compileHookActions({
934
+ name: "bad",
935
+ agents: ["claude-code"],
936
+ config: {
937
+ hooks: {
938
+ PreToolUse: [
939
+ {
940
+ matcher: 42,
941
+ hooks: [{ type: "command", command: "check.sh" }],
942
+ },
943
+ ],
944
+ },
945
+ },
946
+ }, ["claude-code"], "/home/test"), /must be a string when present/);
947
+ });
948
+ });
672
949
  describe("compileAgentDefinitionActions", () => {
673
950
  // Import is added inline to avoid modifying the top-level imports block
674
951
  // (the function is async so we do a dynamic import once and reuse).
@@ -30,6 +30,7 @@ const testManifest = {
30
30
  agentRules: [],
31
31
  permissions: [],
32
32
  agentDefinitions: [],
33
+ hooks: [],
33
34
  };
34
35
  describe("planDeploy", () => {
35
36
  it("creates actions for detected agents only", async () => {
@@ -67,6 +68,32 @@ describe("planDeploy", () => {
67
68
  await rm(sourceDir, { recursive: true });
68
69
  }
69
70
  });
71
+ it("includes executionConfigs actions for supported detected agents", async () => {
72
+ const sourceDir = await makeTmpDir();
73
+ try {
74
+ const manifest = {
75
+ ...testManifest,
76
+ executionConfigs: [
77
+ {
78
+ name: "gemini-safety",
79
+ agents: ["gemini-cli"],
80
+ config: { safeMode: true },
81
+ },
82
+ ],
83
+ };
84
+ await createSkillSource(sourceDir, "skills/test-skill");
85
+ const { actions } = await planDeploy(manifest, sourceDir, ["gemini-cli"], "/home/test");
86
+ const executionAction = actions.find((action) => action.kind === "config-patch" &&
87
+ action.agent === "gemini-cli" &&
88
+ action.skill === "gemini-safety");
89
+ assert.ok(executionAction, "expected planDeploy to include the gemini executionConfigs patch");
90
+ assert.deepEqual(executionAction.patch, { safeMode: true });
91
+ assertPathEndsWith(executionAction.target, ".gemini/settings.json", `expected Gemini execution config target under .gemini/settings.json, got: ${executionAction.target}`);
92
+ }
93
+ finally {
94
+ await rm(sourceDir, { recursive: true });
95
+ }
96
+ });
70
97
  it("throws when skill source path does not exist", async () => {
71
98
  const sourceDir = await makeTmpDir();
72
99
  try {
@@ -647,8 +674,9 @@ describe("planDeploy", () => {
647
674
  assert.ok(geminiAction, "expected a gemini-cli action");
648
675
  assert.match(geminiAction.target, /[\\/]\.gemini[\\/]settings\.json$/);
649
676
  assert.ok(antigravityAction, "expected an antigravity action");
650
- // Antigravity's target for mcpServer is .agents/rules/{name}.md
651
- assert.match(antigravityAction.target, /[\\/]\.agents[\\/]rules[\\/]my-mcp\.md$/);
677
+ // Antigravity's target for global mcpServer is .gemini/antigravity/mcp_config.json
678
+ assert.equal(antigravityAction.kind, "config-patch");
679
+ assert.match(antigravityAction.target, /[\\/]\.gemini[\\/]antigravity[\\/]mcp_config\.json$/);
652
680
  }
653
681
  finally {
654
682
  await rm(sourceDir, { recursive: true });
@@ -802,7 +830,7 @@ describe("planDeploy", () => {
802
830
  name: "my-tool",
803
831
  agents: ["antigravity"],
804
832
  config: { command: "npx", args: ["-y", "my-tool"] },
805
- scope: "global",
833
+ scope: "repo",
806
834
  },
807
835
  ],
808
836
  agentRules: [],
@@ -1,10 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
- import { cp, readFile, rm } from "node:fs/promises";
2
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import path from "node:path";
5
5
  import { describe, it } from "node:test";
6
+ import { runInit } from "../../src/core/init.js";
7
+ import { logger } from "../../src/logger.js";
6
8
  import { makeTmpDir } from "../helpers/fs.js";
7
9
  import { assertPathEndsWith, normalizeSlashes } from "../helpers/path.js";
10
+ logger.silence();
8
11
  const PROJECT_ROOT = path.resolve(import.meta.dirname, "..", "..");
9
12
  const FIXTURE_DIR = path.join(PROJECT_ROOT, "test", "fixtures", "readme-sample");
10
13
  function run(args, env) {
@@ -151,3 +154,84 @@ describe("init against readme-sample fixture", () => {
151
154
  }
152
155
  });
153
156
  });
157
+ // ---------------------------------------------------------------------------
158
+ // Block 3: init discovery of GitHub Copilot native instruction surfaces
159
+ // ---------------------------------------------------------------------------
160
+ describe("init Copilot native instruction discovery", () => {
161
+ it("runInit dryRun succeeds when .github/copilot-instructions.md is present", async () => {
162
+ const dir = await makeTmpDir();
163
+ try {
164
+ await mkdir(path.join(dir, ".github"), { recursive: true });
165
+ await writeFile(path.join(dir, ".github", "copilot-instructions.md"), "# Copilot instructions");
166
+ // Use dryRun to avoid writing inception.json
167
+ const result = await runInit({
168
+ directory: dir,
169
+ agents: null,
170
+ dryRun: true,
171
+ force: false,
172
+ verbose: false,
173
+ });
174
+ assert.equal(result, 0);
175
+ }
176
+ finally {
177
+ await rm(dir, { recursive: true });
178
+ }
179
+ });
180
+ it("init --plan output includes copilot-repo scope for .github/copilot-instructions.md", async () => {
181
+ const dir = await makeTmpDir();
182
+ try {
183
+ await mkdir(path.join(dir, ".github"), { recursive: true });
184
+ await writeFile(path.join(dir, ".github", "copilot-instructions.md"), "# Copilot instructions");
185
+ const { stdout, code } = await run(["init", dir, "--plan"]);
186
+ assert.equal(code, 0, `init --plan failed:\n${stdout}`);
187
+ const manifest = extractPlanJson(stdout);
188
+ const copilotEntry = manifest.agentRules.find((r) => r.scope === "copilot-repo");
189
+ assert.ok(copilotEntry, `expected a copilot-repo entry in agentRules, got: ${JSON.stringify(manifest.agentRules)}`);
190
+ assert.deepEqual(copilotEntry.agents, ["github-copilot"]);
191
+ assertPathEndsWith(copilotEntry.path, ".github/copilot-instructions.md", `copilot-repo entry path should end with .github/copilot-instructions.md`);
192
+ }
193
+ finally {
194
+ await rm(dir, { recursive: true });
195
+ }
196
+ });
197
+ it("init --plan output includes copilot-scoped scope for .github/instructions/*.instructions.md", async () => {
198
+ const dir = await makeTmpDir();
199
+ try {
200
+ await mkdir(path.join(dir, ".github", "instructions"), {
201
+ recursive: true,
202
+ });
203
+ await writeFile(path.join(dir, ".github", "instructions", "typescript.instructions.md"), "# TypeScript scoped instructions");
204
+ await writeFile(path.join(dir, ".github", "instructions", "python.instructions.md"), "# Python scoped instructions");
205
+ const { stdout, code } = await run(["init", dir, "--plan"]);
206
+ assert.equal(code, 0, `init --plan failed:\n${stdout}`);
207
+ const manifest = extractPlanJson(stdout);
208
+ const scopedEntries = manifest.agentRules.filter((r) => r.scope === "copilot-scoped");
209
+ assert.equal(scopedEntries.length, 2, `expected 2 copilot-scoped entries, got ${scopedEntries.length}: ${JSON.stringify(manifest.agentRules)}`);
210
+ for (const entry of scopedEntries) {
211
+ assert.deepEqual(entry.agents, ["github-copilot"]);
212
+ }
213
+ const names = scopedEntries.map((e) => e.name).sort();
214
+ assert.deepEqual(names, ["python", "typescript"]);
215
+ }
216
+ finally {
217
+ await rm(dir, { recursive: true });
218
+ }
219
+ });
220
+ it("non-.github/ copilot-instructions.md still maps to claude-code (backward compat)", async () => {
221
+ const dir = await makeTmpDir();
222
+ try {
223
+ await mkdir(path.join(dir, "rules"), { recursive: true });
224
+ await writeFile(path.join(dir, "rules", "copilot-instructions.md"), "# Copilot instructions");
225
+ const { stdout, code } = await run(["init", dir, "--plan"]);
226
+ assert.equal(code, 0, `init --plan failed:\n${stdout}`);
227
+ const manifest = extractPlanJson(stdout);
228
+ const entry = manifest.agentRules.find((r) => r.path.endsWith("copilot-instructions.md"));
229
+ assert.ok(entry, `expected a copilot-instructions.md entry, got: ${JSON.stringify(manifest.agentRules)}`);
230
+ assert.ok(entry.agents.includes("claude-code"), `expected agents to include claude-code, got: ${JSON.stringify(entry.agents)}`);
231
+ assert.notEqual(entry.scope, "copilot-repo", "rules/ copilot-instructions.md should not use copilot-repo scope");
232
+ }
233
+ finally {
234
+ await rm(dir, { recursive: true });
235
+ }
236
+ });
237
+ });