@kuznai/inception-engine 0.3.0 → 0.4.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
@@ -17,7 +17,7 @@ inception-engine reads a manifest file (`inception.json`) from the target direct
17
17
  - **POSIX (macOS, Linux)**: creates symlinks from the source skill directory to each agent's skill path
18
18
  - **Windows**: copies skill directories to each agent's skill path
19
19
 
20
- Skills always overwrite their previous version. On POSIX systems, symlinks mean updates to the source repo are reflected immediately.
20
+ Managed skills overwrite their previous version. If a target exists but was not created by inception-engine, deployment refuses to replace it. On POSIX systems, symlinks mean updates to the source repo are reflected immediately.
21
21
 
22
22
  ## Agent Compatibility Matrix
23
23
 
@@ -93,14 +93,14 @@ inception-engine revert <directory> [options]
93
93
  | Command | Description |
94
94
  |---|---|
95
95
  | `<directory>` | Deploy skills from the manifest in the given directory |
96
- | `revert <directory>` | Remove all skills declared in the manifest |
96
+ | `revert <directory>` | Remove previously deployed skills declared in the manifest |
97
97
 
98
98
  ### Options
99
99
 
100
100
  | Option | Description |
101
101
  |---|---|
102
102
  | `--dry-run` | Show what would be done without making changes |
103
- | `--agents <list>` | Comma-separated list of agent IDs to target (skips detection) |
103
+ | `--agents <list>` | Comma-separated list of agent IDs to target (overrides deploy detection; restricts revert) |
104
104
  | `--verbose` | Show detailed output including file paths |
105
105
  | `--debug` | Show full error stack traces |
106
106
  | `--help` | Show help message |
@@ -141,7 +141,9 @@ inception-engine automatically detects which agents are installed by checking:
141
141
  1. Whether the agent's config directory exists (e.g., `~/.claude/` for Claude Code)
142
142
  2. Whether the agent's binary is in your PATH (e.g., `claude`, `codex`, `gemini`)
143
143
 
144
- If an agent isn't detected, its skills are skipped. Use `--agents` to override detection.
144
+ If an agent isn't detected, its skills are skipped during deploy. Use `--agents` to override detection.
145
+
146
+ Revert targets all agents listed in the manifest by default (regardless of detection) so that previously deployed skills are cleaned up even if the agent has since been uninstalled. Use `--agents` to restrict revert to specific agents.
145
147
 
146
148
  ## Cross-Platform Behavior
147
149
 
@@ -153,13 +155,17 @@ If an agent isn't detected, its skills are skipped. Use `--agents` to override d
153
155
 
154
156
  ### Ownership Tracking and Safe Revert
155
157
 
156
- inception-engine uses different ownership proofs depending on the deploy method so that `revert` never removes content it did not create:
158
+ inception-engine writes a structured `.inception-totem` marker file during every deploy so that `revert` and future deploys never touch content they did not create. The totem contains metadata (source path, skill name, agent ID, deploy timestamp) and is validated on both revert and redeploy.
159
+
160
+ - **POSIX (symlink)**: `.inception-totem` is written inside the skill source directory. On revert, the tool resolves the symlink target and checks for a valid `.inception-totem` there. Only symlinks whose resolved target contains a valid totem are removed.
161
+
162
+ - **Windows (copy)**: `.inception-totem` is written inside each deployed skill directory. On revert, this file must be present and valid for the directory to be removed.
157
163
 
158
- - **POSIX (symlink)**: The symlink itself is the proof of ownership. On revert, the tool reads the symlink target with `readlink` and verifies it points to a directory containing a `SKILL.md`. Only symlinks that resolve to a valid skill source are removed.
164
+ - **Deploy safety**: Before overwriting an existing target, the engine checks for a valid `.inception-totem`. If the target exists but is not managed by inception-engine, the deploy is skipped with an error the unmanaged content is never removed.
159
165
 
160
- - **Windows (copy)**: A marker file `.inception-totem` is written inside each deployed skill directory. On revert, this file must be present for the directory to be removed. Directories that lack `.inception-totem` are skipped with a warning, even if they contain a `SKILL.md`.
166
+ - **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.
161
167
 
162
- > **Note:** Skills deployed before this ownership tracking was introduced (Windows only) will lack `.inception-totem` and must be re-deployed before `revert` can remove them.
168
+ > **Note:** Skills deployed before `.inception-totem` was introduced must be re-deployed before `revert` can remove them.
163
169
 
164
170
  ## Running with Privilege Escalation
165
171
 
@@ -46,6 +46,9 @@ function validateManifest(data, filePath) {
46
46
  if (path.isAbsolute(skill.path)) {
47
47
  throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a relative path`);
48
48
  }
49
+ if (path.normalize(skill.path).startsWith("..")) {
50
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must not escape the repository root`);
51
+ }
49
52
  if (!Array.isArray(skill.agents) || skill.agents.length === 0) {
50
53
  throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents must be a non-empty array`);
51
54
  }
@@ -1,5 +1,5 @@
1
1
  import type { AgentId, DeployAction, Manifest } from "../types.ts";
2
- export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): DeployAction[];
2
+ export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<DeployAction[]>;
3
3
  export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean): Promise<{
4
4
  succeeded: number;
5
5
  failed: Array<{
@@ -1,18 +1,37 @@
1
- import { access, lstat, mkdir, symlink, cp, unlink, rm, writeFile } from "node:fs/promises";
1
+ import { access, lstat, mkdir, symlink, cp, unlink, rm, rename, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
4
4
  import { resolveAgentSkillPath, getDeployMethod } from "./resolve.js";
5
5
  import { UserError } from "../errors.js";
6
6
  import { logger } from "../logger.js";
7
- export function planDeploy(manifest, sourceDir, detectedAgents, home) {
7
+ import { isOwnedByInceptionEngine, writeTotem } from "./ownership.js";
8
+ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
8
9
  const method = getDeployMethod();
9
10
  const actions = [];
10
11
  const resolvedSourceDir = path.resolve(sourceDir);
12
+ let realRoot;
13
+ try {
14
+ realRoot = await realpath(resolvedSourceDir);
15
+ }
16
+ catch {
17
+ realRoot = resolvedSourceDir;
18
+ }
11
19
  for (const skill of manifest.skills) {
12
20
  const source = path.resolve(sourceDir, skill.path);
13
- if (source !== resolvedSourceDir && !source.startsWith(resolvedSourceDir + path.sep)) {
21
+ if (!source.startsWith(resolvedSourceDir + path.sep)) {
14
22
  throw new UserError("DEPLOY_FAILED", `Skill path "${skill.path}" resolves outside the repository root: ${source}`);
15
23
  }
24
+ try {
25
+ const realSource = await realpath(source);
26
+ if (realSource !== realRoot && !realSource.startsWith(realRoot + path.sep)) {
27
+ throw new UserError("DEPLOY_FAILED", `Skill path "${skill.path}" resolves outside the repository root via symlink: ${source} -> ${realSource}`);
28
+ }
29
+ }
30
+ catch (err) {
31
+ if (err instanceof UserError)
32
+ throw err;
33
+ // Source doesn't exist yet — will be caught during execute
34
+ }
16
35
  for (const agentId of skill.agents) {
17
36
  if (!detectedAgents.includes(agentId))
18
37
  continue;
@@ -48,14 +67,43 @@ export async function executeDeploy(actions, dryRun, verbose) {
48
67
  continue;
49
68
  }
50
69
  try {
51
- await removeExisting(action.target, verbose);
70
+ const backupPath = await backupExisting(action.target, verbose);
52
71
  await mkdir(path.dirname(action.target), { recursive: true });
53
- if (action.method === "symlink") {
54
- await symlink(action.source, action.target, "dir");
72
+ try {
73
+ // Final TOCTOU check: ensure nothing appeared at the target after backup
74
+ try {
75
+ await lstat(action.target);
76
+ throw new Error(`Target path appeared unexpectedly after backup: ${action.target}`);
77
+ }
78
+ catch (err) {
79
+ if (err instanceof Error && err.message.startsWith("Target path appeared"))
80
+ throw err;
81
+ // ENOENT is expected — target should not exist after backup
82
+ }
83
+ if (action.method === "symlink") {
84
+ await symlink(action.source, action.target, "dir");
85
+ await writeTotem(action.source, { source: action.source, skill: action.skill, agent: action.agent });
86
+ }
87
+ else {
88
+ await cp(action.source, action.target, { recursive: true });
89
+ await writeTotem(action.target, { source: action.source, skill: action.skill, agent: action.agent });
90
+ }
91
+ }
92
+ catch (createErr) {
93
+ // Rollback: restore backup if creation failed
94
+ if (backupPath) {
95
+ try {
96
+ await rename(backupPath, action.target);
97
+ }
98
+ catch {
99
+ // Best-effort rollback
100
+ }
101
+ }
102
+ throw createErr;
55
103
  }
56
- else {
57
- await cp(action.source, action.target, { recursive: true });
58
- await writeFile(path.join(action.target, ".inception-totem"), "inception-engine\n");
104
+ // Success: remove backup
105
+ if (backupPath) {
106
+ await removeTarget(backupPath);
59
107
  }
60
108
  logger.ok(label);
61
109
  if (verbose) {
@@ -71,24 +119,38 @@ export async function executeDeploy(actions, dryRun, verbose) {
71
119
  }
72
120
  return { succeeded, failed };
73
121
  }
74
- async function removeExisting(targetPath, verbose) {
122
+ async function backupExisting(targetPath, verbose) {
75
123
  let stat;
76
124
  try {
77
125
  stat = await lstat(targetPath);
78
126
  }
79
127
  catch {
80
- return;
128
+ return null;
81
129
  }
130
+ if (!(await isOwnedByInceptionEngine(targetPath, stat))) {
131
+ throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
132
+ }
133
+ const backupPath = targetPath + ".inception-backup";
134
+ // Clean up any stale backup from a previous failed attempt
135
+ try {
136
+ await lstat(backupPath);
137
+ await removeTarget(backupPath);
138
+ }
139
+ catch {
140
+ // No stale backup — expected
141
+ }
142
+ if (verbose) {
143
+ logger.detail(`backing up existing target: ${targetPath}`);
144
+ }
145
+ await rename(targetPath, backupPath);
146
+ return backupPath;
147
+ }
148
+ async function removeTarget(targetPath) {
149
+ const stat = await lstat(targetPath);
82
150
  if (stat.isSymbolicLink()) {
83
- if (verbose) {
84
- logger.detail(`removing existing symlink: ${targetPath}`);
85
- }
86
151
  await unlink(targetPath);
87
152
  }
88
153
  else {
89
- if (verbose) {
90
- logger.warn(targetPath, "replacing existing directory");
91
- }
92
154
  await rm(targetPath, { recursive: true });
93
155
  }
94
156
  }
@@ -0,0 +1,10 @@
1
+ import type { Stats } from "node:fs";
2
+ import type { AgentId } from "../types.ts";
3
+ export interface TotemData {
4
+ source: string;
5
+ skill: string;
6
+ agent: AgentId;
7
+ }
8
+ export declare function formatTotem(data: TotemData): string;
9
+ export declare function writeTotem(directory: string, data: TotemData): Promise<void>;
10
+ export declare function isOwnedByInceptionEngine(targetPath: string, stat: Stats): Promise<boolean>;
@@ -0,0 +1,44 @@
1
+ import { access, readlink, readFile, writeFile, chmod } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const TOTEM_FILE = ".inception-totem";
4
+ const TOTEM_HEADER = "inception-engine";
5
+ export function formatTotem(data) {
6
+ const lines = [
7
+ TOTEM_HEADER,
8
+ `source=${data.source}`,
9
+ `skill=${data.skill}`,
10
+ `agent=${data.agent}`,
11
+ `deployed=${new Date().toISOString()}`,
12
+ ];
13
+ return lines.join("\n") + "\n";
14
+ }
15
+ export async function writeTotem(directory, data) {
16
+ const totemPath = path.join(directory, TOTEM_FILE);
17
+ await writeFile(totemPath, formatTotem(data));
18
+ await chmod(totemPath, 0o644);
19
+ }
20
+ export async function isOwnedByInceptionEngine(targetPath, stat) {
21
+ const totemLocation = stat.isSymbolicLink()
22
+ ? await resolveSymlinkTotemPath(targetPath)
23
+ : path.join(targetPath, TOTEM_FILE);
24
+ if (!totemLocation)
25
+ return false;
26
+ try {
27
+ const content = await readFile(totemLocation, "utf-8");
28
+ return content.startsWith(TOTEM_HEADER);
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function resolveSymlinkTotemPath(targetPath) {
35
+ try {
36
+ const linkTarget = await readlink(targetPath);
37
+ const resolved = path.resolve(path.dirname(targetPath), linkTarget);
38
+ await access(resolved);
39
+ return path.join(resolved, TOTEM_FILE);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AgentId, Manifest, RevertAction } from "../types.ts";
2
2
  export declare function planRevert(manifest: Manifest, detectedAgents: AgentId[], home: string): RevertAction[];
3
+ export declare function planRevertAll(manifest: Manifest, home: string): RevertAction[];
3
4
  export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean): Promise<{
4
5
  succeeded: number;
5
6
  skipped: number;
@@ -1,8 +1,8 @@
1
- import { access, lstat, unlink, rm, readlink } from "node:fs/promises";
2
- import path from "node:path";
3
- import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
1
+ import { lstat, unlink, rm } from "node:fs/promises";
2
+ import { AGENT_REGISTRY, AGENT_REGISTRY_BY_ID } from "../config/agents.js";
4
3
  import { resolveAgentSkillPath } from "./resolve.js";
5
4
  import { logger } from "../logger.js";
5
+ import { isOwnedByInceptionEngine } from "./ownership.js";
6
6
  export function planRevert(manifest, detectedAgents, home) {
7
7
  const actions = [];
8
8
  for (const skill of manifest.skills) {
@@ -18,6 +18,19 @@ export function planRevert(manifest, detectedAgents, home) {
18
18
  }
19
19
  return actions;
20
20
  }
21
+ export function planRevertAll(manifest, home) {
22
+ const actions = [];
23
+ for (const skill of manifest.skills) {
24
+ for (const agentId of skill.agents) {
25
+ const agent = AGENT_REGISTRY_BY_ID[agentId];
26
+ if (!agent)
27
+ continue;
28
+ const target = resolveAgentSkillPath(agent, skill.name, home);
29
+ actions.push({ skill: skill.name, agent: agentId, target });
30
+ }
31
+ }
32
+ return actions;
33
+ }
21
34
  export async function executeRevert(actions, dryRun, verbose) {
22
35
  let succeeded = 0;
23
36
  let skipped = 0;
@@ -65,25 +78,3 @@ export async function executeRevert(actions, dryRun, verbose) {
65
78
  }
66
79
  return { succeeded, skipped };
67
80
  }
68
- async function isOwnedByInceptionEngine(targetPath, stat) {
69
- if (stat.isSymbolicLink()) {
70
- try {
71
- const linkTarget = await readlink(targetPath);
72
- const resolved = path.resolve(path.dirname(targetPath), linkTarget);
73
- await access(path.join(resolved, "SKILL.md"));
74
- return true;
75
- }
76
- catch {
77
- return false;
78
- }
79
- }
80
- else {
81
- try {
82
- await access(path.join(targetPath, ".inception-totem"));
83
- return true;
84
- }
85
- catch {
86
- return false;
87
- }
88
- }
89
- }
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { AGENT_REGISTRY } from "./config/agents.js";
7
7
  import { resolveHome } from "./core/resolve.js";
8
8
  import { detectInstalledAgents } from "./core/detect.js";
9
9
  import { planDeploy, executeDeploy } from "./core/deploy.js";
10
- import { planRevert, executeRevert } from "./core/revert.js";
10
+ import { planRevert, planRevertAll, executeRevert } from "./core/revert.js";
11
11
  import { UserError } from "./errors.js";
12
12
  import { logger, dryRunPrefix } from "./logger.js";
13
13
  const USAGE = `
@@ -93,26 +93,26 @@ async function main() {
93
93
  }
94
94
  const manifest = await loadManifest(options.directory);
95
95
  const home = resolveHome();
96
- let detectedAgents;
97
- if (options.agents) {
98
- detectedAgents = options.agents;
99
- if (options.verbose) {
100
- logger.info(`Using specified agents: ${detectedAgents.join(", ")}`);
101
- }
102
- }
103
- else {
104
- detectedAgents = await detectInstalledAgents(home);
105
- if (detectedAgents.length === 0) {
106
- logger.info("No supported AI agents detected on this system.");
107
- logger.info(`Install one of: ${AGENT_REGISTRY.map((a) => a.displayName).join(", ")}`);
108
- return 0;
96
+ if (options.command === "deploy") {
97
+ let detectedAgents;
98
+ if (options.agents) {
99
+ detectedAgents = options.agents;
100
+ if (options.verbose) {
101
+ logger.info(`Using specified agents: ${detectedAgents.join(", ")}`);
102
+ }
109
103
  }
110
- if (options.verbose) {
111
- logger.info(`Detected agents: ${detectedAgents.join(", ")}`);
104
+ else {
105
+ detectedAgents = await detectInstalledAgents(home);
106
+ if (detectedAgents.length === 0) {
107
+ logger.info("No supported AI agents detected on this system.");
108
+ logger.info(`Install one of: ${AGENT_REGISTRY.map((a) => a.displayName).join(", ")}`);
109
+ return 0;
110
+ }
111
+ if (options.verbose) {
112
+ logger.info(`Detected agents: ${detectedAgents.join(", ")}`);
113
+ }
112
114
  }
113
- }
114
- if (options.command === "deploy") {
115
- const actions = planDeploy(manifest, options.directory, detectedAgents, home);
115
+ const actions = await planDeploy(manifest, options.directory, detectedAgents, home);
116
116
  if (actions.length === 0) {
117
117
  logger.info("No skills to deploy for detected agents.");
118
118
  return 0;
@@ -129,9 +129,11 @@ async function main() {
129
129
  }
130
130
  }
131
131
  else {
132
- const actions = planRevert(manifest, detectedAgents, home);
132
+ const actions = options.agents
133
+ ? planRevert(manifest, options.agents, home)
134
+ : planRevertAll(manifest, home);
133
135
  if (actions.length === 0) {
134
- logger.info("No skills to revert for detected agents.");
136
+ logger.info("No skills to revert.");
135
137
  return 0;
136
138
  }
137
139
  logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} skill(s):`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuznai/inception-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",