@moskala/oneagent-core 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moskala/oneagent-core",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Core library for oneagent — one source of truth for AI agent rules",
6
6
  "license": "MIT",
package/src/agents.ts CHANGED
@@ -64,7 +64,7 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
64
64
  target: "copilot",
65
65
  displayName: "GitHub Copilot",
66
66
  hint: ".github/instructions/*.instructions.md",
67
- detectIndicators: [".github/copilot-instructions.md", ".github"],
67
+ detectIndicators: [".github/copilot-instructions.md", ".github/instructions"],
68
68
  mainFile: ".github/copilot-instructions.md",
69
69
  skillsDir: ".github/skills",
70
70
  // rules: generated as <name>.instructions.md files, not symlinks
@@ -4,6 +4,7 @@ import { execFile } from "child_process";
4
4
  import { promisify } from "util";
5
5
  import type { AgentTarget } from "./types.ts";
6
6
  import { addOpenCodePlugin } from "./opencode.ts";
7
+ import { ONEAGENT_DIR } from "./constants.ts";
7
8
 
8
9
  const execFileAsync = promisify(execFile);
9
10
 
@@ -29,16 +30,19 @@ export interface TemplateDefinition {
29
30
  // Parses name, description, skills and plugins from a template.yml string.
30
31
  // This is the single source of truth for the template.yml format — used by
31
32
  // both builtin template loading and GitHub URL template fetching.
32
- export function parseTemplateYaml(yamlText: string, fallbackName = "custom"): Pick<TemplateDefinition, "name" | "description" | "skills" | "plugins"> {
33
+ export function parseTemplateYaml(yamlText: string, fallbackName = "custom"): Pick<TemplateDefinition, "name" | "description" | "skills" | "plugins"> & { extends?: string } {
33
34
  const nameMatch = yamlText.match(/^name:\s*(.+)$/m);
34
35
  const name = nameMatch?.[1]?.trim() ?? fallbackName;
35
36
 
36
37
  const descMatch = yamlText.match(/^description:\s*(.+)$/m);
37
38
  const description = descMatch?.[1]?.trim() ?? "";
38
39
 
40
+ const extendsMatch = yamlText.match(/^extends:\s*(.+)$/m);
41
+ const extendsValue = extendsMatch?.[1]?.trim();
42
+
39
43
  const skills = parseSkillsFromYaml(yamlText);
40
44
  const plugins = parsePluginsFromYaml(yamlText);
41
- return { name, description, skills, plugins };
45
+ return { name, description, skills, plugins, ...(extendsValue ? { extends: extendsValue } : {}) };
42
46
  }
43
47
 
44
48
  // Parses the `skills:` block from a template.yml string.
@@ -86,10 +90,40 @@ export function parsePluginsFromYaml(yamlText: string): TemplatePlugin[] {
86
90
  return plugins;
87
91
  }
88
92
 
93
+ // Resolves the `extends` field — loads parent template from builtin name or GitHub URL.
94
+ // Merges skills, plugins, rules (parent first, then child).
95
+ // Instructions are NOT inherited — each template has its own.
96
+ export async function resolveExtends(
97
+ child: TemplateDefinition & { extends?: string },
98
+ resolveBuiltin?: (name: string) => Promise<TemplateDefinition | null>,
99
+ ): Promise<TemplateDefinition> {
100
+ if (!child.extends) return child;
101
+
102
+ let parent: TemplateDefinition;
103
+ if (child.extends.startsWith("https://")) {
104
+ parent = await fetchTemplateFromGitHub(child.extends);
105
+ } else if (resolveBuiltin) {
106
+ const resolved = await resolveBuiltin(child.extends);
107
+ if (!resolved) throw new Error(`Unknown builtin template: "${child.extends}"`);
108
+ parent = resolved;
109
+ } else {
110
+ throw new Error(`Cannot resolve extends: "${child.extends}"`);
111
+ }
112
+
113
+ return {
114
+ name: child.name,
115
+ description: child.description,
116
+ instructions: child.instructions,
117
+ skills: [...parent.skills, ...child.skills],
118
+ plugins: [...parent.plugins, ...child.plugins],
119
+ rules: [...parent.rules, ...child.rules],
120
+ };
121
+ }
122
+
89
123
  // Phase 1: writes instructions.md and rules/*.md.
90
124
  // Call this BEFORE generate() so symlinks to rules are created.
91
125
  export async function applyTemplateFiles(root: string, template: TemplateDefinition): Promise<void> {
92
- const oneagentDir = path.join(root, ".oneagent");
126
+ const oneagentDir = path.join(root, ONEAGENT_DIR);
93
127
 
94
128
  await fs.mkdir(path.join(oneagentDir, "rules"), { recursive: true });
95
129
  await fs.mkdir(path.join(oneagentDir, "skills"), { recursive: true });
@@ -107,28 +141,42 @@ export interface SkillInstallResult {
107
141
  }
108
142
 
109
143
  // Phase 2: installs skills via `npx skills add <repo> --skill <name>`.
110
- // All skills are installed in parallel. Call this AFTER generate() so agent directories exist.
144
+ // Skills are installed sequentially to avoid race conditions each `npx skills add` call
145
+ // sets up agent directories and running them in parallel causes "skills 2" naming conflicts.
146
+ // Call this AFTER generate() so agent directories exist.
111
147
  // Never throws — failed skills are collected and returned in the result.
112
148
  export async function installTemplateSkills(
113
149
  root: string,
114
150
  template: TemplateDefinition,
115
151
  ): Promise<SkillInstallResult> {
116
- const results = await Promise.all(
117
- template.skills.map(async (entry) => {
118
- try {
119
- await execFileAsync("npx", ["skills", "add", entry.repo, "--skill", entry.skill, "--agent", "universal", "--yes"], { cwd: root });
120
- return { entry, ok: true as const };
121
- } catch (err) {
122
- const reason = err instanceof Error ? err.message : String(err);
123
- return { entry, ok: false as const, reason };
124
- }
125
- }),
126
- );
152
+ const installed: SkillEntry[] = [];
153
+ const failed: Array<{ entry: SkillEntry; reason: string }> = [];
127
154
 
128
- return {
129
- installed: results.filter((r) => r.ok).map((r) => r.entry),
130
- failed: results.filter((r) => !r.ok).map((r) => ({ entry: r.entry, reason: (r as { reason: string }).reason })),
131
- };
155
+ for (const entry of template.skills) {
156
+ try {
157
+ await execFileAsync("npx", ["skills", "add", entry.repo, "--skill", entry.skill, "--agent", "universal", "--yes"], { cwd: root });
158
+ installed.push(entry);
159
+ } catch (err) {
160
+ const reason = err instanceof Error ? err.message : String(err);
161
+ failed.push({ entry, reason });
162
+ }
163
+ }
164
+
165
+ return { installed, failed };
166
+ }
167
+
168
+ const BUILTIN_SKILL_REPO = "https://github.com/moskalakamil/oneagent";
169
+ const BUILTIN_SKILL_NAME = "oneagent";
170
+
171
+ // Installs the built-in oneagent skill that teaches agents how to work with the .oneagent/ directory.
172
+ // Never throws — returns true if installed, false if failed.
173
+ export async function installBuiltinSkill(root: string): Promise<boolean> {
174
+ try {
175
+ await execFileAsync("npx", ["skills", "add", BUILTIN_SKILL_REPO, "--skill", BUILTIN_SKILL_NAME, "--agent", "universal", "--yes"], { cwd: root });
176
+ return true;
177
+ } catch {
178
+ return false;
179
+ }
132
180
  }
133
181
 
134
182
  export interface PluginInstallResult {
@@ -192,22 +240,39 @@ export async function installTemplatePlugins(
192
240
 
193
241
  // Fetches a template from a GitHub URL.
194
242
  // Expects the repository to contain: template.yml, instructions.md, and optionally rules/*.md
243
+ // When no branch is specified in the URL, queries the GitHub API for the default branch.
195
244
  export async function fetchTemplateFromGitHub(url: string): Promise<TemplateDefinition> {
196
- // Convert GitHub URL to raw content base URL
197
- // e.g. https://github.com/owner/repo → https://raw.githubusercontent.com/owner/repo/main
198
- const rawBase = githubUrlToRawBase(url);
245
+ const { owner, repo, branch, subdir } = parseGitHubUrl(url);
246
+ const branchExplicit = url.includes("/tree/");
247
+
248
+ const resolvedBranch = branchExplicit ? branch : await fetchDefaultBranch(owner, repo);
249
+ const rawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${resolvedBranch}${subdir ? `/${subdir}` : ""}`;
199
250
 
200
251
  const [yamlText, instructions] = await Promise.all([
201
252
  fetchText(`${rawBase}/template.yml`),
202
253
  fetchText(`${rawBase}/instructions.md`),
203
254
  ]);
204
255
 
205
- const { name, description, skills, plugins } = parseTemplateYaml(yamlText);
256
+ const parsed = parseTemplateYaml(yamlText);
206
257
 
207
258
  // Try to list rules via GitHub API
208
259
  const rules = await fetchGitHubRules(url);
209
260
 
210
- return { name, description, skills, plugins, instructions, rules };
261
+ const base = { ...parsed, instructions, rules };
262
+ return resolveExtends(base);
263
+ }
264
+
265
+ async function fetchDefaultBranch(owner: string, repo: string): Promise<string> {
266
+ try {
267
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
268
+ headers: { Accept: "application/vnd.github.v3+json" },
269
+ });
270
+ if (!response.ok) return "main";
271
+ const data = (await response.json()) as { default_branch?: string };
272
+ return data.default_branch ?? "main";
273
+ } catch {
274
+ return "main";
275
+ }
211
276
  }
212
277
 
213
278
  async function fetchText(url: string): Promise<string> {
package/src/commands.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { CommandFile } from "./types.ts";
4
+ import { ONEAGENT_DIR } from "./constants.ts";
4
5
 
5
6
  export async function readCommands(root: string): Promise<CommandFile[]> {
6
- const commandsDir = path.join(root, ".oneagent/commands");
7
+ const commandsDir = path.join(root, ONEAGENT_DIR, "commands");
7
8
  try {
8
9
  const files = await fs.readdir(commandsDir);
9
10
  return files
package/src/config.ts CHANGED
@@ -2,8 +2,9 @@ import { parse, stringify } from "yaml";
2
2
  import path from "path";
3
3
  import fs from "fs/promises";
4
4
  import type { AgentTarget, Config } from "./types.ts";
5
+ import { ONEAGENT_DIR } from "./constants.ts";
5
6
 
6
- const CONFIG_REL = ".oneagent/config.yml";
7
+ const CONFIG_REL = `${ONEAGENT_DIR}/config.yml`;
7
8
 
8
9
  export const ALL_AGENT_TARGETS: AgentTarget[] = ["claude", "cursor", "windsurf", "opencode", "copilot"];
9
10
 
@@ -0,0 +1 @@
1
+ export const ONEAGENT_DIR = ".oneagent";
package/src/detect.ts CHANGED
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { DetectedFile } from "./types.ts";
4
4
  import { AGENT_DEFINITIONS } from "./agents.ts";
5
+ import { ONEAGENT_DIR } from "./constants.ts";
5
6
 
6
7
  export const AGENT_FILES = [
7
8
  "CLAUDE.md",
@@ -20,7 +21,7 @@ export async function readDetectedFile(root: string, rel: string): Promise<Detec
20
21
  if (stat.isSymbolicLink()) {
21
22
  const linkTarget = await fs.readlink(absolutePath);
22
23
  const resolved = path.resolve(path.dirname(absolutePath), linkTarget);
23
- if (resolved.startsWith(path.join(root, ".one"))) return null;
24
+ if (resolved.startsWith(path.join(root, ONEAGENT_DIR))) return null;
24
25
  }
25
26
 
26
27
  const content = await fs.readFile(absolutePath, "utf-8");
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./constants.ts";
1
2
  export * from "./types.ts";
2
3
  export * from "./agents.ts";
3
4
  export * from "./config.ts";
package/src/opencode.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { RuleFile } from "./types.ts";
4
+ import { ONEAGENT_DIR } from "./constants.ts";
4
5
 
5
6
  export async function readOpencode(root: string): Promise<Record<string, unknown> | null> {
6
7
  try {
@@ -14,7 +15,7 @@ export async function readOpencode(root: string): Promise<Record<string, unknown
14
15
  export function buildOpencodeConfig(existing: Record<string, unknown> | null): object {
15
16
  return {
16
17
  ...existing,
17
- instructions: ".oneagent/instructions.md",
18
+ instructions: `${ONEAGENT_DIR}/instructions.md`,
18
19
  };
19
20
  }
20
21
 
package/src/rules.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { RuleFile } from "./types.ts";
4
+ import { ONEAGENT_DIR } from "./constants.ts";
4
5
 
5
6
  export async function readRules(root: string): Promise<RuleFile[]> {
6
- const rulesDir = path.join(root, ".oneagent/rules");
7
+ const rulesDir = path.join(root, ONEAGENT_DIR, "rules");
7
8
  try {
8
9
  const files = await fs.readdir(rulesDir);
9
10
  return files
package/src/skills.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { SkillFile } from "./types.ts";
4
+ import { ONEAGENT_DIR } from "./constants.ts";
4
5
 
5
6
  const VALID_MODES = ["ask", "edit", "agent"] as const;
6
7
  type SkillMode = (typeof VALID_MODES)[number];
@@ -29,7 +30,7 @@ export async function readSkillFile(filePath: string): Promise<SkillFile> {
29
30
  }
30
31
 
31
32
  export async function readSkills(root: string): Promise<SkillFile[]> {
32
- const skillsDir = path.join(root, ".oneagent/skills");
33
+ const skillsDir = path.join(root, ONEAGENT_DIR, "skills");
33
34
  try {
34
35
  const files = await fs.readdir(skillsDir);
35
36
  const mdFiles = files.filter((f) => f.endsWith(".md"));
package/src/status.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import type { Config, GeneratedFileCheck, OpenCodeCheck, RuleFile, StatusResult } from "./types.ts";
3
3
  import { activeTargets } from "./config.ts";
4
+ import { ONEAGENT_DIR } from "./constants.ts";
4
5
  import { readRules } from "./rules.ts";
5
6
  import { readSkills } from "./skills.ts";
6
7
  import { buildMainSymlinks, buildRulesSymlinks, buildSkillSymlinks, buildCommandSymlinks, buildAgentsDirSymlinks, checkSymlink } from "./symlinks.ts";
@@ -26,7 +27,7 @@ export async function checkOpencodeStatus(
26
27
  ): Promise<OpenCodeCheck> {
27
28
  const existing = await readOpencode(root);
28
29
  if (!existing) return { exists: false, valid: false };
29
- return { exists: true, valid: existing["instructions"] === ".oneagent/instructions.md" };
30
+ return { exists: true, valid: existing["instructions"] === `${ONEAGENT_DIR}/instructions.md` };
30
31
  }
31
32
 
32
33
  export async function checkCopilotPrompt(root: string, skill: import("./types.ts").SkillFile): Promise<GeneratedFileCheck> {
package/src/symlinks.ts CHANGED
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { AgentTarget, SymlinkCheck, SymlinkEntry } from "./types.ts";
4
4
  import { AGENT_DEFINITIONS } from "./agents.ts";
5
+ import { ONEAGENT_DIR } from "./constants.ts";
5
6
 
6
7
  export async function ensureDir(dirPath: string): Promise<void> {
7
8
  await fs.mkdir(dirPath, { recursive: true });
@@ -9,12 +10,27 @@ export async function ensureDir(dirPath: string): Promise<void> {
9
10
 
10
11
  export async function createSymlink(symlinkPath: string, target: string): Promise<void> {
11
12
  await ensureDir(path.dirname(symlinkPath));
13
+
14
+ // Skip if already a correct symlink — avoids unnecessary rm/create that can
15
+ // race with external filesystem watchers (e.g. Cursor IDE recreating .cursor/rules)
12
16
  try {
13
- await fs.rm(symlinkPath, { recursive: true });
17
+ const stat = await fs.lstat(symlinkPath);
18
+ if (stat.isSymbolicLink() && (await fs.readlink(symlinkPath)) === target) return;
14
19
  } catch {
15
- // doesn't exist — that's fine
20
+ // path doesn't exist — proceed
21
+ }
22
+
23
+ // Retry up to 3 times: external tools can recreate the path between our rm
24
+ // and symlink calls, causing EEXIST and macOS conflict copies (e.g. "rules 2")
25
+ for (let attempt = 0; attempt < 3; attempt++) {
26
+ await fs.rm(symlinkPath, { recursive: true, force: true });
27
+ try {
28
+ await fs.symlink(target, symlinkPath);
29
+ return;
30
+ } catch (err) {
31
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST" || attempt === 2) throw err;
32
+ }
16
33
  }
17
- await fs.symlink(target, symlinkPath);
18
34
  }
19
35
 
20
36
  function relativeTarget(symlinkPath: string, targetAbsPath: string): string {
@@ -22,7 +38,7 @@ function relativeTarget(symlinkPath: string, targetAbsPath: string): string {
22
38
  }
23
39
 
24
40
  export function buildMainSymlinks(root: string, targets: AgentTarget[]): SymlinkEntry[] {
25
- const instructionsAbs = path.join(root, ".oneagent/instructions.md");
41
+ const instructionsAbs = path.join(root, ONEAGENT_DIR, "instructions.md");
26
42
  const seen = new Map<string, SymlinkEntry>();
27
43
 
28
44
  for (const target of targets) {
@@ -41,7 +57,7 @@ export function buildMainSymlinks(root: string, targets: AgentTarget[]): Symlink
41
57
  }
42
58
 
43
59
  export function buildRulesSymlinks(root: string, targets: AgentTarget[]): SymlinkEntry[] {
44
- const targetAbs = path.join(root, ".oneagent/rules");
60
+ const targetAbs = path.join(root, ONEAGENT_DIR, "rules");
45
61
  return AGENT_DEFINITIONS
46
62
  .filter((d) => targets.includes(d.target) && d.rulesDir)
47
63
  .map((d) => {
@@ -51,7 +67,7 @@ export function buildRulesSymlinks(root: string, targets: AgentTarget[]): Symlin
51
67
  }
52
68
 
53
69
  export function buildSkillSymlinks(root: string, targets: AgentTarget[]): SymlinkEntry[] {
54
- const targetAbs = path.join(root, ".oneagent/skills");
70
+ const targetAbs = path.join(root, ONEAGENT_DIR, "skills");
55
71
  return AGENT_DEFINITIONS
56
72
  .filter((d) => targets.includes(d.target) && d.skillsDir)
57
73
  .map((d) => {
@@ -61,7 +77,7 @@ export function buildSkillSymlinks(root: string, targets: AgentTarget[]): Symlin
61
77
  }
62
78
 
63
79
  export function buildCommandSymlinks(root: string, targets: AgentTarget[]): SymlinkEntry[] {
64
- const targetAbs = path.join(root, ".oneagent/commands");
80
+ const targetAbs = path.join(root, ONEAGENT_DIR, "commands");
65
81
  return AGENT_DEFINITIONS
66
82
  .filter((d) => targets.includes(d.target) && d.commandsDir)
67
83
  .map((d) => {
@@ -72,7 +88,7 @@ export function buildCommandSymlinks(root: string, targets: AgentTarget[]): Syml
72
88
 
73
89
  export function buildAgentsDirSymlinks(root: string): SymlinkEntry[] {
74
90
  const symlinkPath = path.join(root, ".agents/skills");
75
- const targetAbs = path.join(root, ".oneagent/skills");
91
+ const targetAbs = path.join(root, ONEAGENT_DIR, "skills");
76
92
  return [{ symlinkPath, target: relativeTarget(symlinkPath, targetAbs), label: ".agents/skills" }];
77
93
  }
78
94
 
@@ -85,8 +101,21 @@ async function migrateFilesFromDir(srcDir: string, destDir: string, root: string
85
101
  return; // srcDir doesn't exist — no-op
86
102
  }
87
103
  for (const entry of entries) {
88
- if (!entry.isFile()) continue; // skip symlinks and subdirs
89
104
  const srcFile = path.join(srcDir, entry.name);
105
+ const fileStat = await fs.lstat(srcFile);
106
+ if (fileStat.isSymbolicLink()) continue;
107
+
108
+ // Move subdirectories as whole units (e.g. skills.sh skill packages)
109
+ if (fileStat.isDirectory()) {
110
+ const destSub = path.join(destDir, entry.name);
111
+ try { await fs.access(destSub); } catch {
112
+ await fs.mkdir(destDir, { recursive: true });
113
+ await fs.rename(srcFile, destSub);
114
+ }
115
+ continue;
116
+ }
117
+
118
+ if (!fileStat.isFile()) continue;
90
119
  const destFile = path.join(destDir, entry.name);
91
120
 
92
121
  let destExists = false;
@@ -105,7 +134,7 @@ async function migrateFilesFromDir(srcDir: string, destDir: string, root: string
105
134
  ]);
106
135
  if (srcContent !== destContent) {
107
136
  // Different content — backup source before deleting
108
- const backupDir = path.join(root, ".oneagent/backup");
137
+ const backupDir = path.join(root, ONEAGENT_DIR, "backup");
109
138
  await fs.mkdir(backupDir, { recursive: true });
110
139
  const safeName = path.relative(root, srcFile).replace(/\//g, "_");
111
140
  await fs.writeFile(path.join(backupDir, safeName), srcContent);
@@ -130,13 +159,16 @@ async function migrateAndRemoveDir(src: string, dest: string, root: string): Pro
130
159
  }
131
160
 
132
161
  export async function migrateRuleAndSkillFiles(root: string): Promise<void> {
133
- const destRules = path.join(root, ".oneagent/rules");
134
- const destSkills = path.join(root, ".oneagent/skills");
135
- const destCommands = path.join(root, ".oneagent/commands");
162
+ const destRules = path.join(root, ONEAGENT_DIR, "rules");
163
+ const destSkills = path.join(root, ONEAGENT_DIR, "skills");
164
+ const destCommands = path.join(root, ONEAGENT_DIR, "commands");
136
165
  // Derive migration sources from agent definitions — sequential to avoid same-name conflicts.
137
166
  for (const def of AGENT_DEFINITIONS) {
138
167
  if (def.rulesDir) await migrateAndRemoveDir(path.join(root, def.rulesDir), destRules, root);
139
168
  }
169
+ for (const def of AGENT_DEFINITIONS) {
170
+ if (def.skillsDir) await migrateAndRemoveDir(path.join(root, def.skillsDir), destSkills, root);
171
+ }
140
172
  // .agents/skills — standard skills.sh path, not represented in agent definitions
141
173
  await migrateAndRemoveDir(path.join(root, ".agents/skills"), destSkills, root);
142
174
  for (const def of AGENT_DEFINITIONS) {
@@ -144,8 +176,57 @@ export async function migrateRuleAndSkillFiles(root: string): Promise<void> {
144
176
  }
145
177
  }
146
178
 
179
+ async function backupDirRecursive(srcDir: string, backupDir: string, prefix: string): Promise<void> {
180
+ let entries: import("fs").Dirent[];
181
+ try { entries = await fs.readdir(srcDir, { withFileTypes: true }); } catch { return; }
182
+ for (const entry of entries) {
183
+ const srcPath = path.join(srcDir, entry.name);
184
+ const lstat = await fs.lstat(srcPath);
185
+ if (lstat.isSymbolicLink()) continue;
186
+ if (lstat.isDirectory()) {
187
+ await backupDirRecursive(srcPath, backupDir, `${prefix}_${entry.name}`);
188
+ } else if (lstat.isFile()) {
189
+ await fs.mkdir(backupDir, { recursive: true });
190
+ await fs.copyFile(srcPath, path.join(backupDir, `${prefix}_${entry.name}`));
191
+ }
192
+ }
193
+ }
194
+
195
+ export async function cleanupAgentDir(root: string, target: import("./types.ts").AgentTarget): Promise<void> {
196
+ const def = AGENT_DEFINITIONS.find((d) => d.target === target)!;
197
+ const backupDir = path.join(root, ONEAGENT_DIR, "backup");
198
+
199
+ const agentDir = [def.rulesDir, def.skillsDir, def.commandsDir]
200
+ .filter(Boolean)
201
+ .map((d) => d!.split("/")[0]!)
202
+ .find((d) => d !== ".github");
203
+
204
+ if (agentDir) {
205
+ const agentDirAbs = path.join(root, agentDir);
206
+ let stat;
207
+ try { stat = await fs.lstat(agentDirAbs); } catch {}
208
+ if (stat && stat.isDirectory() && !stat.isSymbolicLink()) {
209
+ await backupDirRecursive(agentDirAbs, backupDir, agentDir);
210
+ await fs.rm(agentDirAbs, { recursive: true, force: true });
211
+ }
212
+ }
213
+
214
+ if (target === "opencode") {
215
+ const opPath = path.join(root, "opencode.json");
216
+ try {
217
+ const content = await fs.readFile(opPath, "utf-8");
218
+ await fs.mkdir(backupDir, { recursive: true });
219
+ await fs.writeFile(path.join(backupDir, "opencode.json"), content);
220
+ } catch {}
221
+ try { await fs.unlink(opPath); } catch {}
222
+ }
223
+ }
224
+
147
225
  export async function createAllSymlinks(entries: SymlinkEntry[]): Promise<void> {
148
- for (const e of entries) {
226
+ // Deduplicate by symlink path — last entry wins
227
+ const deduped = new Map<string, SymlinkEntry>();
228
+ for (const e of entries) deduped.set(e.symlinkPath, e);
229
+ for (const e of deduped.values()) {
149
230
  await createSymlink(e.symlinkPath, e.target);
150
231
  }
151
232
  }