pluidr 0.7.6 → 0.8.1

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 (88) hide show
  1. package/LICENSE +20 -20
  2. package/README.md +409 -399
  3. package/bin/pluidr.js +3 -3
  4. package/package.json +1 -1
  5. package/src/cli/commands/doctor.js +137 -134
  6. package/src/cli/commands/init.js +146 -47
  7. package/src/cli/commands/launch.js +101 -50
  8. package/src/cli/commands/theme.js +91 -0
  9. package/src/cli/commands/theme.test.js +28 -0
  10. package/src/cli/commands/uninstall.js +109 -90
  11. package/src/cli/commands/update.js +9 -9
  12. package/src/cli/index.js +63 -57
  13. package/src/cli/wizard/selectModelTier.js +40 -40
  14. package/src/core/agentPromptWriter.js +49 -32
  15. package/src/core/agentPromptWriter.test.js +56 -56
  16. package/src/core/animation.js +84 -0
  17. package/src/core/animation.test.js +118 -0
  18. package/src/core/backup.js +46 -46
  19. package/src/core/commandsWriter.js +26 -0
  20. package/src/core/commandsWriter.test.js +29 -0
  21. package/src/core/configBuilder.js +32 -32
  22. package/src/core/configBuilder.test.js +93 -93
  23. package/src/core/configWriter.js +10 -10
  24. package/src/core/configWriter.test.js +1 -1
  25. package/src/core/identityHeader.js +8 -8
  26. package/src/core/paths.js +13 -11
  27. package/src/core/paths.test.js +33 -29
  28. package/src/core/pluginWriter.js +43 -29
  29. package/src/core/skillsWriter.js +21 -0
  30. package/src/core/skillsWriter.test.js +30 -0
  31. package/src/core/squeezeInstaller.js +158 -158
  32. package/src/core/squeezeInstaller.test.js +79 -79
  33. package/src/core/themeWriter.js +18 -4
  34. package/src/core/themeWriter.test.js +2 -2
  35. package/src/core/tuiConfigWriter.js +22 -3
  36. package/src/core/tuiConfigWriter.test.js +11 -5
  37. package/src/core/version.js +8 -8
  38. package/src/core/versionCheck.js +44 -44
  39. package/src/plugins/README.md +57 -68
  40. package/src/plugins/pluidr-squeeze.js +77 -77
  41. package/src/templates/agent-prompts/analyze.txt +49 -0
  42. package/src/templates/agent-prompts/builder.txt +10 -0
  43. package/src/templates/agent-prompts/compose.txt +50 -0
  44. package/src/templates/agent-prompts/debug.txt +49 -0
  45. package/src/templates/agent-prompts/explorer.txt +10 -0
  46. package/src/templates/agent-prompts/hierarchy.txt +101 -95
  47. package/src/templates/agent-prompts/reporter.txt +10 -0
  48. package/src/templates/agent-prompts/verifier.txt +10 -0
  49. package/src/templates/commands/squeeze-dashboard.md +5 -0
  50. package/src/templates/commands/squeeze-health.md +10 -0
  51. package/src/templates/commands/squeeze-quick.md +10 -0
  52. package/src/templates/model-defaults.json +59 -73
  53. package/src/templates/opencode.config.json +243 -572
  54. package/src/templates/skills/brooks-lint-rca/SKILL.md +48 -0
  55. package/src/templates/skills/codebase-fact-finding/SKILL.md +39 -0
  56. package/src/templates/skills/diff-review/SKILL.md +42 -0
  57. package/src/templates/skills/general-coding/SKILL.md +44 -0
  58. package/src/templates/skills/minimal-fixing/SKILL.md +25 -0
  59. package/src/templates/skills/plan-checking/SKILL.md +33 -0
  60. package/src/templates/{agent-prompts/patcher.txt → skills/ponytail-patching/SKILL.md} +20 -20
  61. package/src/templates/skills/prd-formatting/SKILL.md +45 -0
  62. package/src/templates/skills/refactoring-patterns/SKILL.md +37 -0
  63. package/src/templates/skills/security-auditing/SKILL.md +35 -0
  64. package/src/templates/skills/security-remediation/SKILL.md +37 -0
  65. package/src/templates/skills/summary-reporting/SKILL.md +83 -0
  66. package/src/templates/skills/test-assurance/SKILL.md +48 -0
  67. package/src/templates/skills/test-mocking-strategy/SKILL.md +18 -0
  68. package/src/templates/skills/ui-design-audit/SKILL.md +23 -0
  69. package/src/templates/skills/ui-design-system/SKILL.md +37 -0
  70. package/src/templates/{agent-prompts/tracer.txt → skills/wstg-recon/SKILL.md} +33 -33
  71. package/src/templates/themes/pluidr-colorful.json +241 -0
  72. package/src/templates/themes/{pluidr-contrast.json → pluidr-dark.json} +68 -68
  73. package/src/templates/themes/pluidr-light.json +241 -0
  74. package/src/templates/agent-prompts/auditor.txt +0 -20
  75. package/src/templates/agent-prompts/coder.txt +0 -88
  76. package/src/templates/agent-prompts/compose-reporter.txt +0 -55
  77. package/src/templates/agent-prompts/composer.txt +0 -414
  78. package/src/templates/agent-prompts/debug-reporter.txt +0 -65
  79. package/src/templates/agent-prompts/debugger.txt +0 -149
  80. package/src/templates/agent-prompts/fixer.txt +0 -66
  81. package/src/templates/agent-prompts/inspector.txt +0 -79
  82. package/src/templates/agent-prompts/plan-checker.txt +0 -45
  83. package/src/templates/agent-prompts/plan-writer.txt +0 -57
  84. package/src/templates/agent-prompts/probe-reporter.txt +0 -62
  85. package/src/templates/agent-prompts/prober.txt +0 -93
  86. package/src/templates/agent-prompts/researcher.txt +0 -48
  87. package/src/templates/agent-prompts/reviewer.txt +0 -57
  88. package/src/templates/agent-prompts/tester.txt +0 -66
@@ -1,158 +1,158 @@
1
- import { execFileSync } from "node:child_process"
2
- import { chmodSync, mkdirSync, existsSync, writeFileSync, unlinkSync, readFileSync, renameSync } from "node:fs"
3
- import { extname, join, resolve, dirname } from "node:path"
4
- import { fileURLToPath } from "node:url"
5
- import { createHash } from "node:crypto"
6
- import { getConfigDir } from "./paths.js"
7
-
8
- const __dirname = dirname(fileURLToPath(import.meta.url))
9
-
10
- // Local binary name — friendlier than the upstream "rtk" name
11
- const BIN_NAME = process.platform === "win32" ? "squeeze.exe" : "squeeze"
12
- // Name of the binary inside the downloaded archive (upstream uses "rtk")
13
- const RTK_BIN_NAME = process.platform === "win32" ? "rtk.exe" : "rtk"
14
- export const ASSETS = {
15
- "darwin-arm64": "rtk-aarch64-apple-darwin.tar.gz",
16
- "darwin-x64": "rtk-x86_64-apple-darwin.tar.gz",
17
- "linux-x64": "rtk-x86_64-unknown-linux-musl.tar.gz",
18
- "linux-arm64": "rtk-aarch64-unknown-linux-gnu.tar.gz",
19
- "win32-x64": "rtk-x86_64-pc-windows-msvc.zip",
20
- }
21
- const RTK_RELEASE_BASE = "https://github.com/rtk-ai/rtk/releases/latest/download"
22
-
23
- export function platformAsset() {
24
- const key = `${process.platform}-${process.arch}`
25
- return ASSETS[key] ?? null
26
- }
27
-
28
- export function validatePath(path) {
29
- if (!/^[a-zA-Z0-9_\-\.\:\/\\ ]+$/.test(path)) {
30
- throw new Error(`Invalid path: "${path}" contains unsafe characters`)
31
- }
32
- }
33
-
34
- export function findSqueezePath() {
35
- try {
36
- const [cmd, ...args] =
37
- process.platform === "win32" ? ["where", "squeeze"] : ["which", "squeeze"]
38
- return execFileSync(cmd, args, { stdio: "pipe" })
39
- .toString()
40
- .trim()
41
- .split(/\r?\n/)[0]
42
- } catch {
43
- return null
44
- }
45
- }
46
-
47
- function extract(archivePath, destDir) {
48
- validatePath(archivePath)
49
- validatePath(destDir)
50
- const isZip = extname(archivePath) === ".zip"
51
-
52
- if (isZip && process.platform === "win32") {
53
- execFileSync("powershell", [
54
- "-NoProfile",
55
- "-Command",
56
- `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destDir}' -Force`
57
- ], { stdio: "ignore" })
58
- } else {
59
- execFileSync("tar", ["-xzf", archivePath, "-C", destDir], { stdio: "ignore" })
60
- }
61
- }
62
-
63
- function verifyChecksum(buffer, assetName) {
64
- const checksumsPath = resolve(__dirname, "../templates/rtk-checksums.json")
65
- let checksums
66
- try {
67
- checksums = JSON.parse(readFileSync(checksumsPath, "utf-8"))
68
- } catch {
69
- // No checksums file — skip verification
70
- return
71
- }
72
-
73
- const expectedHash = checksums[assetName]
74
- if (expectedHash === null || expectedHash === undefined) {
75
- // Hash not yet populated for this platform — silently skip
76
- return
77
- }
78
-
79
- const computed = createHash("sha256").update(buffer).digest("hex")
80
- if (computed !== expectedHash) {
81
- throw new Error(
82
- `SHA256 mismatch for ${assetName}\n expected: ${expectedHash}\n computed: ${computed}`
83
- )
84
- }
85
- }
86
-
87
- export async function installSqueeze() {
88
- let rtkPath = findSqueezePath()
89
-
90
- if (!rtkPath) {
91
- // Not on PATH — download it to the managed location
92
- const asset = platformAsset()
93
- if (!asset) {
94
- console.warn("⚠ Squeeze: unsupported platform — squeeze binary unavailable")
95
- return
96
- }
97
-
98
- const binDir = join(getConfigDir(), "bin")
99
- mkdirSync(binDir, { recursive: true })
100
-
101
- const url = `${RTK_RELEASE_BASE}/${asset}`
102
- const archivePath = join(binDir, asset)
103
-
104
- try {
105
- let response
106
- for (let attempt = 1; attempt <= 3; attempt++) {
107
- try {
108
- response = await fetch(url)
109
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
110
- break
111
- } catch (err) {
112
- if (attempt < 3) {
113
- // Retry on any error (network, HTTP, etc.) with exponential backoff
114
- const delay = Math.pow(2, attempt - 1) * 1000
115
- await new Promise(r => setTimeout(r, delay))
116
- continue
117
- }
118
- throw err
119
- }
120
- }
121
-
122
- const buffer = Buffer.from(await response.arrayBuffer())
123
- verifyChecksum(buffer, asset)
124
- writeFileSync(archivePath, buffer)
125
-
126
- extract(archivePath, binDir)
127
-
128
- try { unlinkSync(archivePath) } catch {}
129
-
130
- // Archive contains "rtk"/"rtk.exe" — rename to local BIN_NAME ("squeeze")
131
- const extracted = join(binDir, RTK_BIN_NAME)
132
- rtkPath = join(binDir, BIN_NAME)
133
- if (existsSync(extracted) && extracted !== rtkPath) {
134
- renameSync(extracted, rtkPath)
135
- }
136
-
137
- if (process.platform !== "win32" && existsSync(rtkPath)) {
138
- chmodSync(rtkPath, 0o755)
139
- }
140
-
141
- // Verify that the downloaded binary is executable and runs on this platform
142
- try {
143
- execFileSync(rtkPath, ["--help"], { stdio: "ignore" })
144
- } catch (err) {
145
- // If it throws because of execution format or permissions (e.g. ENOEXEC, EACCES, ENOENT)
146
- if (err.code === "ENOEXEC" || err.code === "EACCES" || err.code === "ENOENT") {
147
- try { unlinkSync(rtkPath) } catch {}
148
- throw new Error(`Downloaded binary is not executable on this platform: ${err.message}`)
149
- }
150
- // If it ran but exited with non-zero (like returning exit code 1 for --help), that's fine, it means it is executable!
151
- }
152
- } catch (err) {
153
- try { unlinkSync(archivePath) } catch {}
154
- console.warn(`⚠ Squeeze: installation failed (${err.message}) — squeeze plugin disabled`)
155
- return
156
- }
157
- }
158
- }
1
+ import { execFileSync } from "node:child_process"
2
+ import { chmodSync, mkdirSync, existsSync, writeFileSync, unlinkSync, readFileSync, renameSync } from "node:fs"
3
+ import { extname, join, resolve, dirname } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import { createHash } from "node:crypto"
6
+ import { getConfigDir } from "./paths.js"
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+
10
+ // Local binary name — friendlier than the upstream "rtk" name
11
+ const BIN_NAME = process.platform === "win32" ? "squeeze.exe" : "squeeze"
12
+ // Name of the binary inside the downloaded archive (upstream uses "rtk")
13
+ const RTK_BIN_NAME = process.platform === "win32" ? "rtk.exe" : "rtk"
14
+ export const ASSETS = {
15
+ "darwin-arm64": "rtk-aarch64-apple-darwin.tar.gz",
16
+ "darwin-x64": "rtk-x86_64-apple-darwin.tar.gz",
17
+ "linux-x64": "rtk-x86_64-unknown-linux-musl.tar.gz",
18
+ "linux-arm64": "rtk-aarch64-unknown-linux-gnu.tar.gz",
19
+ "win32-x64": "rtk-x86_64-pc-windows-msvc.zip",
20
+ }
21
+ const RTK_RELEASE_BASE = "https://github.com/rtk-ai/rtk/releases/latest/download"
22
+
23
+ export function platformAsset() {
24
+ const key = `${process.platform}-${process.arch}`
25
+ return ASSETS[key] ?? null
26
+ }
27
+
28
+ export function validatePath(path) {
29
+ if (!/^[a-zA-Z0-9_\-\.\:\/\\ ]+$/.test(path)) {
30
+ throw new Error(`Invalid path: "${path}" contains unsafe characters`)
31
+ }
32
+ }
33
+
34
+ export function findSqueezePath() {
35
+ try {
36
+ const [cmd, ...args] =
37
+ process.platform === "win32" ? ["where", "squeeze"] : ["which", "squeeze"]
38
+ return execFileSync(cmd, args, { stdio: "pipe" })
39
+ .toString()
40
+ .trim()
41
+ .split(/\r?\n/)[0]
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ function extract(archivePath, destDir) {
48
+ validatePath(archivePath)
49
+ validatePath(destDir)
50
+ const isZip = extname(archivePath) === ".zip"
51
+
52
+ if (isZip && process.platform === "win32") {
53
+ execFileSync("powershell", [
54
+ "-NoProfile",
55
+ "-Command",
56
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destDir}' -Force`
57
+ ], { stdio: "ignore" })
58
+ } else {
59
+ execFileSync("tar", ["-xzf", archivePath, "-C", destDir], { stdio: "ignore" })
60
+ }
61
+ }
62
+
63
+ function verifyChecksum(buffer, assetName) {
64
+ const checksumsPath = resolve(__dirname, "../templates/rtk-checksums.json")
65
+ let checksums
66
+ try {
67
+ checksums = JSON.parse(readFileSync(checksumsPath, "utf-8"))
68
+ } catch {
69
+ // No checksums file — skip verification
70
+ return
71
+ }
72
+
73
+ const expectedHash = checksums[assetName]
74
+ if (expectedHash === null || expectedHash === undefined) {
75
+ // Hash not yet populated for this platform — silently skip
76
+ return
77
+ }
78
+
79
+ const computed = createHash("sha256").update(buffer).digest("hex")
80
+ if (computed !== expectedHash) {
81
+ throw new Error(
82
+ `SHA256 mismatch for ${assetName}\n expected: ${expectedHash}\n computed: ${computed}`
83
+ )
84
+ }
85
+ }
86
+
87
+ export async function installSqueeze() {
88
+ let rtkPath = findSqueezePath()
89
+
90
+ if (!rtkPath) {
91
+ // Not on PATH — download it to the managed location
92
+ const asset = platformAsset()
93
+ if (!asset) {
94
+ console.warn("⚠ Squeeze: unsupported platform — squeeze binary unavailable")
95
+ return
96
+ }
97
+
98
+ const binDir = join(getConfigDir(), "bin")
99
+ mkdirSync(binDir, { recursive: true })
100
+
101
+ const url = `${RTK_RELEASE_BASE}/${asset}`
102
+ const archivePath = join(binDir, asset)
103
+
104
+ try {
105
+ let response
106
+ for (let attempt = 1; attempt <= 3; attempt++) {
107
+ try {
108
+ response = await fetch(url)
109
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
110
+ break
111
+ } catch (err) {
112
+ if (attempt < 3) {
113
+ // Retry on any error (network, HTTP, etc.) with exponential backoff
114
+ const delay = Math.pow(2, attempt - 1) * 1000
115
+ await new Promise(r => setTimeout(r, delay))
116
+ continue
117
+ }
118
+ throw err
119
+ }
120
+ }
121
+
122
+ const buffer = Buffer.from(await response.arrayBuffer())
123
+ verifyChecksum(buffer, asset)
124
+ writeFileSync(archivePath, buffer)
125
+
126
+ extract(archivePath, binDir)
127
+
128
+ try { unlinkSync(archivePath) } catch {}
129
+
130
+ // Archive contains "rtk"/"rtk.exe" — rename to local BIN_NAME ("squeeze")
131
+ const extracted = join(binDir, RTK_BIN_NAME)
132
+ rtkPath = join(binDir, BIN_NAME)
133
+ if (existsSync(extracted) && extracted !== rtkPath) {
134
+ renameSync(extracted, rtkPath)
135
+ }
136
+
137
+ if (process.platform !== "win32" && existsSync(rtkPath)) {
138
+ chmodSync(rtkPath, 0o755)
139
+ }
140
+
141
+ // Verify that the downloaded binary is executable and runs on this platform
142
+ try {
143
+ execFileSync(rtkPath, ["--help"], { stdio: "ignore" })
144
+ } catch (err) {
145
+ // If it throws because of execution format or permissions (e.g. ENOEXEC, EACCES, ENOENT)
146
+ if (err.code === "ENOEXEC" || err.code === "EACCES" || err.code === "ENOENT") {
147
+ try { unlinkSync(rtkPath) } catch {}
148
+ throw new Error(`Downloaded binary is not executable on this platform: ${err.message}`)
149
+ }
150
+ // If it ran but exited with non-zero (like returning exit code 1 for --help), that's fine, it means it is executable!
151
+ }
152
+ } catch (err) {
153
+ try { unlinkSync(archivePath) } catch {}
154
+ console.warn(`⚠ Squeeze: installation failed (${err.message}) — squeeze plugin disabled`)
155
+ return
156
+ }
157
+ }
158
+ }
@@ -1,79 +1,79 @@
1
- import { describe, it } from "node:test"
2
- import assert from "node:assert"
3
- import { platformAsset, validatePath, findSqueezePath, ASSETS } from "./squeezeInstaller.js"
4
-
5
- describe("platformAsset", () => {
6
- it("returns a string or null for current platform", () => {
7
- const result = platformAsset()
8
- // On a supported platform: string; on unsupported: null
9
- assert.ok(result === null || typeof result === "string")
10
- })
11
-
12
- it("returns known asset names for mapped platforms", () => {
13
- // Verify the ASSETS map has entries for all known platform combos
14
- const knownCombos = [
15
- { plat: "darwin", arch: "arm64" },
16
- { plat: "darwin", arch: "x64" },
17
- { plat: "linux", arch: "x64" },
18
- { plat: "linux", arch: "arm64" },
19
- { plat: "win32", arch: "x64" },
20
- ]
21
- for (const { plat, arch } of knownCombos) {
22
- const key = `${plat}-${arch}`
23
- const asset = ASSETS[key]
24
- assert.ok(asset, `ASSETS map should have entry for ${key}`)
25
- assert.ok(asset.endsWith(".tar.gz") || asset.endsWith(".zip"),
26
- `Asset "${asset}" should end with .tar.gz or .zip`)
27
- }
28
- })
29
- })
30
-
31
- describe("validatePath", () => {
32
- it("accepts simple relative paths", () => {
33
- assert.doesNotThrow(() => validatePath("foo/bar"))
34
- })
35
-
36
- it("accepts absolute POSIX paths", () => {
37
- assert.doesNotThrow(() => validatePath("/home/user/.config"))
38
- })
39
-
40
- it("accepts absolute Windows paths", () => {
41
- assert.doesNotThrow(() => validatePath("C:\\Users\\test"))
42
- })
43
-
44
- it("accepts paths with spaces", () => {
45
- assert.doesNotThrow(() => validatePath("C:\\Users\\John Doe\\.config"))
46
- })
47
-
48
- it("accepts paths with dots and dashes", () => {
49
- assert.doesNotThrow(() => validatePath("./dir/file-name.ext"))
50
- })
51
-
52
- it("allows .. in paths (shell metacharacter check only, not path traversal)", () => {
53
- assert.doesNotThrow(() => validatePath("../evil"))
54
- })
55
-
56
- it("rejects paths with semicolons", () => {
57
- assert.throws(() => validatePath("foo;rm -rf /"), /unsafe characters/)
58
- })
59
-
60
- it("rejects paths with backticks", () => {
61
- assert.throws(() => validatePath("foo`id`"), /unsafe characters/)
62
- })
63
-
64
- it("rejects paths with dollar signs", () => {
65
- assert.throws(() => validatePath("foo$PATH"), /unsafe characters/)
66
- })
67
-
68
- it("rejects paths with pipe characters", () => {
69
- assert.throws(() => validatePath("foo|bar"), /unsafe characters/)
70
- })
71
- })
72
-
73
- describe("findSqueezePath", () => {
74
- it("returns null when squeeze is not available on PATH", () => {
75
- const result = findSqueezePath()
76
- // Either null (not on PATH) or a non-empty string (found)
77
- assert.ok(result === null || typeof result === "string")
78
- })
79
- })
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import { platformAsset, validatePath, findSqueezePath, ASSETS } from "./squeezeInstaller.js"
4
+
5
+ describe("platformAsset", () => {
6
+ it("returns a string or null for current platform", () => {
7
+ const result = platformAsset()
8
+ // On a supported platform: string; on unsupported: null
9
+ assert.ok(result === null || typeof result === "string")
10
+ })
11
+
12
+ it("returns known asset names for mapped platforms", () => {
13
+ // Verify the ASSETS map has entries for all known platform combos
14
+ const knownCombos = [
15
+ { plat: "darwin", arch: "arm64" },
16
+ { plat: "darwin", arch: "x64" },
17
+ { plat: "linux", arch: "x64" },
18
+ { plat: "linux", arch: "arm64" },
19
+ { plat: "win32", arch: "x64" },
20
+ ]
21
+ for (const { plat, arch } of knownCombos) {
22
+ const key = `${plat}-${arch}`
23
+ const asset = ASSETS[key]
24
+ assert.ok(asset, `ASSETS map should have entry for ${key}`)
25
+ assert.ok(asset.endsWith(".tar.gz") || asset.endsWith(".zip"),
26
+ `Asset "${asset}" should end with .tar.gz or .zip`)
27
+ }
28
+ })
29
+ })
30
+
31
+ describe("validatePath", () => {
32
+ it("accepts simple relative paths", () => {
33
+ assert.doesNotThrow(() => validatePath("foo/bar"))
34
+ })
35
+
36
+ it("accepts absolute POSIX paths", () => {
37
+ assert.doesNotThrow(() => validatePath("/home/user/.config"))
38
+ })
39
+
40
+ it("accepts absolute Windows paths", () => {
41
+ assert.doesNotThrow(() => validatePath("C:\\Users\\test"))
42
+ })
43
+
44
+ it("accepts paths with spaces", () => {
45
+ assert.doesNotThrow(() => validatePath("C:\\Users\\John Doe\\.config"))
46
+ })
47
+
48
+ it("accepts paths with dots and dashes", () => {
49
+ assert.doesNotThrow(() => validatePath("./dir/file-name.ext"))
50
+ })
51
+
52
+ it("allows .. in paths (shell metacharacter check only, not path traversal)", () => {
53
+ assert.doesNotThrow(() => validatePath("../evil"))
54
+ })
55
+
56
+ it("rejects paths with semicolons", () => {
57
+ assert.throws(() => validatePath("foo;rm -rf /"), /unsafe characters/)
58
+ })
59
+
60
+ it("rejects paths with backticks", () => {
61
+ assert.throws(() => validatePath("foo`id`"), /unsafe characters/)
62
+ })
63
+
64
+ it("rejects paths with dollar signs", () => {
65
+ assert.throws(() => validatePath("foo$PATH"), /unsafe characters/)
66
+ })
67
+
68
+ it("rejects paths with pipe characters", () => {
69
+ assert.throws(() => validatePath("foo|bar"), /unsafe characters/)
70
+ })
71
+ })
72
+
73
+ describe("findSqueezePath", () => {
74
+ it("returns null when squeeze is not available on PATH", () => {
75
+ const result = findSqueezePath()
76
+ // Either null (not on PATH) or a non-empty string (found)
77
+ assert.ok(result === null || typeof result === "string")
78
+ })
79
+ })
@@ -1,12 +1,26 @@
1
- import { mkdirSync, copyFileSync } from "node:fs"
1
+ import { mkdirSync, cpSync, existsSync, readdirSync, unlinkSync } from "node:fs"
2
2
  import { resolve } from "node:path"
3
3
  import { getThemesDir } from "./paths.js"
4
4
 
5
5
  export function writeThemes(templatesDir, destDir) {
6
- const sourcePath = resolve(templatesDir, "themes", "pluidr-contrast.json")
6
+ const sourcePath = resolve(templatesDir, "themes")
7
7
  const targetDir = destDir || getThemesDir()
8
- const destPath = resolve(targetDir, "pluidr-contrast.json")
8
+
9
+ // Clear any existing .json files in targetDir to prevent stale themes
10
+ if (existsSync(targetDir)) {
11
+ try {
12
+ for (const file of readdirSync(targetDir)) {
13
+ if (file.endsWith(".json")) {
14
+ unlinkSync(resolve(targetDir, file))
15
+ }
16
+ }
17
+ } catch {
18
+ // Ignore
19
+ }
20
+ }
9
21
 
10
22
  mkdirSync(targetDir, { recursive: true })
11
- copyFileSync(sourcePath, destPath)
23
+ if (existsSync(sourcePath)) {
24
+ cpSync(sourcePath, targetDir, { recursive: true })
25
+ }
12
26
  }
@@ -16,11 +16,11 @@ describe("themeWriter", () => {
16
16
  const tmpSrcDir = mkdtempSync(join(tmpdir(), "pluidr-theme-src-"))
17
17
  const srcThemesDir = join(tmpSrcDir, "themes")
18
18
  mkdirSync(srcThemesDir, { recursive: true })
19
- writeFileSync(join(srcThemesDir, "pluidr-contrast.json"), "mock-theme-content", "utf-8")
19
+ writeFileSync(join(srcThemesDir, "pluidr-dark.json"), "mock-theme-content", "utf-8")
20
20
 
21
21
  writeThemes(tmpSrcDir, destDir)
22
22
 
23
- const destPath = join(destDir, "pluidr-contrast.json")
23
+ const destPath = join(destDir, "pluidr-dark.json")
24
24
  assert.ok(existsSync(destPath), "theme file should be copied")
25
25
  assert.strictEqual(readFileSync(destPath, "utf-8"), "mock-theme-content")
26
26
 
@@ -1,7 +1,8 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs"
2
- import { getTuiConfigPath } from "./paths.js"
2
+ import { join } from "node:path"
3
+ import { getTuiConfigPath, getConfigDir } from "./paths.js"
3
4
 
4
- export function writeTuiConfig(themeName, customPath) {
5
+ export function writeTuiConfig(themeName, customPath, animationName) {
5
6
  const path = customPath || getTuiConfigPath()
6
7
  let config = {
7
8
  "$schema": "https://opencode.ai/tui.json"
@@ -16,7 +17,25 @@ export function writeTuiConfig(themeName, customPath) {
16
17
  }
17
18
  }
18
19
 
19
- config.theme = themeName
20
+ if (themeName) config.theme = themeName
21
+ // Ensure animation is never in tui.json to prevent OpenCode validation failure
22
+ delete config.animation
20
23
 
21
24
  writeFileSync(path, JSON.stringify(config, null, 2), "utf-8")
25
+
26
+ // Write animation to pluidr.json instead
27
+ if (animationName) {
28
+ const configDir = customPath ? join(customPath, "..") : getConfigDir()
29
+ const pluidrPath = join(configDir, "pluidr.json")
30
+ let pluidrConfig = {}
31
+ if (existsSync(pluidrPath)) {
32
+ try {
33
+ pluidrConfig = JSON.parse(readFileSync(pluidrPath, "utf-8"))
34
+ } catch {
35
+ // Ignore
36
+ }
37
+ }
38
+ pluidrConfig.animation = animationName
39
+ writeFileSync(pluidrPath, JSON.stringify(pluidrConfig, null, 2), "utf-8")
40
+ }
22
41
  }
@@ -13,13 +13,19 @@ describe("tuiConfigWriter", () => {
13
13
  if (existsSync(destDir)) rmSync(destDir, { recursive: true })
14
14
  })
15
15
 
16
- it("writes new tui.json with theme name if it does not exist", () => {
17
- writeTuiConfig("pluidr-contrast", tuiPath)
16
+ it("writes new tui.json with theme name if it does not exist, and animation to pluidr.json", () => {
17
+ writeTuiConfig("pluidr-dark", tuiPath, "wave")
18
18
 
19
19
  assert.ok(existsSync(tuiPath))
20
20
  const parsed = JSON.parse(readFileSync(tuiPath, "utf-8"))
21
- assert.strictEqual(parsed.theme, "pluidr-contrast")
21
+ assert.strictEqual(parsed.theme, "pluidr-dark")
22
+ assert.strictEqual(parsed.animation, undefined) // should not exist in tui.json
22
23
  assert.strictEqual(parsed["$schema"], "https://opencode.ai/tui.json")
24
+
25
+ const pluidrPath = join(destDir, "pluidr.json")
26
+ assert.ok(existsSync(pluidrPath))
27
+ const pluidrConfig = JSON.parse(readFileSync(pluidrPath, "utf-8"))
28
+ assert.strictEqual(pluidrConfig.animation, "wave")
23
29
  })
24
30
 
25
31
  it("updates existing tui.json theme name while preserving other fields", () => {
@@ -29,10 +35,10 @@ describe("tuiConfigWriter", () => {
29
35
  "font": "monolisa"
30
36
  }, null, 2), "utf-8")
31
37
 
32
- writeTuiConfig("pluidr-contrast", tuiPath)
38
+ writeTuiConfig("pluidr-dark", tuiPath)
33
39
 
34
40
  const parsed = JSON.parse(readFileSync(tuiPath, "utf-8"))
35
- assert.strictEqual(parsed.theme, "pluidr-contrast")
41
+ assert.strictEqual(parsed.theme, "pluidr-dark")
36
42
  assert.strictEqual(parsed.font, "monolisa")
37
43
  })
38
44
  })
@@ -1,8 +1,8 @@
1
- import { readFileSync } from "node:fs"
2
- import { resolve, dirname } from "node:path"
3
- import { fileURLToPath } from "node:url"
4
-
5
- const __dirname = dirname(fileURLToPath(import.meta.url))
6
- export const version = JSON.parse(
7
- readFileSync(resolve(__dirname, "../../package.json"), "utf-8")
8
- ).version
1
+ import { readFileSync } from "node:fs"
2
+ import { resolve, dirname } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+ export const version = JSON.parse(
7
+ readFileSync(resolve(__dirname, "../../package.json"), "utf-8")
8
+ ).version