@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 +14 -8
- package/dist/config/manifest.js +3 -0
- package/dist/core/deploy.d.ts +1 -1
- package/dist/core/deploy.js +79 -17
- package/dist/core/ownership.d.ts +10 -0
- package/dist/core/ownership.js +44 -0
- package/dist/core/revert.d.ts +1 -0
- package/dist/core/revert.js +16 -25
- package/dist/index.js +23 -21
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
package/dist/config/manifest.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/deploy.d.ts
CHANGED
|
@@ -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<{
|
package/dist/core/deploy.js
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
|
-
import { access, lstat, mkdir, symlink, cp, unlink, rm,
|
|
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
|
-
|
|
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 (
|
|
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
|
|
70
|
+
const backupPath = await backupExisting(action.target, verbose);
|
|
52
71
|
await mkdir(path.dirname(action.target), { recursive: true });
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
await
|
|
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
|
|
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
|
+
}
|
package/dist/core/revert.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/revert.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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 =
|
|
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
|
|
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):`);
|