@kuznai/inception-engine 0.3.0 → 0.4.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
@@ -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,15 @@ 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.
157
161
 
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.
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.
159
163
 
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`.
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.
161
165
 
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.
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.
163
167
 
164
168
  ## Running with Privilege Escalation
165
169
 
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { AGENT_IDS } from "../types.js";
4
3
  import { UserError } from "../errors.js";
4
+ import { AGENT_IDS } from "../types.js";
5
5
  export async function loadManifest(directory) {
6
6
  const manifestPath = path.join(directory, "inception.json");
7
7
  let raw;
@@ -28,41 +28,45 @@ function validateManifest(data, filePath) {
28
28
  if (!Array.isArray(obj.skills)) {
29
29
  throw new UserError("MANIFEST_INVALID", `${filePath}: "skills" must be an array`);
30
30
  }
31
- const skills = obj.skills.map((entry, i) => {
32
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
33
- throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}] must be an object`);
34
- }
35
- const skill = entry;
36
- if (typeof skill.name !== "string" || skill.name.length === 0) {
37
- throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must be a non-empty string`);
38
- }
39
- const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
40
- if (!SAFE_NAME_RE.test(skill.name)) {
41
- throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must contain only letters, digits, hyphens, underscores, and dots, and must not start with a dot`);
42
- }
43
- if (typeof skill.path !== "string" || skill.path.length === 0) {
44
- throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a non-empty string`);
45
- }
46
- if (path.isAbsolute(skill.path)) {
47
- throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a relative path`);
48
- }
49
- if (!Array.isArray(skill.agents) || skill.agents.length === 0) {
50
- throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents must be a non-empty array`);
51
- }
52
- for (const agent of skill.agents) {
53
- if (!AGENT_IDS.includes(agent)) {
54
- throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents contains unknown agent "${agent}". Valid agents: ${AGENT_IDS.join(", ")}`);
55
- }
56
- }
57
- return {
58
- name: skill.name,
59
- path: skill.path,
60
- agents: skill.agents,
61
- };
62
- });
31
+ const skills = obj.skills.map((entry, i) => validateSkillEntry(entry, i, filePath));
63
32
  return {
64
33
  skills,
65
34
  mcpServers: Array.isArray(obj.mcpServers) ? obj.mcpServers : [],
66
35
  agentRules: Array.isArray(obj.agentRules) ? obj.agentRules : [],
67
36
  };
68
37
  }
38
+ const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
39
+ function validateSkillEntry(entry, i, filePath) {
40
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
41
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}] must be an object`);
42
+ }
43
+ const skill = entry;
44
+ if (typeof skill.name !== "string" || skill.name.length === 0) {
45
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must be a non-empty string`);
46
+ }
47
+ if (!SAFE_NAME_RE.test(skill.name)) {
48
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must contain only letters, digits, hyphens, underscores, and dots, and must not start with a dot`);
49
+ }
50
+ if (typeof skill.path !== "string" || skill.path.length === 0) {
51
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a non-empty string`);
52
+ }
53
+ if (path.isAbsolute(skill.path)) {
54
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a relative path`);
55
+ }
56
+ if (path.normalize(skill.path).startsWith("..")) {
57
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must not escape the repository root`);
58
+ }
59
+ if (!Array.isArray(skill.agents) || skill.agents.length === 0) {
60
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents must be a non-empty array`);
61
+ }
62
+ for (const agent of skill.agents) {
63
+ if (!AGENT_IDS.includes(agent)) {
64
+ throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents contains unknown agent "${agent}". Valid agents: ${AGENT_IDS.join(", ")}`);
65
+ }
66
+ }
67
+ return {
68
+ name: skill.name,
69
+ path: skill.path,
70
+ agents: skill.agents,
71
+ };
72
+ }
@@ -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,24 @@
1
- import { access, lstat, mkdir, symlink, cp, unlink, rm, writeFile } from "node:fs/promises";
1
+ import { access, cp, lstat, mkdir, realpath, rename, rm, symlink, unlink, } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
4
- import { resolveAgentSkillPath, getDeployMethod } from "./resolve.js";
5
4
  import { UserError } from "../errors.js";
6
5
  import { logger } from "../logger.js";
7
- export function planDeploy(manifest, sourceDir, detectedAgents, home) {
6
+ import { isOwnedByInceptionEngine, writeTotem } from "./ownership.js";
7
+ import { getDeployMethod, resolveAgentSkillPath } from "./resolve.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)) {
14
- throw new UserError("DEPLOY_FAILED", `Skill path "${skill.path}" resolves outside the repository root: ${source}`);
15
- }
21
+ await validateSourcePath(source, skill.path, resolvedSourceDir, realRoot);
16
22
  for (const agentId of skill.agents) {
17
23
  if (!detectedAgents.includes(agentId))
18
24
  continue;
@@ -20,7 +26,13 @@ export function planDeploy(manifest, sourceDir, detectedAgents, home) {
20
26
  if (!agent)
21
27
  continue;
22
28
  const target = resolveAgentSkillPath(agent, skill.name, home);
23
- actions.push({ skill: skill.name, agent: agentId, source, target, method });
29
+ actions.push({
30
+ skill: skill.name,
31
+ agent: agentId,
32
+ source,
33
+ target,
34
+ method,
35
+ });
24
36
  }
25
37
  }
26
38
  return actions;
@@ -48,19 +60,7 @@ export async function executeDeploy(actions, dryRun, verbose) {
48
60
  continue;
49
61
  }
50
62
  try {
51
- await removeExisting(action.target, verbose);
52
- await mkdir(path.dirname(action.target), { recursive: true });
53
- if (action.method === "symlink") {
54
- await symlink(action.source, action.target, "dir");
55
- }
56
- else {
57
- await cp(action.source, action.target, { recursive: true });
58
- await writeFile(path.join(action.target, ".inception-totem"), "inception-engine\n");
59
- }
60
- logger.ok(label);
61
- if (verbose) {
62
- logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
63
- }
63
+ await executeDeployAction(action, verbose);
64
64
  succeeded++;
65
65
  }
66
66
  catch (err) {
@@ -71,24 +71,111 @@ export async function executeDeploy(actions, dryRun, verbose) {
71
71
  }
72
72
  return { succeeded, failed };
73
73
  }
74
- async function removeExisting(targetPath, verbose) {
74
+ async function validateSourcePath(source, skillPath, resolvedSourceDir, realRoot) {
75
+ if (!source.startsWith(resolvedSourceDir + path.sep)) {
76
+ throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root: ${source}`);
77
+ }
78
+ try {
79
+ const realSource = await realpath(source);
80
+ if (realSource !== realRoot &&
81
+ !realSource.startsWith(realRoot + path.sep)) {
82
+ throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root via symlink: ${source} -> ${realSource}`);
83
+ }
84
+ }
85
+ catch (err) {
86
+ if (err instanceof UserError)
87
+ throw err;
88
+ // Source doesn't exist yet — will be caught during execute
89
+ }
90
+ }
91
+ async function assertTargetAbsent(targetPath) {
92
+ try {
93
+ await lstat(targetPath);
94
+ throw new Error(`Target path appeared unexpectedly after backup: ${targetPath}`);
95
+ }
96
+ catch (err) {
97
+ if (err instanceof Error && err.message.startsWith("Target path appeared"))
98
+ throw err;
99
+ // ENOENT is expected — target should not exist after backup
100
+ }
101
+ }
102
+ async function createDeployTarget(action) {
103
+ if (action.method === "symlink") {
104
+ await symlink(action.source, action.target, "dir");
105
+ await writeTotem(action.source, {
106
+ source: action.source,
107
+ skill: action.skill,
108
+ agent: action.agent,
109
+ });
110
+ }
111
+ else {
112
+ await cp(action.source, action.target, { recursive: true });
113
+ await writeTotem(action.target, {
114
+ source: action.source,
115
+ skill: action.skill,
116
+ agent: action.agent,
117
+ });
118
+ }
119
+ }
120
+ async function executeDeployAction(action, verbose) {
121
+ const label = `${action.skill} -> ${action.agent}`;
122
+ const backupPath = await backupExisting(action.target, verbose);
123
+ await mkdir(path.dirname(action.target), { recursive: true });
124
+ try {
125
+ await assertTargetAbsent(action.target);
126
+ await createDeployTarget(action);
127
+ }
128
+ catch (createErr) {
129
+ if (backupPath) {
130
+ try {
131
+ await rename(backupPath, action.target);
132
+ }
133
+ catch {
134
+ // Best-effort rollback
135
+ }
136
+ }
137
+ throw createErr;
138
+ }
139
+ if (backupPath) {
140
+ await removeTarget(backupPath);
141
+ }
142
+ logger.ok(label);
143
+ if (verbose) {
144
+ logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
145
+ }
146
+ }
147
+ async function backupExisting(targetPath, verbose) {
75
148
  let stat;
76
149
  try {
77
150
  stat = await lstat(targetPath);
78
151
  }
79
152
  catch {
80
- return;
153
+ return null;
154
+ }
155
+ if (!(await isOwnedByInceptionEngine(targetPath, stat))) {
156
+ throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
157
+ }
158
+ const backupPath = `${targetPath}.inception-backup`;
159
+ // Clean up any stale backup from a previous failed attempt
160
+ try {
161
+ await lstat(backupPath);
162
+ await removeTarget(backupPath);
163
+ }
164
+ catch {
165
+ // No stale backup — expected
166
+ }
167
+ if (verbose) {
168
+ logger.detail(`backing up existing target: ${targetPath}`);
81
169
  }
170
+ await rename(targetPath, backupPath);
171
+ return backupPath;
172
+ }
173
+ async function removeTarget(targetPath) {
174
+ const stat = await lstat(targetPath);
82
175
  if (stat.isSymbolicLink()) {
83
- if (verbose) {
84
- logger.detail(`removing existing symlink: ${targetPath}`);
85
- }
86
176
  await unlink(targetPath);
87
177
  }
88
178
  else {
89
- if (verbose) {
90
- logger.warn(targetPath, "replacing existing directory");
91
- }
92
179
  await rm(targetPath, { recursive: true });
93
180
  }
94
181
  }
@@ -1,5 +1,5 @@
1
- import { access } from "node:fs/promises";
2
1
  import { execFile } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  import { AGENT_REGISTRY } from "../config/agents.js";
5
5
  import { resolveAgentDetectPath } from "./resolve.js";
@@ -19,7 +19,9 @@ async function isAgentInstalled(agent, home) {
19
19
  await access(detectPath);
20
20
  return true;
21
21
  }
22
- catch { }
22
+ catch {
23
+ // path does not exist — fall through to binary detection
24
+ }
23
25
  if (agent.detectBinary) {
24
26
  return isBinaryInPath(agent.detectBinary);
25
27
  }
@@ -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, chmod, readFile, readlink, writeFile } 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
+ }
@@ -7,41 +7,58 @@ export function resolveHome() {
7
7
  if (process.platform === "win32") {
8
8
  return os.homedir();
9
9
  }
10
- const sudoUser = process.env["SUDO_USER"];
10
+ const sudoUser = process.env.SUDO_USER;
11
11
  if (sudoUser) {
12
12
  return lookupHomeForUser(sudoUser);
13
13
  }
14
14
  return os.homedir();
15
15
  }
16
16
  export function lookupHomeForUserWith(username, platform, execFileFn, readFileFn) {
17
- // Method 1: getent passwd (Linux/POSIX — handles LDAP, NIS, local via NSS)
18
17
  if (platform !== "darwin") {
19
- try {
20
- const out = execFileFn("getent", ["passwd", username], {
21
- encoding: "utf8",
22
- stdio: ["ignore", "pipe", "ignore"],
23
- }).trim();
24
- const home = out.split(":")[5];
25
- if (typeof home === "string" && home.startsWith("/"))
26
- return home;
27
- }
28
- catch {
29
- // getent unavailable or user not found — try next method
30
- }
18
+ const home = lookupViaGetent(username, execFileFn);
19
+ if (home)
20
+ return home;
31
21
  }
32
- // Method 2: dscl (macOS directory services)
33
22
  if (platform === "darwin") {
34
- try {
35
- const out = execFileFn("dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
36
- const home = out.replace(/^NFSHomeDirectory:\s*/, "").trim();
37
- if (home.startsWith("/"))
38
- return home;
39
- }
40
- catch {
41
- // dscl unavailable or user record not found — try next method
42
- }
23
+ const home = lookupViaDscl(username, execFileFn);
24
+ if (home)
25
+ return home;
43
26
  }
44
- // Method 3: parse /etc/passwd directly (universal POSIX fallback)
27
+ const home = lookupViaEtcPasswd(username, readFileFn);
28
+ if (home)
29
+ return home;
30
+ throw new UserError("RESOLVE_FAILED", `Cannot determine home directory for user "${username}". ` +
31
+ `Tried getent, dscl, and /etc/passwd. ` +
32
+ `Run without sudo, or set HOME to the correct path before invoking with sudo.`);
33
+ }
34
+ function lookupViaGetent(username, execFileFn) {
35
+ try {
36
+ const out = execFileFn("getent", ["passwd", username], {
37
+ encoding: "utf8",
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ }).trim();
40
+ const home = out.split(":")[5];
41
+ if (typeof home === "string" && home.startsWith("/"))
42
+ return home;
43
+ }
44
+ catch {
45
+ // getent unavailable or user not found
46
+ }
47
+ return null;
48
+ }
49
+ function lookupViaDscl(username, execFileFn) {
50
+ try {
51
+ const out = execFileFn("dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
52
+ const home = out.replace(/^NFSHomeDirectory:\s*/, "").trim();
53
+ if (home.startsWith("/"))
54
+ return home;
55
+ }
56
+ catch {
57
+ // dscl unavailable or user record not found
58
+ }
59
+ return null;
60
+ }
61
+ function lookupViaEtcPasswd(username, readFileFn) {
45
62
  try {
46
63
  const passwd = readFileFn("/etc/passwd", "utf8");
47
64
  for (const line of passwd.split("\n")) {
@@ -54,11 +71,9 @@ export function lookupHomeForUserWith(username, platform, execFileFn, readFileFn
54
71
  }
55
72
  }
56
73
  catch {
57
- // /etc/passwd unavailable — fall through to error
74
+ // /etc/passwd unavailable
58
75
  }
59
- throw new UserError("RESOLVE_FAILED", `Cannot determine home directory for user "${username}". ` +
60
- `Tried getent, dscl, and /etc/passwd. ` +
61
- `Run without sudo, or set HOME to the correct path before invoking with sudo.`);
76
+ return null;
62
77
  }
63
78
  function lookupHomeForUser(username) {
64
79
  return lookupHomeForUserWith(username, process.platform, execFileSync, readFileSync);
@@ -82,7 +97,7 @@ export function resolveAgentDetectPath(agent, home) {
82
97
  return resolveAgentDetectPathFor(agent, home, getPlatformKey());
83
98
  }
84
99
  function resolvePlaceholders(segments, skillName, home) {
85
- const appdata = process.env["APPDATA"] ?? path.join(home, "AppData", "Roaming");
100
+ const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
86
101
  const resolved = segments.map((seg) => seg
87
102
  .replace("{home}", home)
88
103
  .replace("{name}", skillName)
@@ -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";
1
+ import { lstat, rm, unlink } from "node:fs/promises";
3
2
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
4
- import { resolveAgentSkillPath } from "./resolve.js";
5
3
  import { logger } from "../logger.js";
4
+ import { isOwnedByInceptionEngine } from "./ownership.js";
5
+ import { resolveAgentSkillPath } from "./resolve.js";
6
6
  export function planRevert(manifest, detectedAgents, home) {
7
7
  const actions = [];
8
8
  for (const skill of manifest.skills) {
@@ -18,72 +18,70 @@ 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;
24
37
  for (const action of actions) {
25
- const label = `${action.skill} -> ${action.agent}`;
26
- let stat;
27
- try {
28
- stat = await lstat(action.target);
29
- }
30
- catch {
31
- logger.skip(label, "(not found, skipping)");
32
- skipped++;
33
- continue;
34
- }
35
- if (!(await isOwnedByInceptionEngine(action.target, stat))) {
36
- logger.warn(label, `skipping: ${action.target} does not have inception-engine ownership proof — not managed by inception-engine`);
38
+ const result = await executeRevertAction(action, dryRun, verbose);
39
+ if (result === "skip") {
37
40
  skipped++;
38
- continue;
39
41
  }
40
- if (dryRun) {
41
- logger.plan(label);
42
- if (verbose) {
43
- logger.detail(`would remove: ${action.target}`);
44
- }
42
+ else {
45
43
  succeeded++;
46
- continue;
47
- }
48
- try {
49
- if (stat.isSymbolicLink()) {
50
- await unlink(action.target);
51
- }
52
- else {
53
- await rm(action.target, { recursive: true });
54
- }
55
- logger.ok(label);
56
- if (verbose) {
57
- logger.detail(`removed: ${action.target}`);
58
- }
59
- succeeded++;
60
- }
61
- catch (err) {
62
- const msg = err instanceof Error ? err.message : String(err);
63
- logger.fail(label, msg);
64
44
  }
65
45
  }
66
46
  return { succeeded, skipped };
67
47
  }
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;
48
+ async function executeRevertAction(action, dryRun, verbose) {
49
+ const label = `${action.skill} -> ${action.agent}`;
50
+ let stat;
51
+ try {
52
+ stat = await lstat(action.target);
53
+ }
54
+ catch {
55
+ logger.skip(label, "(not found, skipping)");
56
+ return "skip";
57
+ }
58
+ if (!(await isOwnedByInceptionEngine(action.target, stat))) {
59
+ logger.warn(label, `skipping: ${action.target} does not have inception-engine ownership proof — not managed by inception-engine`);
60
+ return "skip";
61
+ }
62
+ if (dryRun) {
63
+ logger.plan(label);
64
+ if (verbose) {
65
+ logger.detail(`would remove: ${action.target}`);
78
66
  }
67
+ return "ok";
79
68
  }
80
- else {
81
- try {
82
- await access(path.join(targetPath, ".inception-totem"));
83
- return true;
69
+ try {
70
+ if (stat.isSymbolicLink()) {
71
+ await unlink(action.target);
72
+ }
73
+ else {
74
+ await rm(action.target, { recursive: true });
84
75
  }
85
- catch {
86
- return false;
76
+ logger.ok(label);
77
+ if (verbose) {
78
+ logger.detail(`removed: ${action.target}`);
87
79
  }
80
+ return "ok";
81
+ }
82
+ catch (err) {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ logger.fail(label, msg);
85
+ return "skip";
88
86
  }
89
87
  }
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { AGENT_IDS } from "./types.js";
5
- import { loadManifest } from "./config/manifest.js";
6
4
  import { AGENT_REGISTRY } from "./config/agents.js";
7
- import { resolveHome } from "./core/resolve.js";
5
+ import { loadManifest } from "./config/manifest.js";
6
+ import { executeDeploy, planDeploy } from "./core/deploy.js";
8
7
  import { detectInstalledAgents } from "./core/detect.js";
9
- import { planDeploy, executeDeploy } from "./core/deploy.js";
10
- import { planRevert, executeRevert } from "./core/revert.js";
8
+ import { resolveHome } from "./core/resolve.js";
9
+ import { executeRevert, planRevert, planRevertAll } from "./core/revert.js";
11
10
  import { UserError } from "./errors.js";
12
- import { logger, dryRunPrefix } from "./logger.js";
11
+ import { dryRunPrefix, logger } from "./logger.js";
12
+ import { AGENT_IDS } from "./types.js";
13
13
  const USAGE = `
14
14
  inception-engine - Deploy AI agent skills
15
15
 
@@ -30,7 +30,14 @@ Supported agents:
30
30
  function parseCLI(argv) {
31
31
  const args = argv.slice(2);
32
32
  if (args.length === 0) {
33
- return { command: "help", directory: "", dryRun: false, agents: null, verbose: false, debug: false };
33
+ return {
34
+ command: "help",
35
+ directory: "",
36
+ dryRun: false,
37
+ agents: null,
38
+ verbose: false,
39
+ debug: false,
40
+ };
34
41
  }
35
42
  let parsed;
36
43
  try {
@@ -39,10 +46,10 @@ function parseCLI(argv) {
39
46
  allowPositionals: true,
40
47
  options: {
41
48
  "dry-run": { type: "boolean", default: false },
42
- "verbose": { type: "boolean", default: false },
43
- "debug": { type: "boolean", default: false },
44
- "help": { type: "boolean", default: false },
45
- "agents": { type: "string" },
49
+ verbose: { type: "boolean", default: false },
50
+ debug: { type: "boolean", default: false },
51
+ help: { type: "boolean", default: false },
52
+ agents: { type: "string" },
46
53
  },
47
54
  });
48
55
  }
@@ -51,7 +58,14 @@ function parseCLI(argv) {
51
58
  }
52
59
  const { values, positionals } = parsed;
53
60
  if (values.help) {
54
- return { command: "help", directory: "", dryRun: false, agents: null, verbose: false, debug: false };
61
+ return {
62
+ command: "help",
63
+ directory: "",
64
+ dryRun: false,
65
+ agents: null,
66
+ verbose: false,
67
+ debug: false,
68
+ };
55
69
  }
56
70
  let command = "deploy";
57
71
  let pos = positionals;
@@ -93,6 +107,12 @@ async function main() {
93
107
  }
94
108
  const manifest = await loadManifest(options.directory);
95
109
  const home = resolveHome();
110
+ if (options.command === "deploy") {
111
+ return runDeploy(options, manifest, home);
112
+ }
113
+ return runRevert(options, manifest, home);
114
+ }
115
+ async function runDeploy(options, manifest, home) {
96
116
  let detectedAgents;
97
117
  if (options.agents) {
98
118
  detectedAgents = options.agents;
@@ -111,37 +131,36 @@ async function main() {
111
131
  logger.info(`Detected agents: ${detectedAgents.join(", ")}`);
112
132
  }
113
133
  }
114
- if (options.command === "deploy") {
115
- const actions = planDeploy(manifest, options.directory, detectedAgents, home);
116
- if (actions.length === 0) {
117
- logger.info("No skills to deploy for detected agents.");
118
- return 0;
119
- }
120
- logger.info(`${dryRunPrefix(options.dryRun)}Deploying ${actions.length} skill(s):`);
121
- const { succeeded, failed } = await executeDeploy(actions, options.dryRun, options.verbose);
122
- logger.info("");
123
- if (failed.length > 0) {
124
- logger.info(`${succeeded} succeeded, ${failed.length} failed`);
125
- return 1;
126
- }
127
- else {
128
- logger.info(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
129
- }
134
+ const actions = await planDeploy(manifest, options.directory, detectedAgents, home);
135
+ if (actions.length === 0) {
136
+ logger.info("No skills to deploy for detected agents.");
137
+ return 0;
130
138
  }
131
- else {
132
- const actions = planRevert(manifest, detectedAgents, home);
133
- if (actions.length === 0) {
134
- logger.info("No skills to revert for detected agents.");
135
- return 0;
136
- }
137
- logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} skill(s):`);
138
- const { succeeded, skipped } = await executeRevert(actions, options.dryRun, options.verbose);
139
- logger.info("");
140
- const parts = [`${succeeded} removed`];
141
- if (skipped > 0)
142
- parts.push(`${skipped} skipped`);
143
- logger.info(`${parts.join(", ")}${options.dryRun ? " (dry-run)" : ""}`);
139
+ logger.info(`${dryRunPrefix(options.dryRun)}Deploying ${actions.length} skill(s):`);
140
+ const { succeeded, failed } = await executeDeploy(actions, options.dryRun, options.verbose);
141
+ logger.info("");
142
+ if (failed.length > 0) {
143
+ logger.info(`${succeeded} succeeded, ${failed.length} failed`);
144
+ return 1;
145
+ }
146
+ logger.info(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
147
+ return 0;
148
+ }
149
+ async function runRevert(options, manifest, home) {
150
+ const actions = options.agents
151
+ ? planRevert(manifest, options.agents, home)
152
+ : planRevertAll(manifest, home);
153
+ if (actions.length === 0) {
154
+ logger.info("No skills to revert.");
155
+ return 0;
144
156
  }
157
+ logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} skill(s):`);
158
+ const { succeeded, skipped } = await executeRevert(actions, options.dryRun, options.verbose);
159
+ logger.info("");
160
+ const parts = [`${succeeded} removed`];
161
+ if (skipped > 0)
162
+ parts.push(`${skipped} skipped`);
163
+ logger.info(`${parts.join(", ")}${options.dryRun ? " (dry-run)" : ""}`);
145
164
  return 0;
146
165
  }
147
166
  const USER_ERROR_EXIT = {
package/dist/logger.js CHANGED
@@ -57,5 +57,5 @@ export const logger = createLogger();
57
57
  export function dryRunPrefix(dryRun) {
58
58
  if (!dryRun)
59
59
  return "";
60
- return Boolean(process.stdout.isTTY) ? `\x1b[36m[dry-run]\x1b[0m ` : `[dry-run] `;
60
+ return process.stdout.isTTY ? `\x1b[36m[dry-run]\x1b[0m ` : `[dry-run] `;
61
61
  }
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.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",
@@ -36,12 +36,15 @@
36
36
  "scripts": {
37
37
  "build": "tsc",
38
38
  "typecheck": "tsc --noEmit",
39
+ "format": "biome format --write .",
40
+ "lint": "biome lint . --max-diagnostics none",
39
41
  "dev": "node src/index.ts",
40
42
  "test": "node --test test/*.test.ts",
41
- "prepublishOnly": "npm run build"
43
+ "prepublishOnly": "npm run typecheck && npm run lint && npm run build"
42
44
  },
43
45
  "devDependencies": {
46
+ "@biomejs/biome": "^2.4.8",
44
47
  "@types/node": "^25.5.0",
45
- "typescript": "^5.8.0"
48
+ "typescript": "^6.0.2"
46
49
  }
47
50
  }