@kuznai/inception-engine 0.6.2 → 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. It does not yet manage persistent instruction files, MCP configuration, subagents, or agent-specific config patching.
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
 
@@ -40,9 +40,11 @@ Managed skills overwrite their previous version. If a target exists but was not
40
40
 
41
41
  | Feature | Status |
42
42
  |---|---|
43
- | Skills (SKILL.md) | Supported |
44
- | MCP Servers | Accepted in manifest, not implemented |
45
- | Agent Rules | Accepted in manifest, not implemented |
43
+ | Skills (SKILL.md) | Supported via manifest and CLI |
44
+ | File write | Supported via manifest and CLI |
45
+ | Config patch (JSON merge) | Supported via manifest and CLI |
46
+ | MCP Servers | Accepted in manifest for forward compatibility, not implemented |
47
+ | Agent Rules | Accepted in manifest for forward compatibility, not implemented |
46
48
 
47
49
  ## Manifest Format
48
50
 
@@ -57,17 +59,49 @@ Create an `inception.json` file at the root of your skills directory:
57
59
  "agents": ["claude-code", "codex", "gemini-cli", "antigravity", "opencode", "github-copilot"]
58
60
  }
59
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
+ ],
60
78
  "mcpServers": [],
61
79
  "agentRules": []
62
80
  }
63
81
  ```
64
82
 
65
- Each skill entry has:
83
+ Each **skill** entry has:
66
84
 
67
- - **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
68
86
  - **path** - Relative path to the skill directory within the repo
69
87
  - **agents** - Array of agent IDs to deploy this skill to. If an agent isn't installed, it's skipped.
70
88
 
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
+
71
105
  `mcpServers` and `agentRules` are currently parsed for forward compatibility, but the deployment engine ignores them today.
72
106
 
73
107
  ## Creating Skills
@@ -165,7 +199,7 @@ inception-engine maintains a centralized deployment registry at `~/.inception-en
165
199
 
166
200
  - **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.
167
201
 
168
- - **Strong binding**: Each registry entry binds a specific target path to its source, skill, agent, and deploy method. A target is only considered managed if all fields match — a stray entry or a different deployment cannot satisfy the check.
202
+ - **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.
169
203
 
170
204
  - **Atomic redeploy**: When overwriting an existing managed 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.
171
205
 
@@ -201,7 +235,10 @@ Windows deployment currently uses directory-level copy (one `cp -r` per skill).
201
235
 
202
236
  ## Requirements
203
237
 
204
- - Node.js >= 23.6.0
238
+ - Published CLI runtime: Node.js >= 22.3.0
239
+ - Direct TypeScript execution in this repo (`npm run dev`, `npm test`): Node.js >= 22.18.0
240
+
241
+ `inception-engine` publishes compiled JavaScript from `dist/`, so end users do not need the newer Node version required for this repository's direct `.ts` workflows. The higher contributor floor exists only because this repo intentionally runs TypeScript straight through `node` for local development and tests, with no `tsx`, `ts-node`, or experimental TypeScript flags.
205
242
 
206
243
  ## License
207
244
 
@@ -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]));
@@ -1,8 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { UserError } from "../errors.js";
3
4
  import { formatZodPath } from "../schemas/errors.js";
4
5
  import { ManifestSchema } from "../schemas/manifest.js";
5
- import { UserError } from "../errors.js";
6
6
  export async function loadManifest(directory) {
7
7
  const manifestPath = path.join(directory, "inception.json");
8
8
  let raw;
@@ -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,9 +1,13 @@
1
- import type { AgentId, DeployAction, Manifest } from "../types.ts";
2
- export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<DeployAction[]>;
1
+ import type { AgentId, DeployAction, Manifest, PlannedChange, PlanWarning } from "../types.ts";
2
+ export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<{
3
+ actions: DeployAction[];
4
+ warnings: PlanWarning[];
5
+ }>;
3
6
  export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string): Promise<{
4
7
  succeeded: number;
5
8
  failed: Array<{
6
9
  action: DeployAction;
7
10
  error: string;
8
11
  }>;
12
+ planned: PlannedChange[];
9
13
  }>;
@@ -1,10 +1,56 @@
1
- import { access, cp, lstat, mkdir, realpath, rename, rm, symlink, unlink, } from "node:fs/promises";
1
+ import { constants } from "node:fs";
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";
4
5
  import { UserError } from "../errors.js";
5
6
  import { logger } from "../logger.js";
6
- import { registerDeployment, verifyDeployment } from "./ownership.js";
7
+ import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
7
8
  import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
9
+ function isPlainObject(v) {
10
+ return typeof v === "object" && v !== null && !Array.isArray(v);
11
+ }
12
+ async function readJsonConfig(filePath) {
13
+ let rawContent;
14
+ try {
15
+ rawContent = await readFile(filePath, "utf-8");
16
+ }
17
+ catch (err) {
18
+ const code = err.code;
19
+ if (code === "ENOENT")
20
+ throw new Error(`Config file not found: ${filePath}`);
21
+ throw err;
22
+ }
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(rawContent);
26
+ }
27
+ catch {
28
+ throw new Error(`Config file is not valid JSON: ${filePath}`);
29
+ }
30
+ if (!isPlainObject(parsed)) {
31
+ throw new Error(`Config file is not a JSON object: ${filePath}`);
32
+ }
33
+ return parsed;
34
+ }
35
+ function computeUndoPatch(original, patch) {
36
+ const undoPatch = {};
37
+ for (const key of Object.keys(patch)) {
38
+ undoPatch[key] = key in original ? original[key] : null;
39
+ }
40
+ return undoPatch;
41
+ }
42
+ function applyMergePatch(original, patch) {
43
+ const patched = { ...original };
44
+ for (const [key, value] of Object.entries(patch)) {
45
+ if (value === null) {
46
+ delete patched[key];
47
+ }
48
+ else {
49
+ patched[key] = value;
50
+ }
51
+ }
52
+ return patched;
53
+ }
8
54
  function sourceAccessError(err, sourcePath) {
9
55
  const code = err.code;
10
56
  if (code === "ENOENT")
@@ -14,17 +60,58 @@ function sourceAccessError(err, sourcePath) {
14
60
  const detail = err instanceof Error ? err.message : String(err);
15
61
  return `Failed to access source ${sourcePath}: ${detail}`;
16
62
  }
17
- export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
18
- const method = getDeployMethod();
19
- const actions = [];
20
- const resolvedSourceDir = path.resolve(sourceDir);
21
- let realRoot;
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;
22
74
  try {
23
- realRoot = await realpath(resolvedSourceDir);
75
+ stat = await lstat(sourcePath);
24
76
  }
25
- catch {
26
- realRoot = resolvedSourceDir;
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}`);
27
82
  }
83
+ }
84
+ function detectCollisions(actions) {
85
+ const seen = new Map();
86
+ const warnings = [];
87
+ for (const action of actions) {
88
+ const prev = seen.get(action.target);
89
+ if (prev) {
90
+ warnings.push({
91
+ kind: "collision",
92
+ message: `Skill "${action.skill}" for agent "${action.agent}" and skill "${prev.skill}" for agent "${prev.agent}" both resolve to the same target: ${action.target}`,
93
+ });
94
+ }
95
+ else {
96
+ seen.set(action.target, { skill: action.skill, agent: action.agent });
97
+ }
98
+ }
99
+ return warnings;
100
+ }
101
+ function detectAmbiguities(detectedAgents) {
102
+ const warnings = [];
103
+ if (detectedAgents.includes("gemini-cli") &&
104
+ detectedAgents.includes("antigravity")) {
105
+ warnings.push({
106
+ kind: "ambiguity",
107
+ message: 'Both "gemini-cli" and "antigravity" share the ~/.gemini/ base directory. antigravity skill support is implementation-only. Verify that deployed skill paths do not conflict.',
108
+ });
109
+ }
110
+ return warnings;
111
+ }
112
+ async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
113
+ const method = getDeployMethod();
114
+ const actions = [];
28
115
  for (const skill of manifest.skills) {
29
116
  const source = path.resolve(sourceDir, skill.path);
30
117
  await validateSourcePath(source, skill.path, resolvedSourceDir, realRoot);
@@ -35,26 +122,112 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
35
122
  const agent = AGENT_REGISTRY_BY_ID[agentId];
36
123
  if (!agent)
37
124
  continue;
38
- const target = resolveAgentSkillPath(agent, skill.name, home);
39
125
  actions.push({
40
126
  kind: "skill-dir",
41
127
  skill: skill.name,
42
128
  agent: agentId,
43
129
  source,
44
- target,
130
+ target: resolveAgentSkillPath(agent, skill.name, home),
45
131
  method,
132
+ confidence: agent.provenance.skills,
133
+ });
134
+ }
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,
46
157
  });
47
158
  }
48
159
  }
49
160
  return actions;
50
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
+ ];
197
+ const warnings = [
198
+ ...detectAmbiguities(detectedAgents),
199
+ ...detectCollisions(actions),
200
+ ];
201
+ return { actions, warnings };
202
+ }
51
203
  export async function executeDeploy(actions, dryRun, verbose, home) {
52
204
  let succeeded = 0;
53
205
  const failed = [];
206
+ const planned = [];
54
207
  for (const action of actions) {
55
208
  switch (action.kind) {
56
209
  case "skill-dir": {
57
- const result = await deploySkillDir(action, dryRun, verbose, home);
210
+ const result = await deploySkillDir(action, dryRun, verbose, home, planned);
211
+ if (result.error === null) {
212
+ succeeded++;
213
+ }
214
+ else {
215
+ failed.push({ action, error: result.error });
216
+ }
217
+ break;
218
+ }
219
+ case "file-write": {
220
+ const result = await deployFileWrite(action, dryRun, verbose, home, planned);
221
+ if (result.error === null) {
222
+ succeeded++;
223
+ }
224
+ else {
225
+ failed.push({ action, error: result.error });
226
+ }
227
+ break;
228
+ }
229
+ case "config-patch": {
230
+ const result = await deployConfigPatch(action, dryRun, verbose, home, planned);
58
231
  if (result.error === null) {
59
232
  succeeded++;
60
233
  }
@@ -64,14 +237,13 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
64
237
  break;
65
238
  }
66
239
  default: {
67
- const _ = action.kind;
68
- throw new Error(`Unhandled deploy action kind: ${_}`);
240
+ throw new Error(`Unhandled deploy action kind: ${action}`);
69
241
  }
70
242
  }
71
243
  }
72
- return { succeeded, failed };
244
+ return { succeeded, failed, planned };
73
245
  }
74
- async function deploySkillDir(action, dryRun, verbose, home) {
246
+ async function deploySkillDir(action, dryRun, verbose, home, planned) {
75
247
  const label = `${action.skill} -> ${action.agent}`;
76
248
  try {
77
249
  await access(action.source);
@@ -82,14 +254,130 @@ async function deploySkillDir(action, dryRun, verbose, home) {
82
254
  return { error: msg };
83
255
  }
84
256
  if (dryRun) {
85
- logger.plan(label);
257
+ planned.push({
258
+ verb: action.method === "symlink" ? "create-symlink" : "copy-dir",
259
+ kind: "skill-dir",
260
+ skill: action.skill,
261
+ agent: action.agent,
262
+ source: action.source,
263
+ target: action.target,
264
+ method: action.method,
265
+ confidence: action.confidence,
266
+ });
267
+ return { error: null };
268
+ }
269
+ try {
270
+ await executeDeployAction(action, verbose, home);
271
+ return { error: null };
272
+ }
273
+ catch (err) {
274
+ const msg = err instanceof Error ? err.message : String(err);
275
+ logger.fail(label, msg);
276
+ return { error: msg };
277
+ }
278
+ }
279
+ async function deployFileWrite(action, dryRun, verbose, home, planned) {
280
+ const label = `${action.skill} -> ${action.agent}`;
281
+ try {
282
+ await access(action.source);
283
+ }
284
+ catch (err) {
285
+ const msg = sourceAccessError(err, action.source);
286
+ logger.fail(label, msg);
287
+ return { error: msg };
288
+ }
289
+ if (dryRun) {
290
+ planned.push({
291
+ verb: "write-file",
292
+ kind: "file-write",
293
+ skill: action.skill,
294
+ agent: action.agent,
295
+ source: action.source,
296
+ target: action.target,
297
+ });
298
+ return { error: null };
299
+ }
300
+ try {
301
+ // Check if target exists — only allow overwrite if we own it
302
+ try {
303
+ await lstat(action.target);
304
+ const isOwned = await verifyDeployment(home, action.target, {
305
+ kind: "file-write",
306
+ source: action.source,
307
+ skill: action.skill,
308
+ agent: action.agent,
309
+ });
310
+ if (!isOwned) {
311
+ throw new Error(`Target "${action.target}" exists but is not managed by inception-engine — refusing to overwrite`);
312
+ }
313
+ }
314
+ catch (err) {
315
+ if (err instanceof Error && err.message.includes("refusing to overwrite"))
316
+ throw err;
317
+ // ENOENT — target doesn't exist, fine to create
318
+ }
319
+ await mkdir(path.dirname(action.target), { recursive: true });
320
+ await copyFile(action.source, action.target);
321
+ await registerDeployment(home, action.target, {
322
+ kind: "file-write",
323
+ source: action.source,
324
+ skill: action.skill,
325
+ agent: action.agent,
326
+ });
327
+ logger.ok(label);
86
328
  if (verbose) {
87
- logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
329
+ logger.detail(`write-file: ${action.source} -> ${action.target}`);
88
330
  }
89
331
  return { error: null };
90
332
  }
333
+ catch (err) {
334
+ const msg = err instanceof Error ? err.message : String(err);
335
+ logger.fail(label, msg);
336
+ return { error: msg };
337
+ }
338
+ }
339
+ async function deployConfigPatch(action, dryRun, verbose, home, planned) {
340
+ const label = `${action.skill} -> ${action.agent}`;
341
+ if (!isPlainObject(action.patch)) {
342
+ const msg = `Config patch for skill "${action.skill}" must be a plain object`;
343
+ logger.fail(label, msg);
344
+ return { error: msg };
345
+ }
346
+ const patch = action.patch;
347
+ if (dryRun) {
348
+ planned.push({
349
+ verb: "patch-config",
350
+ kind: "config-patch",
351
+ skill: action.skill,
352
+ agent: action.agent,
353
+ target: action.target,
354
+ patch,
355
+ });
356
+ return { error: null };
357
+ }
91
358
  try {
92
- await executeDeployAction(action, verbose, home);
359
+ // Guard against double-patching by a different skill/agent
360
+ const existingEntry = await lookupDeployment(home, action.target);
361
+ if (existingEntry &&
362
+ (existingEntry.skill !== action.skill ||
363
+ existingEntry.agent !== action.agent)) {
364
+ throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" — refusing to double-patch`);
365
+ }
366
+ const original = await readJsonConfig(action.target);
367
+ const undoPatch = computeUndoPatch(original, patch);
368
+ const patched = applyMergePatch(original, patch);
369
+ await writeFile(action.target, `${JSON.stringify(patched, null, 2)}\n`, "utf-8");
370
+ await registerDeployment(home, action.target, {
371
+ kind: "config-patch",
372
+ patch,
373
+ undoPatch,
374
+ skill: action.skill,
375
+ agent: action.agent,
376
+ });
377
+ logger.ok(label);
378
+ if (verbose) {
379
+ logger.detail(`patch-config: applied ${Object.keys(patch).length} key(s) to ${action.target}`);
380
+ }
93
381
  return { error: null };
94
382
  }
95
383
  catch (err) {
@@ -134,9 +422,23 @@ async function validateSkillContract(source, skillPath) {
134
422
  throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is not a directory: ${source}`);
135
423
  }
136
424
  try {
137
- await access(path.join(source, "SKILL.md"));
425
+ await access(source, constants.R_OK);
138
426
  }
139
- 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
+ }
140
442
  throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is missing SKILL.md: ${source}`);
141
443
  }
142
444
  }
@@ -159,6 +461,7 @@ async function createDeployTarget(action, home) {
159
461
  await cp(action.source, action.target, { recursive: true });
160
462
  }
161
463
  await registerDeployment(home, action.target, {
464
+ kind: action.kind,
162
465
  source: action.source,
163
466
  skill: action.skill,
164
467
  agent: action.agent,
@@ -168,6 +471,7 @@ async function createDeployTarget(action, home) {
168
471
  async function executeDeployAction(action, verbose, home) {
169
472
  const label = `${action.skill} -> ${action.agent}`;
170
473
  const backupPath = await backupExisting(action.target, verbose, home, {
474
+ kind: action.kind,
171
475
  source: action.source,
172
476
  skill: action.skill,
173
477
  agent: action.agent,
@@ -180,6 +484,9 @@ async function executeDeployAction(action, verbose, home) {
180
484
  catch (createErr) {
181
485
  if (backupPath) {
182
486
  try {
487
+ await removeTarget(action.target).catch(() => {
488
+ /* best-effort cleanup */
489
+ });
183
490
  await rename(backupPath, action.target);
184
491
  }
185
492
  catch {
@@ -203,21 +510,24 @@ async function backupExisting(targetPath, verbose, home, expected) {
203
510
  catch {
204
511
  return null;
205
512
  }
206
- if (!(await verifyDeployment(home, targetPath, expected))) {
513
+ if (!(await verifyDeployment(home, targetPath, {
514
+ kind: "skill-dir",
515
+ source: expected.source,
516
+ skill: expected.skill,
517
+ agent: expected.agent,
518
+ }))) {
207
519
  throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
208
520
  }
209
521
  const backupPath = `${targetPath}.inception-backup`;
210
- // Clean up any stale backup from a previous failed attempt
211
- try {
212
- await lstat(backupPath);
213
- await removeTarget(backupPath);
214
- }
215
- catch {
216
- // No stale backup — expected
217
- }
218
522
  if (verbose) {
219
523
  logger.detail(`backing up existing target: ${targetPath}`);
220
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 });
221
531
  await rename(targetPath, backupPath);
222
532
  return backupPath;
223
533
  }
@@ -1,7 +1,7 @@
1
1
  import type { AgentId } from "../types.ts";
2
2
  export type ExecFn = (cmd: string, args: readonly string[]) => Promise<void>;
3
3
  export declare function detectInstalledAgents(home: string): Promise<AgentId[]>;
4
- export declare function isBinaryInPath(binary: string, execFn?: ExecFn): Promise<boolean>;
4
+ export declare function isBinaryInPath(binary: string, execFn?: ExecFn, platform?: NodeJS.Platform): Promise<boolean>;
5
5
  export declare function isBinaryViaWhereExe(binary: string, execFn?: ExecFn): Promise<boolean>;
6
- export declare function isBinaryViaCommandV(binary: string): Promise<boolean>;
6
+ export declare function isBinaryViaCommandV(binary: string, execFn?: ExecFn): Promise<boolean>;
7
7
  export declare function isBinaryViaWhich(binary: string, execFn?: ExecFn): Promise<boolean>;
@@ -30,8 +30,8 @@ async function isAgentInstalled(agent, home) {
30
30
  }
31
31
  return false;
32
32
  }
33
- export async function isBinaryInPath(binary, execFn = defaultExecFn) {
34
- if (process.platform === "win32") {
33
+ export async function isBinaryInPath(binary, execFn = defaultExecFn, platform = process.platform) {
34
+ if (platform === "win32") {
35
35
  return isBinaryViaWhereExe(binary, execFn);
36
36
  }
37
37
  try {
@@ -41,7 +41,7 @@ export async function isBinaryInPath(binary, execFn = defaultExecFn) {
41
41
  catch (err) {
42
42
  // `which` itself is not installed — fall back to the POSIX shell built-in
43
43
  if (isENOENT(err)) {
44
- return isBinaryViaCommandV(binary);
44
+ return isBinaryViaCommandV(binary, execFn);
45
45
  }
46
46
  return false;
47
47
  }
@@ -58,10 +58,10 @@ export async function isBinaryViaWhereExe(binary, execFn = defaultExecFn) {
58
58
  }
59
59
  // Used only when `which` is absent (e.g. minimal Alpine containers).
60
60
  // `command -v` is a POSIX shell built-in available wherever /bin/sh is.
61
- export async function isBinaryViaCommandV(binary) {
61
+ export async function isBinaryViaCommandV(binary, execFn = defaultExecFn) {
62
62
  try {
63
63
  // Pass binary as a positional arg ($1) to avoid any shell-injection risk.
64
- await execFileAsync("sh", ["-c", 'command -v "$1"', "--", binary]);
64
+ await execFn("sh", ["-c", 'command -v "$1"', "--", binary]);
65
65
  return true;
66
66
  }
67
67
  catch {