@orchid-labs/pluxx 0.1.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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +574 -0
  3. package/bin/pluxx.js +37 -0
  4. package/dist/cli/agent.d.ts +90 -0
  5. package/dist/cli/agent.d.ts.map +1 -0
  6. package/dist/cli/dev.d.ts +2 -0
  7. package/dist/cli/dev.d.ts.map +1 -0
  8. package/dist/cli/doctor.d.ts +19 -0
  9. package/dist/cli/doctor.d.ts.map +1 -0
  10. package/dist/cli/index.d.ts +24 -0
  11. package/dist/cli/index.d.ts.map +1 -0
  12. package/dist/cli/init-from-mcp.d.ts +145 -0
  13. package/dist/cli/init-from-mcp.d.ts.map +1 -0
  14. package/dist/cli/install.d.ts +56 -0
  15. package/dist/cli/install.d.ts.map +1 -0
  16. package/dist/cli/lint.d.ts +18 -0
  17. package/dist/cli/lint.d.ts.map +1 -0
  18. package/dist/cli/migrate.d.ts +2 -0
  19. package/dist/cli/migrate.d.ts.map +1 -0
  20. package/dist/cli/prompt.d.ts +20 -0
  21. package/dist/cli/prompt.d.ts.map +1 -0
  22. package/dist/cli/publish.d.ts +70 -0
  23. package/dist/cli/publish.d.ts.map +1 -0
  24. package/dist/cli/runtime.d.ts +20 -0
  25. package/dist/cli/runtime.d.ts.map +1 -0
  26. package/dist/cli/sync-from-mcp.d.ts +32 -0
  27. package/dist/cli/sync-from-mcp.d.ts.map +1 -0
  28. package/dist/cli/test.d.ts +33 -0
  29. package/dist/cli/test.d.ts.map +1 -0
  30. package/dist/compatibility/matrix.d.ts +14 -0
  31. package/dist/compatibility/matrix.d.ts.map +1 -0
  32. package/dist/config/define.d.ts +18 -0
  33. package/dist/config/define.d.ts.map +1 -0
  34. package/dist/config/load.d.ts +7 -0
  35. package/dist/config/load.d.ts.map +1 -0
  36. package/dist/generators/amp/index.d.ts +13 -0
  37. package/dist/generators/amp/index.d.ts.map +1 -0
  38. package/dist/generators/base.d.ts +49 -0
  39. package/dist/generators/base.d.ts.map +1 -0
  40. package/dist/generators/claude-code/index.d.ts +7 -0
  41. package/dist/generators/claude-code/index.d.ts.map +1 -0
  42. package/dist/generators/cline/index.d.ts +14 -0
  43. package/dist/generators/cline/index.d.ts.map +1 -0
  44. package/dist/generators/codex/index.d.ts +9 -0
  45. package/dist/generators/codex/index.d.ts.map +1 -0
  46. package/dist/generators/cursor/index.d.ts +11 -0
  47. package/dist/generators/cursor/index.d.ts.map +1 -0
  48. package/dist/generators/gemini-cli/index.d.ts +13 -0
  49. package/dist/generators/gemini-cli/index.d.ts.map +1 -0
  50. package/dist/generators/github-copilot/index.d.ts +11 -0
  51. package/dist/generators/github-copilot/index.d.ts.map +1 -0
  52. package/dist/generators/hooks-warning.d.ts +3 -0
  53. package/dist/generators/hooks-warning.d.ts.map +1 -0
  54. package/dist/generators/index.d.ts +11 -0
  55. package/dist/generators/index.d.ts.map +1 -0
  56. package/dist/generators/opencode/index.d.ts +15 -0
  57. package/dist/generators/opencode/index.d.ts.map +1 -0
  58. package/dist/generators/openhands/index.d.ts +11 -0
  59. package/dist/generators/openhands/index.d.ts.map +1 -0
  60. package/dist/generators/roo-code/index.d.ts +14 -0
  61. package/dist/generators/roo-code/index.d.ts.map +1 -0
  62. package/dist/generators/shared/claude-family.d.ts +18 -0
  63. package/dist/generators/shared/claude-family.d.ts.map +1 -0
  64. package/dist/generators/warp/index.d.ts +13 -0
  65. package/dist/generators/warp/index.d.ts.map +1 -0
  66. package/dist/hook-events.d.ts +4 -0
  67. package/dist/hook-events.d.ts.map +1 -0
  68. package/dist/index.d.ts +7 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +5302 -0
  71. package/dist/mcp/introspect.d.ts +34 -0
  72. package/dist/mcp/introspect.d.ts.map +1 -0
  73. package/dist/permissions.d.ts +18 -0
  74. package/dist/permissions.d.ts.map +1 -0
  75. package/dist/schema.d.ts +9457 -0
  76. package/dist/schema.d.ts.map +1 -0
  77. package/dist/user-config.d.ts +19 -0
  78. package/dist/user-config.d.ts.map +1 -0
  79. package/dist/validation/platform-rules.d.ts +64 -0
  80. package/dist/validation/platform-rules.d.ts.map +1 -0
  81. package/package.json +76 -0
  82. package/src/cli/agent.ts +1030 -0
  83. package/src/cli/dev.ts +112 -0
  84. package/src/cli/doctor.ts +588 -0
  85. package/src/cli/index.ts +2414 -0
  86. package/src/cli/init-from-mcp.ts +1611 -0
  87. package/src/cli/install.ts +698 -0
  88. package/src/cli/lint.ts +1219 -0
  89. package/src/cli/migrate.ts +614 -0
  90. package/src/cli/prompt.ts +82 -0
  91. package/src/cli/publish.ts +401 -0
  92. package/src/cli/runtime.ts +86 -0
  93. package/src/cli/sync-from-mcp.ts +563 -0
  94. package/src/cli/test.ts +134 -0
  95. package/src/compatibility/matrix.ts +149 -0
  96. package/src/config/define.ts +20 -0
  97. package/src/config/load.ts +74 -0
  98. package/src/generators/amp/index.ts +63 -0
  99. package/src/generators/base.ts +188 -0
  100. package/src/generators/claude-code/index.ts +29 -0
  101. package/src/generators/cline/index.ts +35 -0
  102. package/src/generators/codex/index.ts +120 -0
  103. package/src/generators/cursor/index.ts +158 -0
  104. package/src/generators/gemini-cli/index.ts +83 -0
  105. package/src/generators/github-copilot/index.ts +32 -0
  106. package/src/generators/hooks-warning.ts +51 -0
  107. package/src/generators/index.ts +71 -0
  108. package/src/generators/opencode/index.ts +526 -0
  109. package/src/generators/openhands/index.ts +32 -0
  110. package/src/generators/roo-code/index.ts +35 -0
  111. package/src/generators/shared/claude-family.ts +215 -0
  112. package/src/generators/warp/index.ts +32 -0
  113. package/src/hook-events.ts +33 -0
  114. package/src/index.ts +23 -0
  115. package/src/mcp/introspect.ts +834 -0
  116. package/src/permissions.ts +258 -0
  117. package/src/schema.ts +312 -0
  118. package/src/user-config.ts +177 -0
  119. package/src/validation/platform-rules.ts +565 -0
@@ -0,0 +1,526 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
2
+ import { extname, relative, resolve } from 'path'
3
+ import { Generator } from '../base'
4
+ import type { HookEntry, TargetPlatform } from '../../schema'
5
+ import { buildOpenCodePermissionMap } from '../../permissions'
6
+
7
+ type GeneratedHook = {
8
+ command: string
9
+ timeout?: number
10
+ matcher?: HookEntry['matcher']
11
+ failClosed?: boolean
12
+ }
13
+
14
+ interface OpenCodeMcpDefinition {
15
+ transport: 'http' | 'sse' | 'stdio'
16
+ url?: string
17
+ command?: string
18
+ args?: string[]
19
+ env?: Record<string, string>
20
+ auth?: {
21
+ type: 'bearer' | 'header' | 'none'
22
+ envVar?: string
23
+ headerName?: string
24
+ headerTemplate?: string
25
+ }
26
+ }
27
+
28
+ interface OpenCodeHookPlan {
29
+ event: Record<string, GeneratedHook[]>
30
+ toolBefore: {
31
+ all: GeneratedHook[]
32
+ read: GeneratedHook[]
33
+ mcp: GeneratedHook[]
34
+ }
35
+ toolAfter: {
36
+ all: GeneratedHook[]
37
+ edit: GeneratedHook[]
38
+ mcp: GeneratedHook[]
39
+ }
40
+ shellEnv: GeneratedHook[]
41
+ chatMessage: GeneratedHook[]
42
+ }
43
+
44
+ export class OpenCodeGenerator extends Generator {
45
+ readonly platform: TargetPlatform = 'opencode'
46
+
47
+ async generate(): Promise<void> {
48
+ await Promise.all([
49
+ this.generatePackageJson(),
50
+ this.generatePluginWrapper(),
51
+ ])
52
+
53
+ this.copySkills()
54
+ this.copyCommands()
55
+ this.copyScripts()
56
+ this.copyAssets()
57
+ }
58
+
59
+ private async generatePackageJson(): Promise<void> {
60
+ const npmName = this.config.platforms?.opencode?.npmPackage
61
+ ?? `opencode-${this.config.name}`
62
+
63
+ const pkg = {
64
+ name: npmName,
65
+ version: this.config.version,
66
+ description: `${this.config.description} (OpenCode plugin)`,
67
+ main: 'index.ts',
68
+ type: 'module',
69
+ keywords: [
70
+ 'opencode-plugin',
71
+ ...(this.config.keywords ?? []),
72
+ ],
73
+ author: this.config.author.name,
74
+ license: this.config.license,
75
+ peerDependencies: {
76
+ '@opencode-ai/plugin': '*',
77
+ },
78
+ }
79
+
80
+ await this.writeJson('package.json', pkg)
81
+ }
82
+
83
+ private async generatePluginWrapper(): Promise<void> {
84
+ const pluginName = toPascalCase(this.config.name) + 'Plugin'
85
+ const envVars = this.getRequiredEnvVars()
86
+ const mcpDefinitions = this.getOpenCodeMcpDefinitions()
87
+ const commandDefinitions = this.getOpenCodeCommandDefinitions()
88
+ const hookPlan = this.getOpenCodeHookPlan()
89
+ const instructions = this.getInstructionsContent()
90
+ const permissionMap = buildOpenCodePermissionMap(this.config.permissions)
91
+
92
+ const lines: string[] = [
93
+ `import type { Config, Plugin } from "@opencode-ai/plugin"`,
94
+ `import { existsSync, readFileSync } from "fs"`,
95
+ `import { resolve } from "path"`,
96
+ '',
97
+ `type GeneratedHook = {`,
98
+ ` command: string`,
99
+ ` timeout?: number`,
100
+ ` matcher?: string`,
101
+ ` failClosed?: boolean`,
102
+ `}`,
103
+ '',
104
+ `const REQUIRED_ENV_VARS = ${JSON.stringify(envVars, null, 2)}`,
105
+ '',
106
+ `const MCP_DEFINITIONS = ${JSON.stringify(mcpDefinitions, null, 2)}`,
107
+ '',
108
+ `const TUI_COMMANDS = ${JSON.stringify(commandDefinitions, null, 2)}`,
109
+ '',
110
+ `const EVENT_HOOKS: Record<string, GeneratedHook[]> = ${JSON.stringify(hookPlan.event, null, 2)}`,
111
+ '',
112
+ `const TOOL_BEFORE_HOOKS = ${JSON.stringify(hookPlan.toolBefore, null, 2)}`,
113
+ '',
114
+ `const TOOL_AFTER_HOOKS = ${JSON.stringify(hookPlan.toolAfter, null, 2)}`,
115
+ '',
116
+ `const SHELL_ENV_HOOKS = ${JSON.stringify(hookPlan.shellEnv, null, 2)}`,
117
+ '',
118
+ `const CHAT_MESSAGE_HOOKS = ${JSON.stringify(hookPlan.chatMessage, null, 2)}`,
119
+ '',
120
+ `const INSTRUCTIONS = ${JSON.stringify(instructions)}`,
121
+ '',
122
+ `const PERMISSIONS = ${JSON.stringify(permissionMap, null, 2)}`,
123
+ '',
124
+ `const isMcpTool = (tool: string): boolean =>`,
125
+ ` tool === "mcp" || tool.startsWith("mcp.") || tool.startsWith("mcp_")`,
126
+ '',
127
+ `const loadUserConfig = (directory: string): { values?: Record<string, string | number | boolean>; env?: Record<string, string> } => {`,
128
+ ` const filepath = resolve(directory, ".pluxx-user.json")`,
129
+ ` if (!existsSync(filepath)) return {}`,
130
+ ` try {`,
131
+ ` return JSON.parse(readFileSync(filepath, "utf-8"))`,
132
+ ` } catch {`,
133
+ ` return {}`,
134
+ ` }`,
135
+ `}`,
136
+ '',
137
+ `const resolveRuntimeValue = (name: string, userEnv: Record<string, string>): string | undefined =>`,
138
+ ` userEnv[name] ?? process.env[name]`,
139
+ '',
140
+ `const materializeEnv = (input: Record<string, string> | undefined, userEnv: Record<string, string>): Record<string, string> | undefined => {`,
141
+ ` if (!input) return undefined`,
142
+ ` const output: Record<string, string> = {}`,
143
+ ` for (const [key, value] of Object.entries(input)) {`,
144
+ ` output[key] = value.replace(/\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}/g, (_match, name) => resolveRuntimeValue(name, userEnv) ?? \`\\\${\${name}}\`)`,
145
+ ` }`,
146
+ ` return output`,
147
+ `}`,
148
+ '',
149
+ `const buildMcpConfig = (directory: string): NonNullable<Config["mcp"]> => {`,
150
+ ` const config: NonNullable<Config["mcp"]> = {}`,
151
+ ` const userEnv = loadUserConfig(directory).env ?? {}`,
152
+ '',
153
+ ` for (const [name, definition] of Object.entries(MCP_DEFINITIONS)) {`,
154
+ ` if (definition.transport === "stdio" && definition.command) {`,
155
+ ` config[name] = {`,
156
+ ` type: "local",`,
157
+ ` command: [definition.command, ...(definition.args ?? [])],`,
158
+ ` ...(definition.env ? { environment: materializeEnv(definition.env, userEnv) } : {}),`,
159
+ ` }`,
160
+ ` continue`,
161
+ ` }`,
162
+ '',
163
+ ` if (!definition.url) continue`,
164
+ '',
165
+ ` const remote: {`,
166
+ ` type: "remote"`,
167
+ ` url: string`,
168
+ ` headers?: Record<string, string>`,
169
+ ` } = {`,
170
+ ` type: "remote",`,
171
+ ` url: definition.url,`,
172
+ ` }`,
173
+ '',
174
+ ` if (definition.auth?.type === "bearer" && definition.auth.envVar) {`,
175
+ ` const token = resolveRuntimeValue(definition.auth.envVar, userEnv)`,
176
+ ` if (token) remote.headers = { Authorization: \`Bearer \${token}\` }`,
177
+ ` }`,
178
+ '',
179
+ ` if (definition.auth?.type === "header" && definition.auth.envVar && definition.auth.headerName && definition.auth.headerTemplate) {`,
180
+ ` const value = resolveRuntimeValue(definition.auth.envVar, userEnv)`,
181
+ ` if (value) {`,
182
+ ` remote.headers = {`,
183
+ ` ...(remote.headers ?? {}),`,
184
+ ` [definition.auth.headerName]: definition.auth.headerTemplate.replace("\${value}", value),`,
185
+ ` }`,
186
+ ` }`,
187
+ ` }`,
188
+ '',
189
+ ` config[name] = remote`,
190
+ ` }`,
191
+ '',
192
+ ` return config`,
193
+ `}`,
194
+ '',
195
+ `const applyInstructions = (system: string[]): void => {`,
196
+ ` if (!INSTRUCTIONS) return`,
197
+ ` if (!system.includes(INSTRUCTIONS)) {`,
198
+ ` system.unshift(INSTRUCTIONS)`,
199
+ ` }`,
200
+ `}`,
201
+ '',
202
+ `/**`,
203
+ ` * ${this.config.description}`,
204
+ ` * Generated by pluxx — do not edit manually.`,
205
+ ` */`,
206
+ `export const ${pluginName}: Plugin = async ({ project, client, $, directory }) => {`,
207
+ ` const runHook = async (hook: GeneratedHook, context: Record<string, string>): Promise<void> => {`,
208
+ ` try {`,
209
+ ` const command = hook.command.replaceAll("\${PLUGIN_ROOT}", directory)`,
210
+ ` const execution = $\`bash -lc \${command}\``,
211
+ ` if (hook.timeout) {`,
212
+ ` await Promise.race([`,
213
+ ` execution,`,
214
+ ` new Promise((_, reject) => {`,
215
+ ` setTimeout(() => reject(new Error(\`Hook timed out after \${hook.timeout}ms: \${command}\`)), hook.timeout)`,
216
+ ` }),`,
217
+ ` ])`,
218
+ ` } else {`,
219
+ ` await execution`,
220
+ ` }`,
221
+ ` } catch (error) {`,
222
+ ` await client.app.log({`,
223
+ ` body: {`,
224
+ ` service: "${this.config.name}",`,
225
+ ` level: "error",`,
226
+ ` message: "OpenCode hook execution failed",`,
227
+ ` extra: {`,
228
+ ` ...context,`,
229
+ ` hook: hook.command,`,
230
+ ` error: error instanceof Error ? error.message : String(error),`,
231
+ ` },`,
232
+ ` },`,
233
+ ` })`,
234
+ ` if (hook.failClosed) throw error`,
235
+ ` }`,
236
+ ` }`,
237
+ '',
238
+ ` const runHooks = async (hooks: GeneratedHook[], context: Record<string, string>): Promise<void> => {`,
239
+ ` for (const hook of hooks) {`,
240
+ ` await runHook(hook, context)`,
241
+ ` }`,
242
+ ` }`,
243
+ '',
244
+ ` return {`,
245
+ ` config: async (config) => {`,
246
+ ` if (Object.keys(MCP_DEFINITIONS).length > 0) {`,
247
+ ` config.mcp = {`,
248
+ ` ...(config.mcp ?? {}),`,
249
+ ` ...buildMcpConfig(directory),`,
250
+ ` }`,
251
+ ` }`,
252
+ '',
253
+ ` if (Object.keys(TUI_COMMANDS).length > 0) {`,
254
+ ` config.command = {`,
255
+ ` ...(config.command ?? {}),`,
256
+ ` ...TUI_COMMANDS,`,
257
+ ` }`,
258
+ ` }`,
259
+ '',
260
+ ` if (Object.keys(PERMISSIONS).length > 0) {`,
261
+ ` config.permission = {`,
262
+ ` ...(config.permission ?? {}),`,
263
+ ` ...PERMISSIONS,`,
264
+ ` }`,
265
+ ` }`,
266
+ ` },`,
267
+ '',
268
+ ` "tool.execute.before": async (input, output) => {`,
269
+ ` await runHooks(TOOL_BEFORE_HOOKS.all, { hookType: "tool.execute.before", tool: input.tool })`,
270
+ ` if (input.tool === "read") {`,
271
+ ` await runHooks(TOOL_BEFORE_HOOKS.read, { hookType: "tool.execute.before", tool: input.tool })`,
272
+ ` }`,
273
+ ` if (isMcpTool(input.tool)) {`,
274
+ ` await runHooks(TOOL_BEFORE_HOOKS.mcp, { hookType: "tool.execute.before", tool: input.tool })`,
275
+ ` }`,
276
+ ` },`,
277
+ '',
278
+ ` "tool.execute.after": async (input, output) => {`,
279
+ ` await runHooks(TOOL_AFTER_HOOKS.all, { hookType: "tool.execute.after", tool: input.tool })`,
280
+ ` if (input.tool === "edit" || input.tool === "write") {`,
281
+ ` await runHooks(TOOL_AFTER_HOOKS.edit, { hookType: "tool.execute.after", tool: input.tool })`,
282
+ ` }`,
283
+ ` if (isMcpTool(input.tool)) {`,
284
+ ` await runHooks(TOOL_AFTER_HOOKS.mcp, { hookType: "tool.execute.after", tool: input.tool })`,
285
+ ` }`,
286
+ ` },`,
287
+ '',
288
+ ` "shell.env": async (input, output) => {`,
289
+ ` await runHooks(SHELL_ENV_HOOKS, { hookType: "shell.env", cwd: input.cwd })`,
290
+ ` },`,
291
+ '',
292
+ ` "chat.message": async (input, output) => {`,
293
+ ` await runHooks(CHAT_MESSAGE_HOOKS, { hookType: "chat.message", sessionID: input.sessionID })`,
294
+ ` },`,
295
+ '',
296
+ ` "experimental.chat.system.transform": async (input, output) => {`,
297
+ ` applyInstructions(output.system)`,
298
+ ` },`,
299
+ '',
300
+ ` "experimental.session.compacting": async (input, output) => {`,
301
+ ` if (INSTRUCTIONS && !output.context.includes(INSTRUCTIONS)) {`,
302
+ ` output.context.push(INSTRUCTIONS)`,
303
+ ` }`,
304
+ ` },`,
305
+ '',
306
+ ` event: async ({ event }) => {`,
307
+ ` if (event.type === "session.created") {`,
308
+ ]
309
+
310
+ for (const envVar of envVars) {
311
+ lines.push(` if (!process.env.${envVar}) {`)
312
+ lines.push(` await client.app.log({`)
313
+ lines.push(` body: {`)
314
+ lines.push(` service: "${this.config.name}",`)
315
+ lines.push(` level: "warn",`)
316
+ lines.push(` message: "${envVar} is not set. ${this.config.brand?.displayName ?? this.config.name} plugin may not work correctly.",`)
317
+ lines.push(` },`)
318
+ lines.push(` })`)
319
+ lines.push(` }`)
320
+ }
321
+ lines.push(` }`)
322
+ lines.push(` const hooks = EVENT_HOOKS[event.type] ?? []`)
323
+ lines.push(` await runHooks(hooks, { hookType: "event", event: event.type })`)
324
+ lines.push(` },`)
325
+
326
+ lines.push(` }`)
327
+ lines.push(`}`)
328
+ lines.push('')
329
+
330
+ await this.writeFile('index.ts', lines.join('\n'))
331
+ }
332
+
333
+ private getRequiredEnvVars(): string[] {
334
+ const vars = new Set<string>()
335
+ if (this.config.mcp) {
336
+ for (const server of Object.values(this.config.mcp)) {
337
+ if (server.auth && 'envVar' in server.auth && server.auth.envVar) {
338
+ vars.add(server.auth.envVar)
339
+ }
340
+ }
341
+ }
342
+ return [...vars]
343
+ }
344
+
345
+ private getOpenCodeMcpDefinitions(): Record<string, OpenCodeMcpDefinition> {
346
+ if (!this.config.mcp) return {}
347
+
348
+ const output: Record<string, OpenCodeMcpDefinition> = {}
349
+ for (const [name, server] of Object.entries(this.config.mcp)) {
350
+ const auth = server.auth?.type === 'platform'
351
+ ? undefined
352
+ : server.auth
353
+
354
+ output[name] = {
355
+ transport: server.transport,
356
+ ...(server.url ? { url: server.url } : {}),
357
+ ...(server.command ? { command: server.command } : {}),
358
+ ...(server.args ? { args: server.args } : {}),
359
+ ...(server.env ? { env: server.env } : {}),
360
+ ...(auth ? { auth } : {}),
361
+ }
362
+ }
363
+ return output
364
+ }
365
+
366
+ private getOpenCodeCommandDefinitions(): Record<string, { template: string; description?: string }> {
367
+ if (!this.config.commands) return {}
368
+
369
+ const commandsDir = this.resolveConfigPath(this.config.commands, 'commands')
370
+ if (!existsSync(commandsDir)) return {}
371
+
372
+ const files = this.walkFiles(commandsDir).filter(file => extname(file) === '.md')
373
+ const output: Record<string, { template: string; description?: string }> = {}
374
+
375
+ for (const file of files) {
376
+ const raw = readFileSync(file, 'utf-8')
377
+ const { description, body } = parseMarkdownCommand(raw)
378
+ const relativePath = relative(commandsDir, file)
379
+ .replace(/\\/g, '/')
380
+ .replace(/\.md$/i, '')
381
+ const name = relativePath.replace(/\//g, '-').toLowerCase()
382
+
383
+ output[name] = {
384
+ template: body.trim(),
385
+ ...(description ? { description } : {}),
386
+ }
387
+ }
388
+
389
+ return output
390
+ }
391
+
392
+ private getInstructionsContent(): string | null {
393
+ if (!this.config.instructions) return null
394
+ const instructionsPath = this.resolveConfigPath(this.config.instructions, 'instructions')
395
+ if (!existsSync(instructionsPath)) return null
396
+ return readFileSync(instructionsPath, 'utf-8').trim()
397
+ }
398
+
399
+ private getOpenCodeHookPlan(): OpenCodeHookPlan {
400
+ const plan: OpenCodeHookPlan = {
401
+ event: {},
402
+ toolBefore: { all: [], read: [], mcp: [] },
403
+ toolAfter: { all: [], edit: [], mcp: [] },
404
+ shellEnv: [],
405
+ chatMessage: [],
406
+ }
407
+
408
+ if (!this.config.hooks) return plan
409
+
410
+ for (const [event, entries] of Object.entries(this.config.hooks)) {
411
+ if (!entries || entries.length === 0) continue
412
+
413
+ const hooks = entries
414
+ .filter(entry => entry.type !== 'prompt' && entry.command)
415
+ .map(entry => ({
416
+ command: entry.command!,
417
+ ...(entry.timeout ? { timeout: entry.timeout } : {}),
418
+ ...(entry.matcher ? { matcher: entry.matcher } : {}),
419
+ ...(entry.failClosed !== undefined ? { failClosed: entry.failClosed } : {}),
420
+ }))
421
+
422
+ if (hooks.length === 0) continue
423
+
424
+ switch (event) {
425
+ case 'preToolUse':
426
+ plan.toolBefore.all.push(...hooks)
427
+ break
428
+ case 'beforeReadFile':
429
+ plan.toolBefore.read.push(...hooks)
430
+ break
431
+ case 'beforeMCPExecution':
432
+ plan.toolBefore.mcp.push(...hooks)
433
+ break
434
+ case 'postToolUse':
435
+ plan.toolAfter.all.push(...hooks)
436
+ break
437
+ case 'afterFileEdit':
438
+ plan.toolAfter.edit.push(...hooks)
439
+ break
440
+ case 'afterMCPExecution':
441
+ plan.toolAfter.mcp.push(...hooks)
442
+ break
443
+ case 'beforeShellExecution':
444
+ plan.shellEnv.push(...hooks)
445
+ break
446
+ case 'beforeSubmitPrompt':
447
+ plan.chatMessage.push(...hooks)
448
+ break
449
+ default: {
450
+ const opencodeEvent = mapHookEventName(event)
451
+ if (!plan.event[opencodeEvent]) {
452
+ plan.event[opencodeEvent] = []
453
+ }
454
+ plan.event[opencodeEvent].push(...hooks)
455
+ }
456
+ }
457
+ }
458
+
459
+ return plan
460
+ }
461
+
462
+ private walkFiles(dir: string): string[] {
463
+ const entries = readdirSync(dir)
464
+ const files: string[] = []
465
+
466
+ for (const entry of entries) {
467
+ const fullPath = resolve(dir, entry)
468
+ const stat = statSync(fullPath)
469
+ if (stat.isDirectory()) {
470
+ files.push(...this.walkFiles(fullPath))
471
+ } else if (stat.isFile()) {
472
+ files.push(fullPath)
473
+ }
474
+ }
475
+
476
+ return files
477
+ }
478
+ }
479
+
480
+ function parseMarkdownCommand(content: string): { description?: string; body: string } {
481
+ const trimmed = content.trim()
482
+ if (!trimmed.startsWith('---')) {
483
+ return { body: content }
484
+ }
485
+
486
+ const endFrontmatter = trimmed.indexOf('\n---', 3)
487
+ if (endFrontmatter === -1) {
488
+ return { body: content }
489
+ }
490
+
491
+ const frontmatter = trimmed.slice(3, endFrontmatter).trim()
492
+ const body = trimmed.slice(endFrontmatter + 4)
493
+ const descriptionLine = frontmatter
494
+ .split('\n')
495
+ .find(line => line.trim().startsWith('description:'))
496
+ const description = descriptionLine
497
+ ? descriptionLine.split(':').slice(1).join(':').trim().replace(/^["']|["']$/g, '')
498
+ : undefined
499
+
500
+ return { description, body }
501
+ }
502
+
503
+ function mapHookEventName(event: string): string {
504
+ const map: Record<string, string> = {
505
+ sessionStart: 'session.created',
506
+ sessionEnd: 'session.idle',
507
+ stop: 'session.idle',
508
+ beforeShellExecution: 'shell.env',
509
+ afterShellExecution: 'command.executed',
510
+ preToolUse: 'tool.execute.before',
511
+ postToolUse: 'tool.execute.after',
512
+ beforeMCPExecution: 'tool.execute.before',
513
+ afterMCPExecution: 'tool.execute.after',
514
+ afterFileEdit: 'file.edited',
515
+ beforeReadFile: 'tool.execute.before',
516
+ beforeSubmitPrompt: 'chat.message',
517
+ }
518
+ return map[event] ?? event
519
+ }
520
+
521
+ function toPascalCase(str: string): string {
522
+ return str
523
+ .split('-')
524
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
525
+ .join('')
526
+ }
@@ -0,0 +1,32 @@
1
+ import { Generator } from '../base'
2
+ import { generateClaudeFamilyOutputs } from '../shared/claude-family'
3
+ import type { TargetPlatform } from '../../schema'
4
+
5
+ /**
6
+ * OpenHands uses .plugin/plugin.json (Claude Code-compatible format).
7
+ * Discovery dirs: .openhands/skills/, .claude/skills/, .agents/skills/
8
+ */
9
+ export class OpenHandsGenerator extends Generator {
10
+ readonly platform: TargetPlatform = 'openhands'
11
+
12
+ async generate(): Promise<void> {
13
+ await generateClaudeFamilyOutputs({
14
+ config: this.config,
15
+ rootDir: this.rootDir,
16
+ platform: this.platform,
17
+ options: {
18
+ manifestPath: '.plugin/plugin.json',
19
+ instructionsFile: 'AGENTS.md',
20
+ pluginRootVar: 'PLUGIN_ROOT',
21
+ },
22
+ writeJson: (relativePath, data) => this.writeJson(relativePath, data),
23
+ writeFile: (relativePath, content) => this.writeFile(relativePath, content),
24
+ })
25
+
26
+ this.copySkills()
27
+ this.copyCommands()
28
+ this.copyAgents()
29
+ this.copyScripts()
30
+ this.copyAssets()
31
+ }
32
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync } from 'fs'
2
+ import { Generator } from '../base'
3
+ import type { TargetPlatform } from '../../schema'
4
+
5
+ /**
6
+ * Roo Code uses .roo/mcp.json for MCP config, .roorules for instructions,
7
+ * and .roo/skills/ for skills. No plugin manifest.
8
+ */
9
+ export class RooCodeGenerator extends Generator {
10
+ readonly platform: TargetPlatform = 'roo-code'
11
+
12
+ async generate(): Promise<void> {
13
+ await Promise.all([
14
+ this.generateMcpConfig('.roo/mcp.json'),
15
+ this.generateRules(),
16
+ ])
17
+
18
+ this.copyRooSkills()
19
+ this.copyScripts()
20
+ }
21
+
22
+ private async generateRules(): Promise<void> {
23
+ if (!this.config.instructions) return
24
+ const srcPath = this.resolveConfigPath(this.config.instructions, 'instructions')
25
+ if (!existsSync(srcPath)) return
26
+
27
+ const content = await Bun.file(srcPath).text()
28
+ await this.writeFile('.roorules', content)
29
+ }
30
+
31
+ /** Copy skills into .roo/skills/ instead of the default skills/ */
32
+ private copyRooSkills(): void {
33
+ this.copyDir(this.config.skills, '.roo/skills/', 'skills')
34
+ }
35
+ }