@moskala/oneagent-core 0.2.5 → 0.3.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.2.5",
3
+ "version": "0.3.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
@@ -12,6 +12,8 @@ export interface AgentDefinition {
12
12
  rulesDir?: string;
13
13
  /** Whole-dir symlink for skills (relative to root). Omit if not applicable. */
14
14
  skillsDir?: string;
15
+ /** Whole-dir symlink for commands (relative to root). Omit if agent does not support custom commands. */
16
+ commandsDir?: string;
15
17
  /** Legacy files to remove during init (superseded by current format). */
16
18
  deprecatedFiles?: string[];
17
19
  }
@@ -25,6 +27,7 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
25
27
  mainFile: "CLAUDE.md",
26
28
  rulesDir: ".claude/rules",
27
29
  skillsDir: ".claude/skills",
30
+ commandsDir: ".claude/commands",
28
31
  },
29
32
  {
30
33
  target: "cursor",
@@ -35,6 +38,7 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
35
38
  rulesDir: ".cursor/rules",
36
39
  skillsDir: ".cursor/skills",
37
40
  deprecatedFiles: [".cursorrules"],
41
+ commandsDir: ".cursor/commands",
38
42
  },
39
43
  {
40
44
  target: "windsurf",
@@ -49,11 +53,12 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
49
53
  {
50
54
  target: "opencode",
51
55
  displayName: "OpenCode",
52
- hint: "AGENTS.md + opencode.json",
56
+ hint: "AGENTS.md + .opencode/",
53
57
  detectIndicators: ["opencode.json", ".opencode"],
54
58
  mainFile: "AGENTS.md",
55
- // rules: handled via opencode.json config, not symlinks
56
- // skills: handled via .agents/skills dir symlink
59
+ rulesDir: ".opencode/rules",
60
+ skillsDir: ".opencode/skills",
61
+ commandsDir: ".opencode/commands",
57
62
  },
58
63
  {
59
64
  target: "copilot",
@@ -62,7 +67,7 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
62
67
  detectIndicators: [".github/copilot-instructions.md", ".github"],
63
68
  mainFile: ".github/copilot-instructions.md",
64
69
  skillsDir: ".github/skills",
65
- // rules: generated as .instructions.md files, not symlinks
70
+ // rules: generated as <name>.instructions.md files, not symlinks
66
71
  },
67
72
  ];
68
73
 
@@ -2,17 +2,90 @@ import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import { execFile } from "child_process";
4
4
  import { promisify } from "util";
5
+ import type { AgentTarget } from "./types.ts";
6
+ import { addOpenCodePlugin } from "./opencode.ts";
5
7
 
6
8
  const execFileAsync = promisify(execFile);
7
9
 
10
+ export interface TemplatePlugin {
11
+ target: AgentTarget;
12
+ id: string;
13
+ }
14
+
15
+ export interface SkillEntry {
16
+ repo: string;
17
+ skill: string;
18
+ }
19
+
8
20
  export interface TemplateDefinition {
9
21
  name: string;
10
22
  description: string;
11
- skills: string[];
23
+ skills: SkillEntry[];
24
+ plugins: TemplatePlugin[];
12
25
  instructions: string;
13
26
  rules: Array<{ name: string; content: string }>;
14
27
  }
15
28
 
29
+ // Parses name, description, skills and plugins from a template.yml string.
30
+ // This is the single source of truth for the template.yml format — used by
31
+ // both builtin template loading and GitHub URL template fetching.
32
+ export function parseTemplateYaml(yamlText: string, fallbackName = "custom"): Pick<TemplateDefinition, "name" | "description" | "skills" | "plugins"> {
33
+ const nameMatch = yamlText.match(/^name:\s*(.+)$/m);
34
+ const name = nameMatch?.[1]?.trim() ?? fallbackName;
35
+
36
+ const descMatch = yamlText.match(/^description:\s*(.+)$/m);
37
+ const description = descMatch?.[1]?.trim() ?? "";
38
+
39
+ const skills = parseSkillsFromYaml(yamlText);
40
+ const plugins = parsePluginsFromYaml(yamlText);
41
+ return { name, description, skills, plugins };
42
+ }
43
+
44
+ // Parses the `skills:` block from a template.yml string.
45
+ // Expects entries in the format:
46
+ // skills:
47
+ // - repo: https://github.com/owner/skills
48
+ // skill: skill-name
49
+ export function parseSkillsFromYaml(yamlText: string): SkillEntry[] {
50
+ const skills: SkillEntry[] = [];
51
+ const section = yamlText.match(/^skills:\s*\n((?:(?: -.+|\s{4}.+)\n?)*)/m);
52
+ if (!section) return skills;
53
+ const block = section[1]!;
54
+ const entries = block.split(/\n(?= -)/);
55
+ for (const entry of entries) {
56
+ const repoMatch = entry.match(/repo:\s*(\S+)/);
57
+ const skillMatch = entry.match(/skill:\s*(\S+)/);
58
+ if (repoMatch && skillMatch) {
59
+ skills.push({ repo: repoMatch[1]!.trim(), skill: skillMatch[1]!.trim() });
60
+ }
61
+ }
62
+ return skills;
63
+ }
64
+
65
+ // Parses the `plugins:` block from a template.yml string.
66
+ // Expects entries in the format:
67
+ // plugins:
68
+ // - target: claude
69
+ // id: typescript-lsp@claude-plugins-official
70
+ export function parsePluginsFromYaml(yamlText: string): TemplatePlugin[] {
71
+ const plugins: TemplatePlugin[] = [];
72
+ const section = yamlText.match(/^plugins:\s*\n((?:(?: -.+|\s{4}.+)\n?)*)/m);
73
+ if (!section) return plugins;
74
+ const block = section[1]!;
75
+ const entries = block.split(/\n(?= -)/);
76
+ for (const entry of entries) {
77
+ const targetMatch = entry.match(/target:\s*(\S+)/);
78
+ const idMatch = entry.match(/id:\s*(.+)/);
79
+ if (targetMatch && idMatch) {
80
+ plugins.push({
81
+ target: targetMatch[1]!.trim() as AgentTarget,
82
+ id: idMatch[1]!.trim(),
83
+ });
84
+ }
85
+ }
86
+ return plugins;
87
+ }
88
+
16
89
  // Phase 1: writes instructions.md and rules/*.md.
17
90
  // Call this BEFORE generate() so symlinks to rules are created.
18
91
  export async function applyTemplateFiles(root: string, template: TemplateDefinition): Promise<void> {
@@ -28,22 +101,93 @@ export async function applyTemplateFiles(root: string, template: TemplateDefinit
28
101
  }
29
102
  }
30
103
 
31
- // Phase 2: installs skills via `bunx skills add <identifier> --yes`.
32
- // Call this AFTER generate() so agent directories (symlinks) already exist.
104
+ export interface SkillInstallResult {
105
+ installed: SkillEntry[];
106
+ failed: Array<{ entry: SkillEntry; reason: string }>;
107
+ }
108
+
109
+ // 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.
111
+ // Never throws — failed skills are collected and returned in the result.
33
112
  export async function installTemplateSkills(
34
113
  root: string,
35
114
  template: TemplateDefinition,
36
- onSkillInstalled?: (identifier: string) => void,
37
- ): Promise<void> {
38
- for (const identifier of template.skills) {
115
+ ): 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
+ );
127
+
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
+ };
132
+ }
133
+
134
+ export interface PluginInstallResult {
135
+ installed: TemplatePlugin[];
136
+ manual: TemplatePlugin[];
137
+ failed: Array<{ plugin: TemplatePlugin; reason: string }>;
138
+ }
139
+
140
+ // Phase 3: installs plugins for active targets. Call AFTER generate().
141
+ // - claude → `claude plugin install <id>`
142
+ // - copilot → `copilot plugin install <id>`
143
+ // - opencode → adds id to plugin[] in opencode.json
144
+ // - cursor → added to manual list (no CLI yet — user runs /add-plugin in chat)
145
+ // - windsurf → skipped (no marketplace)
146
+ // Never throws — failed plugins are collected and returned in the result.
147
+ export async function installTemplatePlugins(
148
+ root: string,
149
+ template: TemplateDefinition,
150
+ activeTargets: AgentTarget[],
151
+ ): Promise<PluginInstallResult> {
152
+ const installed: TemplatePlugin[] = [];
153
+ const manual: TemplatePlugin[] = [];
154
+ const failed: Array<{ plugin: TemplatePlugin; reason: string }> = [];
155
+
156
+ for (const plugin of template.plugins) {
157
+ if (!activeTargets.includes(plugin.target)) continue;
158
+
39
159
  try {
40
- await execFileAsync("npx", ["skills", "add", identifier, "--agent", "universal", "--yes"], { cwd: root });
41
- onSkillInstalled?.(identifier);
160
+ switch (plugin.target) {
161
+ case "claude":
162
+ await execFileAsync("claude", ["plugin", "install", plugin.id], { cwd: root });
163
+ installed.push(plugin);
164
+ break;
165
+
166
+ case "copilot":
167
+ await execFileAsync("copilot", ["plugin", "install", plugin.id], { cwd: root });
168
+ installed.push(plugin);
169
+ break;
170
+
171
+ case "opencode":
172
+ await addOpenCodePlugin(root, plugin.id);
173
+ installed.push(plugin);
174
+ break;
175
+
176
+ case "cursor":
177
+ manual.push(plugin);
178
+ break;
179
+
180
+ case "windsurf":
181
+ // No marketplace yet — skip silently
182
+ break;
183
+ }
42
184
  } catch (err) {
43
- const message = err instanceof Error ? err.message : String(err);
44
- throw new Error(`Failed to install skill "${identifier}": ${message}`);
185
+ const reason = err instanceof Error ? err.message : String(err);
186
+ failed.push({ plugin, reason });
45
187
  }
46
188
  }
189
+
190
+ return { installed, manual, failed };
47
191
  }
48
192
 
49
193
  // Fetches a template from a GitHub URL.
@@ -58,26 +202,12 @@ export async function fetchTemplateFromGitHub(url: string): Promise<TemplateDefi
58
202
  fetchText(`${rawBase}/instructions.md`),
59
203
  ]);
60
204
 
61
- const descMatch = yamlText.match(/^description:\s*(.+)$/m);
62
- const description = descMatch?.[1]?.trim() ?? "";
63
-
64
- const nameMatch = yamlText.match(/^name:\s*(.+)$/m);
65
- const name = nameMatch?.[1]?.trim() ?? "custom";
66
-
67
- const skills: string[] = [];
68
- const skillsBlockMatch = yamlText.match(/^skills:\s*\n((?: - .+\n?)*)/m);
69
- if (skillsBlockMatch) {
70
- const lines = skillsBlockMatch[1]!.split("\n").filter(Boolean);
71
- for (const line of lines) {
72
- const skill = line.replace(/^\s*-\s*/, "").trim();
73
- if (skill) skills.push(skill);
74
- }
75
- }
205
+ const { name, description, skills, plugins } = parseTemplateYaml(yamlText);
76
206
 
77
207
  // Try to list rules via GitHub API
78
208
  const rules = await fetchGitHubRules(url);
79
209
 
80
- return { name, description, skills, instructions, rules };
210
+ return { name, description, skills, plugins, instructions, rules };
81
211
  }
82
212
 
83
213
  async function fetchText(url: string): Promise<string> {
@@ -88,23 +218,36 @@ async function fetchText(url: string): Promise<string> {
88
218
  return response.text();
89
219
  }
90
220
 
91
- function githubUrlToRawBase(url: string): string {
92
- // Handle https://github.com/owner/repo/tree/branch or https://github.com/owner/repo
93
- const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/([^/]+))?(?:\/.*)?$/);
221
+ interface GitHubUrlParts {
222
+ owner: string;
223
+ repo: string;
224
+ branch: string;
225
+ subdir: string; // "" for root, "path/to/dir" for subdirectories
226
+ }
227
+
228
+ function parseGitHubUrl(url: string): GitHubUrlParts {
229
+ // Supports:
230
+ // https://github.com/owner/repo
231
+ // https://github.com/owner/repo/tree/branch
232
+ // https://github.com/owner/repo/tree/branch/path/to/subdir
233
+ const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/([^/]+?)(?:\/(.+))?)?(?:\/)?$/);
94
234
  if (!match) {
95
235
  throw new Error(`Invalid GitHub URL: "${url}". Expected format: https://github.com/owner/repo`);
96
236
  }
97
- const [, owner, repo, branch = "main"] = match;
98
- return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
237
+ const [, owner, repo, branch = "main", subdir = ""] = match;
238
+ return { owner: owner!, repo: repo!, branch, subdir };
99
239
  }
100
240
 
101
- async function fetchGitHubRules(repoUrl: string): Promise<Array<{ name: string; content: string }>> {
102
- // Parse owner/repo from URL
103
- const match = repoUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/([^/]+))?(?:\/.*)?$/);
104
- if (!match) return [];
105
- const [, owner, repo, branch = "main"] = match;
241
+ function githubUrlToRawBase(url: string): string {
242
+ const { owner, repo, branch, subdir } = parseGitHubUrl(url);
243
+ const base = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
244
+ return subdir ? `${base}/${subdir}` : base;
245
+ }
106
246
 
107
- const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/rules?ref=${branch}`;
247
+ async function fetchGitHubRules(repoUrl: string): Promise<Array<{ name: string; content: string }>> {
248
+ const { owner, repo, branch, subdir } = parseGitHubUrl(repoUrl);
249
+ const rulesPath = subdir ? `${subdir}/rules` : "rules";
250
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${rulesPath}?ref=${branch}`;
108
251
  try {
109
252
  const response = await fetch(apiUrl, {
110
253
  headers: { Accept: "application/vnd.github.v3+json" },
@@ -0,0 +1,16 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import type { CommandFile } from "./types.ts";
4
+
5
+ export async function readCommands(root: string): Promise<CommandFile[]> {
6
+ const commandsDir = path.join(root, ".oneagent/commands");
7
+ try {
8
+ const files = await fs.readdir(commandsDir);
9
+ return files
10
+ .filter((f) => f.endsWith(".md"))
11
+ .map((f) => ({ name: path.basename(f, ".md"), path: path.join(commandsDir, f) }))
12
+ .sort((a, b) => a.name.localeCompare(b.name));
13
+ } catch {
14
+ return [];
15
+ }
16
+ }
package/src/copilot.ts CHANGED
@@ -2,18 +2,14 @@ import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { RuleFile, SkillFile } from "./types.ts";
4
4
 
5
- export function buildCopilotContent(rule: RuleFile): string {
6
- return `---\napplyTo: "${rule.applyTo}"\n---\n${rule.content}`;
7
- }
8
-
9
5
  export function copilotFilePath(root: string, ruleName: string): string {
10
6
  return path.join(root, ".github/instructions", `${ruleName}.instructions.md`);
11
7
  }
12
8
 
13
9
  export async function generateCopilotRule(root: string, rule: RuleFile): Promise<void> {
14
- const filePath = copilotFilePath(root, rule.name);
15
- await fs.mkdir(path.dirname(filePath), { recursive: true });
16
- await fs.writeFile(filePath, buildCopilotContent(rule));
10
+ const destPath = copilotFilePath(root, rule.name);
11
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
12
+ await fs.copyFile(rule.path, destPath);
17
13
  }
18
14
 
19
15
  export async function generateCopilotRules(root: string, rules: RuleFile[]): Promise<void> {
package/src/detect.ts CHANGED
@@ -61,14 +61,3 @@ export async function removeDeprecatedFiles(root: string): Promise<void> {
61
61
  }
62
62
  }
63
63
 
64
- export async function detectDeprecatedCommandFiles(root: string): Promise<string[]> {
65
- const commandsDir = path.join(root, ".claude/commands");
66
- try {
67
- const entries = await fs.readdir(commandsDir, { withFileTypes: true });
68
- return entries
69
- .filter((e) => e.isFile())
70
- .map((e) => path.join(".claude/commands", e.name));
71
- } catch {
72
- return [];
73
- }
74
- }
package/src/generate.ts CHANGED
@@ -4,8 +4,8 @@ import type { Config, DetectedFile } from "./types.ts";
4
4
  import { activeTargets } from "./config.ts";
5
5
  import { readRules } from "./rules.ts";
6
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";
7
+ import { buildMainSymlinks, buildRulesSymlinks, buildSkillSymlinks, buildCommandSymlinks, buildAgentsDirSymlinks, createAllSymlinks, migrateRuleAndSkillFiles } from "./symlinks.ts";
8
+ import { copilotFilePath, buildCopilotPromptContent, copilotPromptFilePath, generateCopilotRules, generateCopilotSkills } from "./copilot.ts";
9
9
  import { writeOpencode } from "./opencode.ts";
10
10
  import { readDetectedFile } from "./detect.ts";
11
11
 
@@ -24,6 +24,7 @@ export async function detectGenerateCollisions(root: string, config: Config): Pr
24
24
  const ruleSkillEntries = [
25
25
  ...buildRulesSymlinks(root, targets),
26
26
  ...buildSkillSymlinks(root, targets),
27
+ ...buildCommandSymlinks(root, targets),
27
28
  // .agents/skills skipped — handled by migrateAgentsSkillsDir
28
29
  ];
29
30
 
@@ -41,10 +42,13 @@ export async function detectGenerateCollisions(root: string, config: Config): Pr
41
42
  ...rules.map(async (rule): Promise<DetectedFile | null> => {
42
43
  const filePath = copilotFilePath(root, rule.name);
43
44
  try {
44
- const content = await fs.readFile(filePath, "utf-8");
45
- if (content === buildCopilotContent(rule)) return null;
45
+ const [source, dest] = await Promise.all([
46
+ fs.readFile(rule.path, "utf-8"),
47
+ fs.readFile(filePath, "utf-8"),
48
+ ]);
49
+ if (source === dest) return null;
46
50
  const stat = await fs.lstat(filePath);
47
- return { relativePath: path.relative(root, filePath), absolutePath: filePath, sizeBytes: stat.size, modifiedAt: stat.mtime, content };
51
+ return { relativePath: path.relative(root, filePath), absolutePath: filePath, sizeBytes: stat.size, modifiedAt: stat.mtime, content: dest };
48
52
  } catch { return null; }
49
53
  }),
50
54
  ...skills.map(async (skill): Promise<DetectedFile | null> => {
@@ -75,7 +79,8 @@ export async function generate(root: string, config: Config): Promise<void> {
75
79
  const mainSymlinks = buildMainSymlinks(root, targets);
76
80
  const rulesSymlinks = buildRulesSymlinks(root, targets);
77
81
  const skillSymlinks = await buildSkillSymlinks(root, targets);
78
- await createAllSymlinks([...mainSymlinks, ...rulesSymlinks, ...skillSymlinks, ...buildAgentsDirSymlinks(root)]);
82
+ const commandSymlinks = buildCommandSymlinks(root, targets);
83
+ await createAllSymlinks([...mainSymlinks, ...rulesSymlinks, ...skillSymlinks, ...commandSymlinks, ...buildAgentsDirSymlinks(root)]);
79
84
 
80
85
  if (targets.includes("copilot")) {
81
86
  await Promise.all([generateCopilotRules(root, rules), generateCopilotSkills(root, skills)]);
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./agents.ts";
3
3
  export * from "./config.ts";
4
4
  export * from "./detect.ts";
5
5
  export * from "./rules.ts";
6
+ export * from "./commands.ts";
6
7
  export * from "./skills.ts";
7
8
  export * from "./symlinks.ts";
8
9
  export * from "./copilot.ts";
package/src/opencode.ts CHANGED
@@ -18,6 +18,20 @@ export function buildOpencodeConfig(existing: Record<string, unknown> | null): o
18
18
  };
19
19
  }
20
20
 
21
+ export async function addOpenCodePlugin(root: string, id: string): Promise<void> {
22
+ const filePath = path.join(root, "opencode.json");
23
+ let existing: Record<string, unknown>;
24
+ try {
25
+ existing = JSON.parse(await fs.readFile(filePath, "utf-8")) as Record<string, unknown>;
26
+ } catch {
27
+ return; // no opencode.json — no-op
28
+ }
29
+ const current = Array.isArray(existing.plugin) ? (existing.plugin as string[]) : [];
30
+ if (current.includes(id)) return;
31
+ existing.plugin = [...current, id];
32
+ await fs.writeFile(filePath, JSON.stringify(existing, null, 2) + "\n");
33
+ }
34
+
21
35
  export async function writeOpencode(root: string, _rules: RuleFile[]): Promise<void> {
22
36
  const existing = await readOpencode(root);
23
37
  const config = buildOpencodeConfig(existing);
package/src/rules.ts CHANGED
@@ -2,32 +2,14 @@ import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { RuleFile } from "./types.ts";
4
4
 
5
- export function parseFrontmatter(raw: string): { applyTo: string; content: string } {
6
- const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
7
- if (!match) return { applyTo: "**", content: raw };
8
-
9
- const frontmatter = match[1] ?? "";
10
- const content = match[2] ?? "";
11
-
12
- const applyToMatch = frontmatter.match(/applyTo:\s*["']?([^"'\n]+)["']?/);
13
- const applyTo = applyToMatch?.[1]?.trim() ?? "**";
14
-
15
- return { applyTo, content };
16
- }
17
-
18
- export async function readRuleFile(filePath: string): Promise<RuleFile> {
19
- const raw = await fs.readFile(filePath, "utf-8");
20
- const { applyTo, content } = parseFrontmatter(raw);
21
- return { name: path.basename(filePath, ".md"), path: filePath, applyTo, content };
22
- }
23
-
24
5
  export async function readRules(root: string): Promise<RuleFile[]> {
25
6
  const rulesDir = path.join(root, ".oneagent/rules");
26
7
  try {
27
8
  const files = await fs.readdir(rulesDir);
28
- const mdFiles = files.filter((f) => f.endsWith(".md"));
29
- const rules = await Promise.all(mdFiles.map((f) => readRuleFile(path.join(rulesDir, f))));
30
- return rules.sort((a, b) => a.name.localeCompare(b.name));
9
+ return files
10
+ .filter((f) => f.endsWith(".md"))
11
+ .map((f) => ({ name: path.basename(f, ".md"), path: path.join(rulesDir, f) }))
12
+ .sort((a, b) => a.name.localeCompare(b.name));
31
13
  } catch {
32
14
  return [];
33
15
  }
package/src/status.ts CHANGED
@@ -3,16 +3,18 @@ import type { Config, GeneratedFileCheck, OpenCodeCheck, RuleFile, StatusResult
3
3
  import { activeTargets } from "./config.ts";
4
4
  import { readRules } from "./rules.ts";
5
5
  import { readSkills } from "./skills.ts";
6
- import { buildMainSymlinks, buildRulesSymlinks, buildSkillSymlinks, buildAgentsDirSymlinks, checkSymlink } from "./symlinks.ts";
7
- import { buildCopilotContent, buildCopilotPromptContent, copilotFilePath, copilotPromptFilePath } from "./copilot.ts";
6
+ import { buildMainSymlinks, buildRulesSymlinks, buildSkillSymlinks, buildCommandSymlinks, buildAgentsDirSymlinks, checkSymlink } from "./symlinks.ts";
7
+ import { buildCopilotPromptContent, copilotFilePath, copilotPromptFilePath } from "./copilot.ts";
8
8
  import { readOpencode } from "./opencode.ts";
9
9
 
10
10
  export async function checkGeneratedFile(root: string, rule: RuleFile): Promise<GeneratedFileCheck> {
11
11
  const filePath = copilotFilePath(root, rule.name);
12
- const expected = buildCopilotContent(rule);
13
12
  try {
14
- const content = await fs.readFile(filePath, "utf-8");
15
- return { path: filePath, exists: true, upToDate: content === expected };
13
+ const [source, dest] = await Promise.all([
14
+ fs.readFile(rule.path, "utf-8"),
15
+ fs.readFile(filePath, "utf-8"),
16
+ ]);
17
+ return { path: filePath, exists: true, upToDate: source === dest };
16
18
  } catch {
17
19
  return { path: filePath, exists: false, upToDate: false };
18
20
  }
@@ -46,6 +48,7 @@ export async function checkStatus(root: string, config: Config): Promise<StatusR
46
48
  ...buildMainSymlinks(root, targets),
47
49
  ...buildRulesSymlinks(root, targets),
48
50
  ...buildSkillSymlinks(root, targets),
51
+ ...buildCommandSymlinks(root, targets),
49
52
  ...buildAgentsDirSymlinks(root),
50
53
  ];
51
54
 
package/src/symlinks.ts CHANGED
@@ -10,9 +10,9 @@ export async function ensureDir(dirPath: string): Promise<void> {
10
10
  export async function createSymlink(symlinkPath: string, target: string): Promise<void> {
11
11
  await ensureDir(path.dirname(symlinkPath));
12
12
  try {
13
- await fs.unlink(symlinkPath);
13
+ await fs.rm(symlinkPath, { recursive: true });
14
14
  } catch {
15
- // file doesn't exist — that's fine
15
+ // doesn't exist — that's fine
16
16
  }
17
17
  await fs.symlink(target, symlinkPath);
18
18
  }
@@ -60,6 +60,16 @@ export function buildSkillSymlinks(root: string, targets: AgentTarget[]): Symlin
60
60
  });
61
61
  }
62
62
 
63
+ export function buildCommandSymlinks(root: string, targets: AgentTarget[]): SymlinkEntry[] {
64
+ const targetAbs = path.join(root, ".oneagent/commands");
65
+ return AGENT_DEFINITIONS
66
+ .filter((d) => targets.includes(d.target) && d.commandsDir)
67
+ .map((d) => {
68
+ const symlinkPath = path.join(root, d.commandsDir!);
69
+ return { symlinkPath, target: relativeTarget(symlinkPath, targetAbs), label: d.commandsDir! };
70
+ });
71
+ }
72
+
63
73
  export function buildAgentsDirSymlinks(root: string): SymlinkEntry[] {
64
74
  const symlinkPath = path.join(root, ".agents/skills");
65
75
  const targetAbs = path.join(root, ".oneagent/skills");
@@ -122,19 +132,22 @@ async function migrateAndRemoveDir(src: string, dest: string, root: string): Pro
122
132
  export async function migrateRuleAndSkillFiles(root: string): Promise<void> {
123
133
  const destRules = path.join(root, ".oneagent/rules");
124
134
  const destSkills = path.join(root, ".oneagent/skills");
125
- // Rules dirs: only individual files become symlinks, so we only move the files.
126
- // The directories themselves staygenerate() recreates per-file symlinks inside them.
127
- // Sequential to avoid same-name conflicts across dirs.
128
- await migrateFilesFromDir(path.join(root, ".cursor/rules"), destRules, root);
129
- await migrateFilesFromDir(path.join(root, ".claude/rules"), destRules, root);
130
- await migrateFilesFromDir(path.join(root, ".windsurf/rules"), destRules, root);
131
- // .agents/skills is different: the entire directory becomes a symlink to .oneagent/skills,
132
- // so the real directory must be removed first to make room for the symlink.
135
+ const destCommands = path.join(root, ".oneagent/commands");
136
+ // Derive migration sources from agent definitions sequential to avoid same-name conflicts.
137
+ for (const def of AGENT_DEFINITIONS) {
138
+ if (def.rulesDir) await migrateAndRemoveDir(path.join(root, def.rulesDir), destRules, root);
139
+ }
140
+ // .agents/skills — standard skills.sh path, not represented in agent definitions
133
141
  await migrateAndRemoveDir(path.join(root, ".agents/skills"), destSkills, root);
142
+ for (const def of AGENT_DEFINITIONS) {
143
+ if (def.commandsDir) await migrateAndRemoveDir(path.join(root, def.commandsDir), destCommands, root);
144
+ }
134
145
  }
135
146
 
136
147
  export async function createAllSymlinks(entries: SymlinkEntry[]): Promise<void> {
137
- await Promise.all(entries.map((e) => createSymlink(e.symlinkPath, e.target)));
148
+ for (const e of entries) {
149
+ await createSymlink(e.symlinkPath, e.target);
150
+ }
138
151
  }
139
152
 
140
153
  export async function checkSymlink(entry: SymlinkEntry): Promise<SymlinkCheck> {
package/src/types.ts CHANGED
@@ -16,8 +16,11 @@ export interface DetectedFile {
16
16
  export interface RuleFile {
17
17
  name: string;
18
18
  path: string;
19
- applyTo: string;
20
- content: string;
19
+ }
20
+
21
+ export interface CommandFile {
22
+ name: string;
23
+ path: string;
21
24
  }
22
25
 
23
26
  export interface SkillFile {