@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 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
- Skills always overwrite their previous version. On POSIX systems, symlinks mean updates to the source repo are reflected immediately.
20
+ Managed skills overwrite their previous version. If a target exists but was not created by inception-engine, deployment refuses to replace it. On POSIX systems, symlinks mean updates to the source repo are reflected immediately.
21
21
 
22
22
  ## Agent Compatibility Matrix
23
23
 
@@ -93,14 +93,14 @@ inception-engine revert <directory> [options]
93
93
  | Command | Description |
94
94
  |---|---|
95
95
  | `<directory>` | Deploy skills from the manifest in the given directory |
96
- | `revert <directory>` | Remove all skills declared in the manifest |
96
+ | `revert <directory>` | Remove previously deployed skills declared in the manifest |
97
97
 
98
98
  ### Options
99
99
 
100
100
  | Option | Description |
101
101
  |---|---|
102
102
  | `--dry-run` | Show what would be done without making changes |
103
- | `--agents <list>` | Comma-separated list of agent IDs to target (skips detection) |
103
+ | `--agents <list>` | Comma-separated list of agent IDs to target (overrides deploy detection; restricts revert) |
104
104
  | `--verbose` | Show detailed output including file paths |
105
105
  | `--debug` | Show full error stack traces |
106
106
  | `--help` | Show help message |
@@ -141,7 +141,9 @@ inception-engine automatically detects which agents are installed by checking:
141
141
  1. Whether the agent's config directory exists (e.g., `~/.claude/` for Claude Code)
142
142
  2. Whether the agent's binary is in your PATH (e.g., `claude`, `codex`, `gemini`)
143
143
 
144
- If an agent isn't detected, its skills are skipped. Use `--agents` to override detection.
144
+ If an agent isn't detected, its skills are skipped during deploy. Use `--agents` to override detection.
145
+
146
+ Revert targets all agents listed in the manifest by default (regardless of detection) so that previously deployed skills are cleaned up even if the agent has since been uninstalled. Use `--agents` to restrict revert to specific agents.
145
147
 
146
148
  ## Cross-Platform Behavior
147
149
 
@@ -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
@@ -1,2 +1,3 @@
1
- import type { AgentConfig } from "../types.ts";
1
+ import type { AgentConfig, AgentId } from "../types.ts";
2
2
  export declare const AGENT_REGISTRY: readonly AgentConfig[];
3
+ export declare const AGENT_REGISTRY_BY_ID: Readonly<Record<AgentId, AgentConfig>>;
@@ -3,12 +3,12 @@ export const AGENT_REGISTRY = [
3
3
  id: "claude-code",
4
4
  displayName: "Claude Code",
5
5
  skills: {
6
- posix: "{home}/.claude/skills/{name}",
7
- windows: "{home}\\.claude\\skills\\{name}",
6
+ posix: ["{home}", ".claude", "skills", "{name}"],
7
+ windows: ["{home}", ".claude", "skills", "{name}"],
8
8
  },
9
9
  detectPaths: {
10
- posix: "{home}/.claude",
11
- windows: "{home}\\.claude",
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}/.codex/skills/{name}",
20
- windows: "{home}\\.codex\\skills\\{name}",
19
+ posix: ["{home}", ".codex", "skills", "{name}"],
20
+ windows: ["{home}", ".codex", "skills", "{name}"],
21
21
  },
22
22
  detectPaths: {
23
- posix: "{home}/.codex",
24
- windows: "{home}\\.codex",
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}/.gemini/skills/{name}",
33
- windows: "{home}\\.gemini\\skills\\{name}",
32
+ posix: ["{home}", ".gemini", "skills", "{name}"],
33
+ windows: ["{home}", ".gemini", "skills", "{name}"],
34
34
  },
35
35
  detectPaths: {
36
- posix: "{home}/.gemini",
37
- windows: "{home}\\.gemini",
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}/.gemini/antigravity/skills/{name}",
46
- windows: "{home}\\.gemini\\antigravity\\skills\\{name}",
45
+ posix: ["{home}", ".gemini", "antigravity", "skills", "{name}"],
46
+ windows: ["{home}", ".gemini", "antigravity", "skills", "{name}"],
47
47
  },
48
48
  detectPaths: {
49
- posix: "{home}/.gemini/antigravity",
50
- windows: "{home}\\.gemini\\antigravity",
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}/.config/opencode/skills/{name}",
59
- windows: "{appdata}\\opencode\\skills\\{name}",
58
+ posix: ["{home}", ".config", "opencode", "skills", "{name}"],
59
+ windows: ["{appdata}", "opencode", "skills", "{name}"],
60
60
  },
61
61
  detectPaths: {
62
- posix: "{home}/.config/opencode",
63
- windows: "{appdata}\\opencode",
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}/.copilot/skills/{name}",
72
- windows: "{home}\\.copilot\\skills\\{name}",
71
+ posix: ["{home}", ".copilot", "skills", "{name}"],
72
+ windows: ["{home}", ".copilot", "skills", "{name}"],
73
73
  },
74
74
  detectPaths: {
75
- posix: "{home}/.copilot",
76
- windows: "{home}\\.copilot",
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>;
@@ -1,57 +1,60 @@
1
- import { readFileSync } from "node:fs";
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 = readFileSync(manifestPath, "utf-8");
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 {
@@ -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
+ }>;
@@ -1,21 +1,41 @@
1
- import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, cpSync, unlinkSync, } from "node:fs";
1
+ import { access, lstat, mkdir, symlink, cp, unlink, rm, rename, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { AGENT_REGISTRY } from "../config/agents.js";
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
- export function planDeploy(manifest, sourceDir, detectedAgents, home) {
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 (source !== resolvedSourceDir && !source.startsWith(resolvedSourceDir + path.sep)) {
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 = AGENT_REGISTRY.find((a) => a.id === agentId);
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
- if (!existsSync(action.source)) {
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
- console.error(` \x1b[31m✗\x1b[0m ${label}: ${msg}`);
58
+ logger.fail(label, msg);
36
59
  continue;
37
60
  }
38
61
  if (dryRun) {
39
- console.log(` \x1b[36m○\x1b[0m ${label}`);
62
+ logger.plan(label);
40
63
  if (verbose) {
41
- console.log(` ${action.method}: ${action.source} -> ${action.target}`);
64
+ logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
42
65
  }
43
66
  succeeded++;
44
67
  continue;
45
68
  }
46
69
  try {
47
- removeExisting(action.target, verbose);
48
- mkdirSync(path.dirname(action.target), { recursive: true });
49
- if (action.method === "symlink") {
50
- symlinkSync(action.source, action.target, "dir");
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
- else {
53
- cpSync(action.source, action.target, { recursive: true });
104
+ // Success: remove backup
105
+ if (backupPath) {
106
+ await removeTarget(backupPath);
54
107
  }
55
- console.log(` \x1b[32m✓\x1b[0m ${label}`);
108
+ logger.ok(label);
56
109
  if (verbose) {
57
- console.log(` ${action.method}: ${action.source} -> ${action.target}`);
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
- console.error(` \x1b[31m✗\x1b[0m ${label}: ${msg}`);
117
+ logger.fail(label, msg);
65
118
  }
66
119
  }
67
120
  return { succeeded, failed };
68
121
  }
69
- function removeExisting(targetPath, verbose) {
70
- if (!existsSync(targetPath) && !isSymlink(targetPath))
71
- return;
72
- if (isSymlink(targetPath)) {
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
- else {
79
- if (verbose) {
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
- function isSymlink(p) {
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
- return lstatSync(p).isSymbolicLink();
136
+ await lstat(backupPath);
137
+ await removeTarget(backupPath);
88
138
  }
89
139
  catch {
90
- return false;
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
  }
@@ -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[]>;
@@ -1,33 +1,66 @@
1
- import { existsSync } from "node:fs";
2
- import { execFileSync } from "node:child_process";
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
- export function detectInstalledAgents(home) {
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
- if (existsSync(detectPath)) {
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
- const command = process.platform === "win32" ? "where.exe" : "which";
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
- execFileSync(command, [binary], { stdio: "ignore" });
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
+ }
@@ -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;
@@ -13,11 +13,11 @@ export function resolveHome() {
13
13
  }
14
14
  return os.homedir();
15
15
  }
16
- function lookupHomeForUser(username) {
16
+ export function lookupHomeForUserWith(username, platform, execFileFn, readFileFn) {
17
17
  // Method 1: getent passwd (Linux/POSIX — handles LDAP, NIS, local via NSS)
18
- if (process.platform !== "darwin") {
18
+ if (platform !== "darwin") {
19
19
  try {
20
- const out = execFileSync("getent", ["passwd", username], {
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 (process.platform === "darwin") {
33
+ if (platform === "darwin") {
34
34
  try {
35
- const out = execFileSync("dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
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 = readFileSync("/etc/passwd", "utf8");
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
- const platform = getPlatformKey();
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
- const platform = getPlatformKey();
76
- const template = agent.detectPaths[platform];
77
- return resolvePlaceholders(template, "", home);
82
+ return resolveAgentDetectPathFor(agent, home, getPlatformKey());
78
83
  }
79
- function resolvePlaceholders(template, skillName, home) {
80
- let result = template.replace("{home}", home);
81
- result = result.replace("{name}", skillName);
82
- if (result.includes("{appdata}")) {
83
- const appdata = process.env["APPDATA"] ?? path.join(home, "AppData", "Roaming");
84
- result = result.replace("{appdata}", appdata);
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
  }
@@ -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 executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean): {
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
+ }>;
@@ -1,13 +1,15 @@
1
- import { existsSync, lstatSync, unlinkSync, rmSync } from "node:fs";
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 = AGENT_REGISTRY.find((a) => a.id === agentId);
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 executeRevert(actions, dryRun, verbose) {
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
- if (!existsSync(action.target) && !isSymlink(action.target)) {
25
- console.log(` \x1b[33m-\x1b[0m ${label} (not found, skipping)`);
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
- console.log(` \x1b[36m○\x1b[0m ${label}`);
54
+ logger.plan(label);
31
55
  if (verbose) {
32
- console.log(` would remove: ${action.target}`);
56
+ logger.detail(`would remove: ${action.target}`);
33
57
  }
34
58
  succeeded++;
35
59
  continue;
36
60
  }
37
61
  try {
38
- if (isSymlink(action.target)) {
39
- unlinkSync(action.target);
62
+ if (stat.isSymbolicLink()) {
63
+ await unlink(action.target);
40
64
  }
41
65
  else {
42
- rmSync(action.target, { recursive: true });
66
+ await rm(action.target, { recursive: true });
43
67
  }
44
- console.log(` \x1b[32m✓\x1b[0m ${label}`);
68
+ logger.ok(label);
45
69
  if (verbose) {
46
- console.log(` removed: ${action.target}`);
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
- console.error(` \x1b[31m✗\x1b[0m ${label}: ${msg}`);
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
@@ -1,3 +1,5 @@
1
+ export type ErrorCode = "INVALID_ARGS" | "MANIFEST_INVALID" | "DEPLOY_FAILED" | "RESOLVE_FAILED";
1
2
  export declare class UserError extends Error {
2
- constructor(message: string);
3
+ readonly code: ErrorCode;
4
+ constructor(code: ErrorCode, message: string);
3
5
  }
package/dist/errors.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export class UserError extends Error {
2
- constructor(message) {
2
+ code;
3
+ constructor(code, message) {
3
4
  super(message);
4
5
  this.name = "UserError";
6
+ this.code = code;
5
7
  }
6
8
  }
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 parseArgs(argv) {
30
+ function parseCLI(argv) {
29
31
  const args = argv.slice(2);
30
- if (args.length === 0 || args.includes("--help")) {
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 directory = "";
35
- let dryRun = false;
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
- i = 1;
60
+ pos = pos.slice(1);
43
61
  }
44
- while (i < args.length) {
45
- const arg = args[i];
46
- if (arg === "--dry-run") {
47
- dryRun = true;
48
- }
49
- else if (arg === "--verbose") {
50
- verbose = true;
51
- }
52
- else if (arg === "--debug") {
53
- debug = true;
54
- }
55
- else if (arg === "--agents") {
56
- i++;
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
- i++;
77
+ agents = ids;
79
78
  }
80
- if (!directory) {
81
- throw new UserError("Missing required <directory> argument");
82
- }
83
- return { command, directory: path.resolve(directory), dryRun, agents, verbose, debug };
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 = parseArgs(process.argv);
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
- let detectedAgents;
94
- if (options.agents) {
95
- detectedAgents = options.agents;
96
- if (options.verbose) {
97
- console.log(`Using specified agents: ${detectedAgents.join(", ")}`);
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
- if (options.verbose) {
108
- console.log(`Detected agents: ${detectedAgents.join(", ")}`);
104
+ else {
105
+ detectedAgents = await detectInstalledAgents(home);
106
+ if (detectedAgents.length === 0) {
107
+ logger.info("No supported AI agents detected on this system.");
108
+ logger.info(`Install one of: ${AGENT_REGISTRY.map((a) => a.displayName).join(", ")}`);
109
+ return 0;
110
+ }
111
+ if (options.verbose) {
112
+ logger.info(`Detected agents: ${detectedAgents.join(", ")}`);
113
+ }
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
- console.log("No skills to deploy for detected agents.");
117
+ logger.info("No skills to deploy for detected agents.");
116
118
  return 0;
117
119
  }
118
- console.log(`${prefix}Deploying ${actions.length} skill(s):`);
119
- const { succeeded, failed } = executeDeploy(actions, options.dryRun, options.verbose);
120
- console.log();
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
- console.log(`${succeeded} succeeded, ${failed.length} failed`);
124
+ logger.info(`${succeeded} succeeded, ${failed.length} failed`);
123
125
  return 1;
124
126
  }
125
127
  else {
126
- console.log(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
128
+ logger.info(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
127
129
  }
128
130
  }
129
131
  else {
130
- const actions = planRevert(manifest, detectedAgents, home);
132
+ const actions = options.agents
133
+ ? planRevert(manifest, options.agents, home)
134
+ : planRevertAll(manifest, home);
131
135
  if (actions.length === 0) {
132
- console.log("No skills to revert for detected agents.");
136
+ logger.info("No skills to revert.");
133
137
  return 0;
134
138
  }
135
- console.log(`${prefix}Reverting ${actions.length} skill(s):`);
136
- const { succeeded, skipped } = executeRevert(actions, options.dryRun, options.verbose);
137
- console.log();
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
- console.log(`${parts.join(", ")}${options.dryRun ? " (dry-run)" : ""}`);
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
- console.error(`Error: ${err.message}`);
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
- console.error("Unexpected error. Run with --debug for details.");
155
- }
156
- if (debugMode) {
157
- console.error(err);
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
  }
@@ -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
@@ -11,8 +11,8 @@ export interface Manifest {
11
11
  agentRules: unknown[];
12
12
  }
13
13
  export interface AgentPaths {
14
- posix: string;
15
- windows: string;
14
+ posix: string[];
15
+ windows: string[];
16
16
  }
17
17
  export interface AgentConfig {
18
18
  id: AgentId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuznai/inception-engine",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Deploy AI agent skills from a git repo to user home directories",
5
5
  "license": "MIT",
6
6
  "author": "Damian Piątkowski",