@moskala/oneagent-core 0.1.0 → 0.2.2

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.1.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Core library for oneagent — one source of truth for AI agent rules",
6
6
  "license": "MIT",
@@ -0,0 +1,123 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+
4
+ export interface TemplateDefinition {
5
+ name: string;
6
+ description: string;
7
+ skills: string[];
8
+ instructions: string;
9
+ rules: Array<{ name: string; content: string }>;
10
+ }
11
+
12
+ // Phase 1: writes instructions.md and rules/*.md.
13
+ // Call this BEFORE generate() so symlinks to rules are created.
14
+ export async function applyTemplateFiles(root: string, template: TemplateDefinition): Promise<void> {
15
+ const oneagentDir = path.join(root, ".oneagent");
16
+
17
+ await fs.mkdir(path.join(oneagentDir, "rules"), { recursive: true });
18
+ await fs.mkdir(path.join(oneagentDir, "skills"), { recursive: true });
19
+
20
+ await Bun.write(path.join(oneagentDir, "instructions.md"), template.instructions);
21
+
22
+ for (const rule of template.rules) {
23
+ await Bun.write(path.join(oneagentDir, "rules", `${rule.name}.md`), rule.content);
24
+ }
25
+ }
26
+
27
+ // Phase 2: installs skills via `bunx skills add <identifier> --yes`.
28
+ // Call this AFTER generate() so agent directories (symlinks) already exist.
29
+ export async function installTemplateSkills(
30
+ root: string,
31
+ template: TemplateDefinition,
32
+ onSkillInstalled?: (identifier: string) => void,
33
+ ): Promise<void> {
34
+ for (const identifier of template.skills) {
35
+ try {
36
+ await Bun.$`bunx skills add ${identifier} --agent universal --yes`.cwd(root).quiet();
37
+ onSkillInstalled?.(identifier);
38
+ } catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ throw new Error(`Failed to install skill "${identifier}": ${message}`);
41
+ }
42
+ }
43
+ }
44
+
45
+ // Fetches a template from a GitHub URL.
46
+ // Expects the repository to contain: template.yml, instructions.md, and optionally rules/*.md
47
+ export async function fetchTemplateFromGitHub(url: string): Promise<TemplateDefinition> {
48
+ // Convert GitHub URL to raw content base URL
49
+ // e.g. https://github.com/owner/repo → https://raw.githubusercontent.com/owner/repo/main
50
+ const rawBase = githubUrlToRawBase(url);
51
+
52
+ const [yamlText, instructions] = await Promise.all([
53
+ fetchText(`${rawBase}/template.yml`),
54
+ fetchText(`${rawBase}/instructions.md`),
55
+ ]);
56
+
57
+ const descMatch = yamlText.match(/^description:\s*(.+)$/m);
58
+ const description = descMatch?.[1]?.trim() ?? "";
59
+
60
+ const nameMatch = yamlText.match(/^name:\s*(.+)$/m);
61
+ const name = nameMatch?.[1]?.trim() ?? "custom";
62
+
63
+ const skills: string[] = [];
64
+ const skillsBlockMatch = yamlText.match(/^skills:\s*\n((?: - .+\n?)*)/m);
65
+ if (skillsBlockMatch) {
66
+ const lines = skillsBlockMatch[1]!.split("\n").filter(Boolean);
67
+ for (const line of lines) {
68
+ const skill = line.replace(/^\s*-\s*/, "").trim();
69
+ if (skill) skills.push(skill);
70
+ }
71
+ }
72
+
73
+ // Try to list rules via GitHub API
74
+ const rules = await fetchGitHubRules(url);
75
+
76
+ return { name, description, skills, instructions, rules };
77
+ }
78
+
79
+ async function fetchText(url: string): Promise<string> {
80
+ const response = await fetch(url);
81
+ if (!response.ok) {
82
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
83
+ }
84
+ return response.text();
85
+ }
86
+
87
+ function githubUrlToRawBase(url: string): string {
88
+ // Handle https://github.com/owner/repo/tree/branch or https://github.com/owner/repo
89
+ const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/([^/]+))?(?:\/.*)?$/);
90
+ if (!match) {
91
+ throw new Error(`Invalid GitHub URL: "${url}". Expected format: https://github.com/owner/repo`);
92
+ }
93
+ const [, owner, repo, branch = "main"] = match;
94
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
95
+ }
96
+
97
+ async function fetchGitHubRules(repoUrl: string): Promise<Array<{ name: string; content: string }>> {
98
+ // Parse owner/repo from URL
99
+ const match = repoUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/([^/]+))?(?:\/.*)?$/);
100
+ if (!match) return [];
101
+ const [, owner, repo, branch = "main"] = match;
102
+
103
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/rules?ref=${branch}`;
104
+ try {
105
+ const response = await fetch(apiUrl, {
106
+ headers: { Accept: "application/vnd.github.v3+json" },
107
+ });
108
+ if (!response.ok) return [];
109
+
110
+ const files = (await response.json()) as Array<{ name: string; download_url: string | null }>;
111
+ const mdFiles = files.filter((f) => f.name.endsWith(".md") && f.download_url);
112
+
113
+ const rules = await Promise.all(
114
+ mdFiles.map(async (f) => {
115
+ const content = await fetchText(f.download_url!);
116
+ return { name: path.basename(f.name, ".md"), content };
117
+ }),
118
+ );
119
+ return rules;
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
package/src/config.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import { parse, stringify } from "yaml";
2
2
  import path from "path";
3
- import type { Config } from "./types.ts";
3
+ import type { AgentTarget, Config } from "./types.ts";
4
4
 
5
5
  const CONFIG_REL = ".oneagent/config.yml";
6
6
 
7
+ export const ALL_AGENT_TARGETS: AgentTarget[] = ["claude", "cursor", "windsurf", "opencode", "copilot"];
8
+
9
+ export function activeTargets(config: Config): AgentTarget[] {
10
+ return ALL_AGENT_TARGETS.filter((t) => config.targets[t]);
11
+ }
12
+
13
+ export function makeTargets(...enabled: AgentTarget[]): Record<AgentTarget, boolean> {
14
+ return Object.fromEntries(ALL_AGENT_TARGETS.map((t) => [t, enabled.includes(t)])) as Record<AgentTarget, boolean>;
15
+ }
16
+
7
17
  export async function configExists(root: string): Promise<boolean> {
8
18
  return Bun.file(path.join(root, CONFIG_REL)).exists();
9
19
  }
package/src/copilot.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
- import type { RuleFile } from "./types.ts";
3
+ import type { RuleFile, SkillFile } from "./types.ts";
4
4
 
5
5
  export function buildCopilotContent(rule: RuleFile): string {
6
6
  return `---\napplyTo: "${rule.applyTo}"\n---\n${rule.content}`;
@@ -19,3 +19,24 @@ export async function generateCopilotRule(root: string, rule: RuleFile): Promise
19
19
  export async function generateCopilotRules(root: string, rules: RuleFile[]): Promise<void> {
20
20
  await Promise.all(rules.map((rule) => generateCopilotRule(root, rule)));
21
21
  }
22
+
23
+ export function buildCopilotPromptContent(skill: SkillFile): string {
24
+ const lines = ["---", `mode: "${skill.mode}"`];
25
+ if (skill.description) lines.push(`description: "${skill.description}"`);
26
+ lines.push("---", skill.content);
27
+ return lines.join("\n");
28
+ }
29
+
30
+ export function copilotPromptFilePath(root: string, skillName: string): string {
31
+ return path.join(root, ".github/prompts", `${skillName}.prompt.md`);
32
+ }
33
+
34
+ export async function generateCopilotSkill(root: string, skill: SkillFile): Promise<void> {
35
+ const filePath = copilotPromptFilePath(root, skill.name);
36
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
37
+ await Bun.write(filePath, buildCopilotPromptContent(skill));
38
+ }
39
+
40
+ export async function generateCopilotSkills(root: string, skills: SkillFile[]): Promise<void> {
41
+ await Promise.all(skills.map((skill) => generateCopilotSkill(root, skill)));
42
+ }
package/src/detect.ts CHANGED
@@ -45,3 +45,29 @@ export function filesHaveSameContent(files: DetectedFile[]): boolean {
45
45
  const first = files[0]!.content;
46
46
  return files.every((f) => f.content === first);
47
47
  }
48
+
49
+ const DEPRECATED_FILES = [".cursorrules"];
50
+
51
+ export async function removeDeprecatedFiles(root: string): Promise<void> {
52
+ for (const rel of DEPRECATED_FILES) {
53
+ const absPath = path.join(root, rel);
54
+ try {
55
+ const stat = await fs.lstat(absPath);
56
+ if (!stat.isSymbolicLink()) await fs.unlink(absPath);
57
+ } catch {
58
+ // doesn't exist — no-op
59
+ }
60
+ }
61
+ }
62
+
63
+ export async function detectDeprecatedCommandFiles(root: string): Promise<string[]> {
64
+ const commandsDir = path.join(root, ".claude/commands");
65
+ try {
66
+ const entries = await fs.readdir(commandsDir, { withFileTypes: true });
67
+ return entries
68
+ .filter((e) => e.isFile())
69
+ .map((e) => path.join(".claude/commands", e.name));
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
package/src/generate.ts CHANGED
@@ -1,21 +1,86 @@
1
- import type { Config } from "./types.ts";
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import type { Config, DetectedFile } from "./types.ts";
4
+ import { activeTargets } from "./config.ts";
2
5
  import { readRules } from "./rules.ts";
3
- import { buildMainSymlinks, buildRulesSymlinks, createAllSymlinks } from "./symlinks.ts";
4
- import { generateCopilotRules } from "./copilot.ts";
6
+ import { readSkills } from "./skills.ts";
7
+ import { buildMainSymlinks, buildRulesSymlinks, buildSkillSymlinks, buildAgentsDirSymlinks, createAllSymlinks, migrateRuleAndSkillFiles } from "./symlinks.ts";
8
+ import { buildCopilotContent, copilotFilePath, buildCopilotPromptContent, copilotPromptFilePath, generateCopilotRules, generateCopilotSkills } from "./copilot.ts";
5
9
  import { writeOpencode } from "./opencode.ts";
10
+ import { readDetectedFile } from "./detect.ts";
11
+
12
+ export interface GenerateCollisions {
13
+ mainFiles: DetectedFile[];
14
+ ruleSkillFiles: DetectedFile[];
15
+ }
16
+
17
+ export async function detectGenerateCollisions(root: string, config: Config): Promise<GenerateCollisions> {
18
+ const [rules, skills] = await Promise.all([readRules(root), readSkills(root)]);
19
+ const targets = activeTargets(config);
20
+
21
+ // 1. Main instruction file symlinks (CLAUDE.md, AGENTS.md, .windsurfrules, etc.)
22
+ const mainEntries = buildMainSymlinks(root, targets);
23
+ // 2. Rule/skill symlink paths
24
+ const ruleSkillEntries = [
25
+ ...buildRulesSymlinks(root, targets, rules),
26
+ ...buildSkillSymlinks(root, targets, skills),
27
+ // .agents/skills skipped — handled by migrateAgentsSkillsDir
28
+ ];
29
+
30
+ const [mainCollisions, ruleSkillSymlinkCollisions] = await Promise.all([
31
+ Promise.all(mainEntries.map((entry) => readDetectedFile(root, path.relative(root, entry.symlinkPath))))
32
+ .then((files) => files.filter((f): f is DetectedFile => f !== null)),
33
+ Promise.all(ruleSkillEntries.map((entry) => readDetectedFile(root, path.relative(root, entry.symlinkPath))))
34
+ .then((files) => files.filter((f): f is DetectedFile => f !== null)),
35
+ ]);
36
+
37
+ // 3. Copilot generated files — collision only if content differs (idempotent-safe)
38
+ const copilotCollisions: DetectedFile[] = [];
39
+ if (targets.includes("copilot")) {
40
+ const checks = await Promise.all([
41
+ ...rules.map(async (rule): Promise<DetectedFile | null> => {
42
+ const filePath = copilotFilePath(root, rule.name);
43
+ try {
44
+ const content = await Bun.file(filePath).text();
45
+ if (content === buildCopilotContent(rule)) return null;
46
+ const stat = await fs.lstat(filePath);
47
+ return { relativePath: path.relative(root, filePath), absolutePath: filePath, sizeBytes: stat.size, modifiedAt: stat.mtime, content };
48
+ } catch { return null; }
49
+ }),
50
+ ...skills.map(async (skill): Promise<DetectedFile | null> => {
51
+ const filePath = copilotPromptFilePath(root, skill.name);
52
+ try {
53
+ const content = await Bun.file(filePath).text();
54
+ if (content === buildCopilotPromptContent(skill)) return null;
55
+ const stat = await fs.lstat(filePath);
56
+ return { relativePath: path.relative(root, filePath), absolutePath: filePath, sizeBytes: stat.size, modifiedAt: stat.mtime, content };
57
+ } catch { return null; }
58
+ }),
59
+ ]);
60
+ copilotCollisions.push(...checks.filter((c): c is DetectedFile => c !== null));
61
+ }
62
+
63
+ return {
64
+ mainFiles: mainCollisions,
65
+ ruleSkillFiles: [...ruleSkillSymlinkCollisions, ...copilotCollisions],
66
+ };
67
+ }
6
68
 
7
69
  export async function generate(root: string, config: Config): Promise<void> {
8
- const rules = await readRules(root);
70
+ const [rules, skills] = await Promise.all([readRules(root), readSkills(root)]);
71
+ const targets = activeTargets(config);
9
72
 
10
- const mainSymlinks = buildMainSymlinks(root, config.targets);
11
- const rulesSymlinks = buildRulesSymlinks(root, config.targets, rules);
12
- await createAllSymlinks([...mainSymlinks, ...rulesSymlinks]);
73
+ const mainSymlinks = buildMainSymlinks(root, targets);
74
+ const rulesSymlinks = buildRulesSymlinks(root, targets, rules);
75
+ const skillSymlinks = buildSkillSymlinks(root, targets, skills);
76
+ await migrateRuleAndSkillFiles(root);
77
+ await createAllSymlinks([...mainSymlinks, ...rulesSymlinks, ...skillSymlinks, ...buildAgentsDirSymlinks(root)]);
13
78
 
14
- if (config.targets.includes("copilot")) {
15
- await generateCopilotRules(root, rules);
79
+ if (targets.includes("copilot")) {
80
+ await Promise.all([generateCopilotRules(root, rules), generateCopilotSkills(root, skills)]);
16
81
  }
17
82
 
18
- if (config.targets.includes("opencode")) {
83
+ if (targets.includes("opencode")) {
19
84
  await writeOpencode(root, rules);
20
85
  }
21
86
  }
package/src/index.ts CHANGED
@@ -2,8 +2,10 @@ export * from "./types.ts";
2
2
  export * from "./config.ts";
3
3
  export * from "./detect.ts";
4
4
  export * from "./rules.ts";
5
+ export * from "./skills.ts";
5
6
  export * from "./symlinks.ts";
6
7
  export * from "./copilot.ts";
7
8
  export * from "./opencode.ts";
8
9
  export * from "./generate.ts";
9
10
  export * from "./status.ts";
11
+ export * from "./apply-template.ts";
package/src/skills.ts ADDED
@@ -0,0 +1,42 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import type { SkillFile } from "./types.ts";
4
+
5
+ const VALID_MODES = ["ask", "edit", "agent"] as const;
6
+ type SkillMode = (typeof VALID_MODES)[number];
7
+
8
+ export function parseSkillFrontmatter(raw: string): { description: string; mode: SkillMode; content: string } {
9
+ const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
10
+ if (!match) return { description: "", mode: "agent", content: raw };
11
+
12
+ const frontmatter = match[1] ?? "";
13
+ const content = match[2] ?? "";
14
+
15
+ const descMatch = frontmatter.match(/description:\s*["']?([^"'\n]+)["']?/);
16
+ const description = descMatch?.[1]?.trim() ?? "";
17
+
18
+ const modeMatch = frontmatter.match(/mode:\s*["']?([^"'\n]+)["']?/);
19
+ const modeRaw = modeMatch?.[1]?.trim() ?? "agent";
20
+ const mode: SkillMode = (VALID_MODES as readonly string[]).includes(modeRaw) ? (modeRaw as SkillMode) : "agent";
21
+
22
+ return { description, mode, content };
23
+ }
24
+
25
+ export async function readSkillFile(filePath: string): Promise<SkillFile> {
26
+ const raw = await Bun.file(filePath).text();
27
+ const { description, mode, content } = parseSkillFrontmatter(raw);
28
+ return { name: path.basename(filePath, ".md"), path: filePath, description, mode, content };
29
+ }
30
+
31
+ export async function readSkills(root: string): Promise<SkillFile[]> {
32
+ const skillsDir = path.join(root, ".oneagent/skills");
33
+ try {
34
+ const files = await fs.readdir(skillsDir);
35
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
36
+ const skills = await Promise.all(mdFiles.map((f) => readSkillFile(path.join(skillsDir, f))));
37
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
package/src/status.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Config, GeneratedFileCheck, OpenCodeCheck, RuleFile, StatusResult } from "./types.ts";
2
+ import { activeTargets } from "./config.ts";
2
3
  import { readRules } from "./rules.ts";
3
- import { buildMainSymlinks, buildRulesSymlinks, checkSymlink } from "./symlinks.ts";
4
- import { buildCopilotContent, copilotFilePath } from "./copilot.ts";
4
+ import { readSkills } from "./skills.ts";
5
+ import { buildMainSymlinks, buildRulesSymlinks, buildSkillSymlinks, buildAgentsDirSymlinks, checkSymlink } from "./symlinks.ts";
6
+ import { buildCopilotContent, buildCopilotPromptContent, copilotFilePath, copilotPromptFilePath } from "./copilot.ts";
5
7
  import { readOpencode } from "./opencode.ts";
6
8
 
7
9
  export async function checkGeneratedFile(root: string, rule: RuleFile): Promise<GeneratedFileCheck> {
@@ -24,18 +26,35 @@ export async function checkOpencodeStatus(
24
26
  return { exists: true, valid: existing["instructions"] === ".oneagent/instructions.md" };
25
27
  }
26
28
 
29
+ export async function checkCopilotPrompt(root: string, skill: import("./types.ts").SkillFile): Promise<GeneratedFileCheck> {
30
+ const filePath = copilotPromptFilePath(root, skill.name);
31
+ const expected = buildCopilotPromptContent(skill);
32
+ try {
33
+ const content = await Bun.file(filePath).text();
34
+ return { path: filePath, exists: true, upToDate: content === expected };
35
+ } catch {
36
+ return { path: filePath, exists: false, upToDate: false };
37
+ }
38
+ }
39
+
27
40
  export async function checkStatus(root: string, config: Config): Promise<StatusResult> {
28
- const rules = await readRules(root);
41
+ const [rules, skills] = await Promise.all([readRules(root), readSkills(root)]);
42
+ const targets = activeTargets(config);
29
43
 
30
44
  const allEntries = [
31
- ...buildMainSymlinks(root, config.targets),
32
- ...buildRulesSymlinks(root, config.targets, rules),
45
+ ...buildMainSymlinks(root, targets),
46
+ ...buildRulesSymlinks(root, targets, rules),
47
+ ...buildSkillSymlinks(root, targets, skills),
48
+ ...buildAgentsDirSymlinks(root),
33
49
  ];
34
50
 
35
51
  const symlinks = await Promise.all(allEntries.map(checkSymlink));
36
52
 
37
- const generatedFiles = config.targets.includes("copilot")
38
- ? await Promise.all(rules.map((rule) => checkGeneratedFile(root, rule)))
53
+ const generatedFiles = targets.includes("copilot")
54
+ ? await Promise.all([
55
+ ...rules.map((rule) => checkGeneratedFile(root, rule)),
56
+ ...skills.map((skill) => checkCopilotPrompt(root, skill)),
57
+ ])
39
58
  : [];
40
59
 
41
60
  const opencode = await checkOpencodeStatus(root, rules);
package/src/symlinks.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
- import type { AgentTarget, RuleFile, SymlinkCheck, SymlinkEntry } from "./types.ts";
3
+ import type { AgentTarget, RuleFile, SkillFile, SymlinkCheck, SymlinkEntry } from "./types.ts";
4
4
 
5
5
  export async function ensureDir(dirPath: string): Promise<void> {
6
6
  await fs.mkdir(dirPath, { recursive: true });
@@ -98,6 +98,93 @@ export function buildRulesSymlinks(
98
98
  return entries;
99
99
  }
100
100
 
101
+ export function buildSkillSymlinks(root: string, targets: AgentTarget[], skills: SkillFile[]): SymlinkEntry[] {
102
+ if (!targets.includes("claude")) return [];
103
+
104
+ const commandsDir = path.join(root, ".claude/commands");
105
+ return skills.map((skill) => {
106
+ const symlinkPath = path.join(commandsDir, `${skill.name}.md`);
107
+ return {
108
+ symlinkPath,
109
+ target: relativeTarget(symlinkPath, skill.path),
110
+ label: path.relative(root, symlinkPath),
111
+ };
112
+ });
113
+ }
114
+
115
+ export function buildAgentsDirSymlinks(root: string): SymlinkEntry[] {
116
+ const symlinkPath = path.join(root, ".agents/skills");
117
+ const targetAbs = path.join(root, ".oneagent/skills");
118
+ return [{ symlinkPath, target: relativeTarget(symlinkPath, targetAbs), label: ".agents/skills" }];
119
+ }
120
+
121
+ async function migrateFilesFromDir(srcDir: string, destDir: string, root: string): Promise<void> {
122
+ await fs.mkdir(destDir, { recursive: true });
123
+ let entries: import("fs").Dirent[];
124
+ try {
125
+ entries = await fs.readdir(srcDir, { withFileTypes: true });
126
+ } catch {
127
+ return; // srcDir doesn't exist — no-op
128
+ }
129
+ for (const entry of entries) {
130
+ if (!entry.isFile()) continue; // skip symlinks and subdirs
131
+ const srcFile = path.join(srcDir, entry.name);
132
+ const destFile = path.join(destDir, entry.name);
133
+
134
+ let destExists = false;
135
+ try {
136
+ await fs.access(destFile);
137
+ destExists = true;
138
+ } catch {
139
+ // dest doesn't exist
140
+ }
141
+
142
+ if (destExists) {
143
+ // dest exists — compare content before deleting source
144
+ const [srcContent, destContent] = await Promise.all([
145
+ Bun.file(srcFile).text(),
146
+ Bun.file(destFile).text(),
147
+ ]);
148
+ if (srcContent !== destContent) {
149
+ // Different content — backup source before deleting
150
+ const backupDir = path.join(root, ".oneagent/backup");
151
+ await fs.mkdir(backupDir, { recursive: true });
152
+ const safeName = path.relative(root, srcFile).replace(/\//g, "_");
153
+ await Bun.write(path.join(backupDir, safeName), srcContent);
154
+ }
155
+ await fs.unlink(srcFile); // safe to delete — dest has the content (or backup was created)
156
+ } else {
157
+ await fs.rename(srcFile, destFile);
158
+ }
159
+ }
160
+ }
161
+
162
+ async function migrateAndRemoveDir(src: string, dest: string, root: string): Promise<void> {
163
+ let stat;
164
+ try {
165
+ stat = await fs.lstat(src);
166
+ } catch {
167
+ return; // doesn't exist — no-op
168
+ }
169
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return;
170
+ await migrateFilesFromDir(src, dest, root);
171
+ await fs.rm(src, { recursive: true, force: true });
172
+ }
173
+
174
+ export async function migrateRuleAndSkillFiles(root: string): Promise<void> {
175
+ const destRules = path.join(root, ".oneagent/rules");
176
+ const destSkills = path.join(root, ".oneagent/skills");
177
+ // Rules dirs: only individual files become symlinks, so we only move the files.
178
+ // The directories themselves stay — generate() recreates per-file symlinks inside them.
179
+ // Sequential to avoid same-name conflicts across dirs.
180
+ await migrateFilesFromDir(path.join(root, ".cursor/rules"), destRules, root);
181
+ await migrateFilesFromDir(path.join(root, ".claude/rules"), destRules, root);
182
+ await migrateFilesFromDir(path.join(root, ".windsurf/rules"), destRules, root);
183
+ // .agents/skills is different: the entire directory becomes a symlink to .oneagent/skills,
184
+ // so the real directory must be removed first to make room for the symlink.
185
+ await migrateAndRemoveDir(path.join(root, ".agents/skills"), destSkills, root);
186
+ }
187
+
101
188
  export async function createAllSymlinks(entries: SymlinkEntry[]): Promise<void> {
102
189
  await Promise.all(entries.map((e) => createSymlink(e.symlinkPath, e.target)));
103
190
  }
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@ export type AgentTarget = "claude" | "cursor" | "windsurf" | "opencode" | "copil
2
2
 
3
3
  export interface Config {
4
4
  version: 1;
5
- targets: AgentTarget[];
5
+ targets: Record<AgentTarget, boolean>;
6
6
  }
7
7
 
8
8
  export interface DetectedFile {
@@ -20,6 +20,14 @@ export interface RuleFile {
20
20
  content: string;
21
21
  }
22
22
 
23
+ export interface SkillFile {
24
+ name: string;
25
+ path: string;
26
+ description: string;
27
+ mode: "ask" | "edit" | "agent";
28
+ content: string;
29
+ }
30
+
23
31
  export interface SymlinkEntry {
24
32
  symlinkPath: string;
25
33
  target: string;