@kuznai/inception-engine 0.9.0 → 0.11.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 +55 -17
- package/dist/core/adapters/index.d.ts +2 -2
- package/dist/core/adapters/index.js +1 -1
- package/dist/core/adapters/rules.js +5 -3
- package/dist/core/deploy.d.ts +22 -2
- package/dist/core/deploy.js +152 -76
- package/dist/core/init.d.ts +9 -0
- package/dist/core/init.js +99 -0
- package/dist/core/ownership.d.ts +10 -5
- package/dist/core/ownership.js +14 -10
- package/dist/core/resolve.js +4 -5
- package/dist/core/revert.d.ts +6 -1
- package/dist/core/revert.js +16 -25
- package/dist/core/runtime-paths.d.ts +8 -0
- package/dist/core/runtime-paths.js +57 -0
- package/dist/core/validation.js +28 -3
- package/dist/index.js +25 -0
- package/dist/schemas/manifest.js +7 -3
- package/dist/types.d.ts +2 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# inception-engine
|
|
2
2
|
|
|
3
|
-
Plant skills directly into the minds of your installed AI coding agents — Claude Code, Codex, Gemini CLI, Antigravity,
|
|
3
|
+
Plant skills directly into the minds of your installed AI coding agents — Claude Code, Codex, Gemini CLI, Antigravity, and OpenCode. One command. They'll think they thought of it themselves.
|
|
4
4
|
|
|
5
|
-
Today, inception-engine
|
|
5
|
+
Today, inception-engine works as a cross-agent deployer for skills on all listed agents, plus single-file writes and JSON config patches. It also supports MCP server registration and global rules-file deployment for the subset of agents whose config surfaces are implemented and validated today.
|
|
6
|
+
|
|
7
|
+
GitHub Copilot is no longer treated as a separate instruction or skill target in the product direction when it can consume Claude-native artifacts directly. If Copilot uses `CLAUDE.md` or Claude-style skill layouts without translation, inception-engine should rely on the Claude deployment path instead of maintaining duplicate Copilot-specific surfaces. Dedicated Copilot customization remains justified only where Copilot exposes a materially different interface, such as MCP-related configuration.
|
|
8
|
+
|
|
9
|
+
The broader portability layer is the roadmap direction, but this README focuses on what is working now.
|
|
10
|
+
|
|
11
|
+
`init` is available as a bootstrap command, but what works today is intentionally narrow: it scans for directories containing `SKILL.md` and generates starter `skills` entries plus empty `mcpServers` and `agentRules` arrays. It does not infer `files`, `configs`, MCP server definitions, or rules files from the repository yet.
|
|
6
12
|
|
|
7
13
|
## Quick Start
|
|
8
14
|
|
|
@@ -30,7 +36,6 @@ Managed skills overwrite their previous version. If a target exists but was not
|
|
|
30
36
|
| Gemini CLI | `gemini-cli` | `~/.gemini/skills/` | Yes | Yes | Yes |
|
|
31
37
|
| Antigravity | `antigravity` | `~/.gemini/antigravity/skills/` | Yes* | Yes* | Yes* |
|
|
32
38
|
| OpenCode | `opencode` | `~/.config/opencode/skills/` | Yes | Yes | Yes* |
|
|
33
|
-
| GitHub Copilot | `github-copilot` | `~/.copilot/skills/` | Yes | Yes | Yes |
|
|
34
39
|
|
|
35
40
|
\* Antigravity support is currently based on the implementation's registry path assumptions and local validation, not a strong official doc set equivalent to the other agents.
|
|
36
41
|
|
|
@@ -43,8 +48,13 @@ Managed skills overwrite their previous version. If a target exists but was not
|
|
|
43
48
|
| Skills (SKILL.md) | All agents via manifest and CLI | All agents |
|
|
44
49
|
| File write | All agents via manifest and CLI | All agents |
|
|
45
50
|
| Config patch (JSON merge) | All agents via manifest and CLI | All agents |
|
|
46
|
-
| MCP Servers | claude-code, gemini-cli;
|
|
47
|
-
|
|
|
51
|
+
| MCP Servers | claude-code, gemini-cli; other agents are warned and skipped | claude-code, gemini-cli |
|
|
52
|
+
| Global Rules Files | claude-code, codex, gemini-cli, opencode; other agents are warned and skipped | claude-code, codex, gemini-cli, opencode |
|
|
53
|
+
| `init` manifest generation | Scans `SKILL.md` directories and writes starter `skills` entries | N/A |
|
|
54
|
+
|
|
55
|
+
Features that depend on agent-specific config surfaces are intentionally conservative: if a target path or schema is not implemented with enough confidence, inception-engine warns and skips it rather than guessing.
|
|
56
|
+
|
|
57
|
+
For GitHub Copilot specifically, the portability rule is Claude-first: if Copilot accepts the same Claude-native instruction or skill artifact, inception-engine should not add a separate Copilot deployment feature for it.
|
|
48
58
|
|
|
49
59
|
## Manifest Format
|
|
50
60
|
|
|
@@ -56,7 +66,7 @@ Create an `inception.json` file at the root of your skills directory:
|
|
|
56
66
|
{
|
|
57
67
|
"name": "my-skill",
|
|
58
68
|
"path": "skills/my-skill",
|
|
59
|
-
"agents": ["claude-code", "codex", "gemini-cli", "antigravity", "opencode"
|
|
69
|
+
"agents": ["claude-code", "codex", "gemini-cli", "antigravity", "opencode"]
|
|
60
70
|
}
|
|
61
71
|
],
|
|
62
72
|
"files": [
|
|
@@ -109,7 +119,7 @@ Each **config** entry applies a [JSON merge patch (RFC 7386)](https://datatracke
|
|
|
109
119
|
|
|
110
120
|
- **name** - Unique identifier (same format as skill names)
|
|
111
121
|
- **target** - Config file to patch, using the same placeholder prefix as file entries
|
|
112
|
-
- **patch** - JSON object of keys to set. A `null` value removes the key from the target file.
|
|
122
|
+
- **patch** - JSON object of keys to set. A `null` value removes the key from the target file. Nested object values are merged recursively; non-object values replace the existing value directly.
|
|
113
123
|
- **agents** - Array of agent IDs to apply this patch to
|
|
114
124
|
|
|
115
125
|
The engine records an undo-patch for each config-patch deployment so that `revert` can restore the original values.
|
|
@@ -122,13 +132,13 @@ Each **mcpServer** entry registers an MCP server into the agent's config file by
|
|
|
122
132
|
|
|
123
133
|
MCP server registration is currently supported for `claude-code` (`~/.claude.json`) and `gemini-cli` (`~/.gemini/settings.json`). Other agents emit a warning and are skipped. Revert removes the registered server entry from the config file.
|
|
124
134
|
|
|
125
|
-
Each **agentRules** entry deploys a Markdown instruction file to
|
|
135
|
+
Each **agentRules** entry deploys a Markdown instruction file to an agent's supported global rules file location:
|
|
126
136
|
|
|
127
137
|
- **name** - Unique identifier (same format as skill names)
|
|
128
138
|
- **path** - Relative path to the source Markdown file within the repo
|
|
129
139
|
- **agents** - Array of agent IDs to deploy this file to
|
|
130
140
|
|
|
131
|
-
|
|
141
|
+
Global rules-file deployment is currently supported for `claude-code` (`~/.claude/CLAUDE.md`), `codex` (`~/.codex/AGENTS.md`), `gemini-cli` (`~/.gemini/GEMINI.md`), and `opencode` (`~/.config/opencode/AGENTS.md`). Other agents emit a warning and are skipped because their instruction surfaces are different, repo-scoped, or not implemented here yet. Revert removes the deployed rules file.
|
|
132
142
|
|
|
133
143
|
## Creating Skills
|
|
134
144
|
|
|
@@ -147,11 +157,30 @@ Instructions for the AI agent...
|
|
|
147
157
|
|
|
148
158
|
The `name` and `description` fields in the frontmatter are used by most agents. The description determines when the agent activates the skill. inception-engine does not currently validate the frontmatter — missing or malformed fields may cause the skill to be ignored or misbehave at the agent level.
|
|
149
159
|
|
|
160
|
+
## `init` Command
|
|
161
|
+
|
|
162
|
+
`init` is meant to bootstrap a repository that already has skill folders. It recursively scans the target directory, treats any directory containing `SKILL.md` as a skill, and writes a starter `inception.json`.
|
|
163
|
+
|
|
164
|
+
Current `init` behavior:
|
|
165
|
+
|
|
166
|
+
- Generates `skills` entries using the discovered relative paths
|
|
167
|
+
- Uses the directory name as the manifest skill name
|
|
168
|
+
- Applies either the `--agents` list or all currently known agent IDs
|
|
169
|
+
- Refuses to overwrite an existing `inception.json` unless `--force` is provided
|
|
170
|
+
- Supports `--dry-run` so you can inspect the generated manifest before writing it
|
|
171
|
+
|
|
172
|
+
Current `init` limitations:
|
|
173
|
+
|
|
174
|
+
- It does not read or validate YAML frontmatter inside `SKILL.md`
|
|
175
|
+
- It does not infer `files`, `configs`, `mcpServers`, or `agentRules`
|
|
176
|
+
- It does not reconcile generated output with the longer-term Claude-first portability direction
|
|
177
|
+
|
|
150
178
|
## CLI Reference
|
|
151
179
|
|
|
152
180
|
```
|
|
153
181
|
inception-engine <directory> [options]
|
|
154
182
|
inception-engine revert <directory> [options]
|
|
183
|
+
inception-engine init <directory> [options]
|
|
155
184
|
```
|
|
156
185
|
|
|
157
186
|
### Commands
|
|
@@ -160,6 +189,7 @@ inception-engine revert <directory> [options]
|
|
|
160
189
|
|---|---|
|
|
161
190
|
| `<directory>` | Deploy skills from the manifest in the given directory |
|
|
162
191
|
| `revert <directory>` | Remove previously deployed skills declared in the manifest |
|
|
192
|
+
| `init <directory>` | Scan a directory for skill folders and generate `inception.json` |
|
|
163
193
|
|
|
164
194
|
### Options
|
|
165
195
|
|
|
@@ -167,6 +197,7 @@ inception-engine revert <directory> [options]
|
|
|
167
197
|
|---|---|
|
|
168
198
|
| `--dry-run` | Show what would be done without making changes |
|
|
169
199
|
| `--agents <list>` | Comma-separated list of agent IDs to target (overrides deploy detection; restricts revert) |
|
|
200
|
+
| `--force` | `init` only; overwrite an existing `inception.json` |
|
|
170
201
|
| `--verbose` | Show detailed output including file paths |
|
|
171
202
|
| `--debug` | Show full error stack traces |
|
|
172
203
|
| `--help` | Show help message |
|
|
@@ -175,19 +206,25 @@ inception-engine revert <directory> [options]
|
|
|
175
206
|
|
|
176
207
|
```bash
|
|
177
208
|
# Deploy all skills to all detected agents
|
|
178
|
-
npx inception-engine ./my-skills-repo
|
|
209
|
+
npx @kuznai/inception-engine ./my-skills-repo
|
|
179
210
|
|
|
180
211
|
# Preview what would be deployed
|
|
181
|
-
npx inception-engine ./my-skills-repo --dry-run
|
|
212
|
+
npx @kuznai/inception-engine ./my-skills-repo --dry-run
|
|
182
213
|
|
|
183
214
|
# Deploy only to Claude Code and Codex
|
|
184
|
-
npx inception-engine ./my-skills-repo --agents claude-code,codex
|
|
215
|
+
npx @kuznai/inception-engine ./my-skills-repo --agents claude-code,codex
|
|
185
216
|
|
|
186
217
|
# Remove deployed skills
|
|
187
|
-
npx inception-engine revert ./my-skills-repo
|
|
218
|
+
npx @kuznai/inception-engine revert ./my-skills-repo
|
|
188
219
|
|
|
189
220
|
# Preview what would be removed
|
|
190
|
-
npx inception-engine revert ./my-skills-repo --dry-run
|
|
221
|
+
npx @kuznai/inception-engine revert ./my-skills-repo --dry-run
|
|
222
|
+
|
|
223
|
+
# Generate a starter manifest from discovered skill folders
|
|
224
|
+
npx @kuznai/inception-engine init ./my-skills-repo
|
|
225
|
+
|
|
226
|
+
# Preview the generated manifest without writing it
|
|
227
|
+
npx @kuznai/inception-engine init ./my-skills-repo --dry-run
|
|
191
228
|
```
|
|
192
229
|
|
|
193
230
|
## Sample Skills
|
|
@@ -197,7 +234,8 @@ The `limbo/` directory contains exceptional sample skills for testing purposes o
|
|
|
197
234
|
Try them out:
|
|
198
235
|
|
|
199
236
|
```bash
|
|
200
|
-
npx inception-engine limbo --dry-run
|
|
237
|
+
npx @kuznai/inception-engine init limbo --dry-run
|
|
238
|
+
npx @kuznai/inception-engine limbo --dry-run
|
|
201
239
|
```
|
|
202
240
|
|
|
203
241
|
## Agent Detection
|
|
@@ -221,11 +259,11 @@ Revert targets all agents listed in the manifest by default (regardless of detec
|
|
|
221
259
|
|
|
222
260
|
### Ownership Tracking and Safe Revert
|
|
223
261
|
|
|
224
|
-
inception-engine maintains a centralized deployment registry at `~/.inception-engine/registry.json`. Each deploy records the target path,
|
|
262
|
+
inception-engine maintains a centralized deployment registry at `~/.inception-engine/registry.json`. Each deploy records the target path, skill name, agent ID, action-specific provenance (`source`/`method` for skill-dir, `source` for file-write, `patch`/`undoPatch` for config-patch), and timestamp. No files are written to the source repository.
|
|
225
263
|
|
|
226
264
|
- **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.
|
|
227
265
|
|
|
228
|
-
- **Strong binding**: Each registry entry binds a specific target path to its skill, agent, and action kind
|
|
266
|
+
- **Strong binding**: Each registry entry binds a specific target path to its skill, agent, and action kind. For `skill-dir` and `file-write`, ownership checks also require the recorded `source` to match before an existing target is treated as managed. For `config-patch`, overwrite protection is keyed by target path, kind, skill, and agent; the stored `patch` and `undoPatch` are used for revert bookkeeping rather than deploy-time identity checks.
|
|
229
267
|
|
|
230
268
|
- **Atomic redeploy**: When overwriting an existing managed `skill-dir` 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. `file-write` and `config-patch` deployments write directly to the target without this backup/rollback model.
|
|
231
269
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AgentRuleEntry, McpServerEntry } from "../../schemas/manifest.ts";
|
|
2
2
|
import type { AgentId, ConfigPatchDeployAction, FileWriteDeployAction, PlanWarning } from "../../types.ts";
|
|
3
3
|
import { compileMcpServerReverts } from "./mcp.ts";
|
|
4
4
|
import { compileAgentRuleReverts } from "./rules.ts";
|
|
5
|
-
export {
|
|
5
|
+
export { compileAgentRuleReverts, compileMcpServerReverts };
|
|
6
6
|
export type AdapterAction = ConfigPatchDeployAction | FileWriteDeployAction;
|
|
7
7
|
export interface AdapterResult {
|
|
8
8
|
actions: AdapterAction[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { compileMcpServerActions, compileMcpServerReverts } from "./mcp.js";
|
|
2
2
|
import { compileAgentRuleActions, compileAgentRuleReverts } from "./rules.js";
|
|
3
|
-
export {
|
|
3
|
+
export { compileAgentRuleReverts, compileMcpServerReverts };
|
|
4
4
|
export async function compileAdapterActions(mcpServers, agentRules, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
|
|
5
5
|
const actions = [];
|
|
6
6
|
const warnings = [];
|
|
@@ -6,13 +6,15 @@ export async function compileAgentRuleActions(entry, sourceDir, resolvedSourceDi
|
|
|
6
6
|
const actions = [];
|
|
7
7
|
const warnings = [];
|
|
8
8
|
const platform = getPlatformKey();
|
|
9
|
+
const targetAgents = entry.agents.filter((agentId) => detectedAgents.includes(agentId));
|
|
10
|
+
if (targetAgents.length === 0) {
|
|
11
|
+
return { actions, warnings };
|
|
12
|
+
}
|
|
9
13
|
// Validate the source file once (before iterating agents) since it is shared.
|
|
10
14
|
const source = path.resolve(sourceDir, entry.path);
|
|
11
15
|
await validateSourcePath(source, entry.path, resolvedSourceDir, realRoot);
|
|
12
16
|
await validateSourceFile(source, entry.path);
|
|
13
|
-
for (const agentId of
|
|
14
|
-
if (!detectedAgents.includes(agentId))
|
|
15
|
-
continue;
|
|
17
|
+
for (const agentId of targetAgents) {
|
|
16
18
|
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
17
19
|
if (!agent?.agentRulesPath) {
|
|
18
20
|
warnings.push({
|
package/dist/core/deploy.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { AgentId, DeployAction, Manifest, PlannedChange, PlanWarning } from "../types.ts";
|
|
1
|
+
import type { AgentId, DeployAction, Manifest, PlannedChange, PlanWarning, SkillDirDeployAction } from "../types.ts";
|
|
2
|
+
import { type RegistryPersistence } from "./ownership.ts";
|
|
2
3
|
export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string): Promise<{
|
|
3
4
|
actions: DeployAction[];
|
|
4
5
|
warnings: PlanWarning[];
|
|
5
6
|
}>;
|
|
6
|
-
export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string): Promise<{
|
|
7
|
+
export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string, deps?: DeployDependencies): Promise<{
|
|
7
8
|
succeeded: number;
|
|
8
9
|
failed: Array<{
|
|
9
10
|
action: DeployAction;
|
|
@@ -11,3 +12,22 @@ export declare function executeDeploy(actions: DeployAction[], dryRun: boolean,
|
|
|
11
12
|
}>;
|
|
12
13
|
planned: PlannedChange[];
|
|
13
14
|
}>;
|
|
15
|
+
interface SkillDirOps {
|
|
16
|
+
createTarget(action: SkillDirDeployAction): Promise<void>;
|
|
17
|
+
removeTarget(targetPath: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
interface DeployFileOps {
|
|
20
|
+
copyFile(source: string, target: string): Promise<void>;
|
|
21
|
+
rename(source: string, target: string): Promise<void>;
|
|
22
|
+
rm(targetPath: string, options?: {
|
|
23
|
+
recursive?: boolean;
|
|
24
|
+
force?: boolean;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
writeFile(filePath: string, content: string, encoding: BufferEncoding): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
interface DeployDependencies {
|
|
29
|
+
registry?: RegistryPersistence;
|
|
30
|
+
skillDirOps?: SkillDirOps;
|
|
31
|
+
fileOps?: DeployFileOps;
|
|
32
|
+
}
|
|
33
|
+
export {};
|
package/dist/core/deploy.js
CHANGED
|
@@ -4,14 +4,15 @@ import path from "node:path";
|
|
|
4
4
|
import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
|
|
5
5
|
import { UserError } from "../errors.js";
|
|
6
6
|
import { logger } from "../logger.js";
|
|
7
|
-
import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
|
|
8
7
|
import { compileAdapterActions } from "./adapters/index.js";
|
|
8
|
+
import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
|
|
9
9
|
import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
|
|
10
|
+
import { resolveTargetTemplate } from "./runtime-paths.js";
|
|
10
11
|
import { sourceAccessError, validateSourceFile, validateSourcePath, } from "./validation.js";
|
|
11
12
|
function isPlainObject(v) {
|
|
12
13
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
14
|
}
|
|
14
|
-
async function
|
|
15
|
+
async function readJsonConfigFile(filePath) {
|
|
15
16
|
let rawContent;
|
|
16
17
|
try {
|
|
17
18
|
rawContent = await readFile(filePath, "utf-8");
|
|
@@ -62,15 +63,6 @@ function applyMergePatch(original, patch) {
|
|
|
62
63
|
}
|
|
63
64
|
return patched;
|
|
64
65
|
}
|
|
65
|
-
function resolveTargetTemplate(template, home) {
|
|
66
|
-
const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
67
|
-
const xdgRaw = process.env.XDG_CONFIG_HOME;
|
|
68
|
-
const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
|
|
69
|
-
return template
|
|
70
|
-
.replace("{home}", home)
|
|
71
|
-
.replace("{appdata}", appdata)
|
|
72
|
-
.replace("{xdg_config}", xdgConfig);
|
|
73
|
-
}
|
|
74
66
|
function detectCollisions(actions) {
|
|
75
67
|
const seen = new Map();
|
|
76
68
|
const warnings = [];
|
|
@@ -103,12 +95,13 @@ async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realR
|
|
|
103
95
|
const method = getDeployMethod();
|
|
104
96
|
const actions = [];
|
|
105
97
|
for (const skill of manifest.skills) {
|
|
98
|
+
const targetAgents = skill.agents.filter((agentId) => detectedAgents.includes(agentId));
|
|
99
|
+
if (targetAgents.length === 0)
|
|
100
|
+
continue;
|
|
106
101
|
const source = path.resolve(sourceDir, skill.path);
|
|
107
102
|
await validateSourcePath(source, skill.path, resolvedSourceDir, realRoot);
|
|
108
103
|
await validateSkillContract(source, skill.path);
|
|
109
|
-
for (const agentId of
|
|
110
|
-
if (!detectedAgents.includes(agentId))
|
|
111
|
-
continue;
|
|
104
|
+
for (const agentId of targetAgents) {
|
|
112
105
|
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
113
106
|
if (!agent)
|
|
114
107
|
continue;
|
|
@@ -128,12 +121,13 @@ async function planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realR
|
|
|
128
121
|
async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home) {
|
|
129
122
|
const actions = [];
|
|
130
123
|
for (const fileEntry of manifest.files ?? []) {
|
|
124
|
+
const targetAgents = fileEntry.agents.filter((agentId) => detectedAgents.includes(agentId));
|
|
125
|
+
if (targetAgents.length === 0)
|
|
126
|
+
continue;
|
|
131
127
|
const source = path.resolve(sourceDir, fileEntry.path);
|
|
132
128
|
await validateSourcePath(source, fileEntry.path, resolvedSourceDir, realRoot);
|
|
133
129
|
await validateSourceFile(source, fileEntry.path);
|
|
134
|
-
for (const agentId of
|
|
135
|
-
if (!detectedAgents.includes(agentId))
|
|
136
|
-
continue;
|
|
130
|
+
for (const agentId of targetAgents) {
|
|
137
131
|
const agent = AGENT_REGISTRY_BY_ID[agentId];
|
|
138
132
|
if (!agent)
|
|
139
133
|
continue;
|
|
@@ -193,14 +187,14 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
|
193
187
|
];
|
|
194
188
|
return { actions, warnings };
|
|
195
189
|
}
|
|
196
|
-
export async function executeDeploy(actions, dryRun, verbose, home) {
|
|
190
|
+
export async function executeDeploy(actions, dryRun, verbose, home, deps = {}) {
|
|
197
191
|
let succeeded = 0;
|
|
198
192
|
const failed = [];
|
|
199
193
|
const planned = [];
|
|
200
194
|
for (const action of actions) {
|
|
201
195
|
switch (action.kind) {
|
|
202
196
|
case "skill-dir": {
|
|
203
|
-
const result = await deploySkillDir(action, dryRun, verbose, home, planned);
|
|
197
|
+
const result = await deploySkillDir(action, dryRun, verbose, home, planned, deps);
|
|
204
198
|
if (result.error === null) {
|
|
205
199
|
succeeded++;
|
|
206
200
|
}
|
|
@@ -210,7 +204,7 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
|
|
|
210
204
|
break;
|
|
211
205
|
}
|
|
212
206
|
case "file-write": {
|
|
213
|
-
const result = await deployFileWrite(action, dryRun, verbose, home, planned);
|
|
207
|
+
const result = await deployFileWrite(action, dryRun, verbose, home, planned, deps);
|
|
214
208
|
if (result.error === null) {
|
|
215
209
|
succeeded++;
|
|
216
210
|
}
|
|
@@ -220,7 +214,7 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
|
|
|
220
214
|
break;
|
|
221
215
|
}
|
|
222
216
|
case "config-patch": {
|
|
223
|
-
const result = await deployConfigPatch(action, dryRun, verbose, home, planned);
|
|
217
|
+
const result = await deployConfigPatch(action, dryRun, verbose, home, planned, deps);
|
|
224
218
|
if (result.error === null) {
|
|
225
219
|
succeeded++;
|
|
226
220
|
}
|
|
@@ -236,7 +230,114 @@ export async function executeDeploy(actions, dryRun, verbose, home) {
|
|
|
236
230
|
}
|
|
237
231
|
return { succeeded, failed, planned };
|
|
238
232
|
}
|
|
239
|
-
|
|
233
|
+
const defaultSkillDirOps = {
|
|
234
|
+
async createTarget(action) {
|
|
235
|
+
if (action.method === "symlink") {
|
|
236
|
+
await symlink(action.source, action.target, "dir");
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await cp(action.source, action.target, { recursive: true });
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
async removeTarget(targetPath) {
|
|
243
|
+
const stat = await lstat(targetPath);
|
|
244
|
+
if (stat.isSymbolicLink()) {
|
|
245
|
+
await unlink(targetPath);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
await rm(targetPath, { recursive: true });
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const defaultDeployFileOps = {
|
|
253
|
+
copyFile,
|
|
254
|
+
rename,
|
|
255
|
+
rm,
|
|
256
|
+
writeFile,
|
|
257
|
+
};
|
|
258
|
+
async function removeFileSystemTarget(targetPath) {
|
|
259
|
+
const stat = await lstat(targetPath);
|
|
260
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
261
|
+
await rm(targetPath, { recursive: true });
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
await unlink(targetPath);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function backupManagedFileWriteTarget(action, home, deps) {
|
|
268
|
+
try {
|
|
269
|
+
await lstat(action.target);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const isOwned = await verifyDeployment(home, action.target, {
|
|
275
|
+
kind: "file-write",
|
|
276
|
+
source: action.source,
|
|
277
|
+
skill: action.skill,
|
|
278
|
+
agent: action.agent,
|
|
279
|
+
}, deps.registry);
|
|
280
|
+
if (!isOwned) {
|
|
281
|
+
throw new Error(`Target "${action.target}" exists but is not managed by inception-engine — refusing to overwrite`);
|
|
282
|
+
}
|
|
283
|
+
const backupPath = `${action.target}.inception-backup`;
|
|
284
|
+
await (deps.fileOps ?? defaultDeployFileOps).rm(backupPath, {
|
|
285
|
+
recursive: true,
|
|
286
|
+
force: true,
|
|
287
|
+
});
|
|
288
|
+
await (deps.fileOps ?? defaultDeployFileOps).rename(action.target, backupPath);
|
|
289
|
+
return backupPath;
|
|
290
|
+
}
|
|
291
|
+
function createAtomicTempPath(targetPath) {
|
|
292
|
+
return `${targetPath}.inception-tmp-${process.pid}-${Date.now()}-${Math.random()
|
|
293
|
+
.toString(36)
|
|
294
|
+
.slice(2)}`;
|
|
295
|
+
}
|
|
296
|
+
async function replaceFileAtomically(targetPath, deps, stageTempFile, prepareBackup, commit) {
|
|
297
|
+
const fileOps = deps.fileOps ?? defaultDeployFileOps;
|
|
298
|
+
const tempPath = createAtomicTempPath(targetPath);
|
|
299
|
+
let backupPath = null;
|
|
300
|
+
let replacedTarget = false;
|
|
301
|
+
try {
|
|
302
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
303
|
+
await stageTempFile(tempPath, fileOps);
|
|
304
|
+
backupPath = await prepareBackup();
|
|
305
|
+
await fileOps.rename(tempPath, targetPath);
|
|
306
|
+
replacedTarget = true;
|
|
307
|
+
await commit();
|
|
308
|
+
}
|
|
309
|
+
catch (writeErr) {
|
|
310
|
+
if (replacedTarget) {
|
|
311
|
+
try {
|
|
312
|
+
await removeFileSystemTarget(targetPath);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
/* best-effort cleanup */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
try {
|
|
320
|
+
await fileOps.rm(tempPath, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
/* best-effort cleanup */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (backupPath) {
|
|
327
|
+
try {
|
|
328
|
+
await fileOps.rename(backupPath, targetPath);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
/* best-effort rollback */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
throw writeErr;
|
|
335
|
+
}
|
|
336
|
+
if (backupPath) {
|
|
337
|
+
await fileOps.rm(backupPath, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async function deploySkillDir(action, dryRun, verbose, home, planned, deps) {
|
|
240
341
|
const label = `${action.skill} -> ${action.agent}`;
|
|
241
342
|
try {
|
|
242
343
|
await access(action.source);
|
|
@@ -260,7 +361,7 @@ async function deploySkillDir(action, dryRun, verbose, home, planned) {
|
|
|
260
361
|
return { error: null };
|
|
261
362
|
}
|
|
262
363
|
try {
|
|
263
|
-
await executeDeployAction(action, verbose, home);
|
|
364
|
+
await executeDeployAction(action, verbose, home, deps);
|
|
264
365
|
return { error: null };
|
|
265
366
|
}
|
|
266
367
|
catch (err) {
|
|
@@ -269,7 +370,7 @@ async function deploySkillDir(action, dryRun, verbose, home, planned) {
|
|
|
269
370
|
return { error: msg };
|
|
270
371
|
}
|
|
271
372
|
}
|
|
272
|
-
async function deployFileWrite(action, dryRun, verbose, home, planned) {
|
|
373
|
+
async function deployFileWrite(action, dryRun, verbose, home, planned, deps) {
|
|
273
374
|
const label = `${action.skill} -> ${action.agent}`;
|
|
274
375
|
try {
|
|
275
376
|
await access(action.source);
|
|
@@ -291,32 +392,12 @@ async function deployFileWrite(action, dryRun, verbose, home, planned) {
|
|
|
291
392
|
return { error: null };
|
|
292
393
|
}
|
|
293
394
|
try {
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
await lstat(action.target);
|
|
297
|
-
const isOwned = await verifyDeployment(home, action.target, {
|
|
298
|
-
kind: "file-write",
|
|
299
|
-
source: action.source,
|
|
300
|
-
skill: action.skill,
|
|
301
|
-
agent: action.agent,
|
|
302
|
-
});
|
|
303
|
-
if (!isOwned) {
|
|
304
|
-
throw new Error(`Target "${action.target}" exists but is not managed by inception-engine — refusing to overwrite`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
catch (err) {
|
|
308
|
-
if (err instanceof Error && err.message.includes("refusing to overwrite"))
|
|
309
|
-
throw err;
|
|
310
|
-
// ENOENT — target doesn't exist, fine to create
|
|
311
|
-
}
|
|
312
|
-
await mkdir(path.dirname(action.target), { recursive: true });
|
|
313
|
-
await copyFile(action.source, action.target);
|
|
314
|
-
await registerDeployment(home, action.target, {
|
|
395
|
+
await replaceFileAtomically(action.target, deps, (tempPath, fileOps) => fileOps.copyFile(action.source, tempPath), () => backupManagedFileWriteTarget(action, home, deps), () => registerDeployment(home, action.target, {
|
|
315
396
|
kind: "file-write",
|
|
316
397
|
source: action.source,
|
|
317
398
|
skill: action.skill,
|
|
318
399
|
agent: action.agent,
|
|
319
|
-
});
|
|
400
|
+
}, deps.registry));
|
|
320
401
|
logger.ok(label);
|
|
321
402
|
if (verbose) {
|
|
322
403
|
logger.detail(`write-file: ${action.source} -> ${action.target}`);
|
|
@@ -329,7 +410,7 @@ async function deployFileWrite(action, dryRun, verbose, home, planned) {
|
|
|
329
410
|
return { error: msg };
|
|
330
411
|
}
|
|
331
412
|
}
|
|
332
|
-
async function deployConfigPatch(action, dryRun, verbose, home, planned) {
|
|
413
|
+
async function deployConfigPatch(action, dryRun, verbose, home, planned, deps) {
|
|
333
414
|
const label = `${action.skill} -> ${action.agent}`;
|
|
334
415
|
if (!isPlainObject(action.patch)) {
|
|
335
416
|
const msg = `Config patch for skill "${action.skill}" must be a plain object`;
|
|
@@ -350,23 +431,30 @@ async function deployConfigPatch(action, dryRun, verbose, home, planned) {
|
|
|
350
431
|
}
|
|
351
432
|
try {
|
|
352
433
|
// Guard against double-patching by a different skill/agent
|
|
353
|
-
const existingEntry = await lookupDeployment(home, action.target);
|
|
434
|
+
const existingEntry = await lookupDeployment(home, action.target, deps.registry);
|
|
354
435
|
if (existingEntry &&
|
|
355
436
|
(existingEntry.skill !== action.skill ||
|
|
356
437
|
existingEntry.agent !== action.agent)) {
|
|
357
438
|
throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" — refusing to double-patch`);
|
|
358
439
|
}
|
|
359
|
-
const original = await
|
|
440
|
+
const original = await readJsonConfigFile(action.target);
|
|
360
441
|
const undoPatch = computeUndoPatch(original, patch);
|
|
361
442
|
const patched = applyMergePatch(original, patch);
|
|
362
|
-
await
|
|
363
|
-
|
|
443
|
+
await replaceFileAtomically(action.target, deps, (tempPath, fileOps) => fileOps.writeFile(tempPath, `${JSON.stringify(patched, null, 2)}\n`, "utf-8"), async () => {
|
|
444
|
+
const backupPath = `${action.target}.inception-backup`;
|
|
445
|
+
await (deps.fileOps ?? defaultDeployFileOps).rm(backupPath, {
|
|
446
|
+
recursive: true,
|
|
447
|
+
force: true,
|
|
448
|
+
});
|
|
449
|
+
await (deps.fileOps ?? defaultDeployFileOps).rename(action.target, backupPath);
|
|
450
|
+
return backupPath;
|
|
451
|
+
}, () => registerDeployment(home, action.target, {
|
|
364
452
|
kind: "config-patch",
|
|
365
453
|
patch,
|
|
366
454
|
undoPatch,
|
|
367
455
|
skill: action.skill,
|
|
368
456
|
agent: action.agent,
|
|
369
|
-
});
|
|
457
|
+
}, deps.registry));
|
|
370
458
|
logger.ok(label);
|
|
371
459
|
if (verbose) {
|
|
372
460
|
logger.detail(`patch-config: applied ${Object.keys(patch).length} key(s) to ${action.target}`);
|
|
@@ -429,38 +517,35 @@ async function assertTargetAbsent(targetPath) {
|
|
|
429
517
|
// ENOENT is expected — target should not exist after backup
|
|
430
518
|
}
|
|
431
519
|
}
|
|
432
|
-
async function createDeployTarget(action, home) {
|
|
433
|
-
|
|
434
|
-
await symlink(action.source, action.target, "dir");
|
|
435
|
-
}
|
|
436
|
-
else {
|
|
437
|
-
await cp(action.source, action.target, { recursive: true });
|
|
438
|
-
}
|
|
520
|
+
async function createDeployTarget(action, home, deps) {
|
|
521
|
+
await (deps.skillDirOps ?? defaultSkillDirOps).createTarget(action);
|
|
439
522
|
await registerDeployment(home, action.target, {
|
|
440
523
|
kind: action.kind,
|
|
441
524
|
source: action.source,
|
|
442
525
|
skill: action.skill,
|
|
443
526
|
agent: action.agent,
|
|
444
527
|
method: action.method,
|
|
445
|
-
});
|
|
528
|
+
}, deps.registry);
|
|
446
529
|
}
|
|
447
|
-
async function executeDeployAction(action, verbose, home) {
|
|
530
|
+
async function executeDeployAction(action, verbose, home, deps) {
|
|
448
531
|
const label = `${action.skill} -> ${action.agent}`;
|
|
449
532
|
const backupPath = await backupExisting(action.target, verbose, home, {
|
|
450
533
|
kind: action.kind,
|
|
451
534
|
source: action.source,
|
|
452
535
|
skill: action.skill,
|
|
453
536
|
agent: action.agent,
|
|
454
|
-
});
|
|
537
|
+
}, deps);
|
|
455
538
|
await mkdir(path.dirname(action.target), { recursive: true });
|
|
456
539
|
try {
|
|
457
540
|
await assertTargetAbsent(action.target);
|
|
458
|
-
await createDeployTarget(action, home);
|
|
541
|
+
await createDeployTarget(action, home, deps);
|
|
459
542
|
}
|
|
460
543
|
catch (createErr) {
|
|
461
544
|
if (backupPath) {
|
|
462
545
|
try {
|
|
463
|
-
await
|
|
546
|
+
await (deps.skillDirOps ?? defaultSkillDirOps)
|
|
547
|
+
.removeTarget(action.target)
|
|
548
|
+
.catch(() => {
|
|
464
549
|
/* best-effort cleanup */
|
|
465
550
|
});
|
|
466
551
|
await rename(backupPath, action.target);
|
|
@@ -472,14 +557,14 @@ async function executeDeployAction(action, verbose, home) {
|
|
|
472
557
|
throw createErr;
|
|
473
558
|
}
|
|
474
559
|
if (backupPath) {
|
|
475
|
-
await removeTarget(backupPath);
|
|
560
|
+
await (deps.skillDirOps ?? defaultSkillDirOps).removeTarget(backupPath);
|
|
476
561
|
}
|
|
477
562
|
logger.ok(label);
|
|
478
563
|
if (verbose) {
|
|
479
564
|
logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
|
|
480
565
|
}
|
|
481
566
|
}
|
|
482
|
-
async function backupExisting(targetPath, verbose, home, expected) {
|
|
567
|
+
async function backupExisting(targetPath, verbose, home, expected, deps) {
|
|
483
568
|
try {
|
|
484
569
|
await lstat(targetPath);
|
|
485
570
|
}
|
|
@@ -491,7 +576,7 @@ async function backupExisting(targetPath, verbose, home, expected) {
|
|
|
491
576
|
source: expected.source,
|
|
492
577
|
skill: expected.skill,
|
|
493
578
|
agent: expected.agent,
|
|
494
|
-
}))) {
|
|
579
|
+
}, deps.registry))) {
|
|
495
580
|
throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
|
|
496
581
|
}
|
|
497
582
|
const backupPath = `${targetPath}.inception-backup`;
|
|
@@ -507,12 +592,3 @@ async function backupExisting(targetPath, verbose, home, expected) {
|
|
|
507
592
|
await rename(targetPath, backupPath);
|
|
508
593
|
return backupPath;
|
|
509
594
|
}
|
|
510
|
-
async function removeTarget(targetPath) {
|
|
511
|
-
const stat = await lstat(targetPath);
|
|
512
|
-
if (stat.isSymbolicLink()) {
|
|
513
|
-
await unlink(targetPath);
|
|
514
|
-
}
|
|
515
|
-
else {
|
|
516
|
-
await rm(targetPath, { recursive: true });
|
|
517
|
-
}
|
|
518
|
-
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AgentId } from "../schemas/manifest.ts";
|
|
2
|
+
export interface InitOptions {
|
|
3
|
+
directory: string;
|
|
4
|
+
agents: AgentId[] | null;
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
force: boolean;
|
|
7
|
+
verbose: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function runInit(options: InitOptions): Promise<number>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { access, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { dryRunPrefix, logger } from "../logger.js";
|
|
4
|
+
import { AGENT_IDS } from "../schemas/manifest.js";
|
|
5
|
+
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
6
|
+
async function findSkillDirs(baseDir, dir, found) {
|
|
7
|
+
let entries;
|
|
8
|
+
try {
|
|
9
|
+
entries = await readdir(dir, { withFileTypes: true, encoding: "utf-8" });
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const hasSkillMd = entries.some((e) => e.isFile() && e.name === "SKILL.md");
|
|
15
|
+
if (hasSkillMd) {
|
|
16
|
+
const relPath = path.relative(baseDir, dir).split(path.sep).join("/");
|
|
17
|
+
const name = path.basename(dir);
|
|
18
|
+
if (SAFE_NAME_RE.test(name)) {
|
|
19
|
+
found.push({ relPath, name });
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
logger.warn("init", `Skipping "${relPath}": directory name "${name}" is not a valid skill name`);
|
|
23
|
+
}
|
|
24
|
+
// Don't recurse into a skill directory
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
29
|
+
await findSkillDirs(baseDir, path.join(dir, entry.name), found);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function resolveSkillName(relPath, name, namesSeen) {
|
|
34
|
+
if (!namesSeen.has(name))
|
|
35
|
+
return name;
|
|
36
|
+
// Collision: derive unique name from path by replacing separators with hyphens
|
|
37
|
+
const derived = relPath.replace(/\//g, "-");
|
|
38
|
+
if (SAFE_NAME_RE.test(derived))
|
|
39
|
+
return derived;
|
|
40
|
+
logger.warn("init", `Skipping "${relPath}": could not generate a unique valid name (collision with "${name}")`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function buildSkills(found, agents) {
|
|
44
|
+
const namesSeen = new Set();
|
|
45
|
+
const skills = [];
|
|
46
|
+
for (const { relPath, name } of found) {
|
|
47
|
+
const skillName = resolveSkillName(relPath, name, namesSeen);
|
|
48
|
+
if (skillName === null)
|
|
49
|
+
continue;
|
|
50
|
+
namesSeen.add(skillName);
|
|
51
|
+
skills.push({ name: skillName, path: relPath, agents });
|
|
52
|
+
}
|
|
53
|
+
return skills;
|
|
54
|
+
}
|
|
55
|
+
async function manifestExists(manifestPath) {
|
|
56
|
+
try {
|
|
57
|
+
await access(manifestPath);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function runInit(options) {
|
|
65
|
+
const { directory, dryRun, force, verbose } = options;
|
|
66
|
+
const agents = options.agents ?? [...AGENT_IDS];
|
|
67
|
+
const manifestPath = path.join(directory, "inception.json");
|
|
68
|
+
if (!dryRun && (await manifestExists(manifestPath)) && !force) {
|
|
69
|
+
logger.error(`Error: ${manifestPath} already exists. Use --force to overwrite.`);
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
const found = [];
|
|
73
|
+
await findSkillDirs(directory, directory, found);
|
|
74
|
+
if (found.length === 0) {
|
|
75
|
+
logger.info("No skill directories found (looking for directories containing SKILL.md).");
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
const skills = buildSkills(found, agents);
|
|
79
|
+
if (skills.length === 0) {
|
|
80
|
+
logger.info("No skills could be added to the manifest.");
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
const manifest = { skills, mcpServers: [], agentRules: [] };
|
|
84
|
+
const json = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
85
|
+
if (dryRun) {
|
|
86
|
+
logger.info(`${dryRunPrefix(true)}Would write ${manifestPath} with ${skills.length} skill(s):`);
|
|
87
|
+
logger.info("");
|
|
88
|
+
logger.info(json);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
await writeFile(manifestPath, json, "utf-8");
|
|
92
|
+
logger.info(`Generated ${manifestPath} with ${skills.length} skill(s).`);
|
|
93
|
+
if (verbose) {
|
|
94
|
+
for (const s of skills) {
|
|
95
|
+
logger.detail(`${s.name} → ${s.path}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
package/dist/core/ownership.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { type ConfigPatchRegistryEntry, type FileWriteRegistryEntry, type RegistryEntry, type SkillDirRegistryEntry } from "../schemas/registry.ts";
|
|
1
|
+
import { type ConfigPatchRegistryEntry, type FileWriteRegistryEntry, type Registry, type RegistryEntry, type SkillDirRegistryEntry } from "../schemas/registry.ts";
|
|
2
2
|
import type { AgentId } from "../types.ts";
|
|
3
3
|
export type { RegistryEntry } from "../schemas/registry.ts";
|
|
4
|
+
export interface RegistryPersistence {
|
|
5
|
+
load(home: string): Promise<Registry>;
|
|
6
|
+
save(home: string, registry: Registry): Promise<void>;
|
|
7
|
+
}
|
|
4
8
|
export type VerifyExpected = {
|
|
5
9
|
kind: "skill-dir";
|
|
6
10
|
source: string;
|
|
@@ -17,8 +21,9 @@ export type VerifyExpected = {
|
|
|
17
21
|
agent: AgentId;
|
|
18
22
|
};
|
|
19
23
|
export declare function registryPath(home: string): string;
|
|
24
|
+
export declare const defaultRegistryPersistence: RegistryPersistence;
|
|
20
25
|
export type RegisterEntry = Omit<SkillDirRegistryEntry, "deployed"> | Omit<FileWriteRegistryEntry, "deployed"> | Omit<ConfigPatchRegistryEntry, "deployed">;
|
|
21
|
-
export declare function registerDeployment(home: string, targetPath: string, entry: RegisterEntry): Promise<void>;
|
|
22
|
-
export declare function unregisterDeployment(home: string, targetPath: string): Promise<void>;
|
|
23
|
-
export declare function lookupDeployment(home: string, targetPath: string): Promise<RegistryEntry | null>;
|
|
24
|
-
export declare function verifyDeployment(home: string, targetPath: string, expected: VerifyExpected): Promise<RegistryEntry | null>;
|
|
26
|
+
export declare function registerDeployment(home: string, targetPath: string, entry: RegisterEntry, persistence?: RegistryPersistence): Promise<void>;
|
|
27
|
+
export declare function unregisterDeployment(home: string, targetPath: string, persistence?: RegistryPersistence): Promise<void>;
|
|
28
|
+
export declare function lookupDeployment(home: string, targetPath: string, persistence?: RegistryPersistence): Promise<RegistryEntry | null>;
|
|
29
|
+
export declare function verifyDeployment(home: string, targetPath: string, expected: VerifyExpected, persistence?: RegistryPersistence): Promise<RegistryEntry | null>;
|
package/dist/core/ownership.js
CHANGED
|
@@ -24,6 +24,10 @@ async function saveRegistry(home, registry) {
|
|
|
24
24
|
await writeFile(filePath, `${JSON.stringify(registry, null, 2)}\n`);
|
|
25
25
|
await setFilePermissions(filePath);
|
|
26
26
|
}
|
|
27
|
+
export const defaultRegistryPersistence = {
|
|
28
|
+
load: loadRegistry,
|
|
29
|
+
save: saveRegistry,
|
|
30
|
+
};
|
|
27
31
|
/**
|
|
28
32
|
* Ensure the file is not world-writable regardless of umask.
|
|
29
33
|
* On Windows, the OS inherits ACLs from the parent directory — no-op is correct.
|
|
@@ -36,27 +40,27 @@ async function setFilePermissions(filePath) {
|
|
|
36
40
|
function emptyRegistry() {
|
|
37
41
|
return { version: 1, deployments: {} };
|
|
38
42
|
}
|
|
39
|
-
export async function registerDeployment(home, targetPath, entry) {
|
|
40
|
-
const registry = await
|
|
43
|
+
export async function registerDeployment(home, targetPath, entry, persistence = defaultRegistryPersistence) {
|
|
44
|
+
const registry = await persistence.load(home);
|
|
41
45
|
registry.deployments[targetPath] = {
|
|
42
46
|
...entry,
|
|
43
47
|
deployed: new Date().toISOString(),
|
|
44
48
|
};
|
|
45
|
-
await
|
|
49
|
+
await persistence.save(home, registry);
|
|
46
50
|
}
|
|
47
|
-
export async function unregisterDeployment(home, targetPath) {
|
|
48
|
-
const registry = await
|
|
51
|
+
export async function unregisterDeployment(home, targetPath, persistence = defaultRegistryPersistence) {
|
|
52
|
+
const registry = await persistence.load(home);
|
|
49
53
|
if (!(targetPath in registry.deployments))
|
|
50
54
|
return;
|
|
51
55
|
delete registry.deployments[targetPath];
|
|
52
|
-
await
|
|
56
|
+
await persistence.save(home, registry);
|
|
53
57
|
}
|
|
54
|
-
export async function lookupDeployment(home, targetPath) {
|
|
55
|
-
const registry = await
|
|
58
|
+
export async function lookupDeployment(home, targetPath, persistence = defaultRegistryPersistence) {
|
|
59
|
+
const registry = await persistence.load(home);
|
|
56
60
|
return registry.deployments[targetPath] ?? null;
|
|
57
61
|
}
|
|
58
|
-
export async function verifyDeployment(home, targetPath, expected) {
|
|
59
|
-
const entry = await lookupDeployment(home, targetPath);
|
|
62
|
+
export async function verifyDeployment(home, targetPath, expected, persistence = defaultRegistryPersistence) {
|
|
63
|
+
const entry = await lookupDeployment(home, targetPath, persistence);
|
|
60
64
|
if (!entry)
|
|
61
65
|
return null;
|
|
62
66
|
if (entry.kind !== expected.kind)
|
package/dist/core/resolve.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
4
|
import { UserError } from "../errors.js";
|
|
5
|
+
import { getPathApi, resolveRuntimePaths } from "./runtime-paths.js";
|
|
6
6
|
export function resolveHome() {
|
|
7
7
|
if (process.platform === "win32") {
|
|
8
8
|
return os.homedir();
|
|
@@ -97,13 +97,12 @@ export function resolveAgentDetectPath(agent, home) {
|
|
|
97
97
|
return resolveAgentDetectPathFor(agent, home, getPlatformKey());
|
|
98
98
|
}
|
|
99
99
|
export function resolvePlaceholders(segments, skillName, home) {
|
|
100
|
-
const appdata
|
|
101
|
-
const xdgRaw = process.env.XDG_CONFIG_HOME;
|
|
102
|
-
const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
|
|
100
|
+
const { appdata, xdgConfig } = resolveRuntimePaths(home);
|
|
103
101
|
const resolved = segments.map((seg) => seg
|
|
104
102
|
.replace("{home}", home)
|
|
105
103
|
.replace("{name}", skillName)
|
|
106
104
|
.replace("{appdata}", appdata)
|
|
107
105
|
.replace("{xdg_config}", xdgConfig));
|
|
108
|
-
|
|
106
|
+
const root = resolved.find((segment) => segment.length > 0) ?? home;
|
|
107
|
+
return getPathApi(root).join(...resolved);
|
|
109
108
|
}
|
package/dist/core/revert.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentId, Manifest, PlannedChange, RevertAction } from "../types.ts";
|
|
2
|
+
import { type RegistryPersistence } from "./ownership.ts";
|
|
2
3
|
export declare function planRevert(manifest: Manifest, detectedAgents: AgentId[], home: string): RevertAction[];
|
|
3
4
|
export declare function planRevertAll(manifest: Manifest, home: string): RevertAction[];
|
|
4
|
-
export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean, home: string): Promise<{
|
|
5
|
+
export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean, home: string, deps?: RevertDependencies): Promise<{
|
|
5
6
|
succeeded: number;
|
|
6
7
|
skipped: number;
|
|
7
8
|
failed: Array<{
|
|
@@ -10,3 +11,7 @@ export declare function executeRevert(actions: RevertAction[], dryRun: boolean,
|
|
|
10
11
|
}>;
|
|
11
12
|
planned: PlannedChange[];
|
|
12
13
|
}>;
|
|
14
|
+
interface RevertDependencies {
|
|
15
|
+
registry?: RegistryPersistence;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
package/dist/core/revert.js
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
2
|
import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
|
|
4
3
|
import { logger } from "../logger.js";
|
|
5
|
-
import {
|
|
6
|
-
import { lookupDeployment, unregisterDeployment } from "./ownership.js";
|
|
4
|
+
import { compileAgentRuleReverts, compileMcpServerReverts, } from "./adapters/index.js";
|
|
5
|
+
import { lookupDeployment, unregisterDeployment, } from "./ownership.js";
|
|
7
6
|
import { resolveAgentSkillPath } from "./resolve.js";
|
|
8
|
-
|
|
9
|
-
const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
10
|
-
const xdgRaw = process.env.XDG_CONFIG_HOME;
|
|
11
|
-
const xdgConfig = xdgRaw && path.isAbsolute(xdgRaw) ? xdgRaw : path.join(home, ".config");
|
|
12
|
-
return template
|
|
13
|
-
.replace("{home}", home)
|
|
14
|
-
.replace("{appdata}", appdata)
|
|
15
|
-
.replace("{xdg_config}", xdgConfig);
|
|
16
|
-
}
|
|
7
|
+
import { resolveTargetTemplate } from "./runtime-paths.js";
|
|
17
8
|
function buildSkillDirReverts(manifest, home, agentFilter) {
|
|
18
9
|
const actions = [];
|
|
19
10
|
for (const skill of manifest.skills) {
|
|
@@ -139,7 +130,7 @@ function lstatOutcome(err) {
|
|
|
139
130
|
const msg = err instanceof Error ? err.message : String(err);
|
|
140
131
|
return { outcome: "fail", error: msg };
|
|
141
132
|
}
|
|
142
|
-
export async function executeRevert(actions, dryRun, verbose, home) {
|
|
133
|
+
export async function executeRevert(actions, dryRun, verbose, home, deps = {}) {
|
|
143
134
|
let succeeded = 0;
|
|
144
135
|
let skipped = 0;
|
|
145
136
|
const failed = [];
|
|
@@ -149,13 +140,13 @@ export async function executeRevert(actions, dryRun, verbose, home) {
|
|
|
149
140
|
let result;
|
|
150
141
|
switch (action.kind) {
|
|
151
142
|
case "skill-dir":
|
|
152
|
-
result = await executeRevertAction(action, dryRun, verbose, home, planned);
|
|
143
|
+
result = await executeRevertAction(action, dryRun, verbose, home, planned, deps);
|
|
153
144
|
break;
|
|
154
145
|
case "file-write":
|
|
155
|
-
result = await revertFileWrite(action, dryRun, verbose, home, planned);
|
|
146
|
+
result = await revertFileWrite(action, dryRun, verbose, home, planned, deps);
|
|
156
147
|
break;
|
|
157
148
|
case "config-patch":
|
|
158
|
-
result = await revertConfigPatch(action, dryRun, verbose, home, planned);
|
|
149
|
+
result = await revertConfigPatch(action, dryRun, verbose, home, planned, deps);
|
|
159
150
|
break;
|
|
160
151
|
default:
|
|
161
152
|
throw new Error(`Unhandled revert action kind: ${action}`);
|
|
@@ -166,7 +157,7 @@ export async function executeRevert(actions, dryRun, verbose, home) {
|
|
|
166
157
|
skipped = counts.skipped;
|
|
167
158
|
return { succeeded, skipped, failed, planned };
|
|
168
159
|
}
|
|
169
|
-
async function executeRevertAction(action, dryRun, verbose, home, planned) {
|
|
160
|
+
async function executeRevertAction(action, dryRun, verbose, home, planned, deps) {
|
|
170
161
|
const label = `${action.skill} -> ${action.agent}`;
|
|
171
162
|
try {
|
|
172
163
|
await lstat(action.target);
|
|
@@ -180,7 +171,7 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
|
|
|
180
171
|
logger.fail(label, result.error);
|
|
181
172
|
return result;
|
|
182
173
|
}
|
|
183
|
-
const entry = await lookupDeployment(home, action.target);
|
|
174
|
+
const entry = await lookupDeployment(home, action.target, deps.registry);
|
|
184
175
|
if (!entry || entry.skill !== action.skill || entry.agent !== action.agent) {
|
|
185
176
|
logger.warn(label, `skipping: ${action.target} is not in the deployment registry — not managed by inception-engine`);
|
|
186
177
|
return { outcome: "skip" };
|
|
@@ -205,7 +196,7 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
|
|
|
205
196
|
else {
|
|
206
197
|
await rm(action.target, { recursive: true });
|
|
207
198
|
}
|
|
208
|
-
await unregisterDeployment(home, action.target);
|
|
199
|
+
await unregisterDeployment(home, action.target, deps.registry);
|
|
209
200
|
logger.ok(label);
|
|
210
201
|
if (verbose) {
|
|
211
202
|
logger.detail(`removed: ${action.target}`);
|
|
@@ -223,7 +214,7 @@ async function executeRevertAction(action, dryRun, verbose, home, planned) {
|
|
|
223
214
|
return { outcome: "fail", error: msg };
|
|
224
215
|
}
|
|
225
216
|
}
|
|
226
|
-
async function revertFileWrite(action, dryRun, verbose, home, planned) {
|
|
217
|
+
async function revertFileWrite(action, dryRun, verbose, home, planned, deps) {
|
|
227
218
|
const label = `${action.skill} -> ${action.agent}`;
|
|
228
219
|
try {
|
|
229
220
|
await lstat(action.target);
|
|
@@ -237,7 +228,7 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
|
|
|
237
228
|
logger.fail(label, result.error);
|
|
238
229
|
return result;
|
|
239
230
|
}
|
|
240
|
-
const entry = await lookupDeployment(home, action.target);
|
|
231
|
+
const entry = await lookupDeployment(home, action.target, deps.registry);
|
|
241
232
|
if (!entry || entry.skill !== action.skill || entry.agent !== action.agent) {
|
|
242
233
|
logger.warn(label, `skipping: ${action.target} is not in the deployment registry — not managed by inception-engine`);
|
|
243
234
|
return { outcome: "skip" };
|
|
@@ -257,7 +248,7 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
|
|
|
257
248
|
// removal window; handle the case where the file has since disappeared.
|
|
258
249
|
await lstat(action.target);
|
|
259
250
|
await unlink(action.target);
|
|
260
|
-
await unregisterDeployment(home, action.target);
|
|
251
|
+
await unregisterDeployment(home, action.target, deps.registry);
|
|
261
252
|
logger.ok(label);
|
|
262
253
|
if (verbose) {
|
|
263
254
|
logger.detail(`removed: ${action.target}`);
|
|
@@ -274,7 +265,7 @@ async function revertFileWrite(action, dryRun, verbose, home, planned) {
|
|
|
274
265
|
return { outcome: "fail", error: msg };
|
|
275
266
|
}
|
|
276
267
|
}
|
|
277
|
-
async function revertConfigPatch(action, dryRun, verbose, home, planned) {
|
|
268
|
+
async function revertConfigPatch(action, dryRun, verbose, home, planned, deps) {
|
|
278
269
|
const label = `${action.skill} -> ${action.agent}`;
|
|
279
270
|
try {
|
|
280
271
|
await lstat(action.target);
|
|
@@ -288,7 +279,7 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned) {
|
|
|
288
279
|
logger.fail(label, result.error);
|
|
289
280
|
return result;
|
|
290
281
|
}
|
|
291
|
-
const entry = await lookupDeployment(home, action.target);
|
|
282
|
+
const entry = await lookupDeployment(home, action.target, deps.registry);
|
|
292
283
|
if (!entry ||
|
|
293
284
|
entry.kind !== "config-patch" ||
|
|
294
285
|
entry.skill !== action.skill ||
|
|
@@ -312,7 +303,7 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned) {
|
|
|
312
303
|
const current = await readJsonConfig(action.target);
|
|
313
304
|
const restored = applyUndoPatch(current, configPatchEntry.undoPatch);
|
|
314
305
|
await writeFile(action.target, `${JSON.stringify(restored, null, 2)}\n`, "utf-8");
|
|
315
|
-
await unregisterDeployment(home, action.target);
|
|
306
|
+
await unregisterDeployment(home, action.target, deps.registry);
|
|
316
307
|
logger.ok(label);
|
|
317
308
|
if (verbose) {
|
|
318
309
|
logger.detail(`unapplied patch from: ${action.target}`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export interface RuntimePaths {
|
|
3
|
+
appdata: string;
|
|
4
|
+
xdgConfig: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function getPathApi(root: string): typeof path.posix | typeof path.win32;
|
|
7
|
+
export declare function resolveRuntimePaths(home: string): RuntimePaths;
|
|
8
|
+
export declare function resolveTargetTemplate(template: string, home: string): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const TARGET_TEMPLATE_RE = /^\{(home|appdata|xdg_config)\}(?<suffix>(?:[\\/].*)?)$/;
|
|
3
|
+
export function getPathApi(root) {
|
|
4
|
+
if (root.includes("\\") || /^[a-zA-Z]:/.test(root)) {
|
|
5
|
+
return path.win32;
|
|
6
|
+
}
|
|
7
|
+
if (root.startsWith("/")) {
|
|
8
|
+
return path.posix;
|
|
9
|
+
}
|
|
10
|
+
return process.platform === "win32" ? path.win32 : path.posix;
|
|
11
|
+
}
|
|
12
|
+
function normalizePathForComparison(candidate, pathApi) {
|
|
13
|
+
const normalized = pathApi.normalize(candidate);
|
|
14
|
+
return pathApi === path.win32 ? normalized.toLowerCase() : normalized;
|
|
15
|
+
}
|
|
16
|
+
function isSameOrDescendantPath(candidate, root) {
|
|
17
|
+
const pathApi = getPathApi(root);
|
|
18
|
+
const normalizedCandidate = normalizePathForComparison(candidate, pathApi);
|
|
19
|
+
const normalizedRoot = normalizePathForComparison(root, pathApi);
|
|
20
|
+
return (normalizedCandidate === normalizedRoot ||
|
|
21
|
+
normalizedCandidate.startsWith(normalizedRoot + pathApi.sep));
|
|
22
|
+
}
|
|
23
|
+
export function resolveRuntimePaths(home) {
|
|
24
|
+
const appdataRaw = process.env.APPDATA;
|
|
25
|
+
const homePathApi = getPathApi(home);
|
|
26
|
+
const appdata = appdataRaw && getPathApi(appdataRaw).isAbsolute(appdataRaw)
|
|
27
|
+
? appdataRaw
|
|
28
|
+
: homePathApi.join(home, "AppData", "Roaming");
|
|
29
|
+
const xdgRaw = process.env.XDG_CONFIG_HOME;
|
|
30
|
+
const xdgConfig = xdgRaw && getPathApi(xdgRaw).isAbsolute(xdgRaw)
|
|
31
|
+
? xdgRaw
|
|
32
|
+
: homePathApi.join(home, ".config");
|
|
33
|
+
return { appdata, xdgConfig };
|
|
34
|
+
}
|
|
35
|
+
export function resolveTargetTemplate(template, home) {
|
|
36
|
+
const { appdata, xdgConfig } = resolveRuntimePaths(home);
|
|
37
|
+
const match = TARGET_TEMPLATE_RE.exec(template);
|
|
38
|
+
if (!match) {
|
|
39
|
+
throw new Error(`Invalid target template: ${template}`);
|
|
40
|
+
}
|
|
41
|
+
const baseByRoot = {
|
|
42
|
+
home,
|
|
43
|
+
appdata,
|
|
44
|
+
xdg_config: xdgConfig,
|
|
45
|
+
};
|
|
46
|
+
const root = match[1];
|
|
47
|
+
const suffix = match.groups?.suffix ?? "";
|
|
48
|
+
const segments = suffix.split(/[\\/]+/).filter(Boolean);
|
|
49
|
+
const pathApi = getPathApi(baseByRoot[root]);
|
|
50
|
+
const resolved = segments.length === 0
|
|
51
|
+
? baseByRoot[root]
|
|
52
|
+
: pathApi.join(baseByRoot[root], ...segments);
|
|
53
|
+
if (!isSameOrDescendantPath(resolved, baseByRoot[root])) {
|
|
54
|
+
throw new Error(`Target template resolves outside its placeholder root: ${template}`);
|
|
55
|
+
}
|
|
56
|
+
return suffix === "" ? baseByRoot[root] : `${baseByRoot[root]}${suffix}`;
|
|
57
|
+
}
|
package/dist/core/validation.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, realpath } from "node:fs/promises";
|
|
1
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { UserError } from "../errors.js";
|
|
4
4
|
export function sourceAccessError(err, sourcePath) {
|
|
@@ -10,14 +10,39 @@ export function sourceAccessError(err, sourcePath) {
|
|
|
10
10
|
const detail = err instanceof Error ? err.message : String(err);
|
|
11
11
|
return `Failed to access source ${sourcePath}: ${detail}`;
|
|
12
12
|
}
|
|
13
|
+
function normalizePathForComparison(candidate) {
|
|
14
|
+
const normalized = path.normalize(candidate);
|
|
15
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
16
|
+
}
|
|
17
|
+
function isSameOrDescendantPath(candidate, root) {
|
|
18
|
+
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
19
|
+
const normalizedRoot = normalizePathForComparison(root);
|
|
20
|
+
return (normalizedCandidate === normalizedRoot ||
|
|
21
|
+
normalizedCandidate.startsWith(normalizedRoot + path.sep));
|
|
22
|
+
}
|
|
23
|
+
async function isSameFileSystemLocation(a, b) {
|
|
24
|
+
const [aStat, bStat] = await Promise.all([stat(a), stat(b)]);
|
|
25
|
+
return aStat.dev === bStat.dev && aStat.ino === bStat.ino;
|
|
26
|
+
}
|
|
27
|
+
async function isWithinRootByIdentity(candidate, root) {
|
|
28
|
+
let current = candidate;
|
|
29
|
+
while (true) {
|
|
30
|
+
if (await isSameFileSystemLocation(current, root))
|
|
31
|
+
return true;
|
|
32
|
+
const parent = path.dirname(current);
|
|
33
|
+
if (parent === current)
|
|
34
|
+
return false;
|
|
35
|
+
current = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
13
38
|
export async function validateSourcePath(source, skillPath, resolvedSourceDir, realRoot) {
|
|
14
39
|
if (!source.startsWith(resolvedSourceDir + path.sep)) {
|
|
15
40
|
throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root: ${source}`);
|
|
16
41
|
}
|
|
17
42
|
try {
|
|
18
43
|
const realSource = await realpath(source);
|
|
19
|
-
if (realSource
|
|
20
|
-
|
|
44
|
+
if (!(isSameOrDescendantPath(realSource, realRoot) ||
|
|
45
|
+
(await isWithinRootByIdentity(realSource, realRoot)))) {
|
|
21
46
|
throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root via symlink: ${source} -> ${realSource}`);
|
|
22
47
|
}
|
|
23
48
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { AGENT_REGISTRY } from "./config/agents.js";
|
|
|
5
5
|
import { loadManifest } from "./config/manifest.js";
|
|
6
6
|
import { executeDeploy, planDeploy } from "./core/deploy.js";
|
|
7
7
|
import { detectInstalledAgents } from "./core/detect.js";
|
|
8
|
+
import { runInit } from "./core/init.js";
|
|
8
9
|
import { runPreflight } from "./core/preflight.js";
|
|
9
10
|
import { resolveHome } from "./core/resolve.js";
|
|
10
11
|
import { executeRevert, planRevert, planRevertAll } from "./core/revert.js";
|
|
@@ -17,10 +18,17 @@ inception-engine - Deploy AI agent skills
|
|
|
17
18
|
Usage:
|
|
18
19
|
inception-engine <directory> [options]
|
|
19
20
|
inception-engine revert <directory> [options]
|
|
21
|
+
inception-engine init <directory> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
<directory> Deploy skills from the manifest in the given directory
|
|
25
|
+
revert <directory> Remove previously deployed skills
|
|
26
|
+
init <directory> Scan a directory for skill folders and generate inception.json
|
|
20
27
|
|
|
21
28
|
Options:
|
|
22
29
|
--dry-run Show what would be done without doing it
|
|
23
30
|
--agents <list> Comma-separated list of agent IDs to target
|
|
31
|
+
--force (init only) Overwrite an existing inception.json
|
|
24
32
|
--verbose Show detailed output
|
|
25
33
|
--debug Show full error stack traces
|
|
26
34
|
--help Show this help message
|
|
@@ -38,6 +46,7 @@ function parseCLI(argv) {
|
|
|
38
46
|
agents: null,
|
|
39
47
|
verbose: false,
|
|
40
48
|
debug: false,
|
|
49
|
+
force: false,
|
|
41
50
|
};
|
|
42
51
|
}
|
|
43
52
|
let parsed;
|
|
@@ -50,6 +59,7 @@ function parseCLI(argv) {
|
|
|
50
59
|
verbose: { type: "boolean", default: false },
|
|
51
60
|
debug: { type: "boolean", default: false },
|
|
52
61
|
help: { type: "boolean", default: false },
|
|
62
|
+
force: { type: "boolean", default: false },
|
|
53
63
|
agents: { type: "string" },
|
|
54
64
|
},
|
|
55
65
|
});
|
|
@@ -66,6 +76,7 @@ function parseCLI(argv) {
|
|
|
66
76
|
agents: null,
|
|
67
77
|
verbose: false,
|
|
68
78
|
debug: false,
|
|
79
|
+
force: false,
|
|
69
80
|
};
|
|
70
81
|
}
|
|
71
82
|
let command = "deploy";
|
|
@@ -74,6 +85,10 @@ function parseCLI(argv) {
|
|
|
74
85
|
command = "revert";
|
|
75
86
|
pos = pos.slice(1);
|
|
76
87
|
}
|
|
88
|
+
else if (pos[0] === "init") {
|
|
89
|
+
command = "init";
|
|
90
|
+
pos = pos.slice(1);
|
|
91
|
+
}
|
|
77
92
|
if (pos.length > 1) {
|
|
78
93
|
throw new UserError("INVALID_ARGS", `Unexpected argument: ${pos[1]}`);
|
|
79
94
|
}
|
|
@@ -96,6 +111,7 @@ function parseCLI(argv) {
|
|
|
96
111
|
agents,
|
|
97
112
|
verbose: values.verbose,
|
|
98
113
|
debug: values.debug,
|
|
114
|
+
force: values.force,
|
|
99
115
|
};
|
|
100
116
|
}
|
|
101
117
|
async function main() {
|
|
@@ -104,6 +120,15 @@ async function main() {
|
|
|
104
120
|
console.log(USAGE);
|
|
105
121
|
return 0;
|
|
106
122
|
}
|
|
123
|
+
if (options.command === "init") {
|
|
124
|
+
return runInit({
|
|
125
|
+
directory: options.directory,
|
|
126
|
+
agents: options.agents,
|
|
127
|
+
dryRun: options.dryRun,
|
|
128
|
+
force: options.force,
|
|
129
|
+
verbose: options.verbose,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
107
132
|
const manifest = await loadManifest(options.directory);
|
|
108
133
|
const home = resolveHome();
|
|
109
134
|
if (options.command === "deploy") {
|
package/dist/schemas/manifest.js
CHANGED
|
@@ -10,9 +10,10 @@ const AGENT_IDS = [
|
|
|
10
10
|
];
|
|
11
11
|
export { AGENT_IDS };
|
|
12
12
|
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
13
|
-
// Target templates must
|
|
14
|
-
//
|
|
15
|
-
|
|
13
|
+
// Target templates must be rooted at a known placeholder and may only add
|
|
14
|
+
// descendant path segments beneath that root. e.g. "{home}/.claude/settings.json"
|
|
15
|
+
// is valid, while "{home}/../.ssh/config" is rejected.
|
|
16
|
+
const TARGET_TEMPLATE_RE = /^\{(home|appdata|xdg_config)\}(?:[\\/].*)?$/;
|
|
16
17
|
// Standalone schema used for type derivation and single-ID validation (e.g. index.ts).
|
|
17
18
|
export const AgentIdSchema = z.enum(AGENT_IDS);
|
|
18
19
|
// Used inside SkillEntrySchema.agents so that enum failures embed the received
|
|
@@ -53,6 +54,9 @@ const targetTemplateField = z
|
|
|
53
54
|
.min(1, { message: "target must be a non-empty string" })
|
|
54
55
|
.refine((t) => TARGET_TEMPLATE_RE.test(t), {
|
|
55
56
|
message: "target must start with a known placeholder: {home}, {appdata}, or {xdg_config}",
|
|
57
|
+
})
|
|
58
|
+
.refine((t) => !t.split(/[\\/]+/).includes(".."), {
|
|
59
|
+
message: "target must not escape its placeholder root",
|
|
56
60
|
});
|
|
57
61
|
export const SkillEntrySchema = z.object({
|
|
58
62
|
name: nameField,
|
package/dist/types.d.ts
CHANGED
|
@@ -85,10 +85,11 @@ export interface PlannedChange {
|
|
|
85
85
|
confidence?: Confidence;
|
|
86
86
|
}
|
|
87
87
|
export interface CliOptions {
|
|
88
|
-
command: "deploy" | "revert" | "help";
|
|
88
|
+
command: "deploy" | "revert" | "init" | "help";
|
|
89
89
|
directory: string;
|
|
90
90
|
dryRun: boolean;
|
|
91
91
|
agents: AgentId[] | null;
|
|
92
92
|
verbose: boolean;
|
|
93
93
|
debug: boolean;
|
|
94
|
+
force: boolean;
|
|
94
95
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuznai/inception-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -38,11 +38,13 @@
|
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
39
|
"fmt": "biome format --write .",
|
|
40
40
|
"lint": "biome lint . --max-diagnostics none",
|
|
41
|
+
"lint:fix": "biome lint . --max-diagnostics none --write --unsafe",
|
|
41
42
|
"predev": "node scripts/assert-direct-ts-node.mjs",
|
|
42
43
|
"dev": "node src/index.ts",
|
|
43
44
|
"pretest": "node scripts/assert-direct-ts-node.mjs",
|
|
44
|
-
"test": "node --test test
|
|
45
|
-
"
|
|
45
|
+
"test": "node --test --test-isolation=none test/**/*.test.ts",
|
|
46
|
+
"test:windows": "node --test --test-isolation=none test/unit/*.test.ts test/os/cross-platform/*.test.ts test/os/windows/*.test.ts",
|
|
47
|
+
"test:posix": "node --test --test-isolation=none test/unit/*.test.ts test/os/cross-platform/*.test.ts test/os/posix/*.test.ts"
|
|
46
48
|
},
|
|
47
49
|
"dependencies": {
|
|
48
50
|
"zod": "^4.0.0"
|