@kuznai/inception-engine 0.6.2 → 0.8.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 +45 -8
- package/dist/config/agents.js +1 -0
- package/dist/config/manifest.js +6 -3
- package/dist/core/deploy.d.ts +6 -2
- package/dist/core/deploy.js +341 -31
- package/dist/core/detect.d.ts +2 -2
- package/dist/core/detect.js +5 -5
- package/dist/core/ownership.d.ts +19 -7
- package/dist/core/ownership.js +9 -3
- package/dist/core/preflight.d.ts +2 -2
- package/dist/core/preflight.js +27 -5
- package/dist/core/revert.d.ts +2 -1
- package/dist/core/revert.js +229 -37
- package/dist/index.js +46 -13
- package/dist/schemas/manifest.d.ts +68 -2
- package/dist/schemas/manifest.js +61 -21
- package/dist/schemas/registry.d.ts +112 -5
- package/dist/schemas/registry.js +23 -2
- package/dist/types.d.ts +49 -3
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Plant skills directly into the minds of your installed AI coding agents — Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, and GitHub Copilot. One command. They'll think they thought of it themselves.
|
|
4
4
|
|
|
5
|
-
Today, inception-engine
|
|
5
|
+
Today, inception-engine deploys skills, single files, and JSON config patches to AI coding agents. MCP configuration and agent rules remain unimplemented at the manifest level.
|
|
6
6
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
@@ -40,9 +40,11 @@ Managed skills overwrite their previous version. If a target exists but was not
|
|
|
40
40
|
|
|
41
41
|
| Feature | Status |
|
|
42
42
|
|---|---|
|
|
43
|
-
| Skills (SKILL.md) | Supported |
|
|
44
|
-
|
|
|
45
|
-
|
|
|
43
|
+
| Skills (SKILL.md) | Supported via manifest and CLI |
|
|
44
|
+
| File write | Supported via manifest and CLI |
|
|
45
|
+
| Config patch (JSON merge) | Supported via manifest and CLI |
|
|
46
|
+
| MCP Servers | Accepted in manifest for forward compatibility, not implemented |
|
|
47
|
+
| Agent Rules | Accepted in manifest for forward compatibility, not implemented |
|
|
46
48
|
|
|
47
49
|
## Manifest Format
|
|
48
50
|
|
|
@@ -57,17 +59,49 @@ Create an `inception.json` file at the root of your skills directory:
|
|
|
57
59
|
"agents": ["claude-code", "codex", "gemini-cli", "antigravity", "opencode", "github-copilot"]
|
|
58
60
|
}
|
|
59
61
|
],
|
|
62
|
+
"files": [
|
|
63
|
+
{
|
|
64
|
+
"name": "my-settings",
|
|
65
|
+
"path": "files/settings.json",
|
|
66
|
+
"target": "{home}/.claude/settings.json",
|
|
67
|
+
"agents": ["claude-code"]
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"configs": [
|
|
71
|
+
{
|
|
72
|
+
"name": "enable-feature",
|
|
73
|
+
"target": "{home}/.claude/settings.json",
|
|
74
|
+
"patch": { "someFeature": true },
|
|
75
|
+
"agents": ["claude-code"]
|
|
76
|
+
}
|
|
77
|
+
],
|
|
60
78
|
"mcpServers": [],
|
|
61
79
|
"agentRules": []
|
|
62
80
|
}
|
|
63
81
|
```
|
|
64
82
|
|
|
65
|
-
Each skill entry has:
|
|
83
|
+
Each **skill** entry has:
|
|
66
84
|
|
|
67
|
-
- **name** - Unique
|
|
85
|
+
- **name** - Unique identifier using letters, digits, dots, underscores, or hyphens; must not start with a dot
|
|
68
86
|
- **path** - Relative path to the skill directory within the repo
|
|
69
87
|
- **agents** - Array of agent IDs to deploy this skill to. If an agent isn't installed, it's skipped.
|
|
70
88
|
|
|
89
|
+
Each **file** entry deploys a single file to an agent's configuration location:
|
|
90
|
+
|
|
91
|
+
- **name** - Unique identifier (same format as skill names)
|
|
92
|
+
- **path** - Relative path to the source file within the repo
|
|
93
|
+
- **target** - Destination path using a placeholder prefix: `{home}`, `{appdata}` (Windows), or `{xdg_config}` (Linux). For example: `{home}/.claude/settings.json`
|
|
94
|
+
- **agents** - Array of agent IDs to deploy this file to
|
|
95
|
+
|
|
96
|
+
Each **config** entry applies a [JSON merge patch (RFC 7386)](https://datatracker.ietf.org/doc/html/rfc7386) to an existing agent config file:
|
|
97
|
+
|
|
98
|
+
- **name** - Unique identifier (same format as skill names)
|
|
99
|
+
- **target** - Config file to patch, using the same placeholder prefix as file entries
|
|
100
|
+
- **patch** - JSON object of keys to set. A `null` value removes the key from the target file. Non-null values are set directly (deep merge is not applied).
|
|
101
|
+
- **agents** - Array of agent IDs to apply this patch to
|
|
102
|
+
|
|
103
|
+
The engine records an undo-patch for each config-patch deployment so that `revert` can restore the original values.
|
|
104
|
+
|
|
71
105
|
`mcpServers` and `agentRules` are currently parsed for forward compatibility, but the deployment engine ignores them today.
|
|
72
106
|
|
|
73
107
|
## Creating Skills
|
|
@@ -165,7 +199,7 @@ inception-engine maintains a centralized deployment registry at `~/.inception-en
|
|
|
165
199
|
|
|
166
200
|
- **Registry-based ownership**: On revert, the registry is checked before removing any target. Only targets with a valid registry entry are removed. On redeploy, unmanaged targets are never replaced.
|
|
167
201
|
|
|
168
|
-
- **Strong binding**: Each registry entry binds a specific target path to its
|
|
202
|
+
- **Strong binding**: Each registry entry binds a specific target path to its skill, agent, and action kind, with action-specific provenance fields (`source` and `method` for skill-dir and file-write; `patch` and `undoPatch` for config-patch). A target is only considered managed if all relevant fields match — a stray entry or a different deployment cannot satisfy the check.
|
|
169
203
|
|
|
170
204
|
- **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.
|
|
171
205
|
|
|
@@ -201,7 +235,10 @@ Windows deployment currently uses directory-level copy (one `cp -r` per skill).
|
|
|
201
235
|
|
|
202
236
|
## Requirements
|
|
203
237
|
|
|
204
|
-
- Node.js >=
|
|
238
|
+
- Published CLI runtime: Node.js >= 22.3.0
|
|
239
|
+
- Direct TypeScript execution in this repo (`npm run dev`, `npm test`): Node.js >= 22.18.0
|
|
240
|
+
|
|
241
|
+
`inception-engine` publishes compiled JavaScript from `dist/`, so end users do not need the newer Node version required for this repository's direct `.ts` workflows. The higher contributor floor exists only because this repo intentionally runs TypeScript straight through `node` for local development and tests, with no `tsx`, `ts-node`, or experimental TypeScript flags.
|
|
205
242
|
|
|
206
243
|
## License
|
|
207
244
|
|
package/dist/config/agents.js
CHANGED
|
@@ -106,6 +106,7 @@ export const AGENT_REGISTRY = [
|
|
|
106
106
|
detectPaths: "documented",
|
|
107
107
|
detectBinary: "documented",
|
|
108
108
|
},
|
|
109
|
+
policyNote: "Organization policies may override locally deployed skills. Verify with your GitHub org admin if deployed skills are not active.",
|
|
109
110
|
},
|
|
110
111
|
];
|
|
111
112
|
export const AGENT_REGISTRY_BY_ID = Object.fromEntries(AGENT_REGISTRY.map((a) => [a.id, a]));
|
package/dist/config/manifest.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { UserError } from "../errors.js";
|
|
3
4
|
import { formatZodPath } from "../schemas/errors.js";
|
|
4
5
|
import { ManifestSchema } from "../schemas/manifest.js";
|
|
5
|
-
import { UserError } from "../errors.js";
|
|
6
6
|
export async function loadManifest(directory) {
|
|
7
7
|
const manifestPath = path.join(directory, "inception.json");
|
|
8
8
|
let raw;
|
|
@@ -38,9 +38,12 @@ function validateManifest(data, filePath) {
|
|
|
38
38
|
if (issuePath.length === 1 && issuePath[0] === "skills") {
|
|
39
39
|
throw new UserError("MANIFEST_INVALID", `${filePath}: "skills" must be an array`);
|
|
40
40
|
}
|
|
41
|
-
// Top-level
|
|
41
|
+
// Top-level array fields with wrong type → uniform message
|
|
42
42
|
if (issuePath.length === 1 &&
|
|
43
|
-
(issuePath[0] === "mcpServers" ||
|
|
43
|
+
(issuePath[0] === "mcpServers" ||
|
|
44
|
+
issuePath[0] === "agentRules" ||
|
|
45
|
+
issuePath[0] === "files" ||
|
|
46
|
+
issuePath[0] === "configs")) {
|
|
44
47
|
throw new UserError("MANIFEST_INVALID", `${filePath}: "${issuePath[0]}" must be an array`);
|
|
45
48
|
}
|
|
46
49
|
throw new UserError("MANIFEST_INVALID", `${filePath}: ${formatZodPath(issuePath)}${issue.message}`);
|
package/dist/core/deploy.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import type { AgentId, DeployAction, Manifest } from "../types.ts";
|
|
2
|
-
export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<
|
|
1
|
+
import type { AgentId, DeployAction, Manifest, PlannedChange, PlanWarning } from "../types.ts";
|
|
2
|
+
export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<{
|
|
3
|
+
actions: DeployAction[];
|
|
4
|
+
warnings: PlanWarning[];
|
|
5
|
+
}>;
|
|
3
6
|
export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string): Promise<{
|
|
4
7
|
succeeded: number;
|
|
5
8
|
failed: Array<{
|
|
6
9
|
action: DeployAction;
|
|
7
10
|
error: string;
|
|
8
11
|
}>;
|
|
12
|
+
planned: PlannedChange[];
|
|
9
13
|
}>;
|
package/dist/core/deploy.js
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, copyFile, cp, lstat, mkdir, readFile, realpath, rename, rm, symlink, unlink, writeFile, } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
|
|
4
5
|
import { UserError } from "../errors.js";
|
|
5
6
|
import { logger } from "../logger.js";
|
|
6
|
-
import { registerDeployment, verifyDeployment } from "./ownership.js";
|
|
7
|
+
import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
|
|
7
8
|
import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
|
|
9
|
+
function isPlainObject(v) {
|
|
10
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
11
|
+
}
|
|
12
|
+
async function readJsonConfig(filePath) {
|
|
13
|
+
let rawContent;
|
|
14
|
+
try {
|
|
15
|
+
rawContent = await readFile(filePath, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const code = err.code;
|
|
19
|
+
if (code === "ENOENT")
|
|
20
|
+
throw new Error(`Config file not found: ${filePath}`);
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(rawContent);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(`Config file is not valid JSON: ${filePath}`);
|
|
29
|
+
}
|
|
30
|
+
if (!isPlainObject(parsed)) {
|
|
31
|
+
throw new Error(`Config file is not a JSON object: ${filePath}`);
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
function computeUndoPatch(original, patch) {
|
|
36
|
+
const undoPatch = {};
|
|
37
|
+
for (const key of Object.keys(patch)) {
|
|
38
|
+
undoPatch[key] = key in original ? original[key] : null;
|
|
39
|
+
}
|
|
40
|
+
return undoPatch;
|
|
41
|
+
}
|
|
42
|
+
function applyMergePatch(original, patch) {
|
|
43
|
+
const patched = { ...original };
|
|
44
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
45
|
+
if (value === null) {
|
|
46
|
+
delete patched[key];
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
patched[key] = value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return patched;
|
|
53
|
+
}
|
|
8
54
|
function sourceAccessError(err, sourcePath) {
|
|
9
55
|
const code = err.code;
|
|
10
56
|
if (code === "ENOENT")
|
|
@@ -14,17 +60,58 @@ function sourceAccessError(err, sourcePath) {
|
|
|
14
60
|
const detail = err instanceof Error ? err.message : String(err);
|
|
15
61
|
return `Failed to access source ${sourcePath}: ${detail}`;
|
|
16
62
|
}
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
63
|
+
function resolveTargetTemplate(template, home) {
|
|
64
|
+
const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
65
|
+
const xdgRaw = process.env.XDG_CONFIG_HOME;
|
|
66
|
+
const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
|
|
67
|
+
return template
|
|
68
|
+
.replace("{home}", home)
|
|
69
|
+
.replace("{appdata}", appdata)
|
|
70
|
+
.replace("{xdg_config}", xdgConfig);
|
|
71
|
+
}
|
|
72
|
+
async function validateSourceFile(sourcePath, manifestPath) {
|
|
73
|
+
let stat;
|
|
22
74
|
try {
|
|
23
|
-
|
|
75
|
+
stat = await lstat(sourcePath);
|
|
24
76
|
}
|
|
25
|
-
catch {
|
|
26
|
-
|
|
77
|
+
catch (err) {
|
|
78
|
+
throw new UserError("DEPLOY_FAILED", sourceAccessError(err, manifestPath));
|
|
79
|
+
}
|
|
80
|
+
if (!stat.isFile()) {
|
|
81
|
+
throw new UserError("DEPLOY_FAILED", `Source is not a file: ${manifestPath}`);
|
|
27
82
|
}
|
|
83
|
+
}
|
|
84
|
+
function detectCollisions(actions) {
|
|
85
|
+
const seen = new Map();
|
|
86
|
+
const warnings = [];
|
|
87
|
+
for (const action of actions) {
|
|
88
|
+
const prev = seen.get(action.target);
|
|
89
|
+
if (prev) {
|
|
90
|
+
warnings.push({
|
|
91
|
+
kind: "collision",
|
|
92
|
+
message: `Skill "${action.skill}" for agent "${action.agent}" and skill "${prev.skill}" for agent "${prev.agent}" both resolve to the same target: ${action.target}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
seen.set(action.target, { skill: action.skill, agent: action.agent });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return warnings;
|
|
100
|
+
}
|
|
101
|
+
function detectAmbiguities(detectedAgents) {
|
|
102
|
+
const warnings = [];
|
|
103
|
+
if (detectedAgents.includes("gemini-cli") &&
|
|
104
|
+
detectedAgents.includes("antigravity")) {
|
|
105
|
+
warnings.push({
|
|
106
|
+
kind: "ambiguity",
|
|
107
|
+
message: 'Both "gemini-cli" and "antigravity" share the ~/.gemini/ base directory. antigravity skill support is implementation-only. Verify that deployed skill paths do not conflict.',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return warnings;
|
|
111
|
+
}
|
|
112
|
+
async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
|
|
113
|
+
const method = getDeployMethod();
|
|
114
|
+
const actions = [];
|
|
28
115
|
for (const skill of manifest.skills) {
|
|
29
116
|
const source = path.resolve(sourceDir, skill.path);
|
|
30
117
|
await validateSourcePath(source, skill.path, resolvedSourceDir, realRoot);
|
|
@@ -35,26 +122,112 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
|
35
122
|
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
36
123
|
if (!agent)
|
|
37
124
|
continue;
|
|
38
|
-
const target = resolveAgentSkillPath(agent, skill.name, home);
|
|
39
125
|
actions.push({
|
|
40
126
|
kind: "skill-dir",
|
|
41
127
|
skill: skill.name,
|
|
42
128
|
agent: agentId,
|
|
43
129
|
source,
|
|
44
|
-
target,
|
|
130
|
+
target: resolveAgentSkillPath(agent, skill.name, home),
|
|
45
131
|
method,
|
|
132
|
+
confidence: agent.provenance.skills,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return actions;
|
|
137
|
+
}
|
|
138
|
+
async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
|
|
139
|
+
const actions = [];
|
|
140
|
+
for (const fileEntry of manifest.files ?? []) {
|
|
141
|
+
const source = path.resolve(sourceDir, fileEntry.path);
|
|
142
|
+
await validateSourcePath(source, fileEntry.path, resolvedSourceDir, realRoot);
|
|
143
|
+
await validateSourceFile(source, fileEntry.path);
|
|
144
|
+
for (const agentId of fileEntry.agents) {
|
|
145
|
+
if (!detectedAgents.includes(agentId))
|
|
146
|
+
continue;
|
|
147
|
+
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
148
|
+
if (!agent)
|
|
149
|
+
continue;
|
|
150
|
+
actions.push({
|
|
151
|
+
kind: "file-write",
|
|
152
|
+
skill: fileEntry.name,
|
|
153
|
+
agent: agentId,
|
|
154
|
+
source,
|
|
155
|
+
target: resolveTargetTemplate(fileEntry.target, home),
|
|
156
|
+
confidence: agent.provenance.skills,
|
|
46
157
|
});
|
|
47
158
|
}
|
|
48
159
|
}
|
|
49
160
|
return actions;
|
|
50
161
|
}
|
|
162
|
+
function planConfigPatchActions(manifest, detectedAgents, home) {
|
|
163
|
+
const actions = [];
|
|
164
|
+
for (const configEntry of manifest.configs ?? []) {
|
|
165
|
+
for (const agentId of configEntry.agents) {
|
|
166
|
+
if (!detectedAgents.includes(agentId))
|
|
167
|
+
continue;
|
|
168
|
+
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
169
|
+
if (!agent)
|
|
170
|
+
continue;
|
|
171
|
+
actions.push({
|
|
172
|
+
kind: "config-patch",
|
|
173
|
+
skill: configEntry.name,
|
|
174
|
+
agent: agentId,
|
|
175
|
+
target: resolveTargetTemplate(configEntry.target, home),
|
|
176
|
+
patch: configEntry.patch,
|
|
177
|
+
confidence: agent.provenance.skills,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return actions;
|
|
182
|
+
}
|
|
183
|
+
export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
184
|
+
const resolvedSourceDir = path.resolve(sourceDir);
|
|
185
|
+
let realRoot;
|
|
186
|
+
try {
|
|
187
|
+
realRoot = await realpath(resolvedSourceDir);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
realRoot = resolvedSourceDir;
|
|
191
|
+
}
|
|
192
|
+
const actions = [
|
|
193
|
+
...(await planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home)),
|
|
194
|
+
...(await planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home)),
|
|
195
|
+
...planConfigPatchActions(manifest, detectedAgents, home),
|
|
196
|
+
];
|
|
197
|
+
const warnings = [
|
|
198
|
+
...detectAmbiguities(detectedAgents),
|
|
199
|
+
...detectCollisions(actions),
|
|
200
|
+
];
|
|
201
|
+
return { actions, warnings };
|
|
202
|
+
}
|
|
51
203
|
export async function executeDeploy(actions, dryRun, verbose, home) {
|
|
52
204
|
let succeeded = 0;
|
|
53
205
|
const failed = [];
|
|
206
|
+
const planned = [];
|
|
54
207
|
for (const action of actions) {
|
|
55
208
|
switch (action.kind) {
|
|
56
209
|
case "skill-dir": {
|
|
57
|
-
const result = await deploySkillDir(action, dryRun, verbose, home);
|
|
210
|
+
const result = await deploySkillDir(action, dryRun, verbose, home, planned);
|
|
211
|
+
if (result.error === null) {
|
|
212
|
+
succeeded++;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
failed.push({ action, error: result.error });
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "file-write": {
|
|
220
|
+
const result = await deployFileWrite(action, dryRun, verbose, home, planned);
|
|
221
|
+
if (result.error === null) {
|
|
222
|
+
succeeded++;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
failed.push({ action, error: result.error });
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case "config-patch": {
|
|
230
|
+
const result = await deployConfigPatch(action, dryRun, verbose, home, planned);
|
|
58
231
|
if (result.error === null) {
|
|
59
232
|
succeeded++;
|
|
60
233
|
}
|
|
@@ -64,14 +237,13 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
|
|
|
64
237
|
break;
|
|
65
238
|
}
|
|
66
239
|
default: {
|
|
67
|
-
|
|
68
|
-
throw new Error(`Unhandled deploy action kind: ${_}`);
|
|
240
|
+
throw new Error(`Unhandled deploy action kind: ${action}`);
|
|
69
241
|
}
|
|
70
242
|
}
|
|
71
243
|
}
|
|
72
|
-
return { succeeded, failed };
|
|
244
|
+
return { succeeded, failed, planned };
|
|
73
245
|
}
|
|
74
|
-
async function deploySkillDir(action, dryRun, verbose, home) {
|
|
246
|
+
async function deploySkillDir(action, dryRun, verbose, home, planned) {
|
|
75
247
|
const label = `${action.skill} -> ${action.agent}`;
|
|
76
248
|
try {
|
|
77
249
|
await access(action.source);
|
|
@@ -82,14 +254,130 @@ async function deploySkillDir(action, dryRun, verbose, home) {
|
|
|
82
254
|
return { error: msg };
|
|
83
255
|
}
|
|
84
256
|
if (dryRun) {
|
|
85
|
-
|
|
257
|
+
planned.push({
|
|
258
|
+
verb: action.method === "symlink" ? "create-symlink" : "copy-dir",
|
|
259
|
+
kind: "skill-dir",
|
|
260
|
+
skill: action.skill,
|
|
261
|
+
agent: action.agent,
|
|
262
|
+
source: action.source,
|
|
263
|
+
target: action.target,
|
|
264
|
+
method: action.method,
|
|
265
|
+
confidence: action.confidence,
|
|
266
|
+
});
|
|
267
|
+
return { error: null };
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await executeDeployAction(action, verbose, home);
|
|
271
|
+
return { error: null };
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
275
|
+
logger.fail(label, msg);
|
|
276
|
+
return { error: msg };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function deployFileWrite(action, dryRun, verbose, home, planned) {
|
|
280
|
+
const label = `${action.skill} -> ${action.agent}`;
|
|
281
|
+
try {
|
|
282
|
+
await access(action.source);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const msg = sourceAccessError(err, action.source);
|
|
286
|
+
logger.fail(label, msg);
|
|
287
|
+
return { error: msg };
|
|
288
|
+
}
|
|
289
|
+
if (dryRun) {
|
|
290
|
+
planned.push({
|
|
291
|
+
verb: "write-file",
|
|
292
|
+
kind: "file-write",
|
|
293
|
+
skill: action.skill,
|
|
294
|
+
agent: action.agent,
|
|
295
|
+
source: action.source,
|
|
296
|
+
target: action.target,
|
|
297
|
+
});
|
|
298
|
+
return { error: null };
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
// Check if target exists — only allow overwrite if we own it
|
|
302
|
+
try {
|
|
303
|
+
await lstat(action.target);
|
|
304
|
+
const isOwned = await verifyDeployment(home, action.target, {
|
|
305
|
+
kind: "file-write",
|
|
306
|
+
source: action.source,
|
|
307
|
+
skill: action.skill,
|
|
308
|
+
agent: action.agent,
|
|
309
|
+
});
|
|
310
|
+
if (!isOwned) {
|
|
311
|
+
throw new Error(`Target "${action.target}" exists but is not managed by inception-engine — refusing to overwrite`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
if (err instanceof Error && err.message.includes("refusing to overwrite"))
|
|
316
|
+
throw err;
|
|
317
|
+
// ENOENT — target doesn't exist, fine to create
|
|
318
|
+
}
|
|
319
|
+
await mkdir(path.dirname(action.target), { recursive: true });
|
|
320
|
+
await copyFile(action.source, action.target);
|
|
321
|
+
await registerDeployment(home, action.target, {
|
|
322
|
+
kind: "file-write",
|
|
323
|
+
source: action.source,
|
|
324
|
+
skill: action.skill,
|
|
325
|
+
agent: action.agent,
|
|
326
|
+
});
|
|
327
|
+
logger.ok(label);
|
|
86
328
|
if (verbose) {
|
|
87
|
-
logger.detail(
|
|
329
|
+
logger.detail(`write-file: ${action.source} -> ${action.target}`);
|
|
88
330
|
}
|
|
89
331
|
return { error: null };
|
|
90
332
|
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
335
|
+
logger.fail(label, msg);
|
|
336
|
+
return { error: msg };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function deployConfigPatch(action, dryRun, verbose, home, planned) {
|
|
340
|
+
const label = `${action.skill} -> ${action.agent}`;
|
|
341
|
+
if (!isPlainObject(action.patch)) {
|
|
342
|
+
const msg = `Config patch for skill "${action.skill}" must be a plain object`;
|
|
343
|
+
logger.fail(label, msg);
|
|
344
|
+
return { error: msg };
|
|
345
|
+
}
|
|
346
|
+
const patch = action.patch;
|
|
347
|
+
if (dryRun) {
|
|
348
|
+
planned.push({
|
|
349
|
+
verb: "patch-config",
|
|
350
|
+
kind: "config-patch",
|
|
351
|
+
skill: action.skill,
|
|
352
|
+
agent: action.agent,
|
|
353
|
+
target: action.target,
|
|
354
|
+
patch,
|
|
355
|
+
});
|
|
356
|
+
return { error: null };
|
|
357
|
+
}
|
|
91
358
|
try {
|
|
92
|
-
|
|
359
|
+
// Guard against double-patching by a different skill/agent
|
|
360
|
+
const existingEntry = await lookupDeployment(home, action.target);
|
|
361
|
+
if (existingEntry &&
|
|
362
|
+
(existingEntry.skill !== action.skill ||
|
|
363
|
+
existingEntry.agent !== action.agent)) {
|
|
364
|
+
throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" — refusing to double-patch`);
|
|
365
|
+
}
|
|
366
|
+
const original = await readJsonConfig(action.target);
|
|
367
|
+
const undoPatch = computeUndoPatch(original, patch);
|
|
368
|
+
const patched = applyMergePatch(original, patch);
|
|
369
|
+
await writeFile(action.target, `${JSON.stringify(patched, null, 2)}\n`, "utf-8");
|
|
370
|
+
await registerDeployment(home, action.target, {
|
|
371
|
+
kind: "config-patch",
|
|
372
|
+
patch,
|
|
373
|
+
undoPatch,
|
|
374
|
+
skill: action.skill,
|
|
375
|
+
agent: action.agent,
|
|
376
|
+
});
|
|
377
|
+
logger.ok(label);
|
|
378
|
+
if (verbose) {
|
|
379
|
+
logger.detail(`patch-config: applied ${Object.keys(patch).length} key(s) to ${action.target}`);
|
|
380
|
+
}
|
|
93
381
|
return { error: null };
|
|
94
382
|
}
|
|
95
383
|
catch (err) {
|
|
@@ -134,9 +422,23 @@ async function validateSkillContract(source, skillPath) {
|
|
|
134
422
|
throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is not a directory: ${source}`);
|
|
135
423
|
}
|
|
136
424
|
try {
|
|
137
|
-
await access(
|
|
425
|
+
await access(source, constants.R_OK);
|
|
138
426
|
}
|
|
139
|
-
catch {
|
|
427
|
+
catch (err) {
|
|
428
|
+
const code = err.code;
|
|
429
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
430
|
+
throw new UserError("DEPLOY_FAILED", `Permission denied reading skill directory "${skillPath}": ${source}`);
|
|
431
|
+
}
|
|
432
|
+
throw new UserError("DEPLOY_FAILED", `Cannot read skill directory "${skillPath}": ${source}`);
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
await access(path.join(source, "SKILL.md"), constants.R_OK);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
const code = err.code;
|
|
439
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
440
|
+
throw new UserError("DEPLOY_FAILED", `Permission denied reading SKILL.md in skill "${skillPath}": ${source}`);
|
|
441
|
+
}
|
|
140
442
|
throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is missing SKILL.md: ${source}`);
|
|
141
443
|
}
|
|
142
444
|
}
|
|
@@ -159,6 +461,7 @@ async function createDeployTarget(action, home) {
|
|
|
159
461
|
await cp(action.source, action.target, { recursive: true });
|
|
160
462
|
}
|
|
161
463
|
await registerDeployment(home, action.target, {
|
|
464
|
+
kind: action.kind,
|
|
162
465
|
source: action.source,
|
|
163
466
|
skill: action.skill,
|
|
164
467
|
agent: action.agent,
|
|
@@ -168,6 +471,7 @@ async function createDeployTarget(action, home) {
|
|
|
168
471
|
async function executeDeployAction(action, verbose, home) {
|
|
169
472
|
const label = `${action.skill} -> ${action.agent}`;
|
|
170
473
|
const backupPath = await backupExisting(action.target, verbose, home, {
|
|
474
|
+
kind: action.kind,
|
|
171
475
|
source: action.source,
|
|
172
476
|
skill: action.skill,
|
|
173
477
|
agent: action.agent,
|
|
@@ -180,6 +484,9 @@ async function executeDeployAction(action, verbose, home) {
|
|
|
180
484
|
catch (createErr) {
|
|
181
485
|
if (backupPath) {
|
|
182
486
|
try {
|
|
487
|
+
await removeTarget(action.target).catch(() => {
|
|
488
|
+
/* best-effort cleanup */
|
|
489
|
+
});
|
|
183
490
|
await rename(backupPath, action.target);
|
|
184
491
|
}
|
|
185
492
|
catch {
|
|
@@ -203,21 +510,24 @@ async function backupExisting(targetPath, verbose, home, expected) {
|
|
|
203
510
|
catch {
|
|
204
511
|
return null;
|
|
205
512
|
}
|
|
206
|
-
if (!(await verifyDeployment(home, targetPath,
|
|
513
|
+
if (!(await verifyDeployment(home, targetPath, {
|
|
514
|
+
kind: "skill-dir",
|
|
515
|
+
source: expected.source,
|
|
516
|
+
skill: expected.skill,
|
|
517
|
+
agent: expected.agent,
|
|
518
|
+
}))) {
|
|
207
519
|
throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
|
|
208
520
|
}
|
|
209
521
|
const backupPath = `${targetPath}.inception-backup`;
|
|
210
|
-
// Clean up any stale backup from a previous failed attempt
|
|
211
|
-
try {
|
|
212
|
-
await lstat(backupPath);
|
|
213
|
-
await removeTarget(backupPath);
|
|
214
|
-
}
|
|
215
|
-
catch {
|
|
216
|
-
// No stale backup — expected
|
|
217
|
-
}
|
|
218
522
|
if (verbose) {
|
|
219
523
|
logger.detail(`backing up existing target: ${targetPath}`);
|
|
220
524
|
}
|
|
525
|
+
// Remove any stale backup from a previous failed attempt. Using rm with
|
|
526
|
+
// { force: true } avoids a separate lstat existence check and handles
|
|
527
|
+
// the case where the stale backup is a directory (which rename cannot
|
|
528
|
+
// atomically replace on POSIX). This reduces the window between the
|
|
529
|
+
// stale-backup removal and the rename to a single step.
|
|
530
|
+
await rm(backupPath, { recursive: true, force: true });
|
|
221
531
|
await rename(targetPath, backupPath);
|
|
222
532
|
return backupPath;
|
|
223
533
|
}
|
package/dist/core/detect.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentId } from "../types.ts";
|
|
2
2
|
export type ExecFn = (cmd: string, args: readonly string[]) => Promise<void>;
|
|
3
3
|
export declare function detectInstalledAgents(home: string): Promise<AgentId[]>;
|
|
4
|
-
export declare function isBinaryInPath(binary: string, execFn?: ExecFn): Promise<boolean>;
|
|
4
|
+
export declare function isBinaryInPath(binary: string, execFn?: ExecFn, platform?: NodeJS.Platform): Promise<boolean>;
|
|
5
5
|
export declare function isBinaryViaWhereExe(binary: string, execFn?: ExecFn): Promise<boolean>;
|
|
6
|
-
export declare function isBinaryViaCommandV(binary: string): Promise<boolean>;
|
|
6
|
+
export declare function isBinaryViaCommandV(binary: string, execFn?: ExecFn): Promise<boolean>;
|
|
7
7
|
export declare function isBinaryViaWhich(binary: string, execFn?: ExecFn): Promise<boolean>;
|
package/dist/core/detect.js
CHANGED
|
@@ -30,8 +30,8 @@ async function isAgentInstalled(agent, home) {
|
|
|
30
30
|
}
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
|
-
export async function isBinaryInPath(binary, execFn = defaultExecFn) {
|
|
34
|
-
if (
|
|
33
|
+
export async function isBinaryInPath(binary, execFn = defaultExecFn, platform = process.platform) {
|
|
34
|
+
if (platform === "win32") {
|
|
35
35
|
return isBinaryViaWhereExe(binary, execFn);
|
|
36
36
|
}
|
|
37
37
|
try {
|
|
@@ -41,7 +41,7 @@ export async function isBinaryInPath(binary, execFn = defaultExecFn) {
|
|
|
41
41
|
catch (err) {
|
|
42
42
|
// `which` itself is not installed — fall back to the POSIX shell built-in
|
|
43
43
|
if (isENOENT(err)) {
|
|
44
|
-
return isBinaryViaCommandV(binary);
|
|
44
|
+
return isBinaryViaCommandV(binary, execFn);
|
|
45
45
|
}
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
@@ -58,10 +58,10 @@ export async function isBinaryViaWhereExe(binary, execFn = defaultExecFn) {
|
|
|
58
58
|
}
|
|
59
59
|
// Used only when `which` is absent (e.g. minimal Alpine containers).
|
|
60
60
|
// `command -v` is a POSIX shell built-in available wherever /bin/sh is.
|
|
61
|
-
export async function isBinaryViaCommandV(binary) {
|
|
61
|
+
export async function isBinaryViaCommandV(binary, execFn = defaultExecFn) {
|
|
62
62
|
try {
|
|
63
63
|
// Pass binary as a positional arg ($1) to avoid any shell-injection risk.
|
|
64
|
-
await
|
|
64
|
+
await execFn("sh", ["-c", 'command -v "$1"', "--", binary]);
|
|
65
65
|
return true;
|
|
66
66
|
}
|
|
67
67
|
catch {
|