@kuznai/inception-engine 0.7.0 → 0.8.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Plant skills directly into the minds of your installed AI coding agents — Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, and GitHub Copilot. One command. They'll think they thought of it themselves.
4
4
 
5
- Today, inception-engine is a skills deployer. The public manifest and CLI planner currently deploy and revert skill directories only. The core executor also has internal support for single-file write and JSON config patch actions, but the manifest format does not expose them yet. MCP configuration and agent-specific config patching remain unimplemented at the manifest level.
5
+ Today, inception-engine deploys skills, single files, and JSON config patches to AI coding agents. MCP configuration and agent rules remain unimplemented at the manifest level.
6
6
 
7
7
  ## Quick Start
8
8
 
@@ -41,8 +41,8 @@ Managed skills overwrite their previous version. If a target exists but was not
41
41
  | Feature | Status |
42
42
  |---|---|
43
43
  | Skills (SKILL.md) | Supported via manifest and CLI |
44
- | File write | Internal executor support only; not exposed in manifest or CLI planning |
45
- | Config patch (JSON merge) | Internal executor support only; not exposed in manifest or CLI planning |
44
+ | File write | Supported via manifest and CLI |
45
+ | Config patch (JSON merge) | Supported via manifest and CLI |
46
46
  | MCP Servers | Accepted in manifest for forward compatibility, not implemented |
47
47
  | Agent Rules | Accepted in manifest for forward compatibility, not implemented |
48
48
 
@@ -59,18 +59,50 @@ Create an `inception.json` file at the root of your skills directory:
59
59
  "agents": ["claude-code", "codex", "gemini-cli", "antigravity", "opencode", "github-copilot"]
60
60
  }
61
61
  ],
62
+ "files": [
63
+ {
64
+ "name": "my-settings",
65
+ "path": "files/settings.json",
66
+ "target": "{home}/.claude/settings.json",
67
+ "agents": ["claude-code"]
68
+ }
69
+ ],
70
+ "configs": [
71
+ {
72
+ "name": "enable-feature",
73
+ "target": "{home}/.claude/settings.json",
74
+ "patch": { "someFeature": true },
75
+ "agents": ["claude-code"]
76
+ }
77
+ ],
62
78
  "mcpServers": [],
63
79
  "agentRules": []
64
80
  }
65
81
  ```
66
82
 
67
- Each skill entry has:
83
+ Each **skill** entry has:
68
84
 
69
- - **name** - Unique skill identifier using letters, digits, dots, underscores, or hyphens; it must not start with a dot
85
+ - **name** - Unique identifier using letters, digits, dots, underscores, or hyphens; must not start with a dot
70
86
  - **path** - Relative path to the skill directory within the repo
71
87
  - **agents** - Array of agent IDs to deploy this skill to. If an agent isn't installed, it's skipped.
72
88
 
73
- `mcpServers` and `agentRules` are currently parsed for forward compatibility, but the deployment engine ignores them today. The manifest-driven planner and revert flow currently derive actions from `skills` entries only.
89
+ Each **file** entry deploys a single file to an agent's configuration location:
90
+
91
+ - **name** - Unique identifier (same format as skill names)
92
+ - **path** - Relative path to the source file within the repo
93
+ - **target** - Destination path using a placeholder prefix: `{home}`, `{appdata}` (Windows), or `{xdg_config}` (Linux). For example: `{home}/.claude/settings.json`
94
+ - **agents** - Array of agent IDs to deploy this file to
95
+
96
+ Each **config** entry applies a [JSON merge patch (RFC 7386)](https://datatracker.ietf.org/doc/html/rfc7386) to an existing agent config file:
97
+
98
+ - **name** - Unique identifier (same format as skill names)
99
+ - **target** - Config file to patch, using the same placeholder prefix as file entries
100
+ - **patch** - JSON object of keys to set. A `null` value removes the key from the target file. Non-null values are set directly (deep merge is not applied).
101
+ - **agents** - Array of agent IDs to apply this patch to
102
+
103
+ The engine records an undo-patch for each config-patch deployment so that `revert` can restore the original values.
104
+
105
+ `mcpServers` and `agentRules` are currently parsed for forward compatibility, but the deployment engine ignores them today.
74
106
 
75
107
  ## Creating Skills
76
108
 
@@ -106,6 +106,7 @@ export const AGENT_REGISTRY = [
106
106
  detectPaths: "documented",
107
107
  detectBinary: "documented",
108
108
  },
109
+ policyNote: "Organization policies may override locally deployed skills. Verify with your GitHub org admin if deployed skills are not active.",
109
110
  },
110
111
  ];
111
112
  export const AGENT_REGISTRY_BY_ID = Object.fromEntries(AGENT_REGISTRY.map((a) => [a.id, a]));
@@ -38,9 +38,12 @@ function validateManifest(data, filePath) {
38
38
  if (issuePath.length === 1 && issuePath[0] === "skills") {
39
39
  throw new UserError("MANIFEST_INVALID", `${filePath}: "skills" must be an array`);
40
40
  }
41
- // Top-level "mcpServers" or "agentRules": wrong type → uniform message
41
+ // Top-level array fields with wrong type → uniform message
42
42
  if (issuePath.length === 1 &&
43
- (issuePath[0] === "mcpServers" || issuePath[0] === "agentRules")) {
43
+ (issuePath[0] === "mcpServers" ||
44
+ issuePath[0] === "agentRules" ||
45
+ issuePath[0] === "files" ||
46
+ issuePath[0] === "configs")) {
44
47
  throw new UserError("MANIFEST_INVALID", `${filePath}: "${issuePath[0]}" must be an array`);
45
48
  }
46
49
  throw new UserError("MANIFEST_INVALID", `${filePath}: ${formatZodPath(issuePath)}${issue.message}`);
@@ -1,3 +1,4 @@
1
+ import { constants } from "node:fs";
1
2
  import { access, copyFile, cp, lstat, mkdir, readFile, realpath, rename, rm, symlink, unlink, writeFile, } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
@@ -59,6 +60,27 @@ function sourceAccessError(err, sourcePath) {
59
60
  const detail = err instanceof Error ? err.message : String(err);
60
61
  return `Failed to access source ${sourcePath}: ${detail}`;
61
62
  }
63
+ function resolveTargetTemplate(template, home) {
64
+ const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
65
+ const xdgRaw = process.env.XDG_CONFIG_HOME;
66
+ const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
67
+ return template
68
+ .replace("{home}", home)
69
+ .replace("{appdata}", appdata)
70
+ .replace("{xdg_config}", xdgConfig);
71
+ }
72
+ async function validateSourceFile(sourcePath, manifestPath) {
73
+ let stat;
74
+ try {
75
+ stat = await lstat(sourcePath);
76
+ }
77
+ catch (err) {
78
+ throw new UserError("DEPLOY_FAILED", sourceAccessError(err, manifestPath));
79
+ }
80
+ if (!stat.isFile()) {
81
+ throw new UserError("DEPLOY_FAILED", `Source is not a file: ${manifestPath}`);
82
+ }
83
+ }
62
84
  function detectCollisions(actions) {
63
85
  const seen = new Map();
64
86
  const warnings = [];
@@ -87,17 +109,9 @@ function detectAmbiguities(detectedAgents) {
87
109
  }
88
110
  return warnings;
89
111
  }
90
- export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
112
+ async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
91
113
  const method = getDeployMethod();
92
114
  const actions = [];
93
- const resolvedSourceDir = path.resolve(sourceDir);
94
- let realRoot;
95
- try {
96
- realRoot = await realpath(resolvedSourceDir);
97
- }
98
- catch {
99
- realRoot = resolvedSourceDir;
100
- }
101
115
  for (const skill of manifest.skills) {
102
116
  const source = path.resolve(sourceDir, skill.path);
103
117
  await validateSourcePath(source, skill.path, resolvedSourceDir, realRoot);
@@ -108,18 +122,78 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
108
122
  const agent = AGENT_REGISTRY_BY_ID[agentId];
109
123
  if (!agent)
110
124
  continue;
111
- const target = resolveAgentSkillPath(agent, skill.name, home);
112
125
  actions.push({
113
126
  kind: "skill-dir",
114
127
  skill: skill.name,
115
128
  agent: agentId,
116
129
  source,
117
- target,
130
+ target: resolveAgentSkillPath(agent, skill.name, home),
118
131
  method,
119
132
  confidence: agent.provenance.skills,
120
133
  });
121
134
  }
122
135
  }
136
+ return actions;
137
+ }
138
+ async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
139
+ const actions = [];
140
+ for (const fileEntry of manifest.files ?? []) {
141
+ const source = path.resolve(sourceDir, fileEntry.path);
142
+ await validateSourcePath(source, fileEntry.path, resolvedSourceDir, realRoot);
143
+ await validateSourceFile(source, fileEntry.path);
144
+ for (const agentId of fileEntry.agents) {
145
+ if (!detectedAgents.includes(agentId))
146
+ continue;
147
+ const agent = AGENT_REGISTRY_BY_ID[agentId];
148
+ if (!agent)
149
+ continue;
150
+ actions.push({
151
+ kind: "file-write",
152
+ skill: fileEntry.name,
153
+ agent: agentId,
154
+ source,
155
+ target: resolveTargetTemplate(fileEntry.target, home),
156
+ confidence: agent.provenance.skills,
157
+ });
158
+ }
159
+ }
160
+ return actions;
161
+ }
162
+ function planConfigPatchActions(manifest, detectedAgents, home) {
163
+ const actions = [];
164
+ for (const configEntry of manifest.configs ?? []) {
165
+ for (const agentId of configEntry.agents) {
166
+ if (!detectedAgents.includes(agentId))
167
+ continue;
168
+ const agent = AGENT_REGISTRY_BY_ID[agentId];
169
+ if (!agent)
170
+ continue;
171
+ actions.push({
172
+ kind: "config-patch",
173
+ skill: configEntry.name,
174
+ agent: agentId,
175
+ target: resolveTargetTemplate(configEntry.target, home),
176
+ patch: configEntry.patch,
177
+ confidence: agent.provenance.skills,
178
+ });
179
+ }
180
+ }
181
+ return actions;
182
+ }
183
+ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
184
+ const resolvedSourceDir = path.resolve(sourceDir);
185
+ let realRoot;
186
+ try {
187
+ realRoot = await realpath(resolvedSourceDir);
188
+ }
189
+ catch {
190
+ realRoot = resolvedSourceDir;
191
+ }
192
+ const actions = [
193
+ ...(await planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home)),
194
+ ...(await planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home)),
195
+ ...planConfigPatchActions(manifest, detectedAgents, home),
196
+ ];
123
197
  const warnings = [
124
198
  ...detectAmbiguities(detectedAgents),
125
199
  ...detectCollisions(actions),
@@ -180,8 +254,6 @@ async function deploySkillDir(action, dryRun, verbose, home, planned) {
180
254
  return { error: msg };
181
255
  }
182
256
  if (dryRun) {
183
- logger.plan(label);
184
- logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
185
257
  planned.push({
186
258
  verb: action.method === "symlink" ? "create-symlink" : "copy-dir",
187
259
  kind: "skill-dir",
@@ -215,8 +287,6 @@ async function deployFileWrite(action, dryRun, verbose, home, planned) {
215
287
  return { error: msg };
216
288
  }
217
289
  if (dryRun) {
218
- logger.plan(label);
219
- logger.detail(`write-file: ${action.source} -> ${action.target}`);
220
290
  planned.push({
221
291
  verb: "write-file",
222
292
  kind: "file-write",
@@ -275,8 +345,6 @@ async function deployConfigPatch(action, dryRun, verbose, home, planned) {
275
345
  }
276
346
  const patch = action.patch;
277
347
  if (dryRun) {
278
- logger.plan(label);
279
- logger.detail(`patch-config: ${JSON.stringify(patch)} -> ${action.target}`);
280
348
  planned.push({
281
349
  verb: "patch-config",
282
350
  kind: "config-patch",
@@ -354,9 +422,23 @@ async function validateSkillContract(source, skillPath) {
354
422
  throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is not a directory: ${source}`);
355
423
  }
356
424
  try {
357
- await access(path.join(source, "SKILL.md"));
425
+ await access(source, constants.R_OK);
358
426
  }
359
- catch {
427
+ catch (err) {
428
+ const code = err.code;
429
+ if (code === "EACCES" || code === "EPERM") {
430
+ throw new UserError("DEPLOY_FAILED", `Permission denied reading skill directory "${skillPath}": ${source}`);
431
+ }
432
+ throw new UserError("DEPLOY_FAILED", `Cannot read skill directory "${skillPath}": ${source}`);
433
+ }
434
+ try {
435
+ await access(path.join(source, "SKILL.md"), constants.R_OK);
436
+ }
437
+ catch (err) {
438
+ const code = err.code;
439
+ if (code === "EACCES" || code === "EPERM") {
440
+ throw new UserError("DEPLOY_FAILED", `Permission denied reading SKILL.md in skill "${skillPath}": ${source}`);
441
+ }
360
442
  throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is missing SKILL.md: ${source}`);
361
443
  }
362
444
  }
@@ -437,17 +519,15 @@ async function backupExisting(targetPath, verbose, home, expected) {
437
519
  throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
438
520
  }
439
521
  const backupPath = `${targetPath}.inception-backup`;
440
- // Clean up any stale backup from a previous failed attempt
441
- try {
442
- await lstat(backupPath);
443
- await removeTarget(backupPath);
444
- }
445
- catch {
446
- // No stale backup — expected
447
- }
448
522
  if (verbose) {
449
523
  logger.detail(`backing up existing target: ${targetPath}`);
450
524
  }
525
+ // Remove any stale backup from a previous failed attempt. Using rm with
526
+ // { force: true } avoids a separate lstat existence check and handles
527
+ // the case where the stale backup is a directory (which rename cannot
528
+ // atomically replace on POSIX). This reduces the window between the
529
+ // stale-backup removal and the rename to a single step.
530
+ await rm(backupPath, { recursive: true, force: true });
451
531
  await rename(targetPath, backupPath);
452
532
  return backupPath;
453
533
  }
@@ -17,6 +17,12 @@ export async function runPreflight(_options, _manifest, _home, detectedAgents) {
17
17
  message: `Agent "${agentId}" skill support is provisional: behavior has not been independently verified.`,
18
18
  });
19
19
  }
20
+ if (agent.policyNote) {
21
+ warnings.push({
22
+ kind: "policy",
23
+ message: `Agent "${agentId}": ${agent.policyNote}`,
24
+ });
25
+ }
20
26
  }
21
27
  return warnings;
22
28
  }
@@ -1,46 +1,89 @@
1
1
  import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
3
4
  import { logger } from "../logger.js";
4
5
  import { lookupDeployment, unregisterDeployment } from "./ownership.js";
5
6
  import { resolveAgentSkillPath } from "./resolve.js";
6
- export function planRevert(manifest, detectedAgents, home) {
7
+ function resolveTargetTemplate(template, home) {
8
+ const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
9
+ const xdgRaw = process.env.XDG_CONFIG_HOME;
10
+ const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
11
+ return template
12
+ .replace("{home}", home)
13
+ .replace("{appdata}", appdata)
14
+ .replace("{xdg_config}", xdgConfig);
15
+ }
16
+ function buildSkillDirReverts(manifest, home, agentFilter) {
7
17
  const actions = [];
8
18
  for (const skill of manifest.skills) {
9
19
  for (const agentId of skill.agents) {
10
- if (!detectedAgents.includes(agentId))
20
+ if (agentFilter && !agentFilter.includes(agentId))
11
21
  continue;
12
22
  const agent = AGENT_REGISTRY_BY_ID[agentId];
13
23
  if (!agent)
14
24
  continue;
15
- const target = resolveAgentSkillPath(agent, skill.name, home);
16
25
  actions.push({
17
26
  kind: "skill-dir",
18
27
  skill: skill.name,
19
28
  agent: agentId,
20
- target,
29
+ target: resolveAgentSkillPath(agent, skill.name, home),
21
30
  });
22
31
  }
23
32
  }
24
33
  return actions;
25
34
  }
26
- export function planRevertAll(manifest, home) {
35
+ function buildFileWriteReverts(manifest, home, agentFilter) {
27
36
  const actions = [];
28
- for (const skill of manifest.skills) {
29
- for (const agentId of skill.agents) {
37
+ for (const fileEntry of manifest.files ?? []) {
38
+ for (const agentId of fileEntry.agents) {
39
+ if (agentFilter && !agentFilter.includes(agentId))
40
+ continue;
30
41
  const agent = AGENT_REGISTRY_BY_ID[agentId];
31
42
  if (!agent)
32
43
  continue;
33
- const target = resolveAgentSkillPath(agent, skill.name, home);
34
44
  actions.push({
35
- kind: "skill-dir",
36
- skill: skill.name,
45
+ kind: "file-write",
46
+ skill: fileEntry.name,
47
+ agent: agentId,
48
+ target: resolveTargetTemplate(fileEntry.target, home),
49
+ });
50
+ }
51
+ }
52
+ return actions;
53
+ }
54
+ function buildConfigPatchReverts(manifest, home, agentFilter) {
55
+ const actions = [];
56
+ for (const configEntry of manifest.configs ?? []) {
57
+ for (const agentId of configEntry.agents) {
58
+ if (agentFilter && !agentFilter.includes(agentId))
59
+ continue;
60
+ const agent = AGENT_REGISTRY_BY_ID[agentId];
61
+ if (!agent)
62
+ continue;
63
+ actions.push({
64
+ kind: "config-patch",
65
+ skill: configEntry.name,
37
66
  agent: agentId,
38
- target,
67
+ target: resolveTargetTemplate(configEntry.target, home),
39
68
  });
40
69
  }
41
70
  }
42
71
  return actions;
43
72
  }
73
+ export function planRevert(manifest, detectedAgents, home) {
74
+ return [
75
+ ...buildSkillDirReverts(manifest, home, detectedAgents),
76
+ ...buildFileWriteReverts(manifest, home, detectedAgents),
77
+ ...buildConfigPatchReverts(manifest, home, detectedAgents),
78
+ ];
79
+ }
80
+ export function planRevertAll(manifest, home) {
81
+ return [
82
+ ...buildSkillDirReverts(manifest, home, null),
83
+ ...buildFileWriteReverts(manifest, home, null),
84
+ ...buildConfigPatchReverts(manifest, home, null),
85
+ ];
86
+ }
44
87
  function recordOutcome(result, action, counts, failed) {
45
88
  if (result.outcome === "fail") {
46
89
  failed.push({ action, error: result.error });
@@ -114,9 +157,8 @@ export async function executeRevert(actions, dryRun, verbose, home) {
114
157
  }
115
158
  async function executeRevertAction(action, dryRun, verbose, home, planned) {
116
159
  const label = `${action.skill} -> ${action.agent}`;
117
- let stat;
118
160
  try {
119
- stat = await lstat(action.target);
161
+ await lstat(action.target);
120
162
  }
121
163
  catch (err) {
122
164
  const result = lstatOutcome(err);
@@ -133,8 +175,6 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
133
175
  return { outcome: "skip" };
134
176
  }
135
177
  if (dryRun) {
136
- logger.plan(label);
137
- logger.detail(`would remove: ${action.target}`);
138
178
  planned.push({
139
179
  verb: "remove",
140
180
  kind: "skill-dir",
@@ -145,7 +185,10 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
145
185
  return { outcome: "ok" };
146
186
  }
147
187
  try {
148
- if (stat.isSymbolicLink()) {
188
+ // Re-stat immediately before deletion to minimise the window between the
189
+ // type-check and the removal syscall.
190
+ const currentStat = await lstat(action.target);
191
+ if (currentStat.isSymbolicLink()) {
149
192
  await unlink(action.target);
150
193
  }
151
194
  else {
@@ -159,6 +202,11 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
159
202
  return { outcome: "ok" };
160
203
  }
161
204
  catch (err) {
205
+ if (err.code === "ENOENT") {
206
+ // Target disappeared between ownership check and removal — treat as skip.
207
+ logger.skip(label, "(disappeared before removal, skipping)");
208
+ return { outcome: "skip" };
209
+ }
162
210
  const msg = err instanceof Error ? err.message : String(err);
163
211
  logger.fail(label, msg);
164
212
  return { outcome: "fail", error: msg };
@@ -184,8 +232,6 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
184
232
  return { outcome: "skip" };
185
233
  }
186
234
  if (dryRun) {
187
- logger.plan(label);
188
- logger.detail(`would remove: ${action.target}`);
189
235
  planned.push({
190
236
  verb: "remove",
191
237
  kind: "file-write",
@@ -196,6 +242,9 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
196
242
  return { outcome: "ok" };
197
243
  }
198
244
  try {
245
+ // Re-stat immediately before deletion to minimise the type-check to
246
+ // removal window; handle the case where the file has since disappeared.
247
+ await lstat(action.target);
199
248
  await unlink(action.target);
200
249
  await unregisterDeployment(home, action.target);
201
250
  logger.ok(label);
@@ -205,6 +254,10 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
205
254
  return { outcome: "ok" };
206
255
  }
207
256
  catch (err) {
257
+ if (err.code === "ENOENT") {
258
+ logger.skip(label, "(disappeared before removal, skipping)");
259
+ return { outcome: "skip" };
260
+ }
208
261
  const msg = err instanceof Error ? err.message : String(err);
209
262
  logger.fail(label, msg);
210
263
  return { outcome: "fail", error: msg };
@@ -234,8 +287,6 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned) {
234
287
  }
235
288
  const configPatchEntry = entry;
236
289
  if (dryRun) {
237
- logger.plan(label);
238
- logger.detail(`would unapply patch: ${JSON.stringify(configPatchEntry.undoPatch)} -> ${action.target}`);
239
290
  planned.push({
240
291
  verb: "unapply-patch",
241
292
  kind: "config-patch",
package/dist/index.js CHANGED
@@ -111,6 +111,21 @@ async function main() {
111
111
  }
112
112
  return runRevert(options, manifest, home);
113
113
  }
114
+ function renderDryRunPlan(planned) {
115
+ for (const change of planned) {
116
+ logger.plan(`[${change.agent}] ${change.verb} ${change.skill}`);
117
+ if (change.source !== undefined) {
118
+ logger.detail(`source: ${change.source}`);
119
+ }
120
+ logger.detail(`target: ${change.target}`);
121
+ if (change.verb === "patch-config" && change.patch !== undefined) {
122
+ logger.detail(`patch: ${JSON.stringify(change.patch)}`);
123
+ }
124
+ else if (change.verb === "unapply-patch" && change.patch !== undefined) {
125
+ logger.detail(`undo: ${JSON.stringify(change.patch)}`);
126
+ }
127
+ }
128
+ }
114
129
  async function runDeploy(options, manifest, home) {
115
130
  let detectedAgents;
116
131
  if (options.agents) {
@@ -132,7 +147,8 @@ async function runDeploy(options, manifest, home) {
132
147
  }
133
148
  const preflightWarnings = await runPreflight(options, manifest, home, detectedAgents);
134
149
  for (const w of preflightWarnings) {
135
- logger.warn("preflight", w.message);
150
+ const label = w.kind === "policy" ? "policy" : "preflight";
151
+ logger.warn(label, w.message);
136
152
  }
137
153
  const { actions, warnings: planWarnings } = await planDeploy(manifest, options.directory, detectedAgents, home);
138
154
  for (const w of planWarnings) {
@@ -142,14 +158,21 @@ async function runDeploy(options, manifest, home) {
142
158
  logger.info("No skills to deploy for detected agents.");
143
159
  return 0;
144
160
  }
145
- logger.info(`${dryRunPrefix(options.dryRun)}Deploying ${actions.length} skill(s):`);
146
- const { succeeded, failed } = await executeDeploy(actions, options.dryRun, options.verbose, home);
161
+ logger.info(`${dryRunPrefix(options.dryRun)}Deploying ${actions.length} action(s):`);
162
+ const { succeeded, failed, planned } = await executeDeploy(actions, options.dryRun, options.verbose, home);
163
+ if (options.dryRun) {
164
+ logger.info("");
165
+ renderDryRunPlan(planned);
166
+ logger.info("");
167
+ logger.info(`${planned.length} action(s) would be applied (dry-run)`);
168
+ return 0;
169
+ }
147
170
  logger.info("");
148
171
  if (failed.length > 0) {
149
172
  logger.info(`${succeeded} succeeded, ${failed.length} failed`);
150
173
  return 1;
151
174
  }
152
- logger.info(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
175
+ logger.info(`${succeeded} action(s) deployed`);
153
176
  return 0;
154
177
  }
155
178
  async function runRevert(options, manifest, home) {
@@ -160,21 +183,28 @@ async function runRevert(options, manifest, home) {
160
183
  logger.info("No skills to revert.");
161
184
  return 0;
162
185
  }
163
- logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} skill(s):`);
164
- const { succeeded, skipped, failed } = await executeRevert(actions, options.dryRun, options.verbose, home);
186
+ logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} action(s):`);
187
+ const { succeeded, skipped, failed, planned } = await executeRevert(actions, options.dryRun, options.verbose, home);
188
+ if (options.dryRun) {
189
+ logger.info("");
190
+ renderDryRunPlan(planned);
191
+ logger.info("");
192
+ logger.info(`${planned.length} action(s) would be removed (dry-run)`);
193
+ return 0;
194
+ }
165
195
  logger.info("");
166
196
  if (failed.length > 0) {
167
197
  const parts = [`${succeeded} removed`];
168
198
  if (skipped > 0)
169
199
  parts.push(`${skipped} skipped`);
170
200
  parts.push(`${failed.length} failed`);
171
- logger.info(`${parts.join(", ")}${options.dryRun ? " (dry-run)" : ""}`);
201
+ logger.info(parts.join(", "));
172
202
  return 1;
173
203
  }
174
204
  const parts = [`${succeeded} removed`];
175
205
  if (skipped > 0)
176
206
  parts.push(`${skipped} skipped`);
177
- logger.info(`${parts.join(", ")}${options.dryRun ? " (dry-run)" : ""}`);
207
+ logger.info(parts.join(", "));
178
208
  return 0;
179
209
  }
180
210
  const USER_ERROR_EXIT = {
@@ -22,6 +22,38 @@ export declare const SkillEntrySchema: z.ZodObject<{
22
22
  "github-copilot": "github-copilot";
23
23
  }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
24
24
  }, z.core.$strip>;
25
+ export declare const FileEntrySchema: z.ZodObject<{
26
+ name: z.ZodString;
27
+ path: z.ZodString;
28
+ target: z.ZodString;
29
+ agents: z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
30
+ "claude-code": "claude-code";
31
+ codex: "codex";
32
+ "gemini-cli": "gemini-cli";
33
+ antigravity: "antigravity";
34
+ opencode: "opencode";
35
+ "github-copilot": "github-copilot";
36
+ }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
37
+ }, z.core.$strip>;
38
+ export declare const ConfigEntrySchema: z.ZodObject<{
39
+ name: z.ZodString;
40
+ target: z.ZodString;
41
+ patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
42
+ agents: z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
43
+ "claude-code": "claude-code";
44
+ codex: "codex";
45
+ "gemini-cli": "gemini-cli";
46
+ antigravity: "antigravity";
47
+ opencode: "opencode";
48
+ "github-copilot": "github-copilot";
49
+ }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
50
+ }, z.core.$strip>;
51
+ export declare const McpServerEntrySchema: z.ZodObject<{
52
+ name: z.ZodString;
53
+ }, z.core.$loose>;
54
+ export declare const AgentRuleEntrySchema: z.ZodObject<{
55
+ name: z.ZodString;
56
+ }, z.core.$loose>;
25
57
  export declare const ManifestSchema: z.ZodObject<{
26
58
  skills: z.ZodArray<z.ZodObject<{
27
59
  name: z.ZodString;
@@ -35,10 +67,44 @@ export declare const ManifestSchema: z.ZodObject<{
35
67
  "github-copilot": "github-copilot";
36
68
  }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
37
69
  }, z.core.$strip>>;
38
- mcpServers: z.ZodDefault<z.ZodArray<z.ZodUnknown>>;
39
- agentRules: z.ZodDefault<z.ZodArray<z.ZodUnknown>>;
70
+ files: z.ZodDefault<z.ZodArray<z.ZodObject<{
71
+ name: z.ZodString;
72
+ path: z.ZodString;
73
+ target: z.ZodString;
74
+ agents: z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
75
+ "claude-code": "claude-code";
76
+ codex: "codex";
77
+ "gemini-cli": "gemini-cli";
78
+ antigravity: "antigravity";
79
+ opencode: "opencode";
80
+ "github-copilot": "github-copilot";
81
+ }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
82
+ }, z.core.$strip>>>;
83
+ configs: z.ZodDefault<z.ZodArray<z.ZodObject<{
84
+ name: z.ZodString;
85
+ target: z.ZodString;
86
+ patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
87
+ agents: z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
88
+ "claude-code": "claude-code";
89
+ codex: "codex";
90
+ "gemini-cli": "gemini-cli";
91
+ antigravity: "antigravity";
92
+ opencode: "opencode";
93
+ "github-copilot": "github-copilot";
94
+ }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
95
+ }, z.core.$strip>>>;
96
+ mcpServers: z.ZodDefault<z.ZodArray<z.ZodObject<{
97
+ name: z.ZodString;
98
+ }, z.core.$loose>>>;
99
+ agentRules: z.ZodDefault<z.ZodArray<z.ZodObject<{
100
+ name: z.ZodString;
101
+ }, z.core.$loose>>>;
40
102
  }, z.core.$strip>;
41
103
  export type SkillEntry = z.infer<typeof SkillEntrySchema>;
104
+ export type FileEntry = z.infer<typeof FileEntrySchema>;
105
+ export type ConfigEntry = z.infer<typeof ConfigEntrySchema>;
106
+ export type McpServerEntry = z.infer<typeof McpServerEntrySchema>;
107
+ export type AgentRuleEntry = z.infer<typeof AgentRuleEntrySchema>;
42
108
  export type Manifest = z.infer<typeof ManifestSchema>;
43
109
  export declare const AgentListSchema: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>, z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
44
110
  "claude-code": "claude-code";
@@ -10,6 +10,9 @@ const AGENT_IDS = [
10
10
  ];
11
11
  export { AGENT_IDS };
12
12
  const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
13
+ // Target templates must start with a known placeholder to prevent raw absolute
14
+ // paths or directory traversal. e.g. "{home}/.claude/settings.json" is valid.
15
+ const TARGET_TEMPLATE_RE = /^\{(home|appdata|xdg_config)\}/;
13
16
  // Standalone schema used for type derivation and single-ID validation (e.g. index.ts).
14
17
  export const AgentIdSchema = z.enum(AGENT_IDS);
15
18
  // Used inside SkillEntrySchema.agents so that enum failures embed the received
@@ -26,27 +29,62 @@ const agentIdElement = z
26
29
  }
27
30
  })
28
31
  .pipe(AgentIdSchema);
32
+ const nameField = z
33
+ .string({ message: "name must be a non-empty string" })
34
+ .min(1, { message: "name must be a non-empty string" })
35
+ .regex(SAFE_NAME_RE, {
36
+ message: "name must contain only letters, digits, hyphens, underscores, and dots, and must not start with a dot",
37
+ });
38
+ const agentsField = z
39
+ .array(agentIdElement, { message: "agents must be a non-empty array" })
40
+ .min(1, { message: "agents must be a non-empty array" })
41
+ .transform((arr) => [...new Set(arr)]);
42
+ const sourcePathField = z
43
+ .string({ message: "path must be a non-empty string" })
44
+ .min(1, { message: "path must be a non-empty string" })
45
+ .refine((p) => !nodePath.isAbsolute(p), {
46
+ message: "path must be a relative path",
47
+ })
48
+ .refine((p) => !nodePath.normalize(p).startsWith(".."), {
49
+ message: "path must not escape the repository root",
50
+ });
51
+ const targetTemplateField = z
52
+ .string({ message: "target must be a non-empty string" })
53
+ .min(1, { message: "target must be a non-empty string" })
54
+ .refine((t) => TARGET_TEMPLATE_RE.test(t), {
55
+ message: "target must start with a known placeholder: {home}, {appdata}, or {xdg_config}",
56
+ });
29
57
  export const SkillEntrySchema = z.object({
30
- name: z
31
- .string({ message: "name must be a non-empty string" })
32
- .min(1, { message: "name must be a non-empty string" })
33
- .regex(SAFE_NAME_RE, {
34
- message: "name must contain only letters, digits, hyphens, underscores, and dots, and must not start with a dot",
35
- }),
36
- path: z
37
- .string({ message: "path must be a non-empty string" })
38
- .min(1, { message: "path must be a non-empty string" })
39
- .refine((p) => !nodePath.isAbsolute(p), {
40
- message: "path must be a relative path",
41
- })
42
- .refine((p) => !nodePath.normalize(p).startsWith(".."), {
43
- message: "path must not escape the repository root",
44
- }),
45
- agents: z
46
- .array(agentIdElement, { message: "agents must be a non-empty array" })
47
- .min(1, { message: "agents must be a non-empty array" })
48
- .transform((arr) => [...new Set(arr)]),
58
+ name: nameField,
59
+ path: sourcePathField,
60
+ agents: agentsField,
49
61
  });
62
+ export const FileEntrySchema = z.object({
63
+ name: nameField,
64
+ path: sourcePathField,
65
+ target: targetTemplateField,
66
+ agents: agentsField,
67
+ });
68
+ export const ConfigEntrySchema = z.object({
69
+ name: nameField,
70
+ target: targetTemplateField,
71
+ patch: z.record(z.string(), z.unknown()),
72
+ agents: agentsField,
73
+ });
74
+ export const McpServerEntrySchema = z
75
+ .object({
76
+ name: z
77
+ .string({ message: "mcpServers entry name must be a non-empty string" })
78
+ .min(1, { message: "mcpServers entry name must be a non-empty string" }),
79
+ })
80
+ .passthrough();
81
+ export const AgentRuleEntrySchema = z
82
+ .object({
83
+ name: z
84
+ .string({ message: "agentRules entry name must be a non-empty string" })
85
+ .min(1, { message: "agentRules entry name must be a non-empty string" }),
86
+ })
87
+ .passthrough();
50
88
  export const ManifestSchema = z.object({
51
89
  skills: z.array(SkillEntrySchema).superRefine((skills, ctx) => {
52
90
  const seen = new Set();
@@ -61,8 +99,10 @@ export const ManifestSchema = z.object({
61
99
  seen.add(skill.name);
62
100
  }
63
101
  }),
64
- mcpServers: z.array(z.unknown()).default([]),
65
- agentRules: z.array(z.unknown()).default([]),
102
+ files: z.array(FileEntrySchema).default([]),
103
+ configs: z.array(ConfigEntrySchema).default([]),
104
+ mcpServers: z.array(McpServerEntrySchema).default([]),
105
+ agentRules: z.array(AgentRuleEntrySchema).default([]),
66
106
  });
67
107
  // Parses the --agents CLI flag: comma-separated agent IDs → AgentId[]
68
108
  export const AgentListSchema = z
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AgentId } from "./schemas/manifest.ts";
2
- export type { AgentId, Manifest, SkillEntry } from "./schemas/manifest.ts";
2
+ export type { AgentId, ConfigEntry, FileEntry, Manifest, SkillEntry, } from "./schemas/manifest.ts";
3
3
  export interface AgentPaths {
4
4
  posix: string[];
5
5
  windows: string[];
@@ -17,6 +17,7 @@ export interface AgentConfig {
17
17
  detectPaths: AgentPaths;
18
18
  detectBinary: string | null;
19
19
  provenance: AgentProvenance;
20
+ policyNote?: string;
20
21
  }
21
22
  export interface PlanWarning {
22
23
  kind: "confidence" | "collision" | "ambiguity";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuznai/inception-engine",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",