@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 +12 -8
- package/dist/config/manifest.js +37 -33
- package/dist/core/deploy.d.ts +1 -1
- package/dist/core/deploy.js +115 -28
- package/dist/core/detect.js +4 -2
- package/dist/core/ownership.d.ts +10 -0
- package/dist/core/ownership.js +44 -0
- package/dist/core/resolve.js +45 -30
- package/dist/core/revert.d.ts +1 -0
- package/dist/core/revert.js +53 -55
- package/dist/index.js +60 -41
- package/dist/logger.js +1 -1
- package/package.json +6 -3
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,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
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
|
package/dist/config/manifest.js
CHANGED
|
@@ -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
|
+
}
|
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,24 @@
|
|
|
1
|
-
import { access, lstat, mkdir,
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/core/detect.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/resolve.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
74
|
+
// /etc/passwd unavailable
|
|
58
75
|
}
|
|
59
|
-
|
|
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
|
|
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)
|
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 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
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
await
|
|
83
|
-
|
|
69
|
+
try {
|
|
70
|
+
if (stat.isSymbolicLink()) {
|
|
71
|
+
await unlink(action.target);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await rm(action.target, { recursive: true });
|
|
84
75
|
}
|
|
85
|
-
|
|
86
|
-
|
|
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 {
|
|
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 {
|
|
10
|
-
import { planRevert,
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 {
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
+
"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": "^
|
|
48
|
+
"typescript": "^6.0.2"
|
|
46
49
|
}
|
|
47
50
|
}
|