@moskala/oneagent-core 0.2.4 → 0.2.6

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.4",
3
+ "version": "0.2.6",
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 ADDED
@@ -0,0 +1,71 @@
1
+ import type { AgentTarget } from "./types.ts";
2
+
3
+ export interface AgentDefinition {
4
+ target: AgentTarget;
5
+ displayName: string;
6
+ hint: string;
7
+ /** Paths (relative to root) checked to detect agent presence. Any match = present. */
8
+ detectIndicators: string[];
9
+ /** Symlink path for main instructions file (relative to root). */
10
+ mainFile: string;
11
+ /** Whole-dir symlink for rules (relative to root). Omit if not applicable. */
12
+ rulesDir?: string;
13
+ /** Whole-dir symlink for skills (relative to root). Omit if not applicable. */
14
+ skillsDir?: string;
15
+ /** Legacy files to remove during init (superseded by current format). */
16
+ deprecatedFiles?: string[];
17
+ }
18
+
19
+ export const AGENT_DEFINITIONS: AgentDefinition[] = [
20
+ {
21
+ target: "claude",
22
+ displayName: "Claude Code",
23
+ hint: "CLAUDE.md + .claude/rules/",
24
+ detectIndicators: ["CLAUDE.md", ".claude"],
25
+ mainFile: "CLAUDE.md",
26
+ rulesDir: ".claude/rules",
27
+ skillsDir: ".claude/skills",
28
+ },
29
+ {
30
+ target: "cursor",
31
+ displayName: "Cursor",
32
+ hint: "AGENTS.md + .cursor/rules/",
33
+ detectIndicators: [".cursor", ".cursorrules"],
34
+ mainFile: "AGENTS.md",
35
+ rulesDir: ".cursor/rules",
36
+ skillsDir: ".cursor/skills",
37
+ deprecatedFiles: [".cursorrules"],
38
+ },
39
+ {
40
+ target: "windsurf",
41
+ displayName: "Windsurf",
42
+ hint: "AGENTS.md + .windsurf/rules/",
43
+ detectIndicators: [".windsurf", ".windsurfrules"],
44
+ mainFile: "AGENTS.md",
45
+ rulesDir: ".windsurf/rules",
46
+ skillsDir: ".windsurf/skills",
47
+ deprecatedFiles: [".windsurfrules"],
48
+ },
49
+ {
50
+ target: "opencode",
51
+ displayName: "OpenCode",
52
+ hint: "AGENTS.md + .opencode/",
53
+ detectIndicators: ["opencode.json", ".opencode"],
54
+ mainFile: "AGENTS.md",
55
+ rulesDir: ".opencode/rules",
56
+ skillsDir: ".opencode/skills",
57
+ },
58
+ {
59
+ target: "copilot",
60
+ displayName: "GitHub Copilot",
61
+ hint: ".github/instructions/*.instructions.md",
62
+ detectIndicators: [".github/copilot-instructions.md", ".github"],
63
+ mainFile: ".github/copilot-instructions.md",
64
+ skillsDir: ".github/skills",
65
+ // rules: generated as .instructions.md files, not symlinks
66
+ },
67
+ ];
68
+
69
+ export function getAgentDef(target: AgentTarget): AgentDefinition {
70
+ return AGENT_DEFINITIONS.find((d) => d.target === target)!;
71
+ }
@@ -2,17 +2,49 @@ 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
+
8
15
  export interface TemplateDefinition {
9
16
  name: string;
10
17
  description: string;
11
18
  skills: string[];
19
+ plugins: TemplatePlugin[];
12
20
  instructions: string;
13
21
  rules: Array<{ name: string; content: string }>;
14
22
  }
15
23
 
24
+ // Parses the `plugins:` block from a template.yml string.
25
+ // Expects entries in the format:
26
+ // plugins:
27
+ // - target: claude
28
+ // id: typescript-lsp@claude-plugins-official
29
+ export function parsePluginsFromYaml(yamlText: string): TemplatePlugin[] {
30
+ const plugins: TemplatePlugin[] = [];
31
+ const section = yamlText.match(/^plugins:\s*\n((?:(?: -.+|\s{4}.+)\n?)*)/m);
32
+ if (!section) return plugins;
33
+ const block = section[1]!;
34
+ const entries = block.split(/\n(?= -)/);
35
+ for (const entry of entries) {
36
+ const targetMatch = entry.match(/target:\s*(\S+)/);
37
+ const idMatch = entry.match(/id:\s*(.+)/);
38
+ if (targetMatch && idMatch) {
39
+ plugins.push({
40
+ target: targetMatch[1]!.trim() as AgentTarget,
41
+ id: idMatch[1]!.trim(),
42
+ });
43
+ }
44
+ }
45
+ return plugins;
46
+ }
47
+
16
48
  // Phase 1: writes instructions.md and rules/*.md.
17
49
  // Call this BEFORE generate() so symlinks to rules are created.
18
50
  export async function applyTemplateFiles(root: string, template: TemplateDefinition): Promise<void> {
@@ -46,6 +78,61 @@ export async function installTemplateSkills(
46
78
  }
47
79
  }
48
80
 
81
+ export interface PluginInstallResult {
82
+ installed: TemplatePlugin[];
83
+ manual: TemplatePlugin[];
84
+ }
85
+
86
+ // Phase 3: installs plugins for active targets. Call AFTER generate().
87
+ // - claude → `claude plugin install <id>`
88
+ // - copilot → `copilot plugin install <id>`
89
+ // - opencode → adds id to plugin[] in opencode.json
90
+ // - cursor → added to manual list (no CLI yet — user runs /add-plugin in chat)
91
+ // - windsurf → skipped (no marketplace)
92
+ export async function installTemplatePlugins(
93
+ root: string,
94
+ template: TemplateDefinition,
95
+ activeTargets: AgentTarget[],
96
+ onPluginInstalled?: (plugin: TemplatePlugin) => void,
97
+ ): Promise<PluginInstallResult> {
98
+ const installed: TemplatePlugin[] = [];
99
+ const manual: TemplatePlugin[] = [];
100
+
101
+ for (const plugin of template.plugins) {
102
+ if (!activeTargets.includes(plugin.target)) continue;
103
+
104
+ switch (plugin.target) {
105
+ case "claude":
106
+ await execFileAsync("claude", ["plugin", "install", plugin.id], { cwd: root });
107
+ installed.push(plugin);
108
+ onPluginInstalled?.(plugin);
109
+ break;
110
+
111
+ case "copilot":
112
+ await execFileAsync("copilot", ["plugin", "install", plugin.id], { cwd: root });
113
+ installed.push(plugin);
114
+ onPluginInstalled?.(plugin);
115
+ break;
116
+
117
+ case "opencode":
118
+ await addOpenCodePlugin(root, plugin.id);
119
+ installed.push(plugin);
120
+ onPluginInstalled?.(plugin);
121
+ break;
122
+
123
+ case "cursor":
124
+ manual.push(plugin);
125
+ break;
126
+
127
+ case "windsurf":
128
+ // No marketplace yet — skip silently
129
+ break;
130
+ }
131
+ }
132
+
133
+ return { installed, manual };
134
+ }
135
+
49
136
  // Fetches a template from a GitHub URL.
50
137
  // Expects the repository to contain: template.yml, instructions.md, and optionally rules/*.md
51
138
  export async function fetchTemplateFromGitHub(url: string): Promise<TemplateDefinition> {
@@ -74,10 +161,12 @@ export async function fetchTemplateFromGitHub(url: string): Promise<TemplateDefi
74
161
  }
75
162
  }
76
163
 
164
+ const plugins = parsePluginsFromYaml(yamlText);
165
+
77
166
  // Try to list rules via GitHub API
78
167
  const rules = await fetchGitHubRules(url);
79
168
 
80
- return { name, description, skills, instructions, rules };
169
+ return { name, description, skills, plugins, instructions, rules };
81
170
  }
82
171
 
83
172
  async function fetchText(url: string): Promise<string> {
package/src/detect.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import type { DetectedFile } from "./types.ts";
4
+ import { AGENT_DEFINITIONS } from "./agents.ts";
4
5
 
5
6
  export const AGENT_FILES = [
6
7
  "CLAUDE.md",
@@ -46,7 +47,7 @@ export function filesHaveSameContent(files: DetectedFile[]): boolean {
46
47
  return files.every((f) => f.content === first);
47
48
  }
48
49
 
49
- const DEPRECATED_FILES = [".cursorrules"];
50
+ const DEPRECATED_FILES = AGENT_DEFINITIONS.flatMap((d) => d.deprecatedFiles ?? []);
50
51
 
51
52
  export async function removeDeprecatedFiles(root: string): Promise<void> {
52
53
  for (const rel of DEPRECATED_FILES) {
package/src/generate.ts CHANGED
@@ -22,7 +22,7 @@ export async function detectGenerateCollisions(root: string, config: Config): Pr
22
22
  const mainEntries = buildMainSymlinks(root, targets);
23
23
  // 2. Rule/skill symlink paths
24
24
  const ruleSkillEntries = [
25
- ...buildRulesSymlinks(root, targets, rules),
25
+ ...buildRulesSymlinks(root, targets),
26
26
  ...buildSkillSymlinks(root, targets),
27
27
  // .agents/skills skipped — handled by migrateAgentsSkillsDir
28
28
  ];
@@ -73,7 +73,7 @@ export async function generate(root: string, config: Config): Promise<void> {
73
73
  await migrateRuleAndSkillFiles(root);
74
74
 
75
75
  const mainSymlinks = buildMainSymlinks(root, targets);
76
- const rulesSymlinks = buildRulesSymlinks(root, targets, rules);
76
+ const rulesSymlinks = buildRulesSymlinks(root, targets);
77
77
  const skillSymlinks = await buildSkillSymlinks(root, targets);
78
78
  await createAllSymlinks([...mainSymlinks, ...rulesSymlinks, ...skillSymlinks, ...buildAgentsDirSymlinks(root)]);
79
79
 
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./types.ts";
2
+ export * from "./agents.ts";
2
3
  export * from "./config.ts";
3
4
  export * from "./detect.ts";
4
5
  export * from "./rules.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/status.ts CHANGED
@@ -44,7 +44,7 @@ export async function checkStatus(root: string, config: Config): Promise<StatusR
44
44
 
45
45
  const allEntries = [
46
46
  ...buildMainSymlinks(root, targets),
47
- ...buildRulesSymlinks(root, targets, rules),
47
+ ...buildRulesSymlinks(root, targets),
48
48
  ...buildSkillSymlinks(root, targets),
49
49
  ...buildAgentsDirSymlinks(root),
50
50
  ];
package/src/symlinks.ts CHANGED
@@ -1,6 +1,7 @@
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, SymlinkCheck, SymlinkEntry } from "./types.ts";
4
+ import { AGENT_DEFINITIONS } from "./agents.ts";
4
5
 
5
6
  export async function ensureDir(dirPath: string): Promise<void> {
6
7
  await fs.mkdir(dirPath, { recursive: true });
@@ -9,9 +10,9 @@ export async function ensureDir(dirPath: string): Promise<void> {
9
10
  export async function createSymlink(symlinkPath: string, target: string): Promise<void> {
10
11
  await ensureDir(path.dirname(symlinkPath));
11
12
  try {
12
- await fs.unlink(symlinkPath);
13
+ await fs.rm(symlinkPath, { recursive: true });
13
14
  } catch {
14
- // file doesn't exist — that's fine
15
+ // doesn't exist — that's fine
15
16
  }
16
17
  await fs.symlink(target, symlinkPath);
17
18
  }
@@ -25,26 +26,8 @@ export function buildMainSymlinks(root: string, targets: AgentTarget[]): Symlink
25
26
  const seen = new Map<string, SymlinkEntry>();
26
27
 
27
28
  for (const target of targets) {
28
- let symlinkPath: string;
29
-
30
- switch (target) {
31
- case "claude":
32
- symlinkPath = path.join(root, "CLAUDE.md");
33
- break;
34
- case "cursor":
35
- symlinkPath = path.join(root, "AGENTS.md");
36
- break;
37
- case "windsurf":
38
- symlinkPath = path.join(root, ".windsurfrules");
39
- break;
40
- case "opencode":
41
- symlinkPath = path.join(root, "AGENTS.md");
42
- break;
43
- case "copilot":
44
- symlinkPath = path.join(root, ".github/copilot-instructions.md");
45
- break;
46
- }
47
-
29
+ const def = AGENT_DEFINITIONS.find((d) => d.target === target)!;
30
+ const symlinkPath = path.join(root, def.mainFile);
48
31
  if (!seen.has(symlinkPath)) {
49
32
  seen.set(symlinkPath, {
50
33
  symlinkPath,
@@ -57,64 +40,24 @@ export function buildMainSymlinks(root: string, targets: AgentTarget[]): Symlink
57
40
  return Array.from(seen.values());
58
41
  }
59
42
 
60
- export function buildRulesSymlinks(
61
- root: string,
62
- targets: AgentTarget[],
63
- rules: RuleFile[],
64
- ): SymlinkEntry[] {
65
- const entries: SymlinkEntry[] = [];
66
-
67
- for (const target of targets) {
68
- let rulesDir: string | null = null;
69
-
70
- switch (target) {
71
- case "claude":
72
- rulesDir = path.join(root, ".claude/rules");
73
- break;
74
- case "cursor":
75
- rulesDir = path.join(root, ".cursor/rules");
76
- break;
77
- case "windsurf":
78
- rulesDir = path.join(root, ".windsurf/rules");
79
- break;
80
- case "opencode":
81
- case "copilot":
82
- rulesDir = null;
83
- break;
84
- }
85
-
86
- if (!rulesDir) continue;
87
-
88
- for (const rule of rules) {
89
- const symlinkPath = path.join(rulesDir, `${rule.name}.md`);
90
- entries.push({
91
- symlinkPath,
92
- target: relativeTarget(symlinkPath, rule.path),
93
- label: path.relative(root, symlinkPath),
94
- });
95
- }
96
- }
97
-
98
- return entries;
43
+ export function buildRulesSymlinks(root: string, targets: AgentTarget[]): SymlinkEntry[] {
44
+ const targetAbs = path.join(root, ".oneagent/rules");
45
+ return AGENT_DEFINITIONS
46
+ .filter((d) => targets.includes(d.target) && d.rulesDir)
47
+ .map((d) => {
48
+ const symlinkPath = path.join(root, d.rulesDir!);
49
+ return { symlinkPath, target: relativeTarget(symlinkPath, targetAbs), label: d.rulesDir! };
50
+ });
99
51
  }
100
52
 
101
- // Creates whole-directory symlinks: .claude/skills → .oneagent/skills, .cursor/skills → .oneagent/skills
102
53
  export function buildSkillSymlinks(root: string, targets: AgentTarget[]): SymlinkEntry[] {
103
54
  const targetAbs = path.join(root, ".oneagent/skills");
104
- const agentDirs: Partial<Record<AgentTarget, string>> = {
105
- claude: path.join(root, ".claude/skills"),
106
- cursor: path.join(root, ".cursor/skills"),
107
- windsurf: path.join(root, ".windsurf/skills"),
108
- copilot: path.join(root, ".github/skills"),
109
- };
110
-
111
- return (Object.entries(agentDirs) as [AgentTarget, string][])
112
- .filter(([target]) => targets.includes(target))
113
- .map(([, dir]) => ({
114
- symlinkPath: dir,
115
- target: relativeTarget(dir, targetAbs),
116
- label: path.relative(root, dir),
117
- }));
55
+ return AGENT_DEFINITIONS
56
+ .filter((d) => targets.includes(d.target) && d.skillsDir)
57
+ .map((d) => {
58
+ const symlinkPath = path.join(root, d.skillsDir!);
59
+ return { symlinkPath, target: relativeTarget(symlinkPath, targetAbs), label: d.skillsDir! };
60
+ });
118
61
  }
119
62
 
120
63
  export function buildAgentsDirSymlinks(root: string): SymlinkEntry[] {
@@ -179,14 +122,12 @@ async function migrateAndRemoveDir(src: string, dest: string, root: string): Pro
179
122
  export async function migrateRuleAndSkillFiles(root: string): Promise<void> {
180
123
  const destRules = path.join(root, ".oneagent/rules");
181
124
  const destSkills = path.join(root, ".oneagent/skills");
182
- // Rules dirs: only individual files become symlinks, so we only move the files.
183
- // The directories themselves stay — generate() recreates per-file symlinks inside them.
125
+ // Rules dirs: the entire directory becomes a symlink, so move files out and remove the dir.
184
126
  // Sequential to avoid same-name conflicts across dirs.
185
- await migrateFilesFromDir(path.join(root, ".cursor/rules"), destRules, root);
186
- await migrateFilesFromDir(path.join(root, ".claude/rules"), destRules, root);
187
- await migrateFilesFromDir(path.join(root, ".windsurf/rules"), destRules, root);
188
- // .agents/skills is different: the entire directory becomes a symlink to .oneagent/skills,
189
- // so the real directory must be removed first to make room for the symlink.
127
+ await migrateAndRemoveDir(path.join(root, ".cursor/rules"), destRules, root);
128
+ await migrateAndRemoveDir(path.join(root, ".claude/rules"), destRules, root);
129
+ await migrateAndRemoveDir(path.join(root, ".windsurf/rules"), destRules, root);
130
+ await migrateAndRemoveDir(path.join(root, ".opencode/rules"), destRules, root);
190
131
  await migrateAndRemoveDir(path.join(root, ".agents/skills"), destSkills, root);
191
132
  }
192
133