@moskala/oneagent-core 0.2.5 → 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.5",
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 CHANGED
@@ -49,11 +49,11 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
49
49
  {
50
50
  target: "opencode",
51
51
  displayName: "OpenCode",
52
- hint: "AGENTS.md + opencode.json",
52
+ hint: "AGENTS.md + .opencode/",
53
53
  detectIndicators: ["opencode.json", ".opencode"],
54
54
  mainFile: "AGENTS.md",
55
- // rules: handled via opencode.json config, not symlinks
56
- // skills: handled via .agents/skills dir symlink
55
+ rulesDir: ".opencode/rules",
56
+ skillsDir: ".opencode/skills",
57
57
  },
58
58
  {
59
59
  target: "copilot",
@@ -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/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/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
  }
@@ -122,14 +122,12 @@ async function migrateAndRemoveDir(src: string, dest: string, root: string): Pro
122
122
  export async function migrateRuleAndSkillFiles(root: string): Promise<void> {
123
123
  const destRules = path.join(root, ".oneagent/rules");
124
124
  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 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.
127
126
  // 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.
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);
133
131
  await migrateAndRemoveDir(path.join(root, ".agents/skills"), destSkills, root);
134
132
  }
135
133