@orchid-labs/pluxx 0.1.1 → 0.1.4

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 (103) hide show
  1. package/README.md +25 -8
  2. package/bin/pluxx.js +19 -28
  3. package/dist/agents.d.ts +16 -0
  4. package/dist/agents.d.ts.map +1 -0
  5. package/dist/cli/agent.d.ts +62 -0
  6. package/dist/cli/agent.d.ts.map +1 -1
  7. package/dist/cli/doctor.d.ts +2 -0
  8. package/dist/cli/doctor.d.ts.map +1 -1
  9. package/dist/cli/entry.d.ts +2 -0
  10. package/dist/cli/entry.d.ts.map +1 -0
  11. package/dist/cli/index.d.ts +7 -1
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +21810 -0
  14. package/dist/cli/init-from-mcp.d.ts +17 -1
  15. package/dist/cli/init-from-mcp.d.ts.map +1 -1
  16. package/dist/cli/install.d.ts +1 -0
  17. package/dist/cli/install.d.ts.map +1 -1
  18. package/dist/cli/lint.d.ts +3 -1
  19. package/dist/cli/lint.d.ts.map +1 -1
  20. package/dist/cli/mcp-proxy.d.ts.map +1 -1
  21. package/dist/cli/migrate.d.ts.map +1 -1
  22. package/dist/cli/primitive-summary.d.ts +14 -0
  23. package/dist/cli/primitive-summary.d.ts.map +1 -0
  24. package/dist/cli/prompt.d.ts +1 -1
  25. package/dist/cli/publish.d.ts +6 -1
  26. package/dist/cli/publish.d.ts.map +1 -1
  27. package/dist/cli/sync-from-mcp.d.ts.map +1 -1
  28. package/dist/cli/verify-install.d.ts +25 -0
  29. package/dist/cli/verify-install.d.ts.map +1 -0
  30. package/dist/commands.d.ts +10 -0
  31. package/dist/commands.d.ts.map +1 -0
  32. package/dist/compiler-intent.d.ts +165 -0
  33. package/dist/compiler-intent.d.ts.map +1 -0
  34. package/dist/config/load.d.ts.map +1 -1
  35. package/dist/delegation.d.ts +11 -0
  36. package/dist/delegation.d.ts.map +1 -0
  37. package/dist/generators/amp/index.d.ts.map +1 -1
  38. package/dist/generators/base.d.ts +5 -0
  39. package/dist/generators/base.d.ts.map +1 -1
  40. package/dist/generators/claude-code/index.d.ts.map +1 -1
  41. package/dist/generators/cline/index.d.ts.map +1 -1
  42. package/dist/generators/codex/index.d.ts +4 -0
  43. package/dist/generators/codex/index.d.ts.map +1 -1
  44. package/dist/generators/cursor/index.d.ts +1 -0
  45. package/dist/generators/cursor/index.d.ts.map +1 -1
  46. package/dist/generators/gemini-cli/index.d.ts.map +1 -1
  47. package/dist/generators/github-copilot/index.d.ts.map +1 -1
  48. package/dist/generators/opencode/index.d.ts +1 -0
  49. package/dist/generators/opencode/index.d.ts.map +1 -1
  50. package/dist/generators/openhands/index.d.ts.map +1 -1
  51. package/dist/generators/roo-code/index.d.ts.map +1 -1
  52. package/dist/generators/shared/claude-family.d.ts.map +1 -1
  53. package/dist/generators/warp/index.d.ts.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5371 -553
  57. package/dist/schema.d.ts +91 -42
  58. package/dist/schema.d.ts.map +1 -1
  59. package/dist/text-files.d.ts +5 -0
  60. package/dist/text-files.d.ts.map +1 -0
  61. package/dist/validation/platform-rules.d.ts +15 -1
  62. package/dist/validation/platform-rules.d.ts.map +1 -1
  63. package/package.json +15 -13
  64. package/src/cli/agent.ts +0 -1455
  65. package/src/cli/dev.ts +0 -112
  66. package/src/cli/doctor.ts +0 -987
  67. package/src/cli/eval.ts +0 -470
  68. package/src/cli/index.ts +0 -2933
  69. package/src/cli/init-from-mcp.ts +0 -2115
  70. package/src/cli/install.ts +0 -860
  71. package/src/cli/lint.ts +0 -1249
  72. package/src/cli/mcp-proxy.ts +0 -322
  73. package/src/cli/migrate.ts +0 -867
  74. package/src/cli/prompt.ts +0 -82
  75. package/src/cli/publish.ts +0 -401
  76. package/src/cli/runtime.ts +0 -86
  77. package/src/cli/sync-from-mcp.ts +0 -586
  78. package/src/cli/test.ts +0 -142
  79. package/src/compatibility/matrix.ts +0 -149
  80. package/src/config/define.ts +0 -20
  81. package/src/config/load.ts +0 -74
  82. package/src/generators/amp/index.ts +0 -63
  83. package/src/generators/base.ts +0 -188
  84. package/src/generators/claude-code/index.ts +0 -172
  85. package/src/generators/cline/index.ts +0 -35
  86. package/src/generators/codex/index.ts +0 -143
  87. package/src/generators/cursor/index.ts +0 -158
  88. package/src/generators/gemini-cli/index.ts +0 -83
  89. package/src/generators/github-copilot/index.ts +0 -32
  90. package/src/generators/hooks-warning.ts +0 -51
  91. package/src/generators/index.ts +0 -71
  92. package/src/generators/opencode/index.ts +0 -526
  93. package/src/generators/openhands/index.ts +0 -32
  94. package/src/generators/roo-code/index.ts +0 -35
  95. package/src/generators/shared/claude-family.ts +0 -215
  96. package/src/generators/warp/index.ts +0 -32
  97. package/src/hook-events.ts +0 -33
  98. package/src/index.ts +0 -34
  99. package/src/mcp/introspect.ts +0 -1107
  100. package/src/permissions.ts +0 -260
  101. package/src/schema.ts +0 -312
  102. package/src/user-config.ts +0 -177
  103. package/src/validation/platform-rules.ts +0 -686
package/src/cli/agent.ts DELETED
@@ -1,1455 +0,0 @@
1
- import { existsSync } from 'fs'
2
- import { chmod, copyFile, mkdir, mkdtemp, readFile, rm } from 'fs/promises'
3
- import { homedir, tmpdir } from 'os'
4
- import { resolve } from 'path'
5
- import { spawn } from 'child_process'
6
- import { loadConfig } from '../config/load'
7
- import { lintProject, type LintResult } from './lint'
8
- import { runTestSuite, type TestRunResult } from './test'
9
- import {
10
- MCP_SCAFFOLD_METADATA_PATH,
11
- MCP_TAXONOMY_PATH,
12
- PLUXX_CUSTOM_END,
13
- PLUXX_CUSTOM_START,
14
- PLUXX_GENERATED_END,
15
- PLUXX_GENERATED_START,
16
- type McpScaffoldMetadata,
17
- } from './init-from-mcp'
18
- import { applyPersistedTaxonomy } from './sync-from-mcp'
19
-
20
- export const AGENT_CONTEXT_PATH = '.pluxx/agent/context.md'
21
- export const AGENT_PLAN_PATH = '.pluxx/agent/plan.json'
22
- export const AGENT_OVERRIDES_PATH = 'pluxx.agent.md'
23
- export const AGENT_PROMPT_KINDS = ['taxonomy', 'instructions', 'review'] as const
24
- export const AGENT_RUNNERS = ['claude', 'opencode', 'codex', 'cursor'] as const
25
- export type AgentPromptKind = typeof AGENT_PROMPT_KINDS[number]
26
- export type AgentRunner = typeof AGENT_RUNNERS[number]
27
-
28
- const AGENT_PROMPT_PATHS: Record<AgentPromptKind, string> = {
29
- taxonomy: '.pluxx/agent/taxonomy-prompt.md',
30
- instructions: '.pluxx/agent/instructions-prompt.md',
31
- review: '.pluxx/agent/review-prompt.md',
32
- }
33
-
34
- const AGENT_RUNNER_BINARIES: Record<AgentRunner, string> = {
35
- claude: 'claude',
36
- opencode: 'opencode',
37
- codex: 'codex',
38
- cursor: 'agent',
39
- }
40
-
41
- const CURSOR_RUNNER_BINARIES = ['agent', 'cursor-agent'] as const
42
-
43
- export interface AgentPreparePlannedFile {
44
- relativePath: string
45
- content: string
46
- action: 'create' | 'update' | 'unchanged'
47
- }
48
-
49
- export interface AgentPrepareSummary {
50
- pluginName: string
51
- targetCount: number
52
- toolCount: number
53
- skillCount: number
54
- editableFiles: string[]
55
- protectedFiles: string[]
56
- generatedFiles: string[]
57
- createdFiles: string[]
58
- updatedFiles: string[]
59
- lint: {
60
- errors: number
61
- warnings: number
62
- }
63
- contextInputs: string[]
64
- dryRun?: boolean
65
- }
66
-
67
- export interface AgentPreparePlan extends AgentPrepareSummary {
68
- files: AgentPreparePlannedFile[]
69
- }
70
-
71
- export interface AgentPrepareOptions {
72
- docsUrl?: string
73
- websiteUrl?: string
74
- contextPaths?: string[]
75
- }
76
-
77
- interface AgentPromptOptions {
78
- allowMissingContext?: boolean
79
- }
80
-
81
- export interface AgentPromptSummary {
82
- pluginName: string
83
- kind: AgentPromptKind
84
- outputPath: string
85
- createdFiles: string[]
86
- updatedFiles: string[]
87
- dryRun?: boolean
88
- }
89
-
90
- export interface AgentPromptPlan extends AgentPromptSummary {
91
- files: AgentPreparePlannedFile[]
92
- }
93
-
94
- export interface AgentRunOptions {
95
- runner: AgentRunner
96
- model?: string
97
- attach?: string
98
- verify?: boolean
99
- }
100
-
101
- export interface AgentRunnerModelSummary {
102
- value?: string
103
- source: 'explicit' | 'default' | 'unknown'
104
- display: string
105
- }
106
-
107
- export interface AgentRunSummary {
108
- pluginName: string
109
- kind: AgentPromptKind
110
- runner: AgentRunner
111
- model: AgentRunnerModelSummary
112
- verify: boolean
113
- command: string[]
114
- commandDisplay: string
115
- promptPath: string
116
- contextPath: string
117
- createdFiles: string[]
118
- updatedFiles: string[]
119
- contextInputs: string[]
120
- dryRun?: boolean
121
- }
122
-
123
- export interface AgentRunPlan extends AgentRunSummary {
124
- files: AgentPreparePlannedFile[]
125
- prepareOptions?: AgentPrepareOptions
126
- }
127
-
128
- export interface AgentRunResult extends AgentRunSummary {
129
- ok: boolean
130
- runnerExitCode: number
131
- verification?: TestRunResult
132
- }
133
-
134
- interface AgentPlanFile {
135
- path: string
136
- managedSections?: Array<{
137
- start: string
138
- end: string
139
- }>
140
- }
141
-
142
- interface AgentModePlanFile {
143
- version: 1
144
- plugin: {
145
- name: string
146
- displayName: string
147
- targets: string[]
148
- }
149
- mcp: {
150
- metadataPath: string
151
- toolCount: number
152
- serverName: string
153
- transport: string
154
- auth: string
155
- }
156
- contextInputs: string[]
157
- files: {
158
- editable: AgentPlanFile[]
159
- protected: string[]
160
- generated: string[]
161
- }
162
- successCriteria: string[]
163
- caveats: string[]
164
- }
165
-
166
- interface AgentContextSource {
167
- label: string
168
- kind: 'website' | 'docs' | 'file'
169
- summary: string
170
- }
171
-
172
- interface AgentOverrides {
173
- path: string
174
- contextPaths: string[]
175
- productHints?: string
176
- setupAuthNotes?: string
177
- groupingHints?: string
178
- taxonomyGuidance?: string
179
- instructionsGuidance?: string
180
- reviewCriteria?: string
181
- }
182
-
183
- function hasManagedCommands(metadata: McpScaffoldMetadata): boolean {
184
- return metadata.managedFiles.some((file) => file.startsWith('commands/'))
185
- }
186
-
187
- export async function planAgentPrepare(
188
- rootDir: string = process.cwd(),
189
- options: AgentPrepareOptions = {},
190
- ): Promise<AgentPreparePlan> {
191
- const config = await loadConfig(rootDir)
192
- const metadata = await loadMcpScaffoldMetadata(rootDir)
193
- const lint = await lintProject(rootDir)
194
- const overrides = await loadAgentOverrides(rootDir)
195
- const contextSources = await collectAgentContextSources(rootDir, options, overrides)
196
- const editableFiles = buildEditableFiles(metadata)
197
- const protectedFiles = buildProtectedFiles()
198
- const generatedFiles = [AGENT_CONTEXT_PATH, AGENT_PLAN_PATH]
199
- const contextContent = buildAgentContext(config, metadata, lint, contextSources, overrides)
200
- const planContent = buildAgentModePlanJson(config, metadata, lint, editableFiles, protectedFiles, generatedFiles, contextSources)
201
-
202
- const files = await Promise.all([
203
- planFile(rootDir, AGENT_CONTEXT_PATH, contextContent),
204
- planFile(rootDir, AGENT_PLAN_PATH, `${planContent}\n`),
205
- ])
206
-
207
- return {
208
- pluginName: config.name,
209
- targetCount: config.targets.length,
210
- toolCount: metadata.tools.length,
211
- skillCount: metadata.skills.length,
212
- editableFiles: editableFiles.map((file) => file.path),
213
- protectedFiles,
214
- generatedFiles,
215
- createdFiles: files.filter((file) => file.action === 'create').map((file) => file.relativePath),
216
- updatedFiles: files.filter((file) => file.action === 'update').map((file) => file.relativePath),
217
- lint: {
218
- errors: lint.errors,
219
- warnings: lint.warnings,
220
- },
221
- contextInputs: contextSources.map((source) => source.label),
222
- files,
223
- }
224
- }
225
-
226
- export async function applyAgentPreparePlan(rootDir: string, plan: AgentPreparePlan): Promise<void> {
227
- for (const file of plan.files) {
228
- const filePath = resolve(rootDir, file.relativePath)
229
- const parentDir = file.relativePath.split('/').slice(0, -1).join('/')
230
- if (parentDir) {
231
- await mkdir(resolve(rootDir, parentDir), { recursive: true })
232
- }
233
- await Bun.write(filePath, file.content)
234
- }
235
- }
236
-
237
- export async function planAgentPrompt(
238
- rootDir: string,
239
- kind: AgentPromptKind,
240
- options: AgentPromptOptions = {},
241
- ): Promise<AgentPromptPlan> {
242
- const config = await loadConfig(rootDir)
243
- const metadata = await loadMcpScaffoldMetadata(rootDir)
244
- const overrides = await loadAgentOverrides(rootDir)
245
- const contextPath = resolve(rootDir, AGENT_CONTEXT_PATH)
246
-
247
- if (!options.allowMissingContext && !existsSync(contextPath)) {
248
- throw new Error(`No agent context found at ${AGENT_CONTEXT_PATH}. Run "pluxx agent prepare" first.`)
249
- }
250
-
251
- const outputPath = AGENT_PROMPT_PATHS[kind]
252
- const content = buildAgentPrompt(kind, {
253
- pluginName: config.name,
254
- displayName: config.brand?.displayName ?? metadata.settings.displayName ?? config.name,
255
- skillPaths: metadata.skills.map((skill) => `skills/${skill.dirName}/SKILL.md`),
256
- commandPaths: hasManagedCommands(metadata)
257
- ? metadata.skills.map((skill) => `commands/${skill.dirName}.md`)
258
- : [],
259
- overrides,
260
- })
261
- const file = await planFile(rootDir, outputPath, content)
262
-
263
- return {
264
- pluginName: config.name,
265
- kind,
266
- outputPath,
267
- createdFiles: file.action === 'create' ? [outputPath] : [],
268
- updatedFiles: file.action === 'update' ? [outputPath] : [],
269
- files: [file],
270
- }
271
- }
272
-
273
- export async function applyAgentPromptPlan(rootDir: string, plan: AgentPromptPlan): Promise<void> {
274
- for (const file of plan.files) {
275
- const filePath = resolve(rootDir, file.relativePath)
276
- const parentDir = file.relativePath.split('/').slice(0, -1).join('/')
277
- if (parentDir) {
278
- await mkdir(resolve(rootDir, parentDir), { recursive: true })
279
- }
280
- await Bun.write(filePath, file.content)
281
- }
282
- }
283
-
284
- export async function planAgentRun(
285
- rootDir: string = process.cwd(),
286
- kind: AgentPromptKind,
287
- options: AgentRunOptions,
288
- prepareOptions: AgentPrepareOptions = {},
289
- ): Promise<AgentRunPlan> {
290
- if (options.runner !== 'opencode' && options.attach) {
291
- throw new Error('--attach is only supported for the opencode runner.')
292
- }
293
-
294
- const preparePlan = await planAgentPrepare(rootDir, prepareOptions)
295
- const promptPlan = await planAgentPrompt(rootDir, kind, { allowMissingContext: true })
296
- const promptPath = AGENT_PROMPT_PATHS[kind]
297
- const verify = kind === 'review' ? false : options.verify !== false
298
- const command = buildAgentRunnerCommand(options.runner, kind, buildAgentRunnerPrompt(kind, promptPath), {
299
- model: options.model,
300
- attach: options.attach,
301
- workspace: rootDir,
302
- })
303
- const model = await resolveAgentRunnerModel(options.runner, options.model)
304
-
305
- return {
306
- pluginName: preparePlan.pluginName,
307
- kind,
308
- runner: options.runner,
309
- model,
310
- verify,
311
- command,
312
- commandDisplay: command.map(shellQuote).join(' '),
313
- promptPath,
314
- contextPath: AGENT_CONTEXT_PATH,
315
- createdFiles: [...preparePlan.createdFiles, ...promptPlan.createdFiles],
316
- updatedFiles: [...preparePlan.updatedFiles, ...promptPlan.updatedFiles],
317
- contextInputs: preparePlan.contextInputs,
318
- files: [...preparePlan.files, ...promptPlan.files],
319
- prepareOptions,
320
- }
321
- }
322
-
323
- export async function runAgentPlan(
324
- rootDir: string,
325
- plan: AgentRunPlan,
326
- options: {
327
- streamOutput?: boolean
328
- } = {},
329
- ): Promise<AgentRunResult> {
330
- const preparePlan = await planAgentPrepare(rootDir, plan.prepareOptions ?? {})
331
- const promptPlan = await planAgentPrompt(rootDir, plan.kind, { allowMissingContext: true })
332
- await writePlannedFiles(rootDir, [...preparePlan.files, ...promptPlan.files])
333
- let createdFiles = [...preparePlan.createdFiles, ...promptPlan.createdFiles]
334
- let updatedFiles = [...preparePlan.updatedFiles, ...promptPlan.updatedFiles]
335
- let contextInputs = preparePlan.contextInputs
336
-
337
- await ensureRunnerAvailable(plan.runner)
338
- await ensureRunnerAuthenticated(plan.runner)
339
- const executionContext = await prepareRunnerExecution(plan.runner)
340
- let runnerExitCode: number
341
- try {
342
- runnerExitCode = await executeCommand(plan.command, rootDir, {
343
- streamOutput: options.streamOutput === true,
344
- env: executionContext.env,
345
- })
346
- } finally {
347
- await executionContext.cleanup?.()
348
- }
349
- if (runnerExitCode === 0 && plan.kind === 'taxonomy') {
350
- await applyPersistedTaxonomy(rootDir)
351
- const refreshedPack = await refreshAgentPack(rootDir, plan.prepareOptions ?? {})
352
- createdFiles = mergeUnique(createdFiles, refreshedPack.createdFiles)
353
- updatedFiles = mergeUnique(updatedFiles, refreshedPack.updatedFiles)
354
- contextInputs = refreshedPack.contextInputs
355
- }
356
- const verification = runnerExitCode === 0 && plan.verify
357
- ? await runTestSuite({ rootDir })
358
- : undefined
359
-
360
- return {
361
- ...plan,
362
- createdFiles,
363
- updatedFiles,
364
- contextInputs,
365
- ok: runnerExitCode === 0 && (verification?.ok ?? true),
366
- runnerExitCode,
367
- verification,
368
- }
369
- }
370
-
371
- async function refreshAgentPack(
372
- rootDir: string,
373
- prepareOptions: AgentPrepareOptions,
374
- ): Promise<{
375
- createdFiles: string[]
376
- updatedFiles: string[]
377
- contextInputs: string[]
378
- }> {
379
- const preparePlan = await planAgentPrepare(rootDir, prepareOptions)
380
- const promptPlans = await Promise.all(
381
- AGENT_PROMPT_KINDS.map((kind) => planAgentPrompt(rootDir, kind, { allowMissingContext: true })),
382
- )
383
- const files = [
384
- ...preparePlan.files,
385
- ...promptPlans.flatMap((promptPlan) => promptPlan.files),
386
- ]
387
- await writePlannedFiles(rootDir, files)
388
-
389
- return {
390
- createdFiles: mergeUnique(
391
- preparePlan.createdFiles,
392
- promptPlans.flatMap((promptPlan) => promptPlan.createdFiles),
393
- ),
394
- updatedFiles: mergeUnique(
395
- preparePlan.updatedFiles,
396
- promptPlans.flatMap((promptPlan) => promptPlan.updatedFiles),
397
- ),
398
- contextInputs: preparePlan.contextInputs,
399
- }
400
- }
401
-
402
- async function writePlannedFiles(rootDir: string, files: AgentPreparePlannedFile[]): Promise<void> {
403
- for (const file of files) {
404
- const filePath = resolve(rootDir, file.relativePath)
405
- const parentDir = file.relativePath.split('/').slice(0, -1).join('/')
406
- if (parentDir) {
407
- await mkdir(resolve(rootDir, parentDir), { recursive: true })
408
- }
409
- await Bun.write(filePath, file.content)
410
- }
411
- }
412
-
413
- function mergeUnique(existing: string[], next: string[]): string[] {
414
- return [...new Set([...existing, ...next])]
415
- }
416
-
417
- function buildEditableFiles(metadata: McpScaffoldMetadata): AgentPlanFile[] {
418
- const files: AgentPlanFile[] = [{
419
- path: MCP_TAXONOMY_PATH,
420
- }, {
421
- path: 'INSTRUCTIONS.md',
422
- managedSections: [{ start: PLUXX_GENERATED_START, end: PLUXX_GENERATED_END }],
423
- }]
424
-
425
- for (const skill of metadata.skills) {
426
- files.push({
427
- path: `skills/${skill.dirName}/SKILL.md`,
428
- managedSections: [{ start: PLUXX_GENERATED_START, end: PLUXX_GENERATED_END }],
429
- })
430
- }
431
-
432
- if (hasManagedCommands(metadata)) {
433
- for (const skill of metadata.skills) {
434
- files.push({
435
- path: `commands/${skill.dirName}.md`,
436
- managedSections: [{ start: PLUXX_GENERATED_START, end: PLUXX_GENERATED_END }],
437
- })
438
- }
439
- }
440
-
441
- return files
442
- }
443
-
444
- function buildProtectedFiles(): string[] {
445
- return [
446
- 'pluxx.config.ts',
447
- 'pluxx.config.js',
448
- 'pluxx.config.json',
449
- AGENT_OVERRIDES_PATH,
450
- MCP_SCAFFOLD_METADATA_PATH,
451
- 'dist/',
452
- ]
453
- }
454
-
455
- async function loadMcpScaffoldMetadata(rootDir: string): Promise<McpScaffoldMetadata> {
456
- const metadataPath = resolve(rootDir, MCP_SCAFFOLD_METADATA_PATH)
457
- if (!existsSync(metadataPath)) {
458
- throw new Error(`No MCP scaffold metadata found at ${MCP_SCAFFOLD_METADATA_PATH}. Run "pluxx init --from-mcp" first.`)
459
- }
460
-
461
- try {
462
- const text = await Bun.file(metadataPath).text()
463
- return JSON.parse(text) as McpScaffoldMetadata
464
- } catch (error) {
465
- throw new Error(
466
- `Failed to parse ${MCP_SCAFFOLD_METADATA_PATH}: ${error instanceof Error ? error.message : String(error)}`,
467
- )
468
- }
469
- }
470
-
471
- async function planFile(rootDir: string, relativePath: string, content: string): Promise<AgentPreparePlannedFile> {
472
- const filePath = resolve(rootDir, relativePath)
473
- const action = existsSync(filePath)
474
- ? ((await Bun.file(filePath).text()) === content ? 'unchanged' : 'update')
475
- : 'create'
476
- return { relativePath, content, action }
477
- }
478
-
479
- function buildAgentContext(
480
- config: Awaited<ReturnType<typeof loadConfig>>,
481
- metadata: McpScaffoldMetadata,
482
- lint: LintResult,
483
- contextSources: AgentContextSource[],
484
- overrides: AgentOverrides | null,
485
- ): string {
486
- const serverEntry = Object.entries(config.mcp ?? {})[0]
487
- const [serverName, server] = serverEntry ?? ['unknown', undefined]
488
- const displayName = config.brand?.displayName ?? metadata.settings.displayName ?? config.name
489
- const resourceByUri = new Map((metadata.resources ?? []).map((resource) => [resource.uri, resource]))
490
- const resourceTemplateByUri = new Map((metadata.resourceTemplates ?? []).map((template) => [template.uriTemplate, template]))
491
- const promptByName = new Map((metadata.prompts ?? []).map((prompt) => [prompt.name, prompt]))
492
- const lines = [
493
- '# Pluxx Agent Context',
494
- '',
495
- '## Plugin',
496
- '',
497
- `- Name: \`${config.name}\``,
498
- `- Display name: ${displayName}`,
499
- `- Targets: ${config.targets.join(', ')}`,
500
- '',
501
- '## MCP',
502
- '',
503
- `- Metadata source: \`${MCP_SCAFFOLD_METADATA_PATH}\``,
504
- `- Semantic taxonomy: \`${MCP_TAXONOMY_PATH}\``,
505
- `- Server name: \`${serverName}\``,
506
- `- Transport: ${server?.transport ?? metadata.source.transport}`,
507
- `- Auth: ${describeAuth(server ?? metadata.source)}`,
508
- `- Tool count: ${metadata.tools.length}`,
509
- `- Resource count: ${metadata.resources?.length ?? 0}`,
510
- `- Prompt template count: ${metadata.prompts?.length ?? 0}`,
511
- '',
512
- '## Generated Skills',
513
- '',
514
- ]
515
-
516
- for (const skill of metadata.skills) {
517
- const relatedResourceLabels = [
518
- ...(skill.resourceUris ?? []).map((uri) => {
519
- const resource = resourceByUri.get(uri)
520
- return resource ? `\`${resource.name ?? resource.title ?? resource.uri}\`` : null
521
- }),
522
- ...(skill.resourceTemplateUris ?? []).map((uriTemplate) => {
523
- const template = resourceTemplateByUri.get(uriTemplate)
524
- return template ? `\`${template.name}\`` : null
525
- }),
526
- ].filter((label): label is string => Boolean(label))
527
- const relatedPromptLabels = (skill.promptNames ?? [])
528
- .map((name) => promptByName.get(name)?.name ?? name)
529
- .map((name) => `\`${name}\``)
530
-
531
- lines.push(`### \`${skill.dirName}\``)
532
- lines.push('')
533
- lines.push(`- Title: ${skill.title}`)
534
- lines.push(`- Tools: ${skill.toolNames.join(', ') || 'none'}`)
535
- if (relatedResourceLabels.length > 0) {
536
- lines.push(`- Related resources: ${relatedResourceLabels.join(', ')}`)
537
- }
538
- if (relatedPromptLabels.length > 0) {
539
- lines.push(`- Related prompt templates: ${relatedPromptLabels.join(', ')}`)
540
- }
541
- lines.push('')
542
- }
543
-
544
- if ((metadata.resources?.length ?? 0) > 0 || (metadata.resourceTemplates?.length ?? 0) > 0 || (metadata.prompts?.length ?? 0) > 0) {
545
- lines.push('## MCP Discovery Surfaces')
546
- lines.push('')
547
-
548
- for (const resource of metadata.resources ?? []) {
549
- const label = resource.name ?? resource.title ?? resource.uri
550
- lines.push(`- Resource \`${label}\`: ${summarizeDiscoveryDescription(resource.description, `URI: ${resource.uri}`)}`)
551
- }
552
-
553
- for (const template of metadata.resourceTemplates ?? []) {
554
- lines.push(`- Resource template \`${template.name}\`: ${summarizeDiscoveryDescription(template.description, `URI template: ${template.uriTemplate}`)}`)
555
- }
556
-
557
- for (const prompt of metadata.prompts ?? []) {
558
- const args = prompt.arguments?.map((argument) => `\`${argument.name}\`${argument.required ? ' (required)' : ''}`).join(', ')
559
- const trailing = args ? `Arguments: ${args}` : undefined
560
- lines.push(`- Prompt \`${prompt.name}\`: ${summarizeDiscoveryDescription(prompt.description, trailing)}`)
561
- }
562
-
563
- lines.push('')
564
- }
565
-
566
- lines.push('## Lint Snapshot')
567
- lines.push('')
568
- lines.push(`- Errors: ${lint.errors}`)
569
- lines.push(`- Warnings: ${lint.warnings}`)
570
- lines.push('')
571
-
572
- if (lint.issues.length > 0) {
573
- lines.push('### Current Issues')
574
- lines.push('')
575
- for (const issue of lint.issues.slice(0, 20)) {
576
- lines.push(`- [${issue.level}] ${issue.code}: ${issue.message}`)
577
- }
578
- lines.push('')
579
- }
580
-
581
- if (contextSources.length > 0) {
582
- lines.push('## Additional Context')
583
- lines.push('')
584
- for (const source of contextSources) {
585
- lines.push(`### ${source.kind === 'file' ? '`' + source.label + '`' : source.label}`)
586
- lines.push('')
587
- lines.push(source.summary)
588
- lines.push('')
589
- }
590
- }
591
-
592
- if (overrides) {
593
- lines.push('## Project Overrides')
594
- lines.push('')
595
- lines.push(`- Source: \`${overrides.path}\``)
596
- lines.push('')
597
-
598
- appendOverrideSection(lines, 'Product Hints', overrides.productHints)
599
- appendOverrideSection(lines, 'Setup/Auth Notes', overrides.setupAuthNotes)
600
- appendOverrideSection(lines, 'Grouping Hints', overrides.groupingHints)
601
- appendOverrideSection(lines, 'Taxonomy Guidance', overrides.taxonomyGuidance)
602
- appendOverrideSection(lines, 'Instructions Guidance', overrides.instructionsGuidance)
603
- appendOverrideSection(lines, 'Review Criteria', overrides.reviewCriteria)
604
- }
605
-
606
- lines.push('## Write Contract')
607
- lines.push('')
608
- lines.push('- Edit only Pluxx-managed generated sections.')
609
- lines.push(`- Preserve custom sections marked by \`${PLUXX_CUSTOM_START}\` and \`${PLUXX_CUSTOM_END}\`.`)
610
- lines.push('- Do not change auth wiring or target-platform config unless explicitly requested.')
611
- lines.push('- Do not edit generated platform bundles in `dist/`.')
612
- lines.push('')
613
- lines.push('## Quality Bar')
614
- lines.push('')
615
- lines.push('- Each skill should represent a real user workflow or product surface.')
616
- lines.push('- Setup, admin, account, and runtime workflows should be grouped intentionally.')
617
- lines.push('- Prefer branded product language in user-facing content; avoid exposing raw MCP server identifiers unless they are operationally required.')
618
- lines.push('- Avoid tiny singleton skills unless the surface is genuinely standalone.')
619
- lines.push('- Examples should be concrete and specific, not generic placeholders.')
620
- lines.push('- Weak MCP metadata (missing/generic tool descriptions) should be called out explicitly before publishing.')
621
- lines.push('- The wording should match the MCP product narrative, not just raw tool names.')
622
- lines.push('- Use discovered MCP resources and prompt templates when they clarify the real product surface.')
623
- lines.push('- Respect the per-skill resource and prompt-template associations in the metadata/context unless stronger discovery evidence shows they are wrong.')
624
- lines.push('- Keep INSTRUCTIONS.md as concise routing guidance; do not dump raw vendor documentation into generated sections.')
625
- lines.push('')
626
-
627
- return `${lines.join('\n')}\n`
628
- }
629
-
630
- function buildAgentModePlanJson(
631
- config: Awaited<ReturnType<typeof loadConfig>>,
632
- metadata: McpScaffoldMetadata,
633
- lint: LintResult,
634
- editableFiles: AgentPlanFile[],
635
- protectedFiles: string[],
636
- generatedFiles: string[],
637
- contextSources: AgentContextSource[],
638
- ): string {
639
- const serverEntry = Object.entries(config.mcp ?? {})[0]
640
- const [serverName, server] = serverEntry ?? ['unknown', metadata.source]
641
- const plan: AgentModePlanFile = {
642
- version: 1,
643
- plugin: {
644
- name: config.name,
645
- displayName: config.brand?.displayName ?? metadata.settings.displayName ?? config.name,
646
- targets: [...config.targets],
647
- },
648
- mcp: {
649
- metadataPath: MCP_SCAFFOLD_METADATA_PATH,
650
- toolCount: metadata.tools.length,
651
- serverName,
652
- transport: server.transport,
653
- auth: describeAuth(server),
654
- },
655
- contextInputs: contextSources.map((source) => source.label),
656
- files: {
657
- editable: editableFiles,
658
- protected: protectedFiles,
659
- generated: generatedFiles,
660
- },
661
- successCriteria: [
662
- 'Each skill represents a real user workflow or product surface.',
663
- 'Setup/admin/account tools are grouped intentionally.',
664
- 'Examples are concrete and realistic.',
665
- 'Weak MCP metadata is surfaced before publishing.',
666
- 'Only Pluxx-managed sections are modified.',
667
- ],
668
- caveats: lint.issues.map((issue) => `[${issue.level}] ${issue.code}: ${issue.message}`),
669
- }
670
-
671
- return JSON.stringify(plan, null, 2)
672
- }
673
-
674
- async function collectAgentContextSources(
675
- rootDir: string,
676
- options: AgentPrepareOptions,
677
- overrides: AgentOverrides | null,
678
- ): Promise<AgentContextSource[]> {
679
- const sources: AgentContextSource[] = []
680
- const seenFilePaths = new Set<string>()
681
-
682
- if (options.websiteUrl) {
683
- sources.push(await fetchContextSource(options.websiteUrl, 'website'))
684
- }
685
-
686
- if (options.docsUrl) {
687
- sources.push(await fetchContextSource(options.docsUrl, 'docs'))
688
- }
689
-
690
- const contextPaths = [
691
- ...(overrides?.contextPaths ?? []),
692
- ...(options.contextPaths ?? []),
693
- ]
694
-
695
- for (const relativePath of contextPaths) {
696
- if (seenFilePaths.has(relativePath)) continue
697
- seenFilePaths.add(relativePath)
698
- const filePath = resolve(rootDir, relativePath)
699
- if (!existsSync(filePath)) {
700
- sources.push({
701
- label: relativePath,
702
- kind: 'file',
703
- summary: `Unavailable: local file not found.`,
704
- })
705
- continue
706
- }
707
-
708
- const content = await Bun.file(filePath).text()
709
- sources.push({
710
- label: relativePath,
711
- kind: 'file',
712
- summary: summarizePlainText(content),
713
- })
714
- }
715
-
716
- return sources
717
- }
718
-
719
- async function fetchContextSource(url: string, kind: 'website' | 'docs'): Promise<AgentContextSource> {
720
- try {
721
- const response = await fetch(url)
722
- if (!response.ok) {
723
- return {
724
- label: url,
725
- kind,
726
- summary: `Unavailable: fetch failed with ${response.status} ${response.statusText}.`,
727
- }
728
- }
729
-
730
- const contentType = response.headers.get('content-type') ?? ''
731
- const body = await response.text()
732
-
733
- return {
734
- label: url,
735
- kind,
736
- summary: contentType.includes('html')
737
- ? summarizeHtml(body)
738
- : summarizePlainText(body),
739
- }
740
- } catch (error) {
741
- return {
742
- label: url,
743
- kind,
744
- summary: `Unavailable: ${error instanceof Error ? error.message : String(error)}.`,
745
- }
746
- }
747
- }
748
-
749
- function summarizeHtml(html: string): string {
750
- const title = matchHtmlTag(html, 'title')
751
- const description = matchMetaDescription(html)
752
- const headings = matchHtmlTags(html, ['h1', 'h2', 'h3']).slice(0, 5)
753
- const paragraphs = matchHtmlTags(html, ['p']).slice(0, 3)
754
- const lines: string[] = []
755
-
756
- if (title) {
757
- lines.push(`Title: ${title}`)
758
- }
759
- if (description) {
760
- lines.push(`Description: ${description}`)
761
- }
762
- if (headings.length > 0) {
763
- lines.push(`Headings: ${headings.join(' | ')}`)
764
- }
765
- if (paragraphs.length > 0) {
766
- lines.push(`Excerpt: ${paragraphs.join(' ').slice(0, 900)}`)
767
- }
768
-
769
- return lines.join('\n')
770
- }
771
-
772
- function summarizePlainText(content: string): string {
773
- return content
774
- .replace(/\s+/g, ' ')
775
- .trim()
776
- .slice(0, 1200)
777
- }
778
-
779
- function matchHtmlTag(html: string, tag: string): string | null {
780
- const match = html.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i'))
781
- return match ? cleanHtmlText(match[1]) : null
782
- }
783
-
784
- function matchMetaDescription(html: string): string | null {
785
- const match = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["'][^>]*>/i)
786
- return match ? cleanHtmlText(match[1]) : null
787
- }
788
-
789
- function matchHtmlTags(html: string, tags: string[]): string[] {
790
- const pattern = new RegExp(`<(?:${tags.join('|')})[^>]*>([\\s\\S]*?)</(?:${tags.join('|')})>`, 'ig')
791
- const values: string[] = []
792
- for (const match of html.matchAll(pattern)) {
793
- const value = cleanHtmlText(match[1])
794
- if (value) values.push(value)
795
- }
796
- return values
797
- }
798
-
799
- function cleanHtmlText(value: string): string {
800
- return value
801
- .replace(/<[^>]+>/g, ' ')
802
- .replace(/&nbsp;/g, ' ')
803
- .replace(/&amp;/g, '&')
804
- .replace(/&quot;/g, '"')
805
- .replace(/&#39;/g, "'")
806
- .replace(/\s+/g, ' ')
807
- .trim()
808
- }
809
-
810
- function buildAgentPrompt(
811
- kind: AgentPromptKind,
812
- input: {
813
- pluginName: string
814
- displayName: string
815
- skillPaths: string[]
816
- commandPaths: string[]
817
- overrides: AgentOverrides | null
818
- },
819
- ): string {
820
- const sharedIntro = [
821
- `# ${titleCase(kind)} Prompt`,
822
- '',
823
- `You are refining the Pluxx-generated plugin scaffold for \`${input.pluginName}\` (${input.displayName}).`,
824
- '',
825
- 'Inputs:',
826
- '- `.pluxx/agent/context.md`',
827
- '- `.pluxx/agent/plan.json`',
828
- `- \`${MCP_TAXONOMY_PATH}\``,
829
- '- `INSTRUCTIONS.md`',
830
- ...input.skillPaths.map((path) => `- \`${path}\``),
831
- ...input.commandPaths.map((path) => `- \`${path}\``),
832
- '',
833
- 'Rules:',
834
- '- Only edit Pluxx-managed generated sections.',
835
- `- Preserve all custom-note blocks between \`${PLUXX_CUSTOM_START}\` and \`${PLUXX_CUSTOM_END}\`.`,
836
- '- Do not change auth wiring or target-platform config.',
837
- '- Do not edit files under `dist/`.',
838
- '- Treat discovered MCP resources, resource templates, and prompt templates as part of the product surface when they are present in the context and metadata.',
839
- '- Treat per-skill related resources and prompt templates in the context as default evidence for workflow boundaries and examples unless stronger discovery evidence contradicts them.',
840
- '',
841
- ]
842
-
843
- if (kind === 'taxonomy') {
844
- return `${sharedIntro.join('\n')}Your job:\n1. Treat \`${MCP_TAXONOMY_PATH}\` as the semantic source of truth for skill grouping and naming.\n2. Infer the MCP's real product surfaces and workflows from tools, resources, resource templates, and prompt templates.\n3. Merge, split, or rename generated skills so labels are product-facing, not lexical buckets.\n4. Update the taxonomy file first; Pluxx will re-render generated skills and commands from that taxonomy after the pass.\n5. Keep setup/onboarding, account-admin, and runtime workflows intentionally separated when appropriate.\n6. Eliminate misleading labels such as contact or people discovery when the tools do not actually perform direct lookup.\n7. Use per-skill related resources and prompt templates as strong evidence for workflow shape, but correct them when broader discovery evidence shows a mismatch.\n8. Reject stale scaffold assumptions; if current files conflict with discovery context, prefer the discovery evidence and flag the mismatch.\n${buildPromptOverrideBlock(kind, input.overrides)}\nSuccess criteria:\n- each skill represents a real user workflow or product surface\n- skill names are product-shaped and avoid raw MCP tool/server identifiers when possible\n- setup/onboarding, account-admin, and runtime workflows are grouped intentionally\n- singleton skills are avoided unless they represent a real standalone user workflow\n- commands stay aligned with the chosen taxonomy and avoid weak command UX\n- per-skill resource and prompt-template associations remain coherent with the chosen taxonomy\n- taxonomy decisions are grounded in current discovery context, not stale scaffold assumptions\n`
845
- }
846
-
847
- if (kind === 'instructions') {
848
- return `${sharedIntro.join('\n')}Your job:\n1. Rewrite only the generated block in \`INSTRUCTIONS.md\`.\n2. Explain what the plugin is for, how the skills should be used, and which setup/admin/account/runtime boundaries matter.\n3. Use discovered tools, resources, resource templates, and prompt templates to produce short routing guidance, not a raw documentation dump.\n4. Keep wording aligned to the MCP's product narrative and branded language; avoid raw MCP server/tool identifiers except when technically required.\n5. Prefer the branded product name in user-facing copy; do not lead with internal MCP server identifiers.\n6. Replace stale scaffold claims with current discovery-backed language and keep command examples operational, concrete, and copy-paste runnable.\n7. When a workflow already has related resources or prompt templates in the context, keep the wording and examples aligned to that surfaced workflow evidence.\n${buildPromptOverrideBlock(kind, input.overrides)}\nSuccess criteria:\n- instructions are concise, actionable, and product-shaped\n- wording is branded and product-facing, not raw MCP-internal naming\n- auth/setup/admin caveats are explicit when relevant\n- raw MCP server identifiers are omitted unless operationally necessary\n- the generated section reads like routing guidance, not pasted vendor docs\n- command examples use strong command UX (clear intent, realistic args, and runnable shapes)\n- workflow guidance stays coherent with related resource and prompt-template evidence in the context\n- the file remains safe for future \`pluxx sync --from-mcp\`\n`
849
- }
850
-
851
- return `${sharedIntro.join('\n')}Your job:\n1. Review the current scaffold critically.\n2. Call out weak skill groupings, missing setup guidance, vague examples, product/category mismatches, raw documentation dumps, lexical skill names, stale scaffold assumptions, weak command UX, incoherent per-skill resource/prompt associations, or weak MCP metadata signals.\n3. Separate scaffold quality findings from runtime-correctness findings.\n4. Propose only the highest-value changes needed to make the scaffold useful.\n${buildPromptOverrideBlock(kind, input.overrides)}\nSuccess criteria:\n- findings are concrete and tied to files\n- scaffold quality gaps are distinguished from runtime correctness\n- stale assumptions, incoherent per-skill discovery associations, and command-UX weaknesses are identified explicitly when present\n- suggested changes improve user-facing plugin quality\n- recommendations stay inside Pluxx-managed boundaries\n`
852
- }
853
-
854
- function summarizeDiscoveryDescription(description: string | undefined, trailing?: string): string {
855
- const base = description
856
- ?.replace(/\s+/g, ' ')
857
- .trim()
858
- .slice(0, 180)
859
- return [base || 'Discovered during MCP introspection.', trailing].filter(Boolean).join(' ')
860
- }
861
-
862
- function buildAgentRunnerPrompt(kind: AgentPromptKind, promptPath: string): string {
863
- const lines = [
864
- 'You are running inside a Pluxx-generated plugin scaffold.',
865
- `Read and follow \`${AGENT_CONTEXT_PATH}\`, \`${AGENT_PLAN_PATH}\`, and \`${promptPath}\` before doing anything else.`,
866
- 'Use the prompt file as the task definition.',
867
- 'Respect the write contract in the plan file.',
868
- `Preserve all custom-note blocks between \`${PLUXX_CUSTOM_START}\` and \`${PLUXX_CUSTOM_END}\`.`,
869
- 'Do not change auth wiring, target-platform config, or generated files under `dist/`.',
870
- ]
871
-
872
- if (kind === 'review') {
873
- lines.push('Do not edit files. Return findings only.')
874
- } else {
875
- lines.push('Edit only the Pluxx-managed generated sections allowed by the plan file.')
876
- lines.push('Do not run lint, build, or tests; Pluxx will verify the result afterward.')
877
- lines.push('When finished, provide a short summary of what you changed.')
878
- }
879
-
880
- return `${lines.join('\n')}\n`
881
- }
882
-
883
- function buildAgentRunnerCommand(
884
- runner: AgentRunner,
885
- kind: AgentPromptKind,
886
- prompt: string,
887
- options: {
888
- model?: string
889
- attach?: string
890
- workspace?: string
891
- } = {},
892
- ): string[] {
893
- const binary = AGENT_RUNNER_BINARIES[runner]
894
-
895
- if (runner === 'claude') {
896
- const args = [binary]
897
- if (options.model) {
898
- args.push('--model', options.model)
899
- }
900
- args.push(
901
- '--no-session-persistence',
902
- '--verbose',
903
- '--output-format',
904
- 'stream-json',
905
- '--permission-mode',
906
- kind === 'review' ? 'plan' : 'acceptEdits',
907
- '-p',
908
- prompt,
909
- )
910
- return args
911
- }
912
-
913
- if (runner === 'codex') {
914
- // Codex headless edits can finish successfully and then stall during
915
- // session persistence/finalization. Ephemeral mode keeps the non-interactive
916
- // worker path stable for Pluxx agent/autopilot runs.
917
- const args = [binary, 'exec', '--ephemeral', '--skip-git-repo-check']
918
- if (options.model) {
919
- args.push('--model', options.model)
920
- }
921
- if (kind !== 'review') {
922
- args.push('--full-auto')
923
- }
924
- args.push(prompt)
925
- return args
926
- }
927
-
928
- if (runner === 'cursor') {
929
- if (!options.workspace) {
930
- throw new Error('Cursor runner requires a workspace path.')
931
- }
932
-
933
- const args = [binary, '-p', '--trust', '--workspace', options.workspace]
934
- if (kind !== 'review') {
935
- args.push('--force')
936
- }
937
- if (options.model) {
938
- args.push('--model', options.model)
939
- }
940
- args.push(prompt)
941
- return args
942
- }
943
-
944
- const args = [binary, 'run']
945
- if (options.model) {
946
- args.push('--model', options.model)
947
- }
948
- if (options.attach) {
949
- args.push('--attach', options.attach)
950
- }
951
- args.push(prompt)
952
- return args
953
- }
954
-
955
- async function resolveAgentRunnerModel(
956
- runner: AgentRunner,
957
- explicitModel?: string,
958
- ): Promise<AgentRunnerModelSummary> {
959
- if (explicitModel) {
960
- return {
961
- value: explicitModel,
962
- source: 'explicit',
963
- display: `${explicitModel} (explicit)`,
964
- }
965
- }
966
-
967
- const detectedModel = runner === 'codex'
968
- ? await readCodexDefaultModel()
969
- : runner === 'opencode'
970
- ? await readOpenCodeDefaultModel()
971
- : runner === 'claude'
972
- ? await readClaudeDefaultModel()
973
- : undefined
974
-
975
- if (detectedModel) {
976
- return {
977
- value: detectedModel,
978
- source: 'default',
979
- display: `${detectedModel} (local default)`,
980
- }
981
- }
982
-
983
- return {
984
- source: 'unknown',
985
- display: 'local default (CLI-managed)',
986
- }
987
- }
988
-
989
- async function readCodexDefaultModel(): Promise<string | undefined> {
990
- const codexHome = process.env.CODEX_HOME?.trim() || resolve(homedir(), '.codex')
991
- return await readTomlStringValue(resolve(codexHome, 'config.toml'), 'model')
992
- }
993
-
994
- async function readOpenCodeDefaultModel(): Promise<string | undefined> {
995
- const configHome = process.env.XDG_CONFIG_HOME?.trim() || resolve(homedir(), '.config')
996
- const configPath = resolve(configHome, 'opencode', 'opencode.json')
997
- const parsed = await readJsonFile(configPath)
998
- if (!parsed || typeof parsed !== 'object') {
999
- return undefined
1000
- }
1001
-
1002
- if (typeof parsed.model === 'string' && parsed.model.trim()) {
1003
- return parsed.model.trim()
1004
- }
1005
-
1006
- if (
1007
- typeof parsed.default_agent === 'string'
1008
- && parsed.agent
1009
- && typeof parsed.agent === 'object'
1010
- && parsed.default_agent in parsed.agent
1011
- ) {
1012
- const defaultAgent = parsed.agent[parsed.default_agent]
1013
- if (
1014
- defaultAgent
1015
- && typeof defaultAgent === 'object'
1016
- && 'model' in defaultAgent
1017
- && typeof defaultAgent.model === 'string'
1018
- && defaultAgent.model.trim()
1019
- ) {
1020
- return defaultAgent.model.trim()
1021
- }
1022
- }
1023
-
1024
- return undefined
1025
- }
1026
-
1027
- async function readClaudeDefaultModel(): Promise<string | undefined> {
1028
- for (const candidate of [
1029
- resolve(homedir(), '.claude', 'settings.json'),
1030
- resolve(homedir(), '.claude', 'settings.local.json'),
1031
- resolve(homedir(), '.claude.json'),
1032
- ]) {
1033
- const parsed = await readJsonFile(candidate)
1034
- if (!parsed || typeof parsed !== 'object') continue
1035
- for (const key of ['model', 'defaultModel', 'default_model']) {
1036
- if (key in parsed && typeof parsed[key] === 'string' && parsed[key].trim()) {
1037
- return parsed[key].trim()
1038
- }
1039
- }
1040
- }
1041
-
1042
- return undefined
1043
- }
1044
-
1045
- async function readTomlStringValue(filePath: string, key: string): Promise<string | undefined> {
1046
- try {
1047
- const raw = await readFile(filePath, 'utf8')
1048
- const match = raw.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"\\s*$`, 'm'))
1049
- return match?.[1]?.trim() || undefined
1050
- } catch {
1051
- return undefined
1052
- }
1053
- }
1054
-
1055
- async function readJsonFile(filePath: string): Promise<Record<string, any> | undefined> {
1056
- try {
1057
- const raw = await readFile(filePath, 'utf8')
1058
- return JSON.parse(raw) as Record<string, any>
1059
- } catch {
1060
- return undefined
1061
- }
1062
- }
1063
-
1064
- async function ensureRunnerAvailable(runner: AgentRunner): Promise<void> {
1065
- const binary = runner === 'cursor'
1066
- ? await resolveCursorBinary()
1067
- : AGENT_RUNNER_BINARIES[runner]
1068
- const available = binary ? await commandExists(binary) : false
1069
- if (!available) {
1070
- if (runner === 'cursor') {
1071
- throw new Error('The cursor runner requires the Cursor CLI `agent` or `cursor-agent` binary on PATH. Install it with `curl https://cursor.com/install -fsS | bash` or choose a different runner.')
1072
- }
1073
- throw new Error(`The ${runner} runner is not available on PATH. Install \`${binary}\` or choose a different runner.`)
1074
- }
1075
- }
1076
-
1077
- async function ensureRunnerAuthenticated(runner: AgentRunner): Promise<void> {
1078
- if (runner !== 'cursor') return
1079
-
1080
- if (process.env.CURSOR_API_KEY && process.env.CURSOR_API_KEY.trim().length > 0) {
1081
- return
1082
- }
1083
-
1084
- const binary = await resolveCursorBinary()
1085
- const isAuthenticated = binary ? await commandSucceeds([binary, 'status']) : false
1086
- if (!isAuthenticated) {
1087
- throw new Error('Cursor CLI authentication is required. Run `agent login` (or `cursor-agent login`) or export `CURSOR_API_KEY` before running Pluxx with `--runner cursor`.')
1088
- }
1089
- }
1090
-
1091
- async function resolveCursorBinary(): Promise<string | undefined> {
1092
- for (const candidate of CURSOR_RUNNER_BINARIES) {
1093
- if (await commandExists(candidate)) {
1094
- return candidate
1095
- }
1096
- }
1097
-
1098
- return undefined
1099
- }
1100
-
1101
- async function commandExists(binary: string): Promise<boolean> {
1102
- return await new Promise<boolean>((resolvePromise) => {
1103
- const child = spawn('sh', ['-c', `command -v ${shellQuote(binary)} >/dev/null 2>&1`], {
1104
- stdio: 'ignore',
1105
- env: process.env,
1106
- })
1107
- child.on('close', (code) => resolvePromise(code === 0))
1108
- child.on('error', () => resolvePromise(false))
1109
- })
1110
- }
1111
-
1112
- async function commandSucceeds(command: string[]): Promise<boolean> {
1113
- return await new Promise<boolean>((resolvePromise) => {
1114
- const child = spawn(command[0], command.slice(1), {
1115
- stdio: 'ignore',
1116
- env: process.env,
1117
- })
1118
- child.on('close', (code) => resolvePromise(code === 0))
1119
- child.on('error', () => resolvePromise(false))
1120
- })
1121
- }
1122
-
1123
- async function executeCommand(
1124
- command: string[],
1125
- cwd: string,
1126
- options: {
1127
- streamOutput?: boolean
1128
- env?: NodeJS.ProcessEnv
1129
- } = {},
1130
- ): Promise<number> {
1131
- const runtimeCommand = [...command]
1132
- let codexOutputDir: string | null = null
1133
- let codexLastMessagePath: string | null = null
1134
- const isClaudeStreamJson = runtimeCommand[0] === 'claude'
1135
- && runtimeCommand.includes('--output-format')
1136
- && runtimeCommand.includes('stream-json')
1137
-
1138
- if (runtimeCommand[0] === 'codex' && runtimeCommand[1] === 'exec') {
1139
- codexOutputDir = await mkdtemp(resolve(tmpdir(), 'pluxx-codex-output-'))
1140
- codexLastMessagePath = resolve(codexOutputDir, 'last-message.txt')
1141
- runtimeCommand.splice(2, 0, '--json', '--output-last-message', codexLastMessagePath)
1142
- }
1143
-
1144
- return await new Promise<number>((resolvePromise, reject) => {
1145
- const child = spawn(runtimeCommand[0], runtimeCommand.slice(1), {
1146
- cwd,
1147
- stdio: ['ignore', 'pipe', 'pipe'],
1148
- env: options.env ?? process.env,
1149
- })
1150
- let killedAfterFinalMessage = false
1151
- let sawFinalMessageAt: number | null = null
1152
- let codexStdoutBuffer = ''
1153
- let codexTurnCompleted = false
1154
- let codexTurnFailed = false
1155
- let claudeStdoutBuffer = ''
1156
- let claudeTurnCompleted = false
1157
- let claudeTurnFailed = false
1158
- const sentinelInterval = (codexLastMessagePath || isClaudeStreamJson)
1159
- ? setInterval(() => {
1160
- const sawCompletionSignal = codexTurnCompleted
1161
- || codexTurnFailed
1162
- || claudeTurnCompleted
1163
- || claudeTurnFailed
1164
- || (codexLastMessagePath ? existsSync(codexLastMessagePath) : false)
1165
- if (!sawCompletionSignal) return
1166
- if (sawFinalMessageAt == null) {
1167
- sawFinalMessageAt = Date.now()
1168
- return
1169
- }
1170
- if (!killedAfterFinalMessage && Date.now() - sawFinalMessageAt >= 1500) {
1171
- killedAfterFinalMessage = true
1172
- child.kill('SIGTERM')
1173
- }
1174
- }, 250)
1175
- : null
1176
-
1177
- const finalize = async (result: number, error?: Error): Promise<void> => {
1178
- if (sentinelInterval) clearInterval(sentinelInterval)
1179
- if (codexOutputDir) {
1180
- await rm(codexOutputDir, { recursive: true, force: true })
1181
- }
1182
- if (error) {
1183
- reject(error)
1184
- return
1185
- }
1186
- resolvePromise(result)
1187
- }
1188
-
1189
- child.stdout?.on('data', (chunk) => {
1190
- const text = chunk.toString()
1191
- if (codexLastMessagePath || isClaudeStreamJson) {
1192
- const buffer = codexLastMessagePath ? codexStdoutBuffer + text : claudeStdoutBuffer + text
1193
- const lines = buffer.split('\n')
1194
- const remainder = lines.pop() ?? ''
1195
- if (codexLastMessagePath) {
1196
- codexStdoutBuffer = remainder
1197
- } else {
1198
- claudeStdoutBuffer = remainder
1199
- }
1200
- for (const line of lines) {
1201
- const trimmed = line.trim()
1202
- if (!trimmed) continue
1203
- try {
1204
- const event = JSON.parse(trimmed) as { type?: string; subtype?: string; is_error?: boolean }
1205
- if (codexLastMessagePath) {
1206
- if (event.type === 'turn.completed') {
1207
- codexTurnCompleted = true
1208
- } else if (event.type === 'turn.failed' || event.type === 'error') {
1209
- codexTurnFailed = true
1210
- }
1211
- } else if (isClaudeStreamJson) {
1212
- if (event.type === 'result') {
1213
- if (event.is_error || event.subtype === 'error') {
1214
- claudeTurnFailed = true
1215
- } else {
1216
- claudeTurnCompleted = true
1217
- }
1218
- }
1219
- }
1220
- } catch {
1221
- // Ignore non-JSON lines. Codex still writes some human-readable output to stderr.
1222
- }
1223
- }
1224
- }
1225
- if (options.streamOutput) process.stdout.write(chunk)
1226
- })
1227
- child.stderr?.on('data', (chunk) => {
1228
- if (options.streamOutput) process.stderr.write(chunk)
1229
- })
1230
-
1231
- child.on('error', (error) => {
1232
- void finalize(1, error)
1233
- })
1234
- child.on('close', (code) => {
1235
- const result = codexTurnFailed || claudeTurnFailed
1236
- ? 1
1237
- : (killedAfterFinalMessage || codexTurnCompleted || claudeTurnCompleted ? 0 : (code ?? 1))
1238
- void finalize(result)
1239
- })
1240
- })
1241
- }
1242
-
1243
- async function prepareRunnerExecution(runner: AgentRunner): Promise<{
1244
- env: NodeJS.ProcessEnv
1245
- cleanup?: () => Promise<void>
1246
- }> {
1247
- if (runner === 'cursor') {
1248
- const cursorBinary = await resolveCursorBinary()
1249
- if (!cursorBinary || cursorBinary === AGENT_RUNNER_BINARIES.cursor) {
1250
- return { env: process.env }
1251
- }
1252
-
1253
- const shimDir = await mkdtemp(resolve(tmpdir(), 'pluxx-cursor-bin-'))
1254
- const shimPath = resolve(shimDir, AGENT_RUNNER_BINARIES.cursor)
1255
- await Bun.write(
1256
- shimPath,
1257
- `#!/bin/sh\nexec ${shellQuote(cursorBinary)} "$@"\n`,
1258
- )
1259
- await chmod(shimPath, 0o755)
1260
-
1261
- return {
1262
- env: {
1263
- ...process.env,
1264
- PATH: `${shimDir}:${process.env.PATH ?? ''}`,
1265
- },
1266
- cleanup: async () => {
1267
- await rm(shimDir, { recursive: true, force: true })
1268
- },
1269
- }
1270
- }
1271
-
1272
- if (runner !== 'codex') {
1273
- return { env: process.env }
1274
- }
1275
-
1276
- const currentCodexHome = process.env.CODEX_HOME?.trim() || resolve(homedir(), '.codex')
1277
- const isolatedCodexHome = await mkdtemp(resolve(tmpdir(), 'pluxx-codex-home-'))
1278
- await mkdir(resolve(isolatedCodexHome, 'memories'), { recursive: true })
1279
-
1280
- for (const relativePath of ['auth.json', 'config.toml', 'hooks.json', 'installation_id']) {
1281
- const sourcePath = resolve(currentCodexHome, relativePath)
1282
- if (!existsSync(sourcePath)) continue
1283
- await copyFile(sourcePath, resolve(isolatedCodexHome, relativePath))
1284
- }
1285
-
1286
- const rulesSourceDir = resolve(currentCodexHome, 'rules')
1287
- if (existsSync(rulesSourceDir)) {
1288
- const rulesTargetDir = resolve(isolatedCodexHome, 'rules')
1289
- await mkdir(rulesTargetDir, { recursive: true })
1290
- const defaultRulesPath = resolve(rulesSourceDir, 'default.rules')
1291
- if (existsSync(defaultRulesPath)) {
1292
- await copyFile(defaultRulesPath, resolve(rulesTargetDir, 'default.rules'))
1293
- }
1294
- }
1295
-
1296
- return {
1297
- env: {
1298
- ...process.env,
1299
- CODEX_HOME: isolatedCodexHome,
1300
- },
1301
- cleanup: async () => {
1302
- await rm(isolatedCodexHome, { recursive: true, force: true })
1303
- },
1304
- }
1305
- }
1306
-
1307
- function shellQuote(value: string): string {
1308
- if (/^[A-Za-z0-9_/:=.,-]+$/.test(value)) {
1309
- return value
1310
- }
1311
-
1312
- return `'${value.replace(/'/g, `'\"'\"'`)}'`
1313
- }
1314
-
1315
- function describeAuth(server: { auth?: { type: string; envVar?: string; headerName?: string } }): string {
1316
- const auth = server.auth
1317
- if (!auth || auth.type === 'none') {
1318
- return 'none'
1319
- }
1320
-
1321
- if (auth.type === 'header') {
1322
- return `header via ${auth.headerName ?? 'custom header'} from ${auth.envVar ?? 'env'}`
1323
- }
1324
-
1325
- if (auth.type === 'platform') {
1326
- return 'platform-managed auth'
1327
- }
1328
-
1329
- return `bearer via ${auth.envVar ?? 'env'}`
1330
- }
1331
-
1332
- function titleCase(value: string): string {
1333
- return value.charAt(0).toUpperCase() + value.slice(1)
1334
- }
1335
-
1336
- async function loadAgentOverrides(rootDir: string): Promise<AgentOverrides | null> {
1337
- const overridesPath = resolve(rootDir, AGENT_OVERRIDES_PATH)
1338
- if (!existsSync(overridesPath)) {
1339
- return null
1340
- }
1341
-
1342
- const content = await Bun.file(overridesPath).text()
1343
- return parseAgentOverrides(content, AGENT_OVERRIDES_PATH)
1344
- }
1345
-
1346
- function parseAgentOverrides(content: string, path: string): AgentOverrides {
1347
- const sections = new Map<string, string[]>()
1348
- let currentSection: string | null = null
1349
-
1350
- for (const rawLine of content.split(/\r?\n/)) {
1351
- const heading = rawLine.match(/^##\s+(.+?)\s*$/)
1352
- if (heading) {
1353
- currentSection = normalizeOverrideHeading(heading[1])
1354
- if (currentSection && !sections.has(currentSection)) {
1355
- sections.set(currentSection, [])
1356
- }
1357
- continue
1358
- }
1359
-
1360
- if (!currentSection) continue
1361
- sections.get(currentSection)?.push(rawLine)
1362
- }
1363
-
1364
- const contextPaths = extractListItems(sections.get('context-paths') ?? [])
1365
-
1366
- return {
1367
- path,
1368
- contextPaths,
1369
- productHints: normalizeOverrideBody(sections.get('product-hints')),
1370
- setupAuthNotes: normalizeOverrideBody(sections.get('setup-auth-notes')),
1371
- groupingHints: normalizeOverrideBody(sections.get('grouping-hints')),
1372
- taxonomyGuidance: normalizeOverrideBody(sections.get('taxonomy-guidance')),
1373
- instructionsGuidance: normalizeOverrideBody(sections.get('instructions-guidance')),
1374
- reviewCriteria: normalizeOverrideBody(sections.get('review-criteria')),
1375
- }
1376
- }
1377
-
1378
- function normalizeOverrideHeading(value: string): string | null {
1379
- const normalized = value
1380
- .trim()
1381
- .toLowerCase()
1382
- .replace(/[^a-z0-9]+/g, '-')
1383
- .replace(/^-+|-+$/g, '')
1384
-
1385
- const aliases: Record<string, string> = {
1386
- 'context-paths': 'context-paths',
1387
- 'context-files': 'context-paths',
1388
- 'product-hints': 'product-hints',
1389
- 'setup-auth-notes': 'setup-auth-notes',
1390
- 'setup-and-auth-notes': 'setup-auth-notes',
1391
- 'setup-auth-guidance': 'setup-auth-notes',
1392
- 'grouping-hints': 'grouping-hints',
1393
- 'tool-grouping-hints': 'grouping-hints',
1394
- 'taxonomy-guidance': 'taxonomy-guidance',
1395
- 'instructions-guidance': 'instructions-guidance',
1396
- 'review-criteria': 'review-criteria',
1397
- }
1398
-
1399
- return aliases[normalized] ?? null
1400
- }
1401
-
1402
- function extractListItems(lines: string[]): string[] {
1403
- return lines
1404
- .map((line) => line.trim())
1405
- .filter((line) => line.startsWith('- ') || line.startsWith('* '))
1406
- .map((line) => line.slice(2).trim())
1407
- .filter(Boolean)
1408
- }
1409
-
1410
- function normalizeOverrideBody(lines: string[] | undefined): string | undefined {
1411
- if (!lines) return undefined
1412
- const value = lines.join('\n').trim()
1413
- return value || undefined
1414
- }
1415
-
1416
- function appendOverrideSection(lines: string[], heading: string, content: string | undefined): void {
1417
- if (!content) return
1418
- lines.push(`### ${heading}`)
1419
- lines.push('')
1420
- lines.push(content)
1421
- lines.push('')
1422
- }
1423
-
1424
- function buildPromptOverrideBlock(kind: AgentPromptKind, overrides: AgentOverrides | null): string {
1425
- if (!overrides) return ''
1426
-
1427
- const additions: string[] = []
1428
-
1429
- if (overrides.productHints) {
1430
- additions.push(`Product hints:\n${overrides.productHints}`)
1431
- }
1432
- if (overrides.setupAuthNotes) {
1433
- additions.push(`Setup/auth notes:\n${overrides.setupAuthNotes}`)
1434
- }
1435
-
1436
- if (kind === 'taxonomy') {
1437
- if (overrides.groupingHints) {
1438
- additions.push(`Grouping hints:\n${overrides.groupingHints}`)
1439
- }
1440
- if (overrides.taxonomyGuidance) {
1441
- additions.push(`Taxonomy guidance:\n${overrides.taxonomyGuidance}`)
1442
- }
1443
- }
1444
-
1445
- if (kind === 'instructions' && overrides.instructionsGuidance) {
1446
- additions.push(`Instructions guidance:\n${overrides.instructionsGuidance}`)
1447
- }
1448
-
1449
- if (kind === 'review' && overrides.reviewCriteria) {
1450
- additions.push(`Additional review criteria:\n${overrides.reviewCriteria}`)
1451
- }
1452
-
1453
- if (additions.length === 0) return ''
1454
- return `\nProject overrides:\n${additions.map((block) => `- ${block.replace(/\n/g, '\n ')}`).join('\n')}\n`
1455
- }