@kuznai/inception-engine 0.11.1 → 0.13.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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Reads and parses a TOML file. Returns an empty object if the file does not
3
+ * exist (treat a missing config.toml as an empty config).
4
+ */
5
+ export declare function readTomlConfig(filePath: string): Promise<Record<string, unknown>>;
6
+ /**
7
+ * Merges a named MCP server entry into `config.toml`'s `[mcpServers]` table.
8
+ * Returns the undo record (the previous value under that key, or null if it
9
+ * was absent) so revert can restore the exact prior state.
10
+ */
11
+ export declare function applyTomlMcpPatch(filePath: string, name: string, config: Record<string, unknown>): Promise<{
12
+ previousValue: unknown | null;
13
+ }>;
14
+ /**
15
+ * Removes a named MCP server entry from `config.toml`'s `[mcpServers]` table.
16
+ * If the entry is absent, this is a no-op. If the table becomes empty after
17
+ * removal, the `mcpServers` key itself is removed from the document.
18
+ */
19
+ export declare function revertTomlMcpPatch(filePath: string, name: string): Promise<void>;
@@ -0,0 +1,96 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse, stringify } from "smol-toml";
4
+ /**
5
+ * Reads and parses a TOML file. Returns an empty object if the file does not
6
+ * exist (treat a missing config.toml as an empty config).
7
+ */
8
+ export async function readTomlConfig(filePath) {
9
+ let raw;
10
+ try {
11
+ raw = await readFile(filePath, "utf-8");
12
+ }
13
+ catch (err) {
14
+ const code = err.code;
15
+ if (code === "ENOENT")
16
+ return {};
17
+ throw err;
18
+ }
19
+ const parsed = parse(raw);
20
+ return parsed;
21
+ }
22
+ function createAtomicTempPath(targetPath) {
23
+ return `${targetPath}.inception-tmp-${process.pid}-${Date.now()}-${Math.random()
24
+ .toString(36)
25
+ .slice(2)}`;
26
+ }
27
+ async function writeTomlConfigAtomic(filePath, obj) {
28
+ const tempPath = createAtomicTempPath(filePath);
29
+ try {
30
+ await mkdir(path.dirname(filePath), { recursive: true });
31
+ await writeFile(tempPath, stringify(obj), "utf-8");
32
+ await rename(tempPath, filePath);
33
+ }
34
+ catch (err) {
35
+ try {
36
+ await rm(tempPath, { force: true });
37
+ }
38
+ catch {
39
+ /* best-effort cleanup */
40
+ }
41
+ throw err;
42
+ }
43
+ }
44
+ /**
45
+ * Merges a named MCP server entry into `config.toml`'s `[mcpServers]` table.
46
+ * Returns the undo record (the previous value under that key, or null if it
47
+ * was absent) so revert can restore the exact prior state.
48
+ */
49
+ export async function applyTomlMcpPatch(filePath, name, config) {
50
+ const current = await readTomlConfig(filePath);
51
+ const mcpServers = typeof current.mcpServers === "object" &&
52
+ current.mcpServers !== null &&
53
+ !Array.isArray(current.mcpServers)
54
+ ? current.mcpServers
55
+ : {};
56
+ const previousValue = Object.hasOwn(mcpServers, name)
57
+ ? mcpServers[name]
58
+ : null;
59
+ const patched = {
60
+ ...current,
61
+ mcpServers: { ...mcpServers, [name]: config },
62
+ };
63
+ await writeTomlConfigAtomic(filePath, patched);
64
+ return { previousValue };
65
+ }
66
+ /**
67
+ * Removes a named MCP server entry from `config.toml`'s `[mcpServers]` table.
68
+ * If the entry is absent, this is a no-op. If the table becomes empty after
69
+ * removal, the `mcpServers` key itself is removed from the document.
70
+ */
71
+ export async function revertTomlMcpPatch(filePath, name) {
72
+ let current;
73
+ try {
74
+ current = await readTomlConfig(filePath);
75
+ }
76
+ catch {
77
+ // File gone — nothing to revert.
78
+ return;
79
+ }
80
+ const mcpServers = typeof current.mcpServers === "object" &&
81
+ current.mcpServers !== null &&
82
+ !Array.isArray(current.mcpServers)
83
+ ? current.mcpServers
84
+ : {};
85
+ if (!Object.hasOwn(mcpServers, name))
86
+ return; // already absent
87
+ const { [name]: _removed, ...remainingServers } = mcpServers;
88
+ const reverted = { ...current };
89
+ if (Object.keys(remainingServers).length === 0) {
90
+ delete reverted.mcpServers;
91
+ }
92
+ else {
93
+ reverted.mcpServers = remainingServers;
94
+ }
95
+ await writeTomlConfigAtomic(filePath, reverted);
96
+ }
@@ -4,11 +4,13 @@ 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 { writeFrontmatterFile } from "./adapters/frontmatter.js";
7
8
  import { compileAdapterActions } from "./adapters/index.js";
9
+ import { applyTomlMcpPatch } from "./adapters/toml.js";
8
10
  import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
9
11
  import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
10
12
  import { resolveTargetTemplate } from "./runtime-paths.js";
11
- import { sourceAccessError, validateSourceFile, validateSourcePath, } from "./validation.js";
13
+ import { sourceAccessError, validateSkillDefinitionFile, validateSourceFile, validateSourcePath, } from "./validation.js";
12
14
  function isPlainObject(v) {
13
15
  return typeof v === "object" && v !== null && !Array.isArray(v);
14
16
  }
@@ -80,14 +82,20 @@ function detectCollisions(actions) {
80
82
  }
81
83
  return warnings;
82
84
  }
83
- function detectAmbiguities(detectedAgents) {
85
+ function detectAmbiguities(detectedAgents, actions, home) {
84
86
  const warnings = [];
85
- if (detectedAgents.includes("gemini-cli") &&
86
- detectedAgents.includes("antigravity")) {
87
- warnings.push({
88
- kind: "ambiguity",
89
- 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.',
90
- });
87
+ const hasGeminiCli = detectedAgents.includes("gemini-cli");
88
+ const hasAntigravity = detectedAgents.includes("antigravity");
89
+ if (hasGeminiCli && hasAntigravity) {
90
+ const sharedGeminiMd = path.resolve(home, ".gemini", "GEMINI.md");
91
+ const sharedSettings = path.resolve(home, ".gemini", "settings.json");
92
+ const targetsShared = actions.some((a) => a.target === sharedGeminiMd || a.target === sharedSettings);
93
+ if (targetsShared) {
94
+ warnings.push({
95
+ kind: "ambiguity",
96
+ message: "Both 'gemini-cli' and 'antigravity' are active, and a deployment targets a shared surface (~/.gemini/GEMINI.md or settings.json). Because Antigravity treats GEMINI.md as an Agent Blueprint, changes intended for one runtime may unexpectedly affect the other.",
97
+ });
98
+ }
91
99
  }
92
100
  return warnings;
93
101
  }
@@ -143,7 +151,7 @@ async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, real
143
151
  }
144
152
  return actions;
145
153
  }
146
- function planConfigPatchActions(manifest, detectedAgents, home) {
154
+ function planConfigPatchActions(manifest, detectedAgents, home, repo) {
147
155
  const actions = [];
148
156
  for (const configEntry of manifest.configs ?? []) {
149
157
  for (const agentId of configEntry.agents) {
@@ -156,7 +164,7 @@ function planConfigPatchActions(manifest, detectedAgents, home) {
156
164
  kind: "config-patch",
157
165
  skill: configEntry.name,
158
166
  agent: agentId,
159
- target: resolveTargetTemplate(configEntry.target, home),
167
+ target: resolveTargetTemplate(configEntry.target, home, repo),
160
168
  patch: configEntry.patch,
161
169
  confidence: agent.provenance.skills,
162
170
  });
@@ -176,56 +184,44 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
176
184
  const actions = [
177
185
  ...(await planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home)),
178
186
  ...(await planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home)),
179
- ...planConfigPatchActions(manifest, detectedAgents, home),
187
+ ...planConfigPatchActions(manifest, detectedAgents, home, resolvedSourceDir),
180
188
  ];
181
- const adapterResult = await compileAdapterActions(manifest.mcpServers, manifest.agentRules, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home);
189
+ const adapterResult = await compileAdapterActions(manifest.mcpServers, manifest.agentRules, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, resolvedSourceDir);
182
190
  actions.push(...adapterResult.actions);
183
191
  const warnings = [
184
- ...detectAmbiguities(detectedAgents),
192
+ ...detectAmbiguities(detectedAgents, actions, home),
185
193
  ...detectCollisions(actions),
186
194
  ...adapterResult.warnings,
187
195
  ];
188
196
  return { actions, warnings };
189
197
  }
198
+ async function dispatchDeployAction(action, dryRun, verbose, home, planned, deps) {
199
+ switch (action.kind) {
200
+ case "skill-dir":
201
+ return deploySkillDir(action, dryRun, verbose, home, planned, deps);
202
+ case "file-write":
203
+ return deployFileWrite(action, dryRun, verbose, home, planned, deps);
204
+ case "config-patch":
205
+ return deployConfigPatch(action, dryRun, verbose, home, planned, deps);
206
+ case "toml-patch":
207
+ return deployTomlPatch(action, dryRun, verbose, home, planned, deps);
208
+ case "frontmatter-emit":
209
+ return deployFrontmatterEmit(action, dryRun, verbose, home, planned, deps);
210
+ default:
211
+ throw new Error(`Unhandled deploy action kind: ${action.kind}`);
212
+ }
213
+ }
190
214
  export async function executeDeploy(actions, dryRun, verbose, home, deps = {}) {
191
215
  let succeeded = 0;
192
216
  const failed = [];
193
217
  const planned = [];
194
218
  for (const action of actions) {
195
- switch (action.kind) {
196
- case "skill-dir": {
197
- const result = await deploySkillDir(action, dryRun, verbose, home, planned, deps);
198
- if (result.error === null) {
199
- succeeded++;
200
- }
201
- else {
202
- failed.push({ action, error: result.error });
203
- }
204
- break;
205
- }
206
- case "file-write": {
207
- const result = await deployFileWrite(action, dryRun, verbose, home, planned, deps);
208
- if (result.error === null) {
209
- succeeded++;
210
- }
211
- else {
212
- failed.push({ action, error: result.error });
213
- }
214
- break;
215
- }
216
- case "config-patch": {
217
- const result = await deployConfigPatch(action, dryRun, verbose, home, planned, deps);
218
- if (result.error === null) {
219
- succeeded++;
220
- }
221
- else {
222
- failed.push({ action, error: result.error });
223
- }
224
- break;
225
- }
226
- default: {
227
- throw new Error(`Unhandled deploy action kind: ${action}`);
228
- }
219
+ const result = await dispatchDeployAction(action, dryRun, verbose, home, planned, deps);
220
+ if (result.error === null) {
221
+ succeeded++;
222
+ }
223
+ else {
224
+ failed.push({ action, error: result.error });
229
225
  }
230
226
  }
231
227
  return { succeeded, failed, planned };
@@ -505,6 +501,7 @@ async function validateSkillContract(source, skillPath) {
505
501
  }
506
502
  throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is missing SKILL.md: ${source}`);
507
503
  }
504
+ await validateSkillDefinitionFile(path.join(source, "SKILL.md"), skillPath);
508
505
  }
509
506
  async function assertTargetAbsent(targetPath) {
510
507
  try {
@@ -592,3 +589,87 @@ async function backupExisting(targetPath, verbose, home, expected, deps) {
592
589
  await rename(targetPath, backupPath);
593
590
  return backupPath;
594
591
  }
592
+ async function deployTomlPatch(action, dryRun, verbose, home, planned, deps) {
593
+ const label = `${action.skill} -> ${action.agent}`;
594
+ if (dryRun) {
595
+ planned.push({
596
+ verb: "patch-toml",
597
+ kind: "toml-patch",
598
+ skill: action.skill,
599
+ agent: action.agent,
600
+ target: action.target,
601
+ confidence: action.confidence,
602
+ });
603
+ return { error: null };
604
+ }
605
+ try {
606
+ // Guard against double-patching by a different skill/agent.
607
+ const existingEntry = await lookupDeployment(home, action.target, deps.registry);
608
+ if (existingEntry &&
609
+ (existingEntry.skill !== action.skill ||
610
+ existingEntry.agent !== action.agent)) {
611
+ throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" — refusing to double-patch`);
612
+ }
613
+ await applyTomlMcpPatch(action.target, action.skill, action.config);
614
+ await registerDeployment(home, action.target, {
615
+ kind: "config-patch",
616
+ patch: { mcpServers: { [action.skill]: action.config } },
617
+ undoPatch: { mcpServers: { [action.skill]: null } },
618
+ skill: action.skill,
619
+ agent: action.agent,
620
+ }, deps.registry);
621
+ logger.ok(label);
622
+ if (verbose) {
623
+ logger.detail(`patch-toml: wrote [mcpServers.${action.skill}] to ${action.target}`);
624
+ }
625
+ return { error: null };
626
+ }
627
+ catch (err) {
628
+ const msg = err instanceof Error ? err.message : String(err);
629
+ logger.fail(label, msg);
630
+ return { error: msg };
631
+ }
632
+ }
633
+ async function deployFrontmatterEmit(action, dryRun, verbose, home, planned, deps) {
634
+ const label = `${action.skill} -> ${action.agent}`;
635
+ if (dryRun) {
636
+ planned.push({
637
+ verb: "emit-frontmatter",
638
+ kind: "frontmatter-emit",
639
+ skill: action.skill,
640
+ agent: action.agent,
641
+ target: action.target,
642
+ frontmatter: action.frontmatter,
643
+ confidence: action.confidence,
644
+ });
645
+ return { error: null };
646
+ }
647
+ try {
648
+ // Guard against double-patching by a different skill/agent.
649
+ const existingEntry = await lookupDeployment(home, action.target, deps.registry);
650
+ if (existingEntry &&
651
+ (existingEntry.skill !== action.skill ||
652
+ existingEntry.agent !== action.agent)) {
653
+ throw new Error(`File "${action.target}" is already managed by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" — refusing to overwrite`);
654
+ }
655
+ await writeFrontmatterFile(action.target, action.frontmatter, {
656
+ preserveBody: true,
657
+ });
658
+ await registerDeployment(home, action.target, {
659
+ kind: "file-write",
660
+ source: action.target,
661
+ skill: action.skill,
662
+ agent: action.agent,
663
+ }, deps.registry);
664
+ logger.ok(label);
665
+ if (verbose) {
666
+ logger.detail(`emit-frontmatter: wrote ${action.target}`);
667
+ }
668
+ return { error: null };
669
+ }
670
+ catch (err) {
671
+ const msg = err instanceof Error ? err.message : String(err);
672
+ logger.fail(label, msg);
673
+ return { error: msg };
674
+ }
675
+ }
@@ -9,4 +9,4 @@ export declare function resolveAgentSkillPathFor(agent: AgentConfig, skillName:
9
9
  export declare function resolveAgentDetectPathFor(agent: AgentConfig, home: string, platform: "posix" | "windows"): string;
10
10
  export declare function resolveAgentSkillPath(agent: AgentConfig, skillName: string, home: string): string;
11
11
  export declare function resolveAgentDetectPath(agent: AgentConfig, home: string): string;
12
- export declare function resolvePlaceholders(segments: string[], skillName: string, home: string): string;
12
+ export declare function resolvePlaceholders(segments: string[], skillName: string, home: string, repo?: string): string;
@@ -96,13 +96,14 @@ export function resolveAgentSkillPath(agent, skillName, home) {
96
96
  export function resolveAgentDetectPath(agent, home) {
97
97
  return resolveAgentDetectPathFor(agent, home, getPlatformKey());
98
98
  }
99
- export function resolvePlaceholders(segments, skillName, home) {
99
+ export function resolvePlaceholders(segments, skillName, home, repo) {
100
100
  const { appdata, xdgConfig } = resolveRuntimePaths(home);
101
101
  const resolved = segments.map((seg) => seg
102
102
  .replace("{home}", home)
103
103
  .replace("{name}", skillName)
104
104
  .replace("{appdata}", appdata)
105
- .replace("{xdg_config}", xdgConfig));
105
+ .replace("{xdg_config}", xdgConfig)
106
+ .replace("{repo}", repo ?? ""));
106
107
  const root = resolved.find((segment) => segment.length > 0) ?? home;
107
108
  return getPathApi(root).join(...resolved);
108
109
  }
@@ -1,7 +1,7 @@
1
1
  import type { AgentId, Manifest, PlannedChange, RevertAction } from "../types.ts";
2
2
  import { type RegistryPersistence } from "./ownership.ts";
3
- export declare function planRevert(manifest: Manifest, detectedAgents: AgentId[], home: string): RevertAction[];
4
- export declare function planRevertAll(manifest: Manifest, home: string): RevertAction[];
3
+ export declare function planRevert(manifest: Manifest, detectedAgents: AgentId[], home: string, repo?: string): RevertAction[];
4
+ export declare function planRevertAll(manifest: Manifest, home: string, repo?: string): RevertAction[];
5
5
  export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean, home: string, deps?: RevertDependencies): Promise<{
6
6
  succeeded: number;
7
7
  skipped: number;
@@ -2,6 +2,7 @@ import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
2
2
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
3
3
  import { logger } from "../logger.js";
4
4
  import { compileAgentRuleReverts, compileMcpServerReverts, } from "./adapters/index.js";
5
+ import { revertTomlMcpPatch } from "./adapters/toml.js";
5
6
  import { lookupDeployment, unregisterDeployment, } from "./ownership.js";
6
7
  import { resolveAgentSkillPath } from "./resolve.js";
7
8
  import { resolveTargetTemplate } from "./runtime-paths.js";
@@ -62,22 +63,22 @@ function buildConfigPatchReverts(manifest, home, agentFilter) {
62
63
  }
63
64
  return actions;
64
65
  }
65
- export function planRevert(manifest, detectedAgents, home) {
66
+ export function planRevert(manifest, detectedAgents, home, repo) {
66
67
  return [
67
68
  ...buildSkillDirReverts(manifest, home, detectedAgents),
68
69
  ...buildFileWriteReverts(manifest, home, detectedAgents),
69
70
  ...buildConfigPatchReverts(manifest, home, detectedAgents),
70
- ...(manifest.mcpServers ?? []).flatMap((e) => compileMcpServerReverts(e, detectedAgents, home)),
71
- ...(manifest.agentRules ?? []).flatMap((e) => compileAgentRuleReverts(e, detectedAgents, home)),
71
+ ...(manifest.mcpServers ?? []).flatMap((e) => compileMcpServerReverts(e, detectedAgents, home, repo)),
72
+ ...(manifest.agentRules ?? []).flatMap((e) => compileAgentRuleReverts(e, detectedAgents, home, repo)),
72
73
  ];
73
74
  }
74
- export function planRevertAll(manifest, home) {
75
+ export function planRevertAll(manifest, home, repo) {
75
76
  return [
76
77
  ...buildSkillDirReverts(manifest, home, null),
77
78
  ...buildFileWriteReverts(manifest, home, null),
78
79
  ...buildConfigPatchReverts(manifest, home, null),
79
- ...(manifest.mcpServers ?? []).flatMap((e) => compileMcpServerReverts(e, null, home)),
80
- ...(manifest.agentRules ?? []).flatMap((e) => compileAgentRuleReverts(e, null, home)),
80
+ ...(manifest.mcpServers ?? []).flatMap((e) => compileMcpServerReverts(e, null, home, repo)),
81
+ ...(manifest.agentRules ?? []).flatMap((e) => compileAgentRuleReverts(e, null, home, repo)),
81
82
  ];
82
83
  }
83
84
  function recordOutcome(result, action, counts, failed) {
@@ -148,8 +149,21 @@ export async function executeRevert(actions, dryRun, verbose, home, deps = {}) {
148
149
  case "config-patch":
149
150
  result = await revertConfigPatch(action, dryRun, verbose, home, planned, deps);
150
151
  break;
152
+ case "toml-patch":
153
+ result = await revertTomlPatch(action, dryRun, verbose, home, planned, deps);
154
+ break;
155
+ case "frontmatter-emit":
156
+ // Frontmatter-emit files are fully managed by inception — revert
157
+ // deletes the file, same as a file-write revert.
158
+ result = await revertFileWrite({
159
+ kind: "file-write",
160
+ skill: action.skill,
161
+ agent: action.agent,
162
+ target: action.target,
163
+ }, dryRun, verbose, home, planned, deps);
164
+ break;
151
165
  default:
152
- throw new Error(`Unhandled revert action kind: ${action}`);
166
+ throw new Error(`Unhandled revert action kind: ${action.kind}`);
153
167
  }
154
168
  recordOutcome(result, action, counts, failed);
155
169
  }
@@ -316,3 +330,50 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned, deps) {
316
330
  return { outcome: "fail", error: msg };
317
331
  }
318
332
  }
333
+ async function revertTomlPatch(action, dryRun, verbose, home, planned, deps) {
334
+ const label = `${action.skill} -> ${action.agent}`;
335
+ try {
336
+ await lstat(action.target);
337
+ }
338
+ catch (err) {
339
+ const result = lstatOutcome(err);
340
+ if (result.outcome === "skip") {
341
+ logger.skip(label, "(not found, skipping)");
342
+ return result;
343
+ }
344
+ logger.fail(label, result.error);
345
+ return result;
346
+ }
347
+ const entry = await lookupDeployment(home, action.target, deps.registry);
348
+ if (!entry ||
349
+ entry.kind !== "config-patch" ||
350
+ entry.skill !== action.skill ||
351
+ entry.agent !== action.agent) {
352
+ logger.warn(label, `skipping: ${action.target} is not in the deployment registry — not managed by inception-engine`);
353
+ return { outcome: "skip" };
354
+ }
355
+ if (dryRun) {
356
+ planned.push({
357
+ verb: "unapply-patch",
358
+ kind: "toml-patch",
359
+ skill: action.skill,
360
+ agent: action.agent,
361
+ target: action.target,
362
+ });
363
+ return { outcome: "ok" };
364
+ }
365
+ try {
366
+ await revertTomlMcpPatch(action.target, action.skill);
367
+ await unregisterDeployment(home, action.target, deps.registry);
368
+ logger.ok(label);
369
+ if (verbose) {
370
+ logger.detail(`removed [mcpServers.${action.skill}] from: ${action.target}`);
371
+ }
372
+ return { outcome: "ok" };
373
+ }
374
+ catch (err) {
375
+ const msg = err instanceof Error ? err.message : String(err);
376
+ logger.fail(label, msg);
377
+ return { outcome: "fail", error: msg };
378
+ }
379
+ }
@@ -5,4 +5,4 @@ export interface RuntimePaths {
5
5
  }
6
6
  export declare function getPathApi(root: string): typeof path.posix | typeof path.win32;
7
7
  export declare function resolveRuntimePaths(home: string): RuntimePaths;
8
- export declare function resolveTargetTemplate(template: string, home: string): string;
8
+ export declare function resolveTargetTemplate(template: string, home: string, repo?: string): string;
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- const TARGET_TEMPLATE_RE = /^\{(home|appdata|xdg_config)\}(?<suffix>(?:[\\/].*)?)$/;
2
+ const TARGET_TEMPLATE_RE = /^\{(home|appdata|xdg_config|repo)\}(?<suffix>(?:[\\/].*)?)$/;
3
3
  export function getPathApi(root) {
4
4
  if (root.includes("\\") || /^[a-zA-Z]:/.test(root)) {
5
5
  return path.win32;
@@ -32,18 +32,31 @@ export function resolveRuntimePaths(home) {
32
32
  : homePathApi.join(home, ".config");
33
33
  return { appdata, xdgConfig };
34
34
  }
35
- export function resolveTargetTemplate(template, home) {
35
+ export function resolveTargetTemplate(template, home, repo) {
36
36
  const { appdata, xdgConfig } = resolveRuntimePaths(home);
37
37
  const match = TARGET_TEMPLATE_RE.exec(template);
38
38
  if (!match) {
39
39
  throw new Error(`Invalid target template: ${template}`);
40
40
  }
41
+ const root = match[1];
42
+ if (root === "repo") {
43
+ if (!repo) {
44
+ throw new Error(`Target template uses {repo} but no manifest directory was provided: ${template}`);
45
+ }
46
+ const suffix = match.groups?.suffix ?? "";
47
+ const segments = suffix.split(/[\\/]+/).filter(Boolean);
48
+ const repoPathApi = getPathApi(repo);
49
+ const resolved = segments.length === 0 ? repo : repoPathApi.join(repo, ...segments);
50
+ if (!isSameOrDescendantPath(resolved, repo)) {
51
+ throw new Error(`Target template resolves outside its placeholder root: ${template}`);
52
+ }
53
+ return suffix === "" ? repo : `${repo}${suffix}`;
54
+ }
41
55
  const baseByRoot = {
42
56
  home,
43
57
  appdata,
44
58
  xdg_config: xdgConfig,
45
59
  };
46
- const root = match[1];
47
60
  const suffix = match.groups?.suffix ?? "";
48
61
  const segments = suffix.split(/[\\/]+/).filter(Boolean);
49
62
  const pathApi = getPathApi(baseByRoot[root]);
@@ -1,3 +1,6 @@
1
1
  export declare function sourceAccessError(err: unknown, sourcePath: string): string;
2
2
  export declare function validateSourcePath(source: string, skillPath: string, resolvedSourceDir: string, realRoot: string): Promise<void>;
3
3
  export declare function validateSourceFile(sourcePath: string, manifestPath: string): Promise<void>;
4
+ export declare function validateMcpServerConfigShape(config: Record<string, unknown>, entryName: string, agentId: string): void;
5
+ export declare function validateAgentRuleMarkdownPath(manifestPath: string, agentId: string): void;
6
+ export declare function validateSkillDefinitionFile(sourcePath: string, manifestPath: string): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { lstat, realpath, stat } from "node:fs/promises";
1
+ import { lstat, readFile, 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) {
@@ -64,3 +64,99 @@ export async function validateSourceFile(sourcePath, manifestPath) {
64
64
  throw new UserError("DEPLOY_FAILED", `Source is not a file: ${manifestPath}`);
65
65
  }
66
66
  }
67
+ function isRecordOfStrings(value) {
68
+ return (typeof value === "object" &&
69
+ value !== null &&
70
+ !Array.isArray(value) &&
71
+ Object.values(value).every((entry) => typeof entry === "string"));
72
+ }
73
+ function validateNonEmptyStringField(value, field, entryName, agentId) {
74
+ if (typeof value !== "string" || value.trim().length === 0) {
75
+ throw new UserError("DEPLOY_FAILED", `mcpServers entry "${entryName}" for agent "${agentId}" must define "${field}" as a non-empty string`);
76
+ }
77
+ }
78
+ export function validateMcpServerConfigShape(config, entryName, agentId) {
79
+ const hasCommand = Object.hasOwn(config, "command");
80
+ const hasUrl = Object.hasOwn(config, "url");
81
+ if (!(hasCommand || hasUrl)) {
82
+ throw new UserError("DEPLOY_FAILED", `mcpServers entry "${entryName}" for agent "${agentId}" must define either a non-empty "command" or "url"`);
83
+ }
84
+ if (hasCommand) {
85
+ validateNonEmptyStringField(config.command, "command", entryName, agentId);
86
+ }
87
+ if (hasUrl) {
88
+ validateNonEmptyStringField(config.url, "url", entryName, agentId);
89
+ }
90
+ if (Object.hasOwn(config, "args") &&
91
+ (!Array.isArray(config.args) ||
92
+ config.args.some((arg) => typeof arg !== "string"))) {
93
+ throw new UserError("DEPLOY_FAILED", `mcpServers entry "${entryName}" for agent "${agentId}" must define "args" as an array of strings when present`);
94
+ }
95
+ if (Object.hasOwn(config, "env") && !isRecordOfStrings(config.env)) {
96
+ throw new UserError("DEPLOY_FAILED", `mcpServers entry "${entryName}" for agent "${agentId}" must define "env" as an object of string values when present`);
97
+ }
98
+ }
99
+ export function validateAgentRuleMarkdownPath(manifestPath, agentId) {
100
+ const extension = path.extname(manifestPath).toLowerCase();
101
+ if (extension !== ".md" && extension !== ".markdown") {
102
+ throw new UserError("DEPLOY_FAILED", `agentRules entry "${manifestPath}" for agent "${agentId}" must point to a Markdown source file`);
103
+ }
104
+ }
105
+ function trimMatchingQuotes(value) {
106
+ if ((value.startsWith('"') && value.endsWith('"')) ||
107
+ (value.startsWith("'") && value.endsWith("'"))) {
108
+ return value.slice(1, -1).trim();
109
+ }
110
+ return value;
111
+ }
112
+ function parseSimpleFrontmatterValue(field, rawValue, manifestPath) {
113
+ const value = trimMatchingQuotes(rawValue.trim());
114
+ if (value.length === 0) {
115
+ throw new UserError("DEPLOY_FAILED", `Skill "${manifestPath}" SKILL.md frontmatter field "${field}" must be a non-empty string`);
116
+ }
117
+ if (rawValue.trim() === "|" || rawValue.trim() === ">") {
118
+ throw new UserError("DEPLOY_FAILED", `Skill "${manifestPath}" SKILL.md frontmatter field "${field}" must be a single-line string`);
119
+ }
120
+ return value;
121
+ }
122
+ export async function validateSkillDefinitionFile(sourcePath, manifestPath) {
123
+ await validateSourceFile(sourcePath, `${manifestPath}/SKILL.md`);
124
+ let raw;
125
+ try {
126
+ raw = await readFile(sourcePath, "utf-8");
127
+ }
128
+ catch (err) {
129
+ throw new UserError("DEPLOY_FAILED", sourceAccessError(err, `${manifestPath}/SKILL.md`));
130
+ }
131
+ const lines = raw.split(/\r?\n/);
132
+ if (lines[0]?.trim() !== "---") {
133
+ throw new UserError("DEPLOY_FAILED", `Skill "${manifestPath}" SKILL.md must start with YAML frontmatter delimited by ---`);
134
+ }
135
+ const closingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
136
+ if (closingIndex === -1) {
137
+ throw new UserError("DEPLOY_FAILED", `Skill "${manifestPath}" SKILL.md is missing the closing --- frontmatter delimiter`);
138
+ }
139
+ let name = null;
140
+ let description = null;
141
+ for (const line of lines.slice(1, closingIndex)) {
142
+ const trimmed = line.trim();
143
+ if (trimmed.length === 0 || trimmed.startsWith("#"))
144
+ continue;
145
+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
146
+ if (!match)
147
+ continue;
148
+ const [, key, rawValue] = match;
149
+ if (key === "name") {
150
+ name = parseSimpleFrontmatterValue("name", rawValue, manifestPath);
151
+ }
152
+ else if (key === "description") {
153
+ description = parseSimpleFrontmatterValue("description", rawValue, manifestPath);
154
+ }
155
+ }
156
+ if (name === null) {
157
+ throw new UserError("DEPLOY_FAILED", `Skill "${manifestPath}" SKILL.md frontmatter must include a non-empty "name" field`);
158
+ }
159
+ if (description === null) {
160
+ throw new UserError("DEPLOY_FAILED", `Skill "${manifestPath}" SKILL.md frontmatter must include a non-empty "description" field`);
161
+ }
162
+ }