@kuznai/inception-engine 0.9.0 → 0.10.1

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,9 @@
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 deploys skills, single files, JSON config patches, MCP server registrations, and agent instruction files to AI coding agents.
5
+ Today, inception-engine works as a cross-agent deployer for skills on all listed agents, plus single-file writes and JSON config patches. It also supports MCP server registration and global rules-file deployment for the subset of agents whose config surfaces are implemented and validated today.
6
+
7
+ The broader portability layer is the roadmap direction, but this README focuses on what is working now.
6
8
 
7
9
  ## Quick Start
8
10
 
@@ -43,8 +45,10 @@ Managed skills overwrite their previous version. If a target exists but was not
43
45
  | Skills (SKILL.md) | All agents via manifest and CLI | All agents |
44
46
  | File write | All agents via manifest and CLI | All agents |
45
47
  | Config patch (JSON merge) | All agents via manifest and CLI | All agents |
46
- | MCP Servers | claude-code, gemini-cli; stub warning for other agents | claude-code, gemini-cli |
47
- | Agent Rules | claude-code, codex, gemini-cli, opencode; stub warning for other agents | claude-code, codex, gemini-cli, opencode |
48
+ | MCP Servers | claude-code, gemini-cli; other agents are warned and skipped | claude-code, gemini-cli |
49
+ | Global Rules Files | claude-code, codex, gemini-cli, opencode; other agents are warned and skipped | claude-code, codex, gemini-cli, opencode |
50
+
51
+ Features that depend on agent-specific config surfaces are intentionally conservative: if a target path or schema is not implemented with enough confidence, inception-engine warns and skips it rather than guessing.
48
52
 
49
53
  ## Manifest Format
50
54
 
@@ -109,7 +113,7 @@ Each **config** entry applies a [JSON merge patch (RFC 7386)](https://datatracke
109
113
 
110
114
  - **name** - Unique identifier (same format as skill names)
111
115
  - **target** - Config file to patch, using the same placeholder prefix as file entries
112
- - **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).
116
+ - **patch** - JSON object of keys to set. A `null` value removes the key from the target file. Nested object values are merged recursively; non-object values replace the existing value directly.
113
117
  - **agents** - Array of agent IDs to apply this patch to
114
118
 
115
119
  The engine records an undo-patch for each config-patch deployment so that `revert` can restore the original values.
@@ -122,13 +126,13 @@ Each **mcpServer** entry registers an MCP server into the agent's config file by
122
126
 
123
127
  MCP server registration is currently supported for `claude-code` (`~/.claude.json`) and `gemini-cli` (`~/.gemini/settings.json`). Other agents emit a warning and are skipped. Revert removes the registered server entry from the config file.
124
128
 
125
- Each **agentRules** entry deploys a Markdown instruction file to the agent's global rules location:
129
+ Each **agentRules** entry deploys a Markdown instruction file to an agent's supported global rules file location:
126
130
 
127
131
  - **name** - Unique identifier (same format as skill names)
128
132
  - **path** - Relative path to the source Markdown file within the repo
129
133
  - **agents** - Array of agent IDs to deploy this file to
130
134
 
131
- Agent rules deployment is currently supported for `claude-code` (`~/.claude/CLAUDE.md`), `codex` (`~/.codex/AGENTS.md`), `gemini-cli` (`~/.gemini/GEMINI.md`), and `opencode` (`~/.config/opencode/AGENTS.md`). Other agents emit a warning and are skipped. Revert removes the deployed rules file.
135
+ Global rules-file deployment is currently supported for `claude-code` (`~/.claude/CLAUDE.md`), `codex` (`~/.codex/AGENTS.md`), `gemini-cli` (`~/.gemini/GEMINI.md`), and `opencode` (`~/.config/opencode/AGENTS.md`). Other agents emit a warning and are skipped because their instruction surfaces are different, repo-scoped, or not implemented here yet. Revert removes the deployed rules file.
132
136
 
133
137
  ## Creating Skills
134
138
 
@@ -175,19 +179,19 @@ inception-engine revert <directory> [options]
175
179
 
176
180
  ```bash
177
181
  # Deploy all skills to all detected agents
178
- npx inception-engine ./my-skills-repo
182
+ npx @kuznai/inception-engine ./my-skills-repo
179
183
 
180
184
  # Preview what would be deployed
181
- npx inception-engine ./my-skills-repo --dry-run
185
+ npx @kuznai/inception-engine ./my-skills-repo --dry-run
182
186
 
183
187
  # Deploy only to Claude Code and Codex
184
- npx inception-engine ./my-skills-repo --agents claude-code,codex
188
+ npx @kuznai/inception-engine ./my-skills-repo --agents claude-code,codex
185
189
 
186
190
  # Remove deployed skills
187
- npx inception-engine revert ./my-skills-repo
191
+ npx @kuznai/inception-engine revert ./my-skills-repo
188
192
 
189
193
  # Preview what would be removed
190
- npx inception-engine revert ./my-skills-repo --dry-run
194
+ npx @kuznai/inception-engine revert ./my-skills-repo --dry-run
191
195
  ```
192
196
 
193
197
  ## Sample Skills
@@ -197,7 +201,7 @@ The `limbo/` directory contains exceptional sample skills for testing purposes o
197
201
  Try them out:
198
202
 
199
203
  ```bash
200
- npx inception-engine limbo --dry-run
204
+ npx @kuznai/inception-engine limbo --dry-run
201
205
  ```
202
206
 
203
207
  ## Agent Detection
@@ -221,11 +225,11 @@ Revert targets all agents listed in the manifest by default (regardless of detec
221
225
 
222
226
  ### Ownership Tracking and Safe Revert
223
227
 
224
- inception-engine maintains a centralized deployment registry at `~/.inception-engine/registry.json`. Each deploy records the target path, source path, skill name, agent ID, deploy method, and timestamp. No files are written to the source repository.
228
+ inception-engine maintains a centralized deployment registry at `~/.inception-engine/registry.json`. Each deploy records the target path, skill name, agent ID, action-specific provenance (`source`/`method` for skill-dir, `source` for file-write, `patch`/`undoPatch` for config-patch), and timestamp. No files are written to the source repository.
225
229
 
226
230
  - **Registry-based ownership**: On revert, the registry is checked before removing any target. Only targets with a valid registry entry are removed. On redeploy, unmanaged targets are never replaced.
227
231
 
228
- - **Strong binding**: Each registry entry binds a specific target path to its skill, agent, and action kind, with action-specific provenance fields (`source` and `method` for skill-dir and file-write; `patch` and `undoPatch` for config-patch). A target is only considered managed if all relevant fields match a stray entry or a different deployment cannot satisfy the check.
232
+ - **Strong binding**: Each registry entry binds a specific target path to its skill, agent, and action kind. For `skill-dir` and `file-write`, ownership checks also require the recorded `source` to match before an existing target is treated as managed. For `config-patch`, overwrite protection is keyed by target path, kind, skill, and agent; the stored `patch` and `undoPatch` are used for revert bookkeeping rather than deploy-time identity checks.
229
233
 
230
234
  - **Atomic redeploy**: When overwriting an existing managed `skill-dir` target, the engine renames the old target to a backup, creates the new deployment, and only removes the backup on success. If the new deployment fails, the backup is restored. `file-write` and `config-patch` deployments write directly to the target without this backup/rollback model.
231
235
 
@@ -1,8 +1,8 @@
1
- import type { McpServerEntry, AgentRuleEntry } from "../../schemas/manifest.ts";
1
+ import type { AgentRuleEntry, McpServerEntry } from "../../schemas/manifest.ts";
2
2
  import type { AgentId, ConfigPatchDeployAction, FileWriteDeployAction, PlanWarning } from "../../types.ts";
3
3
  import { compileMcpServerReverts } from "./mcp.ts";
4
4
  import { compileAgentRuleReverts } from "./rules.ts";
5
- export { compileMcpServerReverts, compileAgentRuleReverts };
5
+ export { compileAgentRuleReverts, compileMcpServerReverts };
6
6
  export type AdapterAction = ConfigPatchDeployAction | FileWriteDeployAction;
7
7
  export interface AdapterResult {
8
8
  actions: AdapterAction[];
@@ -1,6 +1,6 @@
1
1
  import { compileMcpServerActions, compileMcpServerReverts } from "./mcp.js";
2
2
  import { compileAgentRuleActions, compileAgentRuleReverts } from "./rules.js";
3
- export { compileMcpServerReverts, compileAgentRuleReverts };
3
+ export { compileAgentRuleReverts, compileMcpServerReverts };
4
4
  export async function compileAdapterActions(mcpServers, agentRules, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
5
5
  const actions = [];
6
6
  const warnings = [];
@@ -6,13 +6,15 @@ export async function compileAgentRuleActions(entry, sourceDir, resolvedSourceDi
6
6
  const actions = [];
7
7
  const warnings = [];
8
8
  const platform = getPlatformKey();
9
+ const targetAgents = entry.agents.filter((agentId) => detectedAgents.includes(agentId));
10
+ if (targetAgents.length === 0) {
11
+ return { actions, warnings };
12
+ }
9
13
  // Validate the source file once (before iterating agents) since it is shared.
10
14
  const source = path.resolve(sourceDir, entry.path);
11
15
  await validateSourcePath(source, entry.path, resolvedSourceDir, realRoot);
12
16
  await validateSourceFile(source, entry.path);
13
- for (const agentId of entry.agents) {
14
- if (!detectedAgents.includes(agentId))
15
- continue;
17
+ for (const agentId of targetAgents) {
16
18
  const agent = AGENT_REGISTRY_BY_ID[agentId];
17
19
  if (!agent?.agentRulesPath) {
18
20
  warnings.push({
@@ -1,9 +1,10 @@
1
- import type { AgentId, DeployAction, Manifest, PlannedChange, PlanWarning } from "../types.ts";
1
+ import type { AgentId, DeployAction, Manifest, PlannedChange, PlanWarning, SkillDirDeployAction } from "../types.ts";
2
+ import { type RegistryPersistence } from "./ownership.ts";
2
3
  export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<{
3
4
  actions: DeployAction[];
4
5
  warnings: PlanWarning[];
5
6
  }>;
6
- export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string): Promise<{
7
+ export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string, deps?: DeployDependencies): Promise<{
7
8
  succeeded: number;
8
9
  failed: Array<{
9
10
  action: DeployAction;
@@ -11,3 +12,22 @@ export declare function executeDeploy(actions: DeployAction[], dryRun: boolean,
11
12
  }>;
12
13
  planned: PlannedChange[];
13
14
  }>;
15
+ interface SkillDirOps {
16
+ createTarget(action: SkillDirDeployAction): Promise<void>;
17
+ removeTarget(targetPath: string): Promise<void>;
18
+ }
19
+ interface DeployFileOps {
20
+ copyFile(source: string, target: string): Promise<void>;
21
+ rename(source: string, target: string): Promise<void>;
22
+ rm(targetPath: string, options?: {
23
+ recursive?: boolean;
24
+ force?: boolean;
25
+ }): Promise<void>;
26
+ writeFile(filePath: string, content: string, encoding: BufferEncoding): Promise<void>;
27
+ }
28
+ interface DeployDependencies {
29
+ registry?: RegistryPersistence;
30
+ skillDirOps?: SkillDirOps;
31
+ fileOps?: DeployFileOps;
32
+ }
33
+ export {};
@@ -4,14 +4,15 @@ import path from "node:path";
4
4
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
5
5
  import { UserError } from "../errors.js";
6
6
  import { logger } from "../logger.js";
7
- import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
8
7
  import { compileAdapterActions } from "./adapters/index.js";
8
+ import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
9
9
  import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
10
+ import { resolveTargetTemplate } from "./runtime-paths.js";
10
11
  import { sourceAccessError, validateSourceFile, validateSourcePath, } from "./validation.js";
11
12
  function isPlainObject(v) {
12
13
  return typeof v === "object" && v !== null && !Array.isArray(v);
13
14
  }
14
- async function readJsonConfig(filePath) {
15
+ async function readJsonConfigFile(filePath) {
15
16
  let rawContent;
16
17
  try {
17
18
  rawContent = await readFile(filePath, "utf-8");
@@ -62,15 +63,6 @@ function applyMergePatch(original, patch) {
62
63
  }
63
64
  return patched;
64
65
  }
65
- function resolveTargetTemplate(template, home) {
66
- const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
67
- const xdgRaw = process.env.XDG_CONFIG_HOME;
68
- const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
69
- return template
70
- .replace("{home}", home)
71
- .replace("{appdata}", appdata)
72
- .replace("{xdg_config}", xdgConfig);
73
- }
74
66
  function detectCollisions(actions) {
75
67
  const seen = new Map();
76
68
  const warnings = [];
@@ -103,12 +95,13 @@ async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realR
103
95
  const method = getDeployMethod();
104
96
  const actions = [];
105
97
  for (const skill of manifest.skills) {
98
+ const targetAgents = skill.agents.filter((agentId) => detectedAgents.includes(agentId));
99
+ if (targetAgents.length === 0)
100
+ continue;
106
101
  const source = path.resolve(sourceDir, skill.path);
107
102
  await validateSourcePath(source, skill.path, resolvedSourceDir, realRoot);
108
103
  await validateSkillContract(source, skill.path);
109
- for (const agentId of skill.agents) {
110
- if (!detectedAgents.includes(agentId))
111
- continue;
104
+ for (const agentId of targetAgents) {
112
105
  const agent = AGENT_REGISTRY_BY_ID[agentId];
113
106
  if (!agent)
114
107
  continue;
@@ -128,12 +121,13 @@ async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realR
128
121
  async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
129
122
  const actions = [];
130
123
  for (const fileEntry of manifest.files ?? []) {
124
+ const targetAgents = fileEntry.agents.filter((agentId) => detectedAgents.includes(agentId));
125
+ if (targetAgents.length === 0)
126
+ continue;
131
127
  const source = path.resolve(sourceDir, fileEntry.path);
132
128
  await validateSourcePath(source, fileEntry.path, resolvedSourceDir, realRoot);
133
129
  await validateSourceFile(source, fileEntry.path);
134
- for (const agentId of fileEntry.agents) {
135
- if (!detectedAgents.includes(agentId))
136
- continue;
130
+ for (const agentId of targetAgents) {
137
131
  const agent = AGENT_REGISTRY_BY_ID[agentId];
138
132
  if (!agent)
139
133
  continue;
@@ -193,14 +187,14 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
193
187
  ];
194
188
  return { actions, warnings };
195
189
  }
196
- export async function executeDeploy(actions, dryRun, verbose, home) {
190
+ export async function executeDeploy(actions, dryRun, verbose, home, deps = {}) {
197
191
  let succeeded = 0;
198
192
  const failed = [];
199
193
  const planned = [];
200
194
  for (const action of actions) {
201
195
  switch (action.kind) {
202
196
  case "skill-dir": {
203
- const result = await deploySkillDir(action, dryRun, verbose, home, planned);
197
+ const result = await deploySkillDir(action, dryRun, verbose, home, planned, deps);
204
198
  if (result.error === null) {
205
199
  succeeded++;
206
200
  }
@@ -210,7 +204,7 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
210
204
  break;
211
205
  }
212
206
  case "file-write": {
213
- const result = await deployFileWrite(action, dryRun, verbose, home, planned);
207
+ const result = await deployFileWrite(action, dryRun, verbose, home, planned, deps);
214
208
  if (result.error === null) {
215
209
  succeeded++;
216
210
  }
@@ -220,7 +214,7 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
220
214
  break;
221
215
  }
222
216
  case "config-patch": {
223
- const result = await deployConfigPatch(action, dryRun, verbose, home, planned);
217
+ const result = await deployConfigPatch(action, dryRun, verbose, home, planned, deps);
224
218
  if (result.error === null) {
225
219
  succeeded++;
226
220
  }
@@ -236,7 +230,114 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
236
230
  }
237
231
  return { succeeded, failed, planned };
238
232
  }
239
- async function deploySkillDir(action, dryRun, verbose, home, planned) {
233
+ const defaultSkillDirOps = {
234
+ async createTarget(action) {
235
+ if (action.method === "symlink") {
236
+ await symlink(action.source, action.target, "dir");
237
+ }
238
+ else {
239
+ await cp(action.source, action.target, { recursive: true });
240
+ }
241
+ },
242
+ async removeTarget(targetPath) {
243
+ const stat = await lstat(targetPath);
244
+ if (stat.isSymbolicLink()) {
245
+ await unlink(targetPath);
246
+ }
247
+ else {
248
+ await rm(targetPath, { recursive: true });
249
+ }
250
+ },
251
+ };
252
+ const defaultDeployFileOps = {
253
+ copyFile,
254
+ rename,
255
+ rm,
256
+ writeFile,
257
+ };
258
+ async function removeFileSystemTarget(targetPath) {
259
+ const stat = await lstat(targetPath);
260
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
261
+ await rm(targetPath, { recursive: true });
262
+ }
263
+ else {
264
+ await unlink(targetPath);
265
+ }
266
+ }
267
+ async function backupManagedFileWriteTarget(action, home, deps) {
268
+ try {
269
+ await lstat(action.target);
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ const isOwned = await verifyDeployment(home, action.target, {
275
+ kind: "file-write",
276
+ source: action.source,
277
+ skill: action.skill,
278
+ agent: action.agent,
279
+ }, deps.registry);
280
+ if (!isOwned) {
281
+ throw new Error(`Target "${action.target}" exists but is not managed by inception-engine — refusing to overwrite`);
282
+ }
283
+ const backupPath = `${action.target}.inception-backup`;
284
+ await (deps.fileOps ?? defaultDeployFileOps).rm(backupPath, {
285
+ recursive: true,
286
+ force: true,
287
+ });
288
+ await (deps.fileOps ?? defaultDeployFileOps).rename(action.target, backupPath);
289
+ return backupPath;
290
+ }
291
+ function createAtomicTempPath(targetPath) {
292
+ return `${targetPath}.inception-tmp-${process.pid}-${Date.now()}-${Math.random()
293
+ .toString(36)
294
+ .slice(2)}`;
295
+ }
296
+ async function replaceFileAtomically(targetPath, deps, stageTempFile, prepareBackup, commit) {
297
+ const fileOps = deps.fileOps ?? defaultDeployFileOps;
298
+ const tempPath = createAtomicTempPath(targetPath);
299
+ let backupPath = null;
300
+ let replacedTarget = false;
301
+ try {
302
+ await mkdir(path.dirname(targetPath), { recursive: true });
303
+ await stageTempFile(tempPath, fileOps);
304
+ backupPath = await prepareBackup();
305
+ await fileOps.rename(tempPath, targetPath);
306
+ replacedTarget = true;
307
+ await commit();
308
+ }
309
+ catch (writeErr) {
310
+ if (replacedTarget) {
311
+ try {
312
+ await removeFileSystemTarget(targetPath);
313
+ }
314
+ catch {
315
+ /* best-effort cleanup */
316
+ }
317
+ }
318
+ else {
319
+ try {
320
+ await fileOps.rm(tempPath, { recursive: true, force: true });
321
+ }
322
+ catch {
323
+ /* best-effort cleanup */
324
+ }
325
+ }
326
+ if (backupPath) {
327
+ try {
328
+ await fileOps.rename(backupPath, targetPath);
329
+ }
330
+ catch {
331
+ /* best-effort rollback */
332
+ }
333
+ }
334
+ throw writeErr;
335
+ }
336
+ if (backupPath) {
337
+ await fileOps.rm(backupPath, { recursive: true, force: true });
338
+ }
339
+ }
340
+ async function deploySkillDir(action, dryRun, verbose, home, planned, deps) {
240
341
  const label = `${action.skill} -> ${action.agent}`;
241
342
  try {
242
343
  await access(action.source);
@@ -260,7 +361,7 @@ async function deploySkillDir(action, dryRun, verbose, home, planned) {
260
361
  return { error: null };
261
362
  }
262
363
  try {
263
- await executeDeployAction(action, verbose, home);
364
+ await executeDeployAction(action, verbose, home, deps);
264
365
  return { error: null };
265
366
  }
266
367
  catch (err) {
@@ -269,7 +370,7 @@ async function deploySkillDir(action, dryRun, verbose, home, planned) {
269
370
  return { error: msg };
270
371
  }
271
372
  }
272
- async function deployFileWrite(action, dryRun, verbose, home, planned) {
373
+ async function deployFileWrite(action, dryRun, verbose, home, planned, deps) {
273
374
  const label = `${action.skill} -> ${action.agent}`;
274
375
  try {
275
376
  await access(action.source);
@@ -291,32 +392,12 @@ async function deployFileWrite(action, dryRun, verbose, home, planned) {
291
392
  return { error: null };
292
393
  }
293
394
  try {
294
- // Check if target exists only allow overwrite if we own it
295
- try {
296
- await lstat(action.target);
297
- const isOwned = await verifyDeployment(home, action.target, {
298
- kind: "file-write",
299
- source: action.source,
300
- skill: action.skill,
301
- agent: action.agent,
302
- });
303
- if (!isOwned) {
304
- throw new Error(`Target "${action.target}" exists but is not managed by inception-engine — refusing to overwrite`);
305
- }
306
- }
307
- catch (err) {
308
- if (err instanceof Error && err.message.includes("refusing to overwrite"))
309
- throw err;
310
- // ENOENT — target doesn't exist, fine to create
311
- }
312
- await mkdir(path.dirname(action.target), { recursive: true });
313
- await copyFile(action.source, action.target);
314
- await registerDeployment(home, action.target, {
395
+ await replaceFileAtomically(action.target, deps, (tempPath, fileOps) => fileOps.copyFile(action.source, tempPath), () => backupManagedFileWriteTarget(action, home, deps), () => registerDeployment(home, action.target, {
315
396
  kind: "file-write",
316
397
  source: action.source,
317
398
  skill: action.skill,
318
399
  agent: action.agent,
319
- });
400
+ }, deps.registry));
320
401
  logger.ok(label);
321
402
  if (verbose) {
322
403
  logger.detail(`write-file: ${action.source} -> ${action.target}`);
@@ -329,7 +410,7 @@ async function deployFileWrite(action, dryRun, verbose, home, planned) {
329
410
  return { error: msg };
330
411
  }
331
412
  }
332
- async function deployConfigPatch(action, dryRun, verbose, home, planned) {
413
+ async function deployConfigPatch(action, dryRun, verbose, home, planned, deps) {
333
414
  const label = `${action.skill} -> ${action.agent}`;
334
415
  if (!isPlainObject(action.patch)) {
335
416
  const msg = `Config patch for skill "${action.skill}" must be a plain object`;
@@ -350,23 +431,30 @@ async function deployConfigPatch(action, dryRun, verbose, home, planned) {
350
431
  }
351
432
  try {
352
433
  // Guard against double-patching by a different skill/agent
353
- const existingEntry = await lookupDeployment(home, action.target);
434
+ const existingEntry = await lookupDeployment(home, action.target, deps.registry);
354
435
  if (existingEntry &&
355
436
  (existingEntry.skill !== action.skill ||
356
437
  existingEntry.agent !== action.agent)) {
357
438
  throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" — refusing to double-patch`);
358
439
  }
359
- const original = await readJsonConfig(action.target);
440
+ const original = await readJsonConfigFile(action.target);
360
441
  const undoPatch = computeUndoPatch(original, patch);
361
442
  const patched = applyMergePatch(original, patch);
362
- await writeFile(action.target, `${JSON.stringify(patched, null, 2)}\n`, "utf-8");
363
- await registerDeployment(home, action.target, {
443
+ await replaceFileAtomically(action.target, deps, (tempPath, fileOps) => fileOps.writeFile(tempPath, `${JSON.stringify(patched, null, 2)}\n`, "utf-8"), async () => {
444
+ const backupPath = `${action.target}.inception-backup`;
445
+ await (deps.fileOps ?? defaultDeployFileOps).rm(backupPath, {
446
+ recursive: true,
447
+ force: true,
448
+ });
449
+ await (deps.fileOps ?? defaultDeployFileOps).rename(action.target, backupPath);
450
+ return backupPath;
451
+ }, () => registerDeployment(home, action.target, {
364
452
  kind: "config-patch",
365
453
  patch,
366
454
  undoPatch,
367
455
  skill: action.skill,
368
456
  agent: action.agent,
369
- });
457
+ }, deps.registry));
370
458
  logger.ok(label);
371
459
  if (verbose) {
372
460
  logger.detail(`patch-config: applied ${Object.keys(patch).length} key(s) to ${action.target}`);
@@ -429,38 +517,35 @@ async function assertTargetAbsent(targetPath) {
429
517
  // ENOENT is expected — target should not exist after backup
430
518
  }
431
519
  }
432
- async function createDeployTarget(action, home) {
433
- if (action.method === "symlink") {
434
- await symlink(action.source, action.target, "dir");
435
- }
436
- else {
437
- await cp(action.source, action.target, { recursive: true });
438
- }
520
+ async function createDeployTarget(action, home, deps) {
521
+ await (deps.skillDirOps ?? defaultSkillDirOps).createTarget(action);
439
522
  await registerDeployment(home, action.target, {
440
523
  kind: action.kind,
441
524
  source: action.source,
442
525
  skill: action.skill,
443
526
  agent: action.agent,
444
527
  method: action.method,
445
- });
528
+ }, deps.registry);
446
529
  }
447
- async function executeDeployAction(action, verbose, home) {
530
+ async function executeDeployAction(action, verbose, home, deps) {
448
531
  const label = `${action.skill} -> ${action.agent}`;
449
532
  const backupPath = await backupExisting(action.target, verbose, home, {
450
533
  kind: action.kind,
451
534
  source: action.source,
452
535
  skill: action.skill,
453
536
  agent: action.agent,
454
- });
537
+ }, deps);
455
538
  await mkdir(path.dirname(action.target), { recursive: true });
456
539
  try {
457
540
  await assertTargetAbsent(action.target);
458
- await createDeployTarget(action, home);
541
+ await createDeployTarget(action, home, deps);
459
542
  }
460
543
  catch (createErr) {
461
544
  if (backupPath) {
462
545
  try {
463
- await removeTarget(action.target).catch(() => {
546
+ await (deps.skillDirOps ?? defaultSkillDirOps)
547
+ .removeTarget(action.target)
548
+ .catch(() => {
464
549
  /* best-effort cleanup */
465
550
  });
466
551
  await rename(backupPath, action.target);
@@ -472,14 +557,14 @@ async function executeDeployAction(action, verbose, home) {
472
557
  throw createErr;
473
558
  }
474
559
  if (backupPath) {
475
- await removeTarget(backupPath);
560
+ await (deps.skillDirOps ?? defaultSkillDirOps).removeTarget(backupPath);
476
561
  }
477
562
  logger.ok(label);
478
563
  if (verbose) {
479
564
  logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
480
565
  }
481
566
  }
482
- async function backupExisting(targetPath, verbose, home, expected) {
567
+ async function backupExisting(targetPath, verbose, home, expected, deps) {
483
568
  try {
484
569
  await lstat(targetPath);
485
570
  }
@@ -491,7 +576,7 @@ async function backupExisting(targetPath, verbose, home, expected) {
491
576
  source: expected.source,
492
577
  skill: expected.skill,
493
578
  agent: expected.agent,
494
- }))) {
579
+ }, deps.registry))) {
495
580
  throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
496
581
  }
497
582
  const backupPath = `${targetPath}.inception-backup`;
@@ -507,12 +592,3 @@ async function backupExisting(targetPath, verbose, home, expected) {
507
592
  await rename(targetPath, backupPath);
508
593
  return backupPath;
509
594
  }
510
- async function removeTarget(targetPath) {
511
- const stat = await lstat(targetPath);
512
- if (stat.isSymbolicLink()) {
513
- await unlink(targetPath);
514
- }
515
- else {
516
- await rm(targetPath, { recursive: true });
517
- }
518
- }
@@ -1,6 +1,10 @@
1
- import { type ConfigPatchRegistryEntry, type FileWriteRegistryEntry, type RegistryEntry, type SkillDirRegistryEntry } from "../schemas/registry.ts";
1
+ import { type ConfigPatchRegistryEntry, type FileWriteRegistryEntry, type Registry, type RegistryEntry, type SkillDirRegistryEntry } from "../schemas/registry.ts";
2
2
  import type { AgentId } from "../types.ts";
3
3
  export type { RegistryEntry } from "../schemas/registry.ts";
4
+ export interface RegistryPersistence {
5
+ load(home: string): Promise<Registry>;
6
+ save(home: string, registry: Registry): Promise<void>;
7
+ }
4
8
  export type VerifyExpected = {
5
9
  kind: "skill-dir";
6
10
  source: string;
@@ -17,8 +21,9 @@ export type VerifyExpected = {
17
21
  agent: AgentId;
18
22
  };
19
23
  export declare function registryPath(home: string): string;
24
+ export declare const defaultRegistryPersistence: RegistryPersistence;
20
25
  export type RegisterEntry = Omit<SkillDirRegistryEntry, "deployed"> | Omit<FileWriteRegistryEntry, "deployed"> | Omit<ConfigPatchRegistryEntry, "deployed">;
21
- export declare function registerDeployment(home: string, targetPath: string, entry: RegisterEntry): Promise<void>;
22
- export declare function unregisterDeployment(home: string, targetPath: string): Promise<void>;
23
- export declare function lookupDeployment(home: string, targetPath: string): Promise<RegistryEntry | null>;
24
- export declare function verifyDeployment(home: string, targetPath: string, expected: VerifyExpected): Promise<RegistryEntry | null>;
26
+ export declare function registerDeployment(home: string, targetPath: string, entry: RegisterEntry, persistence?: RegistryPersistence): Promise<void>;
27
+ export declare function unregisterDeployment(home: string, targetPath: string, persistence?: RegistryPersistence): Promise<void>;
28
+ export declare function lookupDeployment(home: string, targetPath: string, persistence?: RegistryPersistence): Promise<RegistryEntry | null>;
29
+ export declare function verifyDeployment(home: string, targetPath: string, expected: VerifyExpected, persistence?: RegistryPersistence): Promise<RegistryEntry | null>;
@@ -24,6 +24,10 @@ async function saveRegistry(home, registry) {
24
24
  await writeFile(filePath, `${JSON.stringify(registry, null, 2)}\n`);
25
25
  await setFilePermissions(filePath);
26
26
  }
27
+ export const defaultRegistryPersistence = {
28
+ load: loadRegistry,
29
+ save: saveRegistry,
30
+ };
27
31
  /**
28
32
  * Ensure the file is not world-writable regardless of umask.
29
33
  * On Windows, the OS inherits ACLs from the parent directory — no-op is correct.
@@ -36,27 +40,27 @@ async function setFilePermissions(filePath) {
36
40
  function emptyRegistry() {
37
41
  return { version: 1, deployments: {} };
38
42
  }
39
- export async function registerDeployment(home, targetPath, entry) {
40
- const registry = await loadRegistry(home);
43
+ export async function registerDeployment(home, targetPath, entry, persistence = defaultRegistryPersistence) {
44
+ const registry = await persistence.load(home);
41
45
  registry.deployments[targetPath] = {
42
46
  ...entry,
43
47
  deployed: new Date().toISOString(),
44
48
  };
45
- await saveRegistry(home, registry);
49
+ await persistence.save(home, registry);
46
50
  }
47
- export async function unregisterDeployment(home, targetPath) {
48
- const registry = await loadRegistry(home);
51
+ export async function unregisterDeployment(home, targetPath, persistence = defaultRegistryPersistence) {
52
+ const registry = await persistence.load(home);
49
53
  if (!(targetPath in registry.deployments))
50
54
  return;
51
55
  delete registry.deployments[targetPath];
52
- await saveRegistry(home, registry);
56
+ await persistence.save(home, registry);
53
57
  }
54
- export async function lookupDeployment(home, targetPath) {
55
- const registry = await loadRegistry(home);
58
+ export async function lookupDeployment(home, targetPath, persistence = defaultRegistryPersistence) {
59
+ const registry = await persistence.load(home);
56
60
  return registry.deployments[targetPath] ?? null;
57
61
  }
58
- export async function verifyDeployment(home, targetPath, expected) {
59
- const entry = await lookupDeployment(home, targetPath);
62
+ export async function verifyDeployment(home, targetPath, expected, persistence = defaultRegistryPersistence) {
63
+ const entry = await lookupDeployment(home, targetPath, persistence);
60
64
  if (!entry)
61
65
  return null;
62
66
  if (entry.kind !== expected.kind)
@@ -1,8 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
- import path from "node:path";
5
4
  import { UserError } from "../errors.js";
5
+ import { getPathApi, resolveRuntimePaths } from "./runtime-paths.js";
6
6
  export function resolveHome() {
7
7
  if (process.platform === "win32") {
8
8
  return os.homedir();
@@ -97,13 +97,12 @@ export function resolveAgentDetectPath(agent, home) {
97
97
  return resolveAgentDetectPathFor(agent, home, getPlatformKey());
98
98
  }
99
99
  export function resolvePlaceholders(segments, skillName, home) {
100
- const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
101
- const xdgRaw = process.env.XDG_CONFIG_HOME;
102
- const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
100
+ const { appdata, xdgConfig } = resolveRuntimePaths(home);
103
101
  const resolved = segments.map((seg) => seg
104
102
  .replace("{home}", home)
105
103
  .replace("{name}", skillName)
106
104
  .replace("{appdata}", appdata)
107
105
  .replace("{xdg_config}", xdgConfig));
108
- return path.join(...resolved);
106
+ const root = resolved.find((segment) => segment.length > 0) ?? home;
107
+ return getPathApi(root).join(...resolved);
109
108
  }
@@ -1,7 +1,8 @@
1
1
  import type { AgentId, Manifest, PlannedChange, RevertAction } from "../types.ts";
2
+ import { type RegistryPersistence } from "./ownership.ts";
2
3
  export declare function planRevert(manifest: Manifest, detectedAgents: AgentId[], home: string): RevertAction[];
3
4
  export declare function planRevertAll(manifest: Manifest, home: string): RevertAction[];
4
- export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean, home: string): Promise<{
5
+ export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean, home: string, deps?: RevertDependencies): Promise<{
5
6
  succeeded: number;
6
7
  skipped: number;
7
8
  failed: Array<{
@@ -10,3 +11,7 @@ export declare function executeRevert(actions: RevertAction[], dryRun: boolean,
10
11
  }>;
11
12
  planned: PlannedChange[];
12
13
  }>;
14
+ interface RevertDependencies {
15
+ registry?: RegistryPersistence;
16
+ }
17
+ export {};
@@ -1,19 +1,10 @@
1
1
  import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
2
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
4
3
  import { logger } from "../logger.js";
5
- import { compileMcpServerReverts, compileAgentRuleReverts, } from "./adapters/index.js";
6
- import { lookupDeployment, unregisterDeployment } from "./ownership.js";
4
+ import { compileAgentRuleReverts, compileMcpServerReverts, } from "./adapters/index.js";
5
+ import { lookupDeployment, unregisterDeployment, } from "./ownership.js";
7
6
  import { resolveAgentSkillPath } from "./resolve.js";
8
- function resolveTargetTemplate(template, home) {
9
- const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
10
- const xdgRaw = process.env.XDG_CONFIG_HOME;
11
- const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
12
- return template
13
- .replace("{home}", home)
14
- .replace("{appdata}", appdata)
15
- .replace("{xdg_config}", xdgConfig);
16
- }
7
+ import { resolveTargetTemplate } from "./runtime-paths.js";
17
8
  function buildSkillDirReverts(manifest, home, agentFilter) {
18
9
  const actions = [];
19
10
  for (const skill of manifest.skills) {
@@ -139,7 +130,7 @@ function lstatOutcome(err) {
139
130
  const msg = err instanceof Error ? err.message : String(err);
140
131
  return { outcome: "fail", error: msg };
141
132
  }
142
- export async function executeRevert(actions, dryRun, verbose, home) {
133
+ export async function executeRevert(actions, dryRun, verbose, home, deps = {}) {
143
134
  let succeeded = 0;
144
135
  let skipped = 0;
145
136
  const failed = [];
@@ -149,13 +140,13 @@ export async function executeRevert(actions, dryRun, verbose, home) {
149
140
  let result;
150
141
  switch (action.kind) {
151
142
  case "skill-dir":
152
- result = await executeRevertAction(action, dryRun, verbose, home, planned);
143
+ result = await executeRevertAction(action, dryRun, verbose, home, planned, deps);
153
144
  break;
154
145
  case "file-write":
155
- result = await revertFileWrite(action, dryRun, verbose, home, planned);
146
+ result = await revertFileWrite(action, dryRun, verbose, home, planned, deps);
156
147
  break;
157
148
  case "config-patch":
158
- result = await revertConfigPatch(action, dryRun, verbose, home, planned);
149
+ result = await revertConfigPatch(action, dryRun, verbose, home, planned, deps);
159
150
  break;
160
151
  default:
161
152
  throw new Error(`Unhandled revert action kind: ${action}`);
@@ -166,7 +157,7 @@ export async function executeRevert(actions, dryRun, verbose, home) {
166
157
  skipped = counts.skipped;
167
158
  return { succeeded, skipped, failed, planned };
168
159
  }
169
- async function executeRevertAction(action, dryRun, verbose, home, planned) {
160
+ async function executeRevertAction(action, dryRun, verbose, home, planned, deps) {
170
161
  const label = `${action.skill} -> ${action.agent}`;
171
162
  try {
172
163
  await lstat(action.target);
@@ -180,7 +171,7 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
180
171
  logger.fail(label, result.error);
181
172
  return result;
182
173
  }
183
- const entry = await lookupDeployment(home, action.target);
174
+ const entry = await lookupDeployment(home, action.target, deps.registry);
184
175
  if (!entry || entry.skill !== action.skill || entry.agent !== action.agent) {
185
176
  logger.warn(label, `skipping: ${action.target} is not in the deployment registry — not managed by inception-engine`);
186
177
  return { outcome: "skip" };
@@ -205,7 +196,7 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
205
196
  else {
206
197
  await rm(action.target, { recursive: true });
207
198
  }
208
- await unregisterDeployment(home, action.target);
199
+ await unregisterDeployment(home, action.target, deps.registry);
209
200
  logger.ok(label);
210
201
  if (verbose) {
211
202
  logger.detail(`removed: ${action.target}`);
@@ -223,7 +214,7 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
223
214
  return { outcome: "fail", error: msg };
224
215
  }
225
216
  }
226
- async function revertFileWrite(action, dryRun, verbose, home, planned) {
217
+ async function revertFileWrite(action, dryRun, verbose, home, planned, deps) {
227
218
  const label = `${action.skill} -> ${action.agent}`;
228
219
  try {
229
220
  await lstat(action.target);
@@ -237,7 +228,7 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
237
228
  logger.fail(label, result.error);
238
229
  return result;
239
230
  }
240
- const entry = await lookupDeployment(home, action.target);
231
+ const entry = await lookupDeployment(home, action.target, deps.registry);
241
232
  if (!entry || entry.skill !== action.skill || entry.agent !== action.agent) {
242
233
  logger.warn(label, `skipping: ${action.target} is not in the deployment registry — not managed by inception-engine`);
243
234
  return { outcome: "skip" };
@@ -257,7 +248,7 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
257
248
  // removal window; handle the case where the file has since disappeared.
258
249
  await lstat(action.target);
259
250
  await unlink(action.target);
260
- await unregisterDeployment(home, action.target);
251
+ await unregisterDeployment(home, action.target, deps.registry);
261
252
  logger.ok(label);
262
253
  if (verbose) {
263
254
  logger.detail(`removed: ${action.target}`);
@@ -274,7 +265,7 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
274
265
  return { outcome: "fail", error: msg };
275
266
  }
276
267
  }
277
- async function revertConfigPatch(action, dryRun, verbose, home, planned) {
268
+ async function revertConfigPatch(action, dryRun, verbose, home, planned, deps) {
278
269
  const label = `${action.skill} -> ${action.agent}`;
279
270
  try {
280
271
  await lstat(action.target);
@@ -288,7 +279,7 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned) {
288
279
  logger.fail(label, result.error);
289
280
  return result;
290
281
  }
291
- const entry = await lookupDeployment(home, action.target);
282
+ const entry = await lookupDeployment(home, action.target, deps.registry);
292
283
  if (!entry ||
293
284
  entry.kind !== "config-patch" ||
294
285
  entry.skill !== action.skill ||
@@ -312,7 +303,7 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned) {
312
303
  const current = await readJsonConfig(action.target);
313
304
  const restored = applyUndoPatch(current, configPatchEntry.undoPatch);
314
305
  await writeFile(action.target, `${JSON.stringify(restored, null, 2)}\n`, "utf-8");
315
- await unregisterDeployment(home, action.target);
306
+ await unregisterDeployment(home, action.target, deps.registry);
316
307
  logger.ok(label);
317
308
  if (verbose) {
318
309
  logger.detail(`unapplied patch from: ${action.target}`);
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+ export interface RuntimePaths {
3
+ appdata: string;
4
+ xdgConfig: string;
5
+ }
6
+ export declare function getPathApi(root: string): typeof path.posix | typeof path.win32;
7
+ export declare function resolveRuntimePaths(home: string): RuntimePaths;
8
+ export declare function resolveTargetTemplate(template: string, home: string): string;
@@ -0,0 +1,57 @@
1
+ import path from "node:path";
2
+ const TARGET_TEMPLATE_RE = /^\{(home|appdata|xdg_config)\}(?<suffix>(?:[\\/].*)?)$/;
3
+ export function getPathApi(root) {
4
+ if (root.includes("\\") || /^[a-zA-Z]:/.test(root)) {
5
+ return path.win32;
6
+ }
7
+ if (root.startsWith("/")) {
8
+ return path.posix;
9
+ }
10
+ return process.platform === "win32" ? path.win32 : path.posix;
11
+ }
12
+ function normalizePathForComparison(candidate, pathApi) {
13
+ const normalized = pathApi.normalize(candidate);
14
+ return pathApi === path.win32 ? normalized.toLowerCase() : normalized;
15
+ }
16
+ function isSameOrDescendantPath(candidate, root) {
17
+ const pathApi = getPathApi(root);
18
+ const normalizedCandidate = normalizePathForComparison(candidate, pathApi);
19
+ const normalizedRoot = normalizePathForComparison(root, pathApi);
20
+ return (normalizedCandidate === normalizedRoot ||
21
+ normalizedCandidate.startsWith(normalizedRoot + pathApi.sep));
22
+ }
23
+ export function resolveRuntimePaths(home) {
24
+ const appdataRaw = process.env.APPDATA;
25
+ const homePathApi = getPathApi(home);
26
+ const appdata = appdataRaw && getPathApi(appdataRaw).isAbsolute(appdataRaw)
27
+ ? appdataRaw
28
+ : homePathApi.join(home, "AppData", "Roaming");
29
+ const xdgRaw = process.env.XDG_CONFIG_HOME;
30
+ const xdgConfig = xdgRaw && getPathApi(xdgRaw).isAbsolute(xdgRaw)
31
+ ? xdgRaw
32
+ : homePathApi.join(home, ".config");
33
+ return { appdata, xdgConfig };
34
+ }
35
+ export function resolveTargetTemplate(template, home) {
36
+ const { appdata, xdgConfig } = resolveRuntimePaths(home);
37
+ const match = TARGET_TEMPLATE_RE.exec(template);
38
+ if (!match) {
39
+ throw new Error(`Invalid target template: ${template}`);
40
+ }
41
+ const baseByRoot = {
42
+ home,
43
+ appdata,
44
+ xdg_config: xdgConfig,
45
+ };
46
+ const root = match[1];
47
+ const suffix = match.groups?.suffix ?? "";
48
+ const segments = suffix.split(/[\\/]+/).filter(Boolean);
49
+ const pathApi = getPathApi(baseByRoot[root]);
50
+ const resolved = segments.length === 0
51
+ ? baseByRoot[root]
52
+ : pathApi.join(baseByRoot[root], ...segments);
53
+ if (!isSameOrDescendantPath(resolved, baseByRoot[root])) {
54
+ throw new Error(`Target template resolves outside its placeholder root: ${template}`);
55
+ }
56
+ return suffix === "" ? baseByRoot[root] : `${baseByRoot[root]}${suffix}`;
57
+ }
@@ -1,4 +1,4 @@
1
- import { lstat, realpath } from "node:fs/promises";
1
+ import { lstat, realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { UserError } from "../errors.js";
4
4
  export function sourceAccessError(err, sourcePath) {
@@ -10,14 +10,39 @@ export function sourceAccessError(err, sourcePath) {
10
10
  const detail = err instanceof Error ? err.message : String(err);
11
11
  return `Failed to access source ${sourcePath}: ${detail}`;
12
12
  }
13
+ function normalizePathForComparison(candidate) {
14
+ const normalized = path.normalize(candidate);
15
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
16
+ }
17
+ function isSameOrDescendantPath(candidate, root) {
18
+ const normalizedCandidate = normalizePathForComparison(candidate);
19
+ const normalizedRoot = normalizePathForComparison(root);
20
+ return (normalizedCandidate === normalizedRoot ||
21
+ normalizedCandidate.startsWith(normalizedRoot + path.sep));
22
+ }
23
+ async function isSameFileSystemLocation(a, b) {
24
+ const [aStat, bStat] = await Promise.all([stat(a), stat(b)]);
25
+ return aStat.dev === bStat.dev && aStat.ino === bStat.ino;
26
+ }
27
+ async function isWithinRootByIdentity(candidate, root) {
28
+ let current = candidate;
29
+ while (true) {
30
+ if (await isSameFileSystemLocation(current, root))
31
+ return true;
32
+ const parent = path.dirname(current);
33
+ if (parent === current)
34
+ return false;
35
+ current = parent;
36
+ }
37
+ }
13
38
  export async function validateSourcePath(source, skillPath, resolvedSourceDir, realRoot) {
14
39
  if (!source.startsWith(resolvedSourceDir + path.sep)) {
15
40
  throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root: ${source}`);
16
41
  }
17
42
  try {
18
43
  const realSource = await realpath(source);
19
- if (realSource !== realRoot &&
20
- !realSource.startsWith(realRoot + path.sep)) {
44
+ if (!(isSameOrDescendantPath(realSource, realRoot) ||
45
+ (await isWithinRootByIdentity(realSource, realRoot)))) {
21
46
  throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root via symlink: ${source} -> ${realSource}`);
22
47
  }
23
48
  }
@@ -10,9 +10,10 @@ 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
+ // Target templates must be rooted at a known placeholder and may only add
14
+ // descendant path segments beneath that root. e.g. "{home}/.claude/settings.json"
15
+ // is valid, while "{home}/../.ssh/config" is rejected.
16
+ const TARGET_TEMPLATE_RE = /^\{(home|appdata|xdg_config)\}(?:[\\/].*)?$/;
16
17
  // Standalone schema used for type derivation and single-ID validation (e.g. index.ts).
17
18
  export const AgentIdSchema = z.enum(AGENT_IDS);
18
19
  // Used inside SkillEntrySchema.agents so that enum failures embed the received
@@ -53,6 +54,9 @@ const targetTemplateField = z
53
54
  .min(1, { message: "target must be a non-empty string" })
54
55
  .refine((t) => TARGET_TEMPLATE_RE.test(t), {
55
56
  message: "target must start with a known placeholder: {home}, {appdata}, or {xdg_config}",
57
+ })
58
+ .refine((t) => !t.split(/[\\/]+/).includes(".."), {
59
+ message: "target must not escape its placeholder root",
56
60
  });
57
61
  export const SkillEntrySchema = z.object({
58
62
  name: nameField,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuznai/inception-engine",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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",
@@ -38,11 +38,13 @@
38
38
  "typecheck": "tsc --noEmit",
39
39
  "fmt": "biome format --write .",
40
40
  "lint": "biome lint . --max-diagnostics none",
41
+ "lint:fix": "biome lint . --max-diagnostics none --write --unsafe",
41
42
  "predev": "node scripts/assert-direct-ts-node.mjs",
42
43
  "dev": "node src/index.ts",
43
44
  "pretest": "node scripts/assert-direct-ts-node.mjs",
44
- "test": "node --test test/*.test.ts",
45
- "prepublishOnly": "npm run typecheck && npm run lint && npm run build"
45
+ "test": "node --test --test-isolation=none test/**/*.test.ts",
46
+ "test:windows": "node --test --test-isolation=none test/unit/*.test.ts test/os/cross-platform/*.test.ts test/os/windows/*.test.ts",
47
+ "test:posix": "node --test --test-isolation=none test/unit/*.test.ts test/os/cross-platform/*.test.ts test/os/posix/*.test.ts"
46
48
  },
47
49
  "dependencies": {
48
50
  "zod": "^4.0.0"