@kuznai/inception-engine 0.2.4 → 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 +37 -5
- package/dist/config/agents.d.ts +2 -1
- package/dist/config/agents.js +25 -24
- package/dist/config/manifest.d.ts +1 -1
- package/dist/config/manifest.js +17 -14
- package/dist/core/deploy.d.ts +3 -3
- package/dist/core/deploy.js +101 -37
- package/dist/core/detect.d.ts +1 -1
- package/dist/core/detect.js +42 -9
- package/dist/core/ownership.d.ts +10 -0
- package/dist/core/ownership.js +44 -0
- package/dist/core/resolve.d.ts +5 -0
- package/dist/core/resolve.js +25 -21
- package/dist/core/revert.d.ts +3 -2
- package/dist/core/revert.js +38 -22
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +3 -1
- package/dist/index.js +103 -89
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +61 -0
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Plant skills directly into the minds of your installed AI coding agents — Clau
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx inception-engine <directory>
|
|
8
|
+
npx @kuznai/inception-engine <directory>
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Where `<directory>` is a repo (or subdirectory) containing an `inception.json` manifest and skill files.
|
|
@@ -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
|
|
|
@@ -151,6 +153,20 @@ If an agent isn't detected, its skills are skipped. Use `--agents` to override d
|
|
|
151
153
|
| Linux | Symlink | Source changes are reflected immediately |
|
|
152
154
|
| Windows | Copy | Source must be re-deployed after changes |
|
|
153
155
|
|
|
156
|
+
### Ownership Tracking and Safe Revert
|
|
157
|
+
|
|
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.
|
|
163
|
+
|
|
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.
|
|
165
|
+
|
|
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.
|
|
167
|
+
|
|
168
|
+
> **Note:** Skills deployed before `.inception-totem` was introduced must be re-deployed before `revert` can remove them.
|
|
169
|
+
|
|
154
170
|
## Running with Privilege Escalation
|
|
155
171
|
|
|
156
172
|
The tool works without elevated privileges. If run with `sudo` on POSIX systems, it looks up the real user's home directory from the OS directory services (`getent passwd` on Linux, `dscl` on macOS, `/etc/passwd` as a universal fallback) so skills are deployed to the correct location regardless of where home directories are stored — standard `/home/<user>`, LDAP/NIS paths, enterprise layouts, or otherwise.
|
|
@@ -159,6 +175,22 @@ If the real home cannot be determined, the tool exits with an error rather than
|
|
|
159
175
|
|
|
160
176
|
On Windows, `os.homedir()` correctly resolves even in elevated PowerShell or cmd.
|
|
161
177
|
|
|
178
|
+
## Security & Compatibility Notes
|
|
179
|
+
|
|
180
|
+
### SUDO_USER handling
|
|
181
|
+
|
|
182
|
+
When run under `sudo` on POSIX, `SUDO_USER` is read as an advisory signal to identify the real user. The username is validated through OS directory services (`getent passwd` on Linux, `dscl` on macOS, `/etc/passwd` as a fallback) — it is never used as a raw path. This is standard practice for sudo-aware CLI tools.
|
|
183
|
+
|
|
184
|
+
Automation edge case: if the tool runs inside a pipeline where `SUDO_USER` has been set externally in the environment before the process starts, the tool will look up that username's home directory. To avoid this in automation, either run without `sudo` or set `HOME` explicitly before invoking the tool.
|
|
185
|
+
|
|
186
|
+
### Windows %APPDATA% fallback
|
|
187
|
+
|
|
188
|
+
On Windows, `%APPDATA%` is used to locate agent config directories (currently only OpenCode). When `%APPDATA%` is unset — which can happen in some service or minimal sandbox contexts — the tool falls back to `AppData\Roaming` under the resolved home directory. This fallback is intentional and correct for interactive user sessions and elevated PowerShell. Running as a Windows service account is outside the supported use case for this tool.
|
|
189
|
+
|
|
190
|
+
### Future: mixed asset layouts on Windows
|
|
191
|
+
|
|
192
|
+
Windows deployment currently uses directory-level copy (one `cp -r` per skill). This works correctly for the present skill model where each skill is a single directory. If file-based assets or mixed (file + directory) skill layouts are added in the future, the copy strategy in `src/core/deploy.ts` will need revisiting — including `.inception-totem` placement and revert logic.
|
|
193
|
+
|
|
162
194
|
## Requirements
|
|
163
195
|
|
|
164
196
|
- Node.js >= 23.6.0
|
package/dist/config/agents.d.ts
CHANGED
package/dist/config/agents.js
CHANGED
|
@@ -3,12 +3,12 @@ export const AGENT_REGISTRY = [
|
|
|
3
3
|
id: "claude-code",
|
|
4
4
|
displayName: "Claude Code",
|
|
5
5
|
skills: {
|
|
6
|
-
posix: "{home}
|
|
7
|
-
windows: "{home}
|
|
6
|
+
posix: ["{home}", ".claude", "skills", "{name}"],
|
|
7
|
+
windows: ["{home}", ".claude", "skills", "{name}"],
|
|
8
8
|
},
|
|
9
9
|
detectPaths: {
|
|
10
|
-
posix: "{home}
|
|
11
|
-
windows: "{home}
|
|
10
|
+
posix: ["{home}", ".claude"],
|
|
11
|
+
windows: ["{home}", ".claude"],
|
|
12
12
|
},
|
|
13
13
|
detectBinary: "claude",
|
|
14
14
|
},
|
|
@@ -16,12 +16,12 @@ export const AGENT_REGISTRY = [
|
|
|
16
16
|
id: "codex",
|
|
17
17
|
displayName: "OpenAI Codex",
|
|
18
18
|
skills: {
|
|
19
|
-
posix: "{home}
|
|
20
|
-
windows: "{home}
|
|
19
|
+
posix: ["{home}", ".codex", "skills", "{name}"],
|
|
20
|
+
windows: ["{home}", ".codex", "skills", "{name}"],
|
|
21
21
|
},
|
|
22
22
|
detectPaths: {
|
|
23
|
-
posix: "{home}
|
|
24
|
-
windows: "{home}
|
|
23
|
+
posix: ["{home}", ".codex"],
|
|
24
|
+
windows: ["{home}", ".codex"],
|
|
25
25
|
},
|
|
26
26
|
detectBinary: "codex",
|
|
27
27
|
},
|
|
@@ -29,12 +29,12 @@ export const AGENT_REGISTRY = [
|
|
|
29
29
|
id: "gemini-cli",
|
|
30
30
|
displayName: "Gemini CLI",
|
|
31
31
|
skills: {
|
|
32
|
-
posix: "{home}
|
|
33
|
-
windows: "{home}
|
|
32
|
+
posix: ["{home}", ".gemini", "skills", "{name}"],
|
|
33
|
+
windows: ["{home}", ".gemini", "skills", "{name}"],
|
|
34
34
|
},
|
|
35
35
|
detectPaths: {
|
|
36
|
-
posix: "{home}
|
|
37
|
-
windows: "{home}
|
|
36
|
+
posix: ["{home}", ".gemini"],
|
|
37
|
+
windows: ["{home}", ".gemini"],
|
|
38
38
|
},
|
|
39
39
|
detectBinary: "gemini",
|
|
40
40
|
},
|
|
@@ -42,12 +42,12 @@ export const AGENT_REGISTRY = [
|
|
|
42
42
|
id: "antigravity",
|
|
43
43
|
displayName: "Antigravity",
|
|
44
44
|
skills: {
|
|
45
|
-
posix: "{home}
|
|
46
|
-
windows: "{home}
|
|
45
|
+
posix: ["{home}", ".gemini", "antigravity", "skills", "{name}"],
|
|
46
|
+
windows: ["{home}", ".gemini", "antigravity", "skills", "{name}"],
|
|
47
47
|
},
|
|
48
48
|
detectPaths: {
|
|
49
|
-
posix: "{home}
|
|
50
|
-
windows: "{home}
|
|
49
|
+
posix: ["{home}", ".gemini", "antigravity"],
|
|
50
|
+
windows: ["{home}", ".gemini", "antigravity"],
|
|
51
51
|
},
|
|
52
52
|
detectBinary: null,
|
|
53
53
|
},
|
|
@@ -55,12 +55,12 @@ export const AGENT_REGISTRY = [
|
|
|
55
55
|
id: "opencode",
|
|
56
56
|
displayName: "OpenCode",
|
|
57
57
|
skills: {
|
|
58
|
-
posix: "{home}
|
|
59
|
-
windows: "{appdata}
|
|
58
|
+
posix: ["{home}", ".config", "opencode", "skills", "{name}"],
|
|
59
|
+
windows: ["{appdata}", "opencode", "skills", "{name}"],
|
|
60
60
|
},
|
|
61
61
|
detectPaths: {
|
|
62
|
-
posix: "{home}
|
|
63
|
-
windows: "{appdata}
|
|
62
|
+
posix: ["{home}", ".config", "opencode"],
|
|
63
|
+
windows: ["{appdata}", "opencode"],
|
|
64
64
|
},
|
|
65
65
|
detectBinary: "opencode",
|
|
66
66
|
},
|
|
@@ -68,13 +68,14 @@ export const AGENT_REGISTRY = [
|
|
|
68
68
|
id: "github-copilot",
|
|
69
69
|
displayName: "GitHub Copilot",
|
|
70
70
|
skills: {
|
|
71
|
-
posix: "{home}
|
|
72
|
-
windows: "{home}
|
|
71
|
+
posix: ["{home}", ".copilot", "skills", "{name}"],
|
|
72
|
+
windows: ["{home}", ".copilot", "skills", "{name}"],
|
|
73
73
|
},
|
|
74
74
|
detectPaths: {
|
|
75
|
-
posix: "{home}
|
|
76
|
-
windows: "{home}
|
|
75
|
+
posix: ["{home}", ".copilot"],
|
|
76
|
+
windows: ["{home}", ".copilot"],
|
|
77
77
|
},
|
|
78
78
|
detectBinary: "github-copilot",
|
|
79
79
|
},
|
|
80
80
|
];
|
|
81
|
+
export const AGENT_REGISTRY_BY_ID = Object.fromEntries(AGENT_REGISTRY.map((a) => [a.id, a]));
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { Manifest } from "../types.ts";
|
|
2
|
-
export declare function loadManifest(directory: string): Manifest
|
|
2
|
+
export declare function loadManifest(directory: string): Promise<Manifest>;
|
package/dist/config/manifest.js
CHANGED
|
@@ -1,57 +1,60 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { AGENT_IDS } from "../types.js";
|
|
4
4
|
import { UserError } from "../errors.js";
|
|
5
|
-
export function loadManifest(directory) {
|
|
5
|
+
export async function loadManifest(directory) {
|
|
6
6
|
const manifestPath = path.join(directory, "inception.json");
|
|
7
7
|
let raw;
|
|
8
8
|
try {
|
|
9
|
-
raw =
|
|
9
|
+
raw = await readFile(manifestPath, "utf-8");
|
|
10
10
|
}
|
|
11
11
|
catch {
|
|
12
|
-
throw new UserError(`No inception.json found in ${directory}. Are you pointing to the right repo?`);
|
|
12
|
+
throw new UserError("MANIFEST_INVALID", `No inception.json found in ${directory}. Are you pointing to the right repo?`);
|
|
13
13
|
}
|
|
14
14
|
let parsed;
|
|
15
15
|
try {
|
|
16
16
|
parsed = JSON.parse(raw);
|
|
17
17
|
}
|
|
18
18
|
catch {
|
|
19
|
-
throw new UserError(`Invalid JSON in ${manifestPath}`);
|
|
19
|
+
throw new UserError("MANIFEST_INVALID", `Invalid JSON in ${manifestPath}`);
|
|
20
20
|
}
|
|
21
21
|
return validateManifest(parsed, manifestPath);
|
|
22
22
|
}
|
|
23
23
|
function validateManifest(data, filePath) {
|
|
24
24
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
25
|
-
throw new UserError(`${filePath}: manifest must be a JSON object`);
|
|
25
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: manifest must be a JSON object`);
|
|
26
26
|
}
|
|
27
27
|
const obj = data;
|
|
28
28
|
if (!Array.isArray(obj.skills)) {
|
|
29
|
-
throw new UserError(`${filePath}: "skills" must be an array`);
|
|
29
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: "skills" must be an array`);
|
|
30
30
|
}
|
|
31
31
|
const skills = obj.skills.map((entry, i) => {
|
|
32
32
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
33
|
-
throw new UserError(`${filePath}: skills[${i}] must be an object`);
|
|
33
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}] must be an object`);
|
|
34
34
|
}
|
|
35
35
|
const skill = entry;
|
|
36
36
|
if (typeof skill.name !== "string" || skill.name.length === 0) {
|
|
37
|
-
throw new UserError(`${filePath}: skills[${i}].name must be a non-empty string`);
|
|
37
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must be a non-empty string`);
|
|
38
38
|
}
|
|
39
39
|
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
40
40
|
if (!SAFE_NAME_RE.test(skill.name)) {
|
|
41
|
-
throw new UserError(`${filePath}: skills[${i}].name must contain only letters, digits, hyphens, underscores, and dots, and must not start with a dot`);
|
|
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
42
|
}
|
|
43
43
|
if (typeof skill.path !== "string" || skill.path.length === 0) {
|
|
44
|
-
throw new UserError(`${filePath}: skills[${i}].path must be a non-empty string`);
|
|
44
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a non-empty string`);
|
|
45
45
|
}
|
|
46
46
|
if (path.isAbsolute(skill.path)) {
|
|
47
|
-
throw new UserError(`${filePath}: skills[${i}].path must be a relative path`);
|
|
47
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a relative path`);
|
|
48
|
+
}
|
|
49
|
+
if (path.normalize(skill.path).startsWith("..")) {
|
|
50
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must not escape the repository root`);
|
|
48
51
|
}
|
|
49
52
|
if (!Array.isArray(skill.agents) || skill.agents.length === 0) {
|
|
50
|
-
throw new UserError(`${filePath}: skills[${i}].agents must be a non-empty array`);
|
|
53
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents must be a non-empty array`);
|
|
51
54
|
}
|
|
52
55
|
for (const agent of skill.agents) {
|
|
53
56
|
if (!AGENT_IDS.includes(agent)) {
|
|
54
|
-
throw new UserError(`${filePath}: skills[${i}].agents contains unknown agent "${agent}". Valid agents: ${AGENT_IDS.join(", ")}`);
|
|
57
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents contains unknown agent "${agent}". Valid agents: ${AGENT_IDS.join(", ")}`);
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
return {
|
package/dist/core/deploy.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { AgentId, DeployAction, Manifest } from "../types.ts";
|
|
2
|
-
export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): DeployAction[]
|
|
3
|
-
export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean): {
|
|
2
|
+
export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<DeployAction[]>;
|
|
3
|
+
export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean): Promise<{
|
|
4
4
|
succeeded: number;
|
|
5
5
|
failed: Array<{
|
|
6
6
|
action: DeployAction;
|
|
7
7
|
error: string;
|
|
8
8
|
}>;
|
|
9
|
-
}
|
|
9
|
+
}>;
|
package/dist/core/deploy.js
CHANGED
|
@@ -1,21 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { access, lstat, mkdir, symlink, cp, unlink, rm, rename, realpath } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
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
|
+
import { isOwnedByInceptionEngine, writeTotem } from "./ownership.js";
|
|
8
|
+
export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
7
9
|
const method = getDeployMethod();
|
|
8
10
|
const actions = [];
|
|
9
11
|
const resolvedSourceDir = path.resolve(sourceDir);
|
|
12
|
+
let realRoot;
|
|
13
|
+
try {
|
|
14
|
+
realRoot = await realpath(resolvedSourceDir);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
realRoot = resolvedSourceDir;
|
|
18
|
+
}
|
|
10
19
|
for (const skill of manifest.skills) {
|
|
11
20
|
const source = path.resolve(sourceDir, skill.path);
|
|
12
|
-
if (
|
|
13
|
-
throw new UserError(`Skill path "${skill.path}" resolves outside the repository root: ${source}`);
|
|
21
|
+
if (!source.startsWith(resolvedSourceDir + path.sep)) {
|
|
22
|
+
throw new UserError("DEPLOY_FAILED", `Skill path "${skill.path}" resolves outside the repository root: ${source}`);
|
|
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
|
|
14
34
|
}
|
|
15
35
|
for (const agentId of skill.agents) {
|
|
16
36
|
if (!detectedAgents.includes(agentId))
|
|
17
37
|
continue;
|
|
18
|
-
const agent =
|
|
38
|
+
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
19
39
|
if (!agent)
|
|
20
40
|
continue;
|
|
21
41
|
const target = resolveAgentSkillPath(agent, skill.name, home);
|
|
@@ -24,69 +44,113 @@ export function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
|
24
44
|
}
|
|
25
45
|
return actions;
|
|
26
46
|
}
|
|
27
|
-
export function executeDeploy(actions, dryRun, verbose) {
|
|
47
|
+
export async function executeDeploy(actions, dryRun, verbose) {
|
|
28
48
|
let succeeded = 0;
|
|
29
49
|
const failed = [];
|
|
30
50
|
for (const action of actions) {
|
|
31
51
|
const label = `${action.skill} -> ${action.agent}`;
|
|
32
|
-
|
|
52
|
+
try {
|
|
53
|
+
await access(action.source);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
33
56
|
const msg = `Source not found: ${action.source}`;
|
|
34
57
|
failed.push({ action, error: msg });
|
|
35
|
-
|
|
58
|
+
logger.fail(label, msg);
|
|
36
59
|
continue;
|
|
37
60
|
}
|
|
38
61
|
if (dryRun) {
|
|
39
|
-
|
|
62
|
+
logger.plan(label);
|
|
40
63
|
if (verbose) {
|
|
41
|
-
|
|
64
|
+
logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
|
|
42
65
|
}
|
|
43
66
|
succeeded++;
|
|
44
67
|
continue;
|
|
45
68
|
}
|
|
46
69
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
const backupPath = await backupExisting(action.target, verbose);
|
|
71
|
+
await mkdir(path.dirname(action.target), { recursive: true });
|
|
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;
|
|
51
103
|
}
|
|
52
|
-
|
|
53
|
-
|
|
104
|
+
// Success: remove backup
|
|
105
|
+
if (backupPath) {
|
|
106
|
+
await removeTarget(backupPath);
|
|
54
107
|
}
|
|
55
|
-
|
|
108
|
+
logger.ok(label);
|
|
56
109
|
if (verbose) {
|
|
57
|
-
|
|
110
|
+
logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
|
|
58
111
|
}
|
|
59
112
|
succeeded++;
|
|
60
113
|
}
|
|
61
114
|
catch (err) {
|
|
62
115
|
const msg = err instanceof Error ? err.message : String(err);
|
|
63
116
|
failed.push({ action, error: msg });
|
|
64
|
-
|
|
117
|
+
logger.fail(label, msg);
|
|
65
118
|
}
|
|
66
119
|
}
|
|
67
120
|
return { succeeded, failed };
|
|
68
121
|
}
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (verbose) {
|
|
74
|
-
console.log(` removing existing symlink: ${targetPath}`);
|
|
75
|
-
}
|
|
76
|
-
unlinkSync(targetPath);
|
|
122
|
+
async function backupExisting(targetPath, verbose) {
|
|
123
|
+
let stat;
|
|
124
|
+
try {
|
|
125
|
+
stat = await lstat(targetPath);
|
|
77
126
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.log(` \x1b[33m!\x1b[0m replacing existing directory: ${targetPath}`);
|
|
81
|
-
}
|
|
82
|
-
rmSync(targetPath, { recursive: true });
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
83
129
|
}
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
86
135
|
try {
|
|
87
|
-
|
|
136
|
+
await lstat(backupPath);
|
|
137
|
+
await removeTarget(backupPath);
|
|
88
138
|
}
|
|
89
139
|
catch {
|
|
90
|
-
|
|
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);
|
|
150
|
+
if (stat.isSymbolicLink()) {
|
|
151
|
+
await unlink(targetPath);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
await rm(targetPath, { recursive: true });
|
|
91
155
|
}
|
|
92
156
|
}
|
package/dist/core/detect.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { AgentId } from "../types.ts";
|
|
2
|
-
export declare function detectInstalledAgents(home: string): AgentId[]
|
|
2
|
+
export declare function detectInstalledAgents(home: string): Promise<AgentId[]>;
|
package/dist/core/detect.js
CHANGED
|
@@ -1,33 +1,66 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
3
4
|
import { AGENT_REGISTRY } from "../config/agents.js";
|
|
4
5
|
import { resolveAgentDetectPath } from "./resolve.js";
|
|
5
|
-
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function detectInstalledAgents(home) {
|
|
6
8
|
const detected = [];
|
|
7
9
|
for (const agent of AGENT_REGISTRY) {
|
|
8
|
-
if (isAgentInstalled(agent, home)) {
|
|
10
|
+
if (await isAgentInstalled(agent, home)) {
|
|
9
11
|
detected.push(agent.id);
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
14
|
return detected;
|
|
13
15
|
}
|
|
14
|
-
function isAgentInstalled(agent, home) {
|
|
16
|
+
async function isAgentInstalled(agent, home) {
|
|
15
17
|
const detectPath = resolveAgentDetectPath(agent, home);
|
|
16
|
-
|
|
18
|
+
try {
|
|
19
|
+
await access(detectPath);
|
|
17
20
|
return true;
|
|
18
21
|
}
|
|
22
|
+
catch { }
|
|
19
23
|
if (agent.detectBinary) {
|
|
20
24
|
return isBinaryInPath(agent.detectBinary);
|
|
21
25
|
}
|
|
22
26
|
return false;
|
|
23
27
|
}
|
|
24
|
-
function isBinaryInPath(binary) {
|
|
25
|
-
|
|
28
|
+
async function isBinaryInPath(binary) {
|
|
29
|
+
if (process.platform === "win32") {
|
|
30
|
+
try {
|
|
31
|
+
await execFileAsync("where.exe", [binary]);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await execFileAsync("which", [binary]);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
// `which` itself is not installed — fall back to the POSIX shell built-in
|
|
44
|
+
if (isENOENT(err)) {
|
|
45
|
+
return isBinaryViaCommandV(binary);
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Used only when `which` is absent (e.g. minimal Alpine containers).
|
|
51
|
+
// `command -v` is a POSIX shell built-in available wherever /bin/sh is.
|
|
52
|
+
async function isBinaryViaCommandV(binary) {
|
|
26
53
|
try {
|
|
27
|
-
|
|
54
|
+
// Pass binary as a positional arg ($1) to avoid any shell-injection risk.
|
|
55
|
+
await execFileAsync("sh", ["-c", 'command -v "$1"', "--", binary]);
|
|
28
56
|
return true;
|
|
29
57
|
}
|
|
30
58
|
catch {
|
|
31
59
|
return false;
|
|
32
60
|
}
|
|
33
61
|
}
|
|
62
|
+
function isENOENT(err) {
|
|
63
|
+
return (typeof err === "object" &&
|
|
64
|
+
err !== null &&
|
|
65
|
+
err.code === "ENOENT");
|
|
66
|
+
}
|
|
@@ -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/resolve.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
1
3
|
import type { AgentConfig } from "../types.ts";
|
|
2
4
|
export declare function resolveHome(): string;
|
|
5
|
+
export declare function lookupHomeForUserWith(username: string, platform: NodeJS.Platform, execFileFn: typeof execFileSync, readFileFn: typeof readFileSync): string;
|
|
3
6
|
export declare function getPlatformKey(): "posix" | "windows";
|
|
4
7
|
export declare function getDeployMethod(): "symlink" | "copy";
|
|
8
|
+
export declare function resolveAgentSkillPathFor(agent: AgentConfig, skillName: string, home: string, platform: "posix" | "windows"): string;
|
|
9
|
+
export declare function resolveAgentDetectPathFor(agent: AgentConfig, home: string, platform: "posix" | "windows"): string;
|
|
5
10
|
export declare function resolveAgentSkillPath(agent: AgentConfig, skillName: string, home: string): string;
|
|
6
11
|
export declare function resolveAgentDetectPath(agent: AgentConfig, home: string): string;
|
package/dist/core/resolve.js
CHANGED
|
@@ -13,11 +13,11 @@ export function resolveHome() {
|
|
|
13
13
|
}
|
|
14
14
|
return os.homedir();
|
|
15
15
|
}
|
|
16
|
-
function
|
|
16
|
+
export function lookupHomeForUserWith(username, platform, execFileFn, readFileFn) {
|
|
17
17
|
// Method 1: getent passwd (Linux/POSIX — handles LDAP, NIS, local via NSS)
|
|
18
|
-
if (
|
|
18
|
+
if (platform !== "darwin") {
|
|
19
19
|
try {
|
|
20
|
-
const out =
|
|
20
|
+
const out = execFileFn("getent", ["passwd", username], {
|
|
21
21
|
encoding: "utf8",
|
|
22
22
|
stdio: ["ignore", "pipe", "ignore"],
|
|
23
23
|
}).trim();
|
|
@@ -30,9 +30,9 @@ function lookupHomeForUser(username) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
// Method 2: dscl (macOS directory services)
|
|
33
|
-
if (
|
|
33
|
+
if (platform === "darwin") {
|
|
34
34
|
try {
|
|
35
|
-
const out =
|
|
35
|
+
const out = execFileFn("dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
36
36
|
const home = out.replace(/^NFSHomeDirectory:\s*/, "").trim();
|
|
37
37
|
if (home.startsWith("/"))
|
|
38
38
|
return home;
|
|
@@ -43,7 +43,7 @@ function lookupHomeForUser(username) {
|
|
|
43
43
|
}
|
|
44
44
|
// Method 3: parse /etc/passwd directly (universal POSIX fallback)
|
|
45
45
|
try {
|
|
46
|
-
const passwd =
|
|
46
|
+
const passwd = readFileFn("/etc/passwd", "utf8");
|
|
47
47
|
for (const line of passwd.split("\n")) {
|
|
48
48
|
const parts = line.split(":");
|
|
49
49
|
if (parts[0] === username) {
|
|
@@ -56,32 +56,36 @@ function lookupHomeForUser(username) {
|
|
|
56
56
|
catch {
|
|
57
57
|
// /etc/passwd unavailable — fall through to error
|
|
58
58
|
}
|
|
59
|
-
throw new UserError(`Cannot determine home directory for user "${username}". ` +
|
|
59
|
+
throw new UserError("RESOLVE_FAILED", `Cannot determine home directory for user "${username}". ` +
|
|
60
60
|
`Tried getent, dscl, and /etc/passwd. ` +
|
|
61
61
|
`Run without sudo, or set HOME to the correct path before invoking with sudo.`);
|
|
62
62
|
}
|
|
63
|
+
function lookupHomeForUser(username) {
|
|
64
|
+
return lookupHomeForUserWith(username, process.platform, execFileSync, readFileSync);
|
|
65
|
+
}
|
|
63
66
|
export function getPlatformKey() {
|
|
64
67
|
return process.platform === "win32" ? "windows" : "posix";
|
|
65
68
|
}
|
|
66
69
|
export function getDeployMethod() {
|
|
67
70
|
return process.platform === "win32" ? "copy" : "symlink";
|
|
68
71
|
}
|
|
72
|
+
export function resolveAgentSkillPathFor(agent, skillName, home, platform) {
|
|
73
|
+
return resolvePlaceholders(agent.skills[platform], skillName, home);
|
|
74
|
+
}
|
|
75
|
+
export function resolveAgentDetectPathFor(agent, home, platform) {
|
|
76
|
+
return resolvePlaceholders(agent.detectPaths[platform], "", home);
|
|
77
|
+
}
|
|
69
78
|
export function resolveAgentSkillPath(agent, skillName, home) {
|
|
70
|
-
|
|
71
|
-
const template = agent.skills[platform];
|
|
72
|
-
return resolvePlaceholders(template, skillName, home);
|
|
79
|
+
return resolveAgentSkillPathFor(agent, skillName, home, getPlatformKey());
|
|
73
80
|
}
|
|
74
81
|
export function resolveAgentDetectPath(agent, home) {
|
|
75
|
-
|
|
76
|
-
const template = agent.detectPaths[platform];
|
|
77
|
-
return resolvePlaceholders(template, "", home);
|
|
82
|
+
return resolveAgentDetectPathFor(agent, home, getPlatformKey());
|
|
78
83
|
}
|
|
79
|
-
function resolvePlaceholders(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return result;
|
|
84
|
+
function resolvePlaceholders(segments, skillName, home) {
|
|
85
|
+
const appdata = process.env["APPDATA"] ?? path.join(home, "AppData", "Roaming");
|
|
86
|
+
const resolved = segments.map((seg) => seg
|
|
87
|
+
.replace("{home}", home)
|
|
88
|
+
.replace("{name}", skillName)
|
|
89
|
+
.replace("{appdata}", appdata));
|
|
90
|
+
return path.join(...resolved);
|
|
87
91
|
}
|
package/dist/core/revert.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
|
3
|
+
export declare function planRevertAll(manifest: Manifest, home: string): RevertAction[];
|
|
4
|
+
export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean): Promise<{
|
|
4
5
|
succeeded: number;
|
|
5
6
|
skipped: number;
|
|
6
|
-
}
|
|
7
|
+
}>;
|
package/dist/core/revert.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { AGENT_REGISTRY } 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";
|
|
3
3
|
import { resolveAgentSkillPath } from "./resolve.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
import { isOwnedByInceptionEngine } from "./ownership.js";
|
|
4
6
|
export function planRevert(manifest, detectedAgents, home) {
|
|
5
7
|
const actions = [];
|
|
6
8
|
for (const skill of manifest.skills) {
|
|
7
9
|
for (const agentId of skill.agents) {
|
|
8
10
|
if (!detectedAgents.includes(agentId))
|
|
9
11
|
continue;
|
|
10
|
-
const agent =
|
|
12
|
+
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
11
13
|
if (!agent)
|
|
12
14
|
continue;
|
|
13
15
|
const target = resolveAgentSkillPath(agent, skill.name, home);
|
|
@@ -16,49 +18,63 @@ export function planRevert(manifest, detectedAgents, home) {
|
|
|
16
18
|
}
|
|
17
19
|
return actions;
|
|
18
20
|
}
|
|
19
|
-
export function
|
|
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
|
+
}
|
|
34
|
+
export async function executeRevert(actions, dryRun, verbose) {
|
|
20
35
|
let succeeded = 0;
|
|
21
36
|
let skipped = 0;
|
|
22
37
|
for (const action of actions) {
|
|
23
38
|
const label = `${action.skill} -> ${action.agent}`;
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
let stat;
|
|
40
|
+
try {
|
|
41
|
+
stat = await lstat(action.target);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
logger.skip(label, "(not found, skipping)");
|
|
45
|
+
skipped++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!(await isOwnedByInceptionEngine(action.target, stat))) {
|
|
49
|
+
logger.warn(label, `skipping: ${action.target} does not have inception-engine ownership proof — not managed by inception-engine`);
|
|
26
50
|
skipped++;
|
|
27
51
|
continue;
|
|
28
52
|
}
|
|
29
53
|
if (dryRun) {
|
|
30
|
-
|
|
54
|
+
logger.plan(label);
|
|
31
55
|
if (verbose) {
|
|
32
|
-
|
|
56
|
+
logger.detail(`would remove: ${action.target}`);
|
|
33
57
|
}
|
|
34
58
|
succeeded++;
|
|
35
59
|
continue;
|
|
36
60
|
}
|
|
37
61
|
try {
|
|
38
|
-
if (
|
|
39
|
-
|
|
62
|
+
if (stat.isSymbolicLink()) {
|
|
63
|
+
await unlink(action.target);
|
|
40
64
|
}
|
|
41
65
|
else {
|
|
42
|
-
|
|
66
|
+
await rm(action.target, { recursive: true });
|
|
43
67
|
}
|
|
44
|
-
|
|
68
|
+
logger.ok(label);
|
|
45
69
|
if (verbose) {
|
|
46
|
-
|
|
70
|
+
logger.detail(`removed: ${action.target}`);
|
|
47
71
|
}
|
|
48
72
|
succeeded++;
|
|
49
73
|
}
|
|
50
74
|
catch (err) {
|
|
51
75
|
const msg = err instanceof Error ? err.message : String(err);
|
|
52
|
-
|
|
76
|
+
logger.fail(label, msg);
|
|
53
77
|
}
|
|
54
78
|
}
|
|
55
79
|
return { succeeded, skipped };
|
|
56
80
|
}
|
|
57
|
-
function isSymlink(p) {
|
|
58
|
-
try {
|
|
59
|
-
return lstatSync(p).isSymbolicLink();
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
}
|
package/dist/errors.d.ts
CHANGED
package/dist/errors.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
3
4
|
import { AGENT_IDS } from "./types.js";
|
|
4
5
|
import { loadManifest } from "./config/manifest.js";
|
|
5
6
|
import { AGENT_REGISTRY } from "./config/agents.js";
|
|
6
7
|
import { resolveHome } from "./core/resolve.js";
|
|
7
8
|
import { detectInstalledAgents } from "./core/detect.js";
|
|
8
9
|
import { planDeploy, executeDeploy } from "./core/deploy.js";
|
|
9
|
-
import { planRevert, executeRevert } from "./core/revert.js";
|
|
10
|
+
import { planRevert, planRevertAll, executeRevert } from "./core/revert.js";
|
|
10
11
|
import { UserError } from "./errors.js";
|
|
12
|
+
import { logger, dryRunPrefix } from "./logger.js";
|
|
11
13
|
const USAGE = `
|
|
12
14
|
inception-engine - Deploy AI agent skills
|
|
13
15
|
|
|
@@ -25,136 +27,148 @@ Options:
|
|
|
25
27
|
Supported agents:
|
|
26
28
|
${AGENT_REGISTRY.map((a) => `${a.id} (${a.displayName})`).join(", ")}
|
|
27
29
|
`.trim();
|
|
28
|
-
function
|
|
30
|
+
function parseCLI(argv) {
|
|
29
31
|
const args = argv.slice(2);
|
|
30
|
-
if (args.length === 0
|
|
32
|
+
if (args.length === 0) {
|
|
33
|
+
return { command: "help", directory: "", dryRun: false, agents: null, verbose: false, debug: false };
|
|
34
|
+
}
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = parseArgs({
|
|
38
|
+
args,
|
|
39
|
+
allowPositionals: true,
|
|
40
|
+
options: {
|
|
41
|
+
"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" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
throw new UserError("INVALID_ARGS", err.message);
|
|
51
|
+
}
|
|
52
|
+
const { values, positionals } = parsed;
|
|
53
|
+
if (values.help) {
|
|
31
54
|
return { command: "help", directory: "", dryRun: false, agents: null, verbose: false, debug: false };
|
|
32
55
|
}
|
|
33
56
|
let command = "deploy";
|
|
34
|
-
let
|
|
35
|
-
|
|
36
|
-
let agents = null;
|
|
37
|
-
let verbose = false;
|
|
38
|
-
let debug = false;
|
|
39
|
-
let i = 0;
|
|
40
|
-
if (args[0] === "revert") {
|
|
57
|
+
let pos = positionals;
|
|
58
|
+
if (pos[0] === "revert") {
|
|
41
59
|
command = "revert";
|
|
42
|
-
|
|
60
|
+
pos = pos.slice(1);
|
|
43
61
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const next = args[i];
|
|
58
|
-
if (!next) {
|
|
59
|
-
throw new UserError("--agents requires a comma-separated list");
|
|
60
|
-
}
|
|
61
|
-
const ids = next.split(",").map((s) => s.trim());
|
|
62
|
-
for (const id of ids) {
|
|
63
|
-
if (!AGENT_IDS.includes(id)) {
|
|
64
|
-
throw new UserError(`Unknown agent: "${id}". Valid agents: ${AGENT_IDS.join(", ")}`);
|
|
65
|
-
}
|
|
62
|
+
if (pos.length > 1) {
|
|
63
|
+
throw new UserError("INVALID_ARGS", `Unexpected argument: ${pos[1]}`);
|
|
64
|
+
}
|
|
65
|
+
const rawDir = pos[0];
|
|
66
|
+
if (!rawDir) {
|
|
67
|
+
throw new UserError("INVALID_ARGS", "Missing required <directory> argument");
|
|
68
|
+
}
|
|
69
|
+
let agents = null;
|
|
70
|
+
if (typeof values.agents === "string") {
|
|
71
|
+
const ids = values.agents.split(",").map((s) => s.trim());
|
|
72
|
+
for (const id of ids) {
|
|
73
|
+
if (!AGENT_IDS.includes(id)) {
|
|
74
|
+
throw new UserError("INVALID_ARGS", `Unknown agent: "${id}". Valid agents: ${AGENT_IDS.join(", ")}`);
|
|
66
75
|
}
|
|
67
|
-
agents = ids;
|
|
68
|
-
}
|
|
69
|
-
else if (arg.startsWith("--")) {
|
|
70
|
-
throw new UserError(`Unknown option: ${arg}`);
|
|
71
|
-
}
|
|
72
|
-
else if (!directory) {
|
|
73
|
-
directory = arg;
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
throw new UserError(`Unexpected argument: ${arg}`);
|
|
77
76
|
}
|
|
78
|
-
|
|
77
|
+
agents = ids;
|
|
79
78
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
return {
|
|
80
|
+
command,
|
|
81
|
+
directory: path.resolve(rawDir),
|
|
82
|
+
dryRun: values["dry-run"],
|
|
83
|
+
agents,
|
|
84
|
+
verbose: values.verbose,
|
|
85
|
+
debug: values.debug,
|
|
86
|
+
};
|
|
84
87
|
}
|
|
85
|
-
function main() {
|
|
86
|
-
const options =
|
|
88
|
+
async function main() {
|
|
89
|
+
const options = parseCLI(process.argv);
|
|
87
90
|
if (options.command === "help") {
|
|
88
91
|
console.log(USAGE);
|
|
89
92
|
return 0;
|
|
90
93
|
}
|
|
91
|
-
const manifest = loadManifest(options.directory);
|
|
94
|
+
const manifest = await loadManifest(options.directory);
|
|
92
95
|
const home = resolveHome();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
else {
|
|
101
|
-
detectedAgents = detectInstalledAgents(home);
|
|
102
|
-
if (detectedAgents.length === 0) {
|
|
103
|
-
console.log("No supported AI agents detected on this system.");
|
|
104
|
-
console.log(`Install one of: ${AGENT_REGISTRY.map((a) => a.displayName).join(", ")}`);
|
|
105
|
-
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
|
+
}
|
|
106
103
|
}
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
}
|
|
109
114
|
}
|
|
110
|
-
|
|
111
|
-
const prefix = options.dryRun ? "\x1b[36m[dry-run]\x1b[0m " : "";
|
|
112
|
-
if (options.command === "deploy") {
|
|
113
|
-
const actions = planDeploy(manifest, options.directory, detectedAgents, home);
|
|
115
|
+
const actions = await planDeploy(manifest, options.directory, detectedAgents, home);
|
|
114
116
|
if (actions.length === 0) {
|
|
115
|
-
|
|
117
|
+
logger.info("No skills to deploy for detected agents.");
|
|
116
118
|
return 0;
|
|
117
119
|
}
|
|
118
|
-
|
|
119
|
-
const { succeeded, failed } = executeDeploy(actions, options.dryRun, options.verbose);
|
|
120
|
-
|
|
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("");
|
|
121
123
|
if (failed.length > 0) {
|
|
122
|
-
|
|
124
|
+
logger.info(`${succeeded} succeeded, ${failed.length} failed`);
|
|
123
125
|
return 1;
|
|
124
126
|
}
|
|
125
127
|
else {
|
|
126
|
-
|
|
128
|
+
logger.info(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
else {
|
|
130
|
-
const actions =
|
|
132
|
+
const actions = options.agents
|
|
133
|
+
? planRevert(manifest, options.agents, home)
|
|
134
|
+
: planRevertAll(manifest, home);
|
|
131
135
|
if (actions.length === 0) {
|
|
132
|
-
|
|
136
|
+
logger.info("No skills to revert.");
|
|
133
137
|
return 0;
|
|
134
138
|
}
|
|
135
|
-
|
|
136
|
-
const { succeeded, skipped } = executeRevert(actions, options.dryRun, options.verbose);
|
|
137
|
-
|
|
139
|
+
logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} skill(s):`);
|
|
140
|
+
const { succeeded, skipped } = await executeRevert(actions, options.dryRun, options.verbose);
|
|
141
|
+
logger.info("");
|
|
138
142
|
const parts = [`${succeeded} removed`];
|
|
139
143
|
if (skipped > 0)
|
|
140
144
|
parts.push(`${skipped} skipped`);
|
|
141
|
-
|
|
145
|
+
logger.info(`${parts.join(", ")}${options.dryRun ? " (dry-run)" : ""}`);
|
|
142
146
|
}
|
|
143
147
|
return 0;
|
|
144
148
|
}
|
|
149
|
+
const USER_ERROR_EXIT = {
|
|
150
|
+
INVALID_ARGS: 2,
|
|
151
|
+
MANIFEST_INVALID: 3,
|
|
152
|
+
DEPLOY_FAILED: 1,
|
|
153
|
+
RESOLVE_FAILED: 1,
|
|
154
|
+
};
|
|
145
155
|
const debugMode = process.argv.includes("--debug");
|
|
146
156
|
try {
|
|
147
|
-
process.exit(main());
|
|
157
|
+
process.exit(await main());
|
|
148
158
|
}
|
|
149
159
|
catch (err) {
|
|
150
160
|
if (err instanceof UserError) {
|
|
151
|
-
|
|
161
|
+
logger.error(`Error: ${err.message}`);
|
|
162
|
+
if (debugMode) {
|
|
163
|
+
logger.errorRaw(err);
|
|
164
|
+
}
|
|
165
|
+
process.exit(USER_ERROR_EXIT[err.code]);
|
|
152
166
|
}
|
|
153
167
|
else {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
logger.error("Unexpected error. Run with --debug for details.");
|
|
169
|
+
if (debugMode) {
|
|
170
|
+
logger.errorRaw(err);
|
|
171
|
+
}
|
|
172
|
+
process.exit(1);
|
|
158
173
|
}
|
|
159
|
-
process.exit(1);
|
|
160
174
|
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface Logger {
|
|
2
|
+
fail(label: string, msg: string): void;
|
|
3
|
+
ok(label: string): void;
|
|
4
|
+
skip(label: string, note: string): void;
|
|
5
|
+
plan(label: string): void;
|
|
6
|
+
warn(label: string, msg: string): void;
|
|
7
|
+
info(msg: string): void;
|
|
8
|
+
error(msg: string): void;
|
|
9
|
+
errorRaw(value: unknown): void;
|
|
10
|
+
detail(msg: string): void;
|
|
11
|
+
silence(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createLogger(): Logger;
|
|
14
|
+
export declare const logger: Logger;
|
|
15
|
+
export declare function dryRunPrefix(dryRun: boolean): string;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
const C = {
|
|
3
|
+
red: "\x1b[31m",
|
|
4
|
+
green: "\x1b[32m",
|
|
5
|
+
yellow: "\x1b[33m",
|
|
6
|
+
cyan: "\x1b[36m",
|
|
7
|
+
reset: "\x1b[0m",
|
|
8
|
+
};
|
|
9
|
+
export function createLogger() {
|
|
10
|
+
let silent = false;
|
|
11
|
+
const tty = Boolean(process.stdout.isTTY);
|
|
12
|
+
const errTTY = Boolean(process.stderr.isTTY);
|
|
13
|
+
const c = (col, text, onTTY) => onTTY ? `${C[col]}${text}${C.reset}` : text;
|
|
14
|
+
return {
|
|
15
|
+
fail(label, msg) {
|
|
16
|
+
if (!silent)
|
|
17
|
+
process.stderr.write(` ${c("red", "✗", errTTY)} ${label}: ${msg}\n`);
|
|
18
|
+
},
|
|
19
|
+
ok(label) {
|
|
20
|
+
if (!silent)
|
|
21
|
+
process.stdout.write(` ${c("green", "✓", tty)} ${label}\n`);
|
|
22
|
+
},
|
|
23
|
+
skip(label, note) {
|
|
24
|
+
if (!silent)
|
|
25
|
+
process.stdout.write(` ${c("yellow", "-", tty)} ${label} ${note}\n`);
|
|
26
|
+
},
|
|
27
|
+
plan(label) {
|
|
28
|
+
if (!silent)
|
|
29
|
+
process.stdout.write(` ${c("cyan", "○", tty)} ${label}\n`);
|
|
30
|
+
},
|
|
31
|
+
warn(label, msg) {
|
|
32
|
+
if (!silent)
|
|
33
|
+
process.stdout.write(` ${c("yellow", "!", tty)} ${label}: ${msg}\n`);
|
|
34
|
+
},
|
|
35
|
+
info(msg) {
|
|
36
|
+
if (!silent)
|
|
37
|
+
process.stdout.write(`${msg}\n`);
|
|
38
|
+
},
|
|
39
|
+
error(msg) {
|
|
40
|
+
if (!silent)
|
|
41
|
+
process.stderr.write(`${msg}\n`);
|
|
42
|
+
},
|
|
43
|
+
errorRaw(value) {
|
|
44
|
+
if (!silent)
|
|
45
|
+
console.error(value);
|
|
46
|
+
},
|
|
47
|
+
detail(msg) {
|
|
48
|
+
if (!silent)
|
|
49
|
+
process.stdout.write(` ${msg}\n`);
|
|
50
|
+
},
|
|
51
|
+
silence() {
|
|
52
|
+
silent = true;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export const logger = createLogger();
|
|
57
|
+
export function dryRunPrefix(dryRun) {
|
|
58
|
+
if (!dryRun)
|
|
59
|
+
return "";
|
|
60
|
+
return Boolean(process.stdout.isTTY) ? `\x1b[36m[dry-run]\x1b[0m ` : `[dry-run] `;
|
|
61
|
+
}
|
package/dist/types.d.ts
CHANGED