pluidr 0.4.1 → 0.6.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 (49) hide show
  1. package/README.md +294 -73
  2. package/package.json +7 -2
  3. package/src/cli/commands/doctor.js +99 -0
  4. package/src/cli/commands/init.js +14 -7
  5. package/src/cli/commands/uninstall.js +67 -0
  6. package/src/cli/commands/update.js +22 -0
  7. package/src/cli/index.js +31 -1
  8. package/src/cli/wizard/selectModelTier.js +22 -31
  9. package/src/core/agentPromptWriter.js +2 -2
  10. package/src/core/agentPromptWriter.test.js +56 -0
  11. package/src/core/backup.js +35 -5
  12. package/src/core/backup.test.js +51 -0
  13. package/src/core/configBuilder.js +13 -0
  14. package/src/core/configBuilder.test.js +47 -0
  15. package/src/core/configWriter.js +7 -4
  16. package/src/core/configWriter.test.js +26 -0
  17. package/src/core/identityHeader.test.js +15 -0
  18. package/src/core/paths.js +4 -15
  19. package/src/core/paths.test.js +21 -0
  20. package/src/core/pluginWriter.js +12 -8
  21. package/src/core/pluginWriter.test.js +41 -0
  22. package/src/core/squeezeInstaller.js +141 -0
  23. package/src/core/squeezeInstaller.test.js +77 -0
  24. package/src/plugins/README.md +29 -15
  25. package/src/plugins/{parent-session.js → pluidr-flow.js} +1 -6
  26. package/src/plugins/pluidr-squeeze.js +56 -0
  27. package/src/templates/agent-prompts/auditor.txt +20 -0
  28. package/src/templates/agent-prompts/coder.txt +33 -7
  29. package/src/templates/agent-prompts/{writer.txt → compose-reporter.txt} +7 -7
  30. package/src/templates/agent-prompts/composer.txt +428 -0
  31. package/src/templates/agent-prompts/{reporter.txt → debug-reporter.txt} +3 -7
  32. package/src/templates/agent-prompts/debugger.txt +64 -18
  33. package/src/templates/agent-prompts/fixer.txt +7 -0
  34. package/src/templates/agent-prompts/hierarchy.txt +29 -15
  35. package/src/templates/agent-prompts/patcher.txt +20 -0
  36. package/src/templates/agent-prompts/plan-checker.txt +5 -5
  37. package/src/templates/agent-prompts/plan-writer.txt +3 -3
  38. package/src/templates/agent-prompts/probe-reporter.txt +62 -0
  39. package/src/templates/agent-prompts/prober.txt +90 -0
  40. package/src/templates/agent-prompts/researcher.txt +3 -3
  41. package/src/templates/agent-prompts/reviewer.txt +16 -4
  42. package/src/templates/agent-prompts/tester.txt +10 -1
  43. package/src/templates/agent-prompts/tracer.txt +33 -0
  44. package/src/templates/model-defaults.json +45 -2
  45. package/src/templates/opencode.config.json +227 -73
  46. package/src/templates/rtk-checksums.json +7 -0
  47. package/src/templates/agent-prompts/builder.txt +0 -107
  48. package/src/templates/agent-prompts/explorer.txt +0 -53
  49. package/src/templates/agent-prompts/planner.txt +0 -126
@@ -0,0 +1,67 @@
1
+ import { existsSync, copyFileSync, rmSync, readdirSync } from "node:fs"
2
+ import { join, dirname } from "node:path"
3
+ import { getConfigDir, getConfigPath } from "../../core/paths.js"
4
+
5
+ function findLatestBackup() {
6
+ const dir = getConfigDir()
7
+ let backups = []
8
+ try {
9
+ backups = readdirSync(dir)
10
+ .filter((f) => f.startsWith("opencode.jsonc.bak."))
11
+ .sort()
12
+ .reverse()
13
+ } catch {
14
+ return null
15
+ }
16
+ return backups.length > 0 ? join(dir, backups[0]) : null
17
+ }
18
+
19
+ export async function runUninstall() {
20
+ const configDir = getConfigDir()
21
+ const configPath = getConfigPath()
22
+ const promptsDir = join(configDir, "prompts")
23
+ const pluginsDir = join(configDir, "plugins")
24
+ const binDir = join(configDir, "bin")
25
+
26
+ const summary = { restored: null, removed: [] }
27
+
28
+ // Restore latest backup
29
+ const latestBackup = findLatestBackup()
30
+ if (latestBackup && existsSync(configPath)) {
31
+ copyFileSync(latestBackup, configPath)
32
+ summary.restored = latestBackup
33
+ } else if (latestBackup) {
34
+ // Config doesn't exist but backup does — restore anyway
35
+ copyFileSync(latestBackup, configPath)
36
+ summary.restored = latestBackup
37
+ }
38
+
39
+ // Remove Pluidr-installed artifacts
40
+ if (existsSync(promptsDir)) {
41
+ rmSync(promptsDir, { recursive: true, force: true })
42
+ summary.removed.push("prompts/")
43
+ }
44
+ if (existsSync(pluginsDir)) {
45
+ rmSync(pluginsDir, { recursive: true, force: true })
46
+ summary.removed.push("plugins/")
47
+ }
48
+ if (existsSync(binDir)) {
49
+ rmSync(binDir, { recursive: true, force: true })
50
+ summary.removed.push("bin/")
51
+ }
52
+
53
+ // Print summary
54
+ console.log("Uninstall complete:")
55
+ if (summary.restored) {
56
+ console.log(` ✓ Restored ${summary.restored}`)
57
+ } else {
58
+ console.log(" - No backup found to restore")
59
+ }
60
+ if (summary.removed.length > 0) {
61
+ console.log(` ✓ Removed: ${summary.removed.join(", ")}`)
62
+ } else {
63
+ console.log(" - No Pluidr artifacts found to remove")
64
+ }
65
+ console.log(" - Kept opencode.jsonc (preserves user customizations)")
66
+ console.log(" - Kept package.json (may be used by other plugins)")
67
+ }
@@ -0,0 +1,22 @@
1
+ import { existsSync } from "node:fs"
2
+ import { confirm, isCancel } from "@clack/prompts"
3
+ import { getConfigPath } from "../../core/paths.js"
4
+ import { runInit } from "./init.js"
5
+
6
+ export async function runUpdate() {
7
+ const configPath = getConfigPath()
8
+
9
+ if (existsSync(configPath)) {
10
+ const answer = await confirm({
11
+ message: "Existing Pluidr config found. Overwrite? (Y/n)",
12
+ initialValue: true,
13
+ })
14
+
15
+ if (isCancel(answer) || !answer) {
16
+ console.log("Update cancelled.")
17
+ return
18
+ }
19
+ }
20
+
21
+ await runInit()
22
+ }
package/src/cli/index.js CHANGED
@@ -2,13 +2,31 @@ import { readFileSync } from "node:fs"
2
2
  import { resolve, dirname } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
  import { runInit } from "./commands/init.js"
5
+ import { runUninstall } from "./commands/uninstall.js"
6
+ import { runUpdate } from "./commands/update.js"
7
+ import { runDoctor } from "./commands/doctor.js"
5
8
 
6
9
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
10
  const pkg = JSON.parse(readFileSync(resolve(__dirname, "../../package.json"), "utf-8"))
8
11
 
12
+ const HELP = `Usage: pluidr <command>
13
+
14
+ Commands:
15
+ init Set up OpenCode with Pluidr's 12-agent pipeline
16
+ uninstall Remove Pluidr artifacts and restore previous config
17
+ update Re-run setup (overwrites current config)
18
+ doctor Verify installation health
19
+ --version, -v Print version number
20
+ --help, -h Show this help message`
21
+
9
22
  export function run(argv) {
10
23
  const cmd = argv[2]
11
24
 
25
+ if (cmd === "--help" || cmd === "-h") {
26
+ console.log(HELP)
27
+ return
28
+ }
29
+
12
30
  if (cmd === "--version" || cmd === "-v") {
13
31
  console.log(pkg.version)
14
32
  return
@@ -18,6 +36,18 @@ export function run(argv) {
18
36
  return runInit()
19
37
  }
20
38
 
21
- console.log("Usage: pluidr init")
39
+ if (cmd === "uninstall") {
40
+ return runUninstall()
41
+ }
42
+
43
+ if (cmd === "update") {
44
+ return runUpdate()
45
+ }
46
+
47
+ if (cmd === "doctor") {
48
+ return runDoctor()
49
+ }
50
+
51
+ console.log(HELP)
22
52
  process.exit(1)
23
53
  }
@@ -7,42 +7,33 @@ const TIER_LABELS = {
7
7
 
8
8
  export async function selectModelTier(modelDefaults) {
9
9
  const choices = {}
10
-
11
10
  for (const [tierKey, tierInfo] of Object.entries(modelDefaults)) {
12
11
  const defaultModel = `${tierInfo.provider}/${tierInfo.model}`
13
12
  const label = TIER_LABELS[tierKey] ?? tierKey
14
13
 
15
- let selectedModel = defaultModel
16
-
17
- try {
18
- const answer = await select({
19
- message: label,
20
- options: [
21
- { value: defaultModel, label: `default (${defaultModel})` },
22
- { value: "__custom__", label: "custom (provider/model)" },
23
- ],
24
- })
25
-
26
- if (isCancel(answer)) {
27
- selectedModel = defaultModel
28
- } else if (answer === "__custom__") {
29
- try {
30
- const customModel = await text({
31
- message: "Enter model:",
32
- })
33
- selectedModel = isCancel(customModel) ? defaultModel : (customModel || defaultModel)
34
- } catch {
35
- selectedModel = defaultModel
36
- }
37
- } else {
38
- selectedModel = answer
39
- }
40
- } catch {
41
- selectedModel = defaultModel
14
+ const recommendedOptions = (tierInfo.recommended || []).map((r) => ({
15
+ value: r.value,
16
+ label: r.label,
17
+ }))
18
+
19
+ const options = [
20
+ ...recommendedOptions,
21
+ { value: "__custom__", label: "custom (enter provider/model)" },
22
+ ]
23
+
24
+ const answer = await select({
25
+ message: label,
26
+ options,
27
+ })
28
+
29
+ if (isCancel(answer)) {
30
+ choices[tierKey] = defaultModel
31
+ } else if (answer === "__custom__") {
32
+ const customModel = await text({ message: "Enter model:" })
33
+ choices[tierKey] = isCancel(customModel) ? defaultModel : (customModel || defaultModel)
34
+ } else {
35
+ choices[tierKey] = answer
42
36
  }
43
-
44
- choices[tierKey] = selectedModel
45
37
  }
46
-
47
38
  return choices
48
39
  }
@@ -8,9 +8,9 @@ import IDENTITY_HEADER from "./identityHeader.js"
8
8
  // copied verbatim — no agent-identity header.
9
9
  const REFERENCE_FILES = new Set(["hierarchy"])
10
10
 
11
- export function writeAgentPrompts(templatesDir) {
11
+ export function writeAgentPrompts(templatesDir, destDir) {
12
12
  const sourceDir = resolve(templatesDir, "agent-prompts")
13
- const destDir = getPromptsDir()
13
+ if (!destDir) destDir = getPromptsDir()
14
14
 
15
15
  mkdirSync(destDir, { recursive: true })
16
16
 
@@ -0,0 +1,56 @@
1
+ import { describe, it, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { writeAgentPrompts } from "./agentPromptWriter.js"
7
+
8
+ describe("agentPromptWriter", () => {
9
+ const destDir = mkdtempSync(join(tmpdir(), "pluidr-apw-dest-"))
10
+
11
+ after(() => {
12
+ // Clean up any files written during tests
13
+ if (existsSync(destDir)) rmSync(destDir, { recursive: true })
14
+ })
15
+
16
+ it("prepends identity header to agent prompts", () => {
17
+ const tmpDir = mkdtempSync(join(tmpdir(), "apw-source-"))
18
+ const agentPromptsDir = join(tmpDir, "agent-prompts")
19
+ mkdirSync(agentPromptsDir, { recursive: true })
20
+
21
+ writeFileSync(join(agentPromptsDir, "coder.txt"), "test instructions", "utf-8")
22
+ writeFileSync(join(agentPromptsDir, "compose-reporter.txt"), "write docs", "utf-8")
23
+
24
+ writeAgentPrompts(tmpDir, destDir)
25
+
26
+ const coderContent = readFileSync(join(destDir, "coder.txt"), "utf-8")
27
+ const composeReporterContent = readFileSync(join(destDir, "compose-reporter.txt"), "utf-8")
28
+
29
+ assert.ok(coderContent.startsWith('You are the "coder" agent.\n\n'),
30
+ "coder should have identity header")
31
+ assert.ok(composeReporterContent.startsWith('You are the "compose-reporter" agent.\n\n'),
32
+ "compose-reporter should have identity header")
33
+ assert.ok(coderContent.includes("test instructions"),
34
+ "original content preserved in coder")
35
+ assert.ok(composeReporterContent.includes("write docs"),
36
+ "original content preserved in compose-reporter")
37
+
38
+ rmSync(tmpDir, { recursive: true })
39
+ })
40
+
41
+ it("copies hierarchy.txt verbatim without identity header", () => {
42
+ const tmpDir = mkdtempSync(join(tmpdir(), "apw-source2-"))
43
+ const agentPromptsDir = join(tmpDir, "agent-prompts")
44
+ mkdirSync(agentPromptsDir, { recursive: true })
45
+
46
+ writeFileSync(join(agentPromptsDir, "hierarchy.txt"), "global reference rules", "utf-8")
47
+
48
+ writeAgentPrompts(tmpDir, destDir)
49
+
50
+ const hierarchyContent = readFileSync(join(destDir, "hierarchy.txt"), "utf-8")
51
+ assert.strictEqual(hierarchyContent, "global reference rules",
52
+ "hierarchy.txt should be copied verbatim without header")
53
+
54
+ rmSync(tmpDir, { recursive: true })
55
+ })
56
+ })
@@ -1,8 +1,38 @@
1
- import { existsSync, copyFileSync } from "node:fs"
2
- import { getConfigPath, getBackupPath } from "./paths.js"
1
+ import { existsSync, copyFileSync, readdirSync, unlinkSync } from "node:fs"
2
+ import { join, dirname } from "node:path"
3
+ import { getConfigPath } from "./paths.js"
3
4
 
4
- export function backupExistingConfig() {
5
- if (existsSync(getConfigPath())) {
6
- copyFileSync(getConfigPath(), getBackupPath())
5
+ const MAX_BACKUPS = 5
6
+
7
+ function timestamp() {
8
+ // ponytail: simplified formatting using native Date serialization
9
+ const s = new Date().toISOString().replace(/[-:]/g, "")
10
+ return `${s.slice(0, 8)}_${s.slice(9, 15)}`
11
+ }
12
+
13
+ function rotateBackups(configPath) {
14
+ const dir = dirname(configPath)
15
+ const baseName = "opencode.jsonc.bak"
16
+ let backups = []
17
+
18
+ try {
19
+ backups = readdirSync(dir)
20
+ .filter((f) => f.startsWith(baseName + "."))
21
+ .map((f) => join(dir, f))
22
+ .sort()
23
+ } catch { /* dir doesn't exist yet */ }
24
+
25
+ while (backups.length >= MAX_BACKUPS) {
26
+ const oldest = backups.shift()
27
+ try { unlinkSync(oldest) } catch {}
7
28
  }
8
29
  }
30
+
31
+ export function backupExistingConfig(configPath) {
32
+ const resolvedPath = configPath || getConfigPath()
33
+ if (!existsSync(resolvedPath)) return
34
+
35
+ rotateBackups(resolvedPath)
36
+ const backupPath = `${resolvedPath}.bak.${timestamp()}`
37
+ copyFileSync(resolvedPath, backupPath)
38
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, before, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, writeFileSync, readFileSync, rmSync, readdirSync, mkdirSync, mkdtempSync } from "node:fs"
4
+ import { join, dirname } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { backupExistingConfig } from "./backup.js"
7
+
8
+ describe("backup", () => {
9
+ const tempDir = mkdtempSync(join(tmpdir(), "pluidr-backup-"))
10
+ const configPath = join(tempDir, "opencode.jsonc")
11
+ const TEST_CONTENT = JSON.stringify({ test: true })
12
+
13
+ function findBackups() {
14
+ try {
15
+ return readdirSync(tempDir).filter((f) => f.startsWith("opencode.jsonc.bak."))
16
+ } catch { return [] }
17
+ }
18
+
19
+ before(() => {
20
+ mkdirSync(tempDir, { recursive: true })
21
+ })
22
+
23
+ after(() => {
24
+ if (existsSync(tempDir)) rmSync(tempDir, { recursive: true })
25
+ })
26
+
27
+ it("skips if no config exists (no throw)", () => {
28
+ // Ensure no config file exists in temp dir
29
+ if (existsSync(configPath)) rmSync(configPath)
30
+ for (const bak of findBackups()) {
31
+ rmSync(join(tempDir, bak))
32
+ }
33
+ backupExistingConfig(configPath)
34
+ assert.ok(true)
35
+ })
36
+
37
+ it("copies existing config to backup with timestamped name", () => {
38
+ // Write test config
39
+ writeFileSync(configPath, TEST_CONTENT, "utf-8")
40
+
41
+ backupExistingConfig(configPath)
42
+
43
+ const backups = findBackups()
44
+ assert.ok(backups.length > 0, "timestamped backup file should exist")
45
+ const newest = backups.sort().reverse()[0]
46
+ const newestPath = join(tempDir, newest)
47
+ assert.ok(newestPath.includes("opencode.jsonc.bak."), "backup should be timestamped")
48
+ const backupContent = JSON.parse(readFileSync(newestPath, "utf-8"))
49
+ assert.deepStrictEqual(backupContent, { test: true })
50
+ })
51
+ })
@@ -8,6 +8,19 @@ export function buildConfig(tierChoices, templatesDir) {
8
8
  const config = JSON.parse(readFileSync(configPath, "utf-8"))
9
9
  const modelDefaults = JSON.parse(readFileSync(defaultsPath, "utf-8"))
10
10
 
11
+ // Validate: every agent in model-defaults must have a key in config.agent
12
+ const defaultedAgents = new Set(
13
+ Object.values(modelDefaults).flatMap((tier) => tier.agents),
14
+ )
15
+ const missingAgents = [...defaultedAgents].filter(
16
+ (name) => !(name in config.agent),
17
+ )
18
+ if (missingAgents.length > 0) {
19
+ throw new Error(
20
+ `Template validation failed: agent(s) not found in opencode.config.json: ${missingAgents.join(", ")}`,
21
+ )
22
+ }
23
+
11
24
  for (const [tierKey, tier] of Object.entries(modelDefaults)) {
12
25
  const model = tierChoices[tierKey]
13
26
  for (const agentName of tier.agents) {
@@ -0,0 +1,47 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { buildConfig } from "./configBuilder.js"
7
+
8
+ describe("configBuilder", () => {
9
+ it("injects models into agent config per tier", () => {
10
+ const tmpDir = mkdtempSync(join(tmpdir(), "configbuilder-test-"))
11
+
12
+ const configJson = {
13
+ agent: {
14
+ composer: {},
15
+ coder: {},
16
+ },
17
+ }
18
+ const defaultsJson = {
19
+ reasoningHeavy: {
20
+ provider: "test",
21
+ model: "big-model",
22
+ agents: ["composer"],
23
+ },
24
+ fast: {
25
+ provider: "test",
26
+ model: "small-model",
27
+ agents: ["coder"],
28
+ },
29
+ }
30
+
31
+ writeFileSync(join(tmpDir, "opencode.config.json"), JSON.stringify(configJson), "utf-8")
32
+ writeFileSync(join(tmpDir, "model-defaults.json"), JSON.stringify(defaultsJson), "utf-8")
33
+
34
+ const result = buildConfig(
35
+ {
36
+ reasoningHeavy: "test/big-model",
37
+ fast: "test/small-model",
38
+ },
39
+ tmpDir,
40
+ )
41
+
42
+ assert.strictEqual(result.agent.composer.model, "test/big-model")
43
+ assert.strictEqual(result.agent.coder.model, "test/small-model")
44
+
45
+ rmSync(tmpDir, { recursive: true })
46
+ })
47
+ })
@@ -1,7 +1,10 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs"
2
- import { getConfigDir, getConfigPath } from "./paths.js"
2
+ import { join } from "node:path"
3
+ import { getConfigDir } from "./paths.js"
3
4
 
4
- export function writeConfig(configObject) {
5
- mkdirSync(getConfigDir(), { recursive: true })
6
- writeFileSync(getConfigPath(), JSON.stringify(configObject, null, 2), "utf-8")
5
+ export function writeConfig(configObject, targetDir) {
6
+ const baseDir = targetDir || getConfigDir()
7
+ const configPath = join(baseDir, "opencode.jsonc")
8
+ mkdirSync(baseDir, { recursive: true })
9
+ writeFileSync(configPath, JSON.stringify(configObject, null, 2), "utf-8")
7
10
  }
@@ -0,0 +1,26 @@
1
+ import { describe, it, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { writeConfig } from "./configWriter.js"
7
+
8
+ describe("configWriter", () => {
9
+ const tempDir = mkdtempSync(join(tmpdir(), "pluidr-configwriter-"))
10
+
11
+ after(() => {
12
+ if (existsSync(tempDir)) rmSync(tempDir, { recursive: true })
13
+ })
14
+
15
+ it("writes config as formatted JSON to the correct path", () => {
16
+ const configObj = { agent: { composer: { model: "test/model" } } }
17
+ const expected = JSON.stringify(configObj, null, 2)
18
+
19
+ writeConfig(configObj, tempDir)
20
+
21
+ const configPath = join(tempDir, "opencode.jsonc")
22
+ assert.ok(existsSync(configPath), "config file should exist")
23
+ const written = readFileSync(configPath, "utf-8")
24
+ assert.strictEqual(written, expected)
25
+ })
26
+ })
@@ -0,0 +1,15 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import IDENTITY_HEADER from "./identityHeader.js"
4
+
5
+ describe("identityHeader", () => {
6
+ it('returns "You are the <name> agent.\\n\\n" format', () => {
7
+ const result = IDENTITY_HEADER("coder")
8
+ assert.strictEqual(result, 'You are the "coder" agent.\n\n')
9
+ })
10
+
11
+ it('handles multi-word agent names', () => {
12
+ const result = IDENTITY_HEADER("plan-writer")
13
+ assert.strictEqual(result, 'You are the "plan-writer" agent.\n\n')
14
+ })
15
+ })
package/src/core/paths.js CHANGED
@@ -3,18 +3,7 @@ import { join } from "node:path"
3
3
 
4
4
  const BASE = join(homedir(), ".config", "opencode")
5
5
 
6
- export function getConfigDir() {
7
- return BASE
8
- }
9
-
10
- export function getConfigPath() {
11
- return join(BASE, "opencode.jsonc")
12
- }
13
-
14
- export function getBackupPath() {
15
- return join(BASE, "opencode.jsonc.bak")
16
- }
17
-
18
- export function getPromptsDir() {
19
- return join(BASE, "prompts")
20
- }
6
+ export const getConfigDir = () => BASE
7
+ export const getConfigPath = () => join(BASE, "opencode.jsonc")
8
+ export const getPromptsDir = () => join(BASE, "prompts")
9
+ // ponytail: getBackupPath removed (YAGNI)
@@ -0,0 +1,21 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import { homedir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { getConfigDir, getConfigPath, getPromptsDir } from "./paths.js"
6
+
7
+ const BASE = join(homedir(), ".config", "opencode")
8
+
9
+ describe("paths", () => {
10
+ it("getConfigDir returns ~/.config/opencode", () => {
11
+ assert.strictEqual(getConfigDir(), BASE)
12
+ })
13
+
14
+ it("getConfigPath returns opencode.jsonc path", () => {
15
+ assert.strictEqual(getConfigPath(), join(BASE, "opencode.jsonc"))
16
+ })
17
+
18
+ it("getPromptsDir returns prompts path", () => {
19
+ assert.strictEqual(getPromptsDir(), join(BASE, "prompts"))
20
+ })
21
+ })
@@ -4,22 +4,26 @@ import { fileURLToPath } from "node:url"
4
4
  import { getConfigDir } from "./paths.js"
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
- const PLUGIN_NAME = "parent-session.js"
7
+ const PLUGINS = ["pluidr-flow.js", "pluidr-squeeze.js"]
8
8
  const PACKAGE_JSON = {
9
+ type: "module",
9
10
  dependencies: { "@opencode-ai/plugin": "^1.17.9" },
10
11
  }
11
12
 
12
- export function writePluginBundle() {
13
- const pluginsDir = join(getConfigDir(), "plugins")
13
+ export function writePluginBundle(targetDir) {
14
+ const baseDir = targetDir || getConfigDir()
15
+ const pluginsDir = join(baseDir, "plugins")
14
16
  mkdirSync(pluginsDir, { recursive: true })
15
17
 
16
- const sourcePath = resolve(__dirname, "../plugins", PLUGIN_NAME)
17
- copyFileSync(sourcePath, join(pluginsDir, PLUGIN_NAME))
18
+ for (const name of PLUGINS) {
19
+ const sourcePath = resolve(__dirname, "../plugins", name)
20
+ copyFileSync(sourcePath, join(pluginsDir, name))
21
+ }
18
22
  }
19
23
 
20
- export function writePluginPackageJson() {
21
- const packageJsonPath = join(getConfigDir(), "package.json")
22
- if (existsSync(packageJsonPath)) return
24
+ export function writePluginPackageJson(targetDir) {
25
+ const baseDir = targetDir || getConfigDir()
26
+ const packageJsonPath = join(baseDir, "package.json")
23
27
 
24
28
  writeFileSync(packageJsonPath, JSON.stringify(PACKAGE_JSON, null, 2), "utf-8")
25
29
  }
@@ -0,0 +1,41 @@
1
+ import { describe, it, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
4
+ import { join, resolve, dirname } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+
10
+ describe("pluginWriter", () => {
11
+ const tempDir = mkdtempSync(join(tmpdir(), "pluidr-pluginwriter-"))
12
+
13
+ after(() => {
14
+ if (existsSync(tempDir)) rmSync(tempDir, { recursive: true })
15
+ })
16
+
17
+ it("copies plugin files and writes package.json", async () => {
18
+ const { writePluginBundle, writePluginPackageJson } = await import("./pluginWriter.js")
19
+
20
+ // Use temp dir as target — sandboxed, no real ~/.config/opencode writes
21
+ writePluginBundle(tempDir)
22
+
23
+ const pluginsDestDir = join(tempDir, "plugins")
24
+ const pluginFiles = ["pluidr-flow.js", "pluidr-squeeze.js"]
25
+ for (const f of pluginFiles) {
26
+ assert.ok(existsSync(join(pluginsDestDir, f)),
27
+ `plugin ${f} should be copied to plugins dir`)
28
+ }
29
+
30
+ // Clean plugins dir to test package.json separately
31
+ rmSync(pluginsDestDir, { recursive: true })
32
+
33
+ writePluginPackageJson(tempDir)
34
+
35
+ const pkgPath = join(tempDir, "package.json")
36
+ assert.ok(existsSync(pkgPath), "package.json should exist in temp dir")
37
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
38
+ assert.strictEqual(pkg.type, "module")
39
+ assert.ok(pkg.dependencies["@opencode-ai/plugin"])
40
+ })
41
+ })