opencode-thrum-pro 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/installer.js CHANGED
@@ -5,16 +5,32 @@ import { fileURLToPath } from "url";
5
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
6
  const ASSETS_DIR = path.resolve(__dirname, "..", "assets");
7
7
  const VERSION_FILE = ".thrum-plugin-version";
8
- function getPluginVersion() {
8
+ // Written next to the version file so a later release can tell pro from
9
+ // open-core installs and clean up safely.
10
+ const FLAVOR_FILE = ".thrum-plugin-flavor";
11
+ const DEFAULT_SKILLS_SUBDIR = "thrum";
12
+ function readPackageJson() {
9
13
  const pkgPath = path.resolve(__dirname, "..", "package.json");
10
14
  try {
11
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
12
- return pkg.version ?? "0.0.0";
15
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8"));
13
16
  }
14
17
  catch {
15
- return "0.0.0";
18
+ return {};
16
19
  }
17
20
  }
21
+ function getPluginVersion() {
22
+ return readPackageJson().version ?? "0.0.0";
23
+ }
24
+ // The pro build sets thrum.skillsSubdir in the packaged package.json to
25
+ // "thrum-pro" so it never shares a directory with the open-core package.
26
+ export function sanitizeSkillsSubdir(sub) {
27
+ return sub && /^[A-Za-z0-9._-]+$/.test(sub) && sub !== "." && sub !== ".."
28
+ ? sub
29
+ : DEFAULT_SKILLS_SUBDIR;
30
+ }
31
+ export function getSkillsSubdir() {
32
+ return sanitizeSkillsSubdir(readPackageJson().thrum?.skillsSubdir);
33
+ }
18
34
  export function getConfigDir() {
19
35
  const xdg = process.env.XDG_CONFIG_HOME;
20
36
  if (xdg)
@@ -34,11 +50,77 @@ function copyDirRecursive(src, dest) {
34
50
  }
35
51
  }
36
52
  }
37
- export async function installAssets(ctx) {
53
+ // Older pro releases (0.1.x) installed flat (skills/<name>) with the marker in
54
+ // skills/thrum, the same layout open-core still uses. OpenCode resolves
55
+ // duplicate skill names to the flat copy, so stale flat pro skills would shadow
56
+ // the nested ones. Remove them only when the install is positively identified
57
+ // as pro; anything else (open-core, unknown version, unreadable) is left alone.
58
+ function isLegacyProInstall(markerDir) {
59
+ const read = (name) => {
60
+ try {
61
+ return fs.readFileSync(path.join(markerDir, name), "utf8").trim();
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ };
67
+ const flavor = read(FLAVOR_FILE);
68
+ if (flavor !== undefined)
69
+ return flavor === "pro";
70
+ const version = read(VERSION_FILE);
71
+ return version !== undefined && /^0\.1\.\d+([-+].*)?$/.test(version);
72
+ }
73
+ async function cleanLegacyFlatInstall(skillsDir, log) {
74
+ const markerDir = path.join(skillsDir, DEFAULT_SKILLS_SUBDIR);
75
+ if (!fs.existsSync(path.join(markerDir, VERSION_FILE)))
76
+ return;
77
+ if (!isLegacyProInstall(markerDir)) {
78
+ await log(`Legacy flat install found at ${markerDir}; not removed (not identified as an old pro install). If it is one, delete it and its sibling skill dirs manually.`);
79
+ return;
80
+ }
81
+ const assetsSkillsDir = path.join(ASSETS_DIR, "skills");
82
+ let removed = 0;
83
+ if (fs.existsSync(assetsSkillsDir)) {
84
+ for (const entry of fs.readdirSync(assetsSkillsDir, { withFileTypes: true })) {
85
+ if (!entry.isDirectory())
86
+ continue;
87
+ const flat = path.join(skillsDir, entry.name);
88
+ if (!fs.existsSync(flat))
89
+ continue;
90
+ try {
91
+ fs.rmSync(flat, { recursive: true });
92
+ removed++;
93
+ }
94
+ catch {
95
+ // best-effort; a locked dir must not block the install
96
+ }
97
+ }
98
+ }
99
+ for (const marker of [VERSION_FILE, FLAVOR_FILE]) {
100
+ try {
101
+ fs.rmSync(path.join(markerDir, marker), { force: true });
102
+ }
103
+ catch {
104
+ // best-effort
105
+ }
106
+ }
107
+ try {
108
+ fs.rmdirSync(markerDir); // only succeeds when empty
109
+ }
110
+ catch {
111
+ // not empty or already gone
112
+ }
113
+ await log(`Removed ${removed} legacy flat pro skill dirs under ${skillsDir}`);
114
+ }
115
+ export async function installAssets(ctx, opts = {}) {
38
116
  const configDir = getConfigDir();
39
117
  const skillsDir = path.join(configDir, "skills");
40
118
  const commandsDir = path.join(configDir, "commands");
41
- const versionPath = path.join(skillsDir, "thrum", VERSION_FILE);
119
+ const subdir = opts.skillsSubdir !== undefined
120
+ ? sanitizeSkillsSubdir(opts.skillsSubdir)
121
+ : getSkillsSubdir();
122
+ const installRoot = path.join(skillsDir, subdir);
123
+ const versionPath = path.join(installRoot, VERSION_FILE);
42
124
  const currentVersion = getPluginVersion();
43
125
  // Check if already installed at this version
44
126
  try {
@@ -52,6 +134,9 @@ export async function installAssets(ctx) {
52
134
  const log = (msg) => ctx.client.app.log({
53
135
  body: { service: "opencode-thrum", level: "info", message: msg },
54
136
  });
137
+ if (subdir !== DEFAULT_SKILLS_SUBDIR) {
138
+ await cleanLegacyFlatInstall(skillsDir, log);
139
+ }
55
140
  // Copy each skill subdirectory
56
141
  const assetsSkillsDir = path.join(ASSETS_DIR, "skills");
57
142
  if (fs.existsSync(assetsSkillsDir)) {
@@ -61,7 +146,9 @@ export async function installAssets(ctx) {
61
146
  if (!entry.isDirectory())
62
147
  continue;
63
148
  const src = path.join(assetsSkillsDir, entry.name);
64
- const dest = path.join(skillsDir, entry.name);
149
+ // Open-core keeps its historical flat layout (skills/<name>); only a
150
+ // non-default subdir (pro) nests skills under skills/<subdir>/.
151
+ const dest = path.join(subdir === DEFAULT_SKILLS_SUBDIR ? skillsDir : installRoot, entry.name);
65
152
  copyDirRecursive(src, dest);
66
153
  await log(`Installed skill: ${entry.name}`);
67
154
  }
@@ -82,6 +169,7 @@ export async function installAssets(ctx) {
82
169
  // Write version marker
83
170
  fs.mkdirSync(path.dirname(versionPath), { recursive: true });
84
171
  fs.writeFileSync(versionPath, currentVersion);
172
+ fs.writeFileSync(path.join(installRoot, FLAVOR_FILE), subdir === DEFAULT_SKILLS_SUBDIR ? "open-core" : "pro");
85
173
  await log(`opencode-thrum v${currentVersion} assets installed`);
86
174
  }
87
175
  const ALLOWLIST_ASSET_PATH = path.join(ASSETS_DIR, "thrum_allowlist.json");
@@ -0,0 +1,102 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { installAssets, sanitizeSkillsSubdir } from "./installer.js";
7
+ const ctx = { client: { app: { log: async () => { } } } };
8
+ function withConfig(fn) {
9
+ return async () => {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-inst-"));
11
+ const prev = process.env.XDG_CONFIG_HOME;
12
+ process.env.XDG_CONFIG_HOME = dir;
13
+ try {
14
+ await fn(path.join(dir, "opencode"));
15
+ }
16
+ finally {
17
+ if (prev === undefined)
18
+ delete process.env.XDG_CONFIG_HOME;
19
+ else
20
+ process.env.XDG_CONFIG_HOME = prev;
21
+ fs.rmSync(dir, { recursive: true });
22
+ }
23
+ };
24
+ }
25
+ const pkgVersion = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "..", "package.json"), "utf8")).version;
26
+ test("open-core: flat skills/<name>, marker in skills/thrum", withConfig(async (cfg) => {
27
+ await installAssets(ctx, { skillsSubdir: "thrum" });
28
+ assert.ok(fs.existsSync(path.join(cfg, "skills", "thrum", ".thrum-plugin-version")));
29
+ assert.ok(fs.existsSync(path.join(cfg, "skills", "configure-roles")));
30
+ assert.ok(!fs.existsSync(path.join(cfg, "skills", "thrum-pro")));
31
+ assert.equal(fs.readFileSync(path.join(cfg, "skills", "thrum", ".thrum-plugin-flavor"), "utf8"), "open-core");
32
+ }));
33
+ test("pro: skills nested under skills/thrum-pro, marker there", withConfig(async (cfg) => {
34
+ await installAssets(ctx, { skillsSubdir: "thrum-pro" });
35
+ const v = fs.readFileSync(path.join(cfg, "skills", "thrum-pro", ".thrum-plugin-version"), "utf8");
36
+ assert.equal(v, pkgVersion);
37
+ assert.ok(fs.existsSync(path.join(cfg, "skills", "thrum-pro", "configure-roles")));
38
+ assert.ok(!fs.existsSync(path.join(cfg, "skills", "configure-roles")));
39
+ assert.equal(fs.readFileSync(path.join(cfg, "skills", "thrum-pro", ".thrum-plugin-flavor"), "utf8"), "pro");
40
+ }));
41
+ test("pro: leaves an existing skills/thrum install untouched", withConfig(async (cfg) => {
42
+ const legacy = path.join(cfg, "skills", "thrum");
43
+ fs.mkdirSync(legacy, { recursive: true });
44
+ fs.writeFileSync(path.join(legacy, ".thrum-plugin-version"), "9.9.9");
45
+ await installAssets(ctx, { skillsSubdir: "thrum-pro" });
46
+ assert.equal(fs.readFileSync(path.join(legacy, ".thrum-plugin-version"), "utf8"), "9.9.9");
47
+ }));
48
+ test("invalid subdir falls back to default", () => {
49
+ for (const bad of ["../evil", "a/b", "", "..", ".", undefined]) {
50
+ assert.equal(sanitizeSkillsSubdir(bad), "thrum");
51
+ }
52
+ assert.equal(sanitizeSkillsSubdir("thrum-pro"), "thrum-pro");
53
+ });
54
+ function seedLegacy(cfg, marker) {
55
+ const skills = path.join(cfg, "skills");
56
+ const thrumDir = path.join(skills, "thrum");
57
+ fs.mkdirSync(thrumDir, { recursive: true });
58
+ if (marker.version !== undefined)
59
+ fs.writeFileSync(path.join(thrumDir, ".thrum-plugin-version"), marker.version);
60
+ if (marker.flavor !== undefined)
61
+ fs.writeFileSync(path.join(thrumDir, ".thrum-plugin-flavor"), marker.flavor);
62
+ for (const n of ["configure-roles", "adversarial-critique", "user-own-skill"]) {
63
+ fs.mkdirSync(path.join(skills, n), { recursive: true });
64
+ fs.writeFileSync(path.join(skills, n, "SKILL.md"), "stale");
65
+ }
66
+ return skills;
67
+ }
68
+ test("pro: legacy flavor=pro removes matching flat dirs, keeps others", withConfig(async (cfg) => {
69
+ const skills = seedLegacy(cfg, { version: "0.1.16", flavor: "pro" });
70
+ await installAssets(ctx, { skillsSubdir: "thrum-pro" });
71
+ assert.ok(!fs.existsSync(path.join(skills, "configure-roles")));
72
+ assert.ok(!fs.existsSync(path.join(skills, "adversarial-critique")));
73
+ assert.ok(fs.existsSync(path.join(skills, "user-own-skill", "SKILL.md")));
74
+ assert.ok(!fs.existsSync(path.join(skills, "thrum", ".thrum-plugin-version")));
75
+ assert.ok(!fs.existsSync(path.join(skills, "thrum", ".thrum-plugin-flavor")));
76
+ assert.ok(fs.existsSync(path.join(skills, "thrum-pro", "configure-roles")));
77
+ }));
78
+ test("pro: legacy 0.1.16 without flavor is removed", withConfig(async (cfg) => {
79
+ const skills = seedLegacy(cfg, { version: "0.1.16" });
80
+ await installAssets(ctx, { skillsSubdir: "thrum-pro" });
81
+ assert.ok(!fs.existsSync(path.join(skills, "configure-roles")));
82
+ assert.ok(fs.existsSync(path.join(skills, "user-own-skill")));
83
+ assert.ok(!fs.existsSync(path.join(skills, "thrum", ".thrum-plugin-version")));
84
+ }));
85
+ test("pro: legacy 0.3.105 without flavor removes nothing", withConfig(async (cfg) => {
86
+ const skills = seedLegacy(cfg, { version: "0.3.105" });
87
+ await installAssets(ctx, { skillsSubdir: "thrum-pro" });
88
+ assert.ok(fs.existsSync(path.join(skills, "configure-roles", "SKILL.md")));
89
+ assert.ok(fs.existsSync(path.join(skills, "thrum", ".thrum-plugin-version")));
90
+ }));
91
+ test("pro: legacy flavor=open-core removes nothing", withConfig(async (cfg) => {
92
+ const skills = seedLegacy(cfg, { version: "0.1.16", flavor: "open-core" });
93
+ await installAssets(ctx, { skillsSubdir: "thrum-pro" });
94
+ assert.ok(fs.existsSync(path.join(skills, "configure-roles", "SKILL.md")));
95
+ assert.ok(fs.existsSync(path.join(skills, "thrum", ".thrum-plugin-flavor")));
96
+ }));
97
+ test("open-core build: existing flat install is not cleaned", withConfig(async (cfg) => {
98
+ const skills = seedLegacy(cfg, { version: "0.1.16", flavor: "pro" });
99
+ await installAssets(ctx, { skillsSubdir: "thrum" });
100
+ assert.ok(fs.existsSync(path.join(skills, "user-own-skill", "SKILL.md")));
101
+ assert.equal(fs.readFileSync(path.join(skills, "thrum", ".thrum-plugin-flavor"), "utf8"), "open-core");
102
+ }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-thrum-pro",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Thrum multi-agent coordination plugin for Open Code",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,5 +46,8 @@
46
46
  "@opencode-ai/plugin": "^1.4.0",
47
47
  "@types/node": "^25.5.2",
48
48
  "typescript": "^5.3.0"
49
+ },
50
+ "thrum": {
51
+ "skillsSubdir": "thrum-pro"
49
52
  }
50
53
  }