@openthread/claude-code-plugin 0.1.8 → 0.1.9
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/.claude-plugin/plugin.json +1 -1
- package/bin/cli.sh +11 -1
- package/bin/lib/settings-writer.js +39 -3
- package/bin/postinstall.js +110 -49
- package/bin/preuninstall.js +72 -0
- package/package.json +17 -3
- package/scripts/export.sh +73 -0
- package/scripts/import.sh +54 -0
- package/scripts/search.sh +75 -0
- package/bin/__tests__/settings-writer.test.js +0 -122
package/bin/cli.sh
CHANGED
|
@@ -22,6 +22,12 @@ usage() {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
install_plugin() {
|
|
25
|
+
# Clean destination to prevent stale files from previous versions.
|
|
26
|
+
# cp -r does NOT delete files that exist in dest but not in src.
|
|
27
|
+
if [ -d "$DEST_DIR" ]; then
|
|
28
|
+
rm -rf "$DEST_DIR"
|
|
29
|
+
fi
|
|
30
|
+
|
|
25
31
|
# Copy plugin files into marketplace structure
|
|
26
32
|
mkdir -p "$DEST_DIR"
|
|
27
33
|
for dir in .claude-plugin commands skills scripts; do
|
|
@@ -92,7 +98,11 @@ with open('$KNOWN_FILE', 'w') as f:
|
|
|
92
98
|
# Disable in settings.json via the guarded writer (G16).
|
|
93
99
|
[ -f "$SETTINGS_FILE" ] && node "$PLUGIN_DIR/bin/lib/settings-writer.js" disable 2>/dev/null || true
|
|
94
100
|
|
|
95
|
-
|
|
101
|
+
# Clear stored credentials from keychain and file
|
|
102
|
+
bash "$PLUGIN_DIR/scripts/token.sh" clear 2>/dev/null || true
|
|
103
|
+
[ -d "$HOME/.config/openthread" ] && rm -rf "$HOME/.config/openthread"
|
|
104
|
+
|
|
105
|
+
echo "✓ OpenThread plugin removed, deregistered, and credentials cleared"
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
check_status() {
|
|
@@ -14,9 +14,23 @@ const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
|
14
14
|
const ALLOWED_TOP_LEVEL_KEYS = new Set(["enabledPlugins"]);
|
|
15
15
|
const ALLOWED_PLUGIN_KEYS = /^ot@openthread$/;
|
|
16
16
|
|
|
17
|
+
// Keys that must never be used as property names (prototype pollution defense).
|
|
18
|
+
const RESERVED_KEYS = new Set([
|
|
19
|
+
"__proto__",
|
|
20
|
+
"constructor",
|
|
21
|
+
"prototype",
|
|
22
|
+
"toString",
|
|
23
|
+
"valueOf",
|
|
24
|
+
"hasOwnProperty",
|
|
25
|
+
"__defineGetter__",
|
|
26
|
+
"__defineSetter__",
|
|
27
|
+
]);
|
|
28
|
+
|
|
17
29
|
function readSettings(settingsPath = SETTINGS_PATH) {
|
|
18
30
|
try {
|
|
19
|
-
|
|
31
|
+
const raw = fs.readFileSync(settingsPath, "utf8");
|
|
32
|
+
if (!raw.trim()) return {}; // handle 0-byte / whitespace-only files
|
|
33
|
+
return JSON.parse(raw);
|
|
20
34
|
} catch (e) {
|
|
21
35
|
if (e.code === "ENOENT") return {};
|
|
22
36
|
throw e;
|
|
@@ -26,8 +40,21 @@ function readSettings(settingsPath = SETTINGS_PATH) {
|
|
|
26
40
|
function writeAtomically(data, settingsPath = SETTINGS_PATH) {
|
|
27
41
|
const tmp = settingsPath + ".part";
|
|
28
42
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", {
|
|
45
|
+
mode: 0o600,
|
|
46
|
+
});
|
|
47
|
+
fs.renameSync(tmp, settingsPath);
|
|
48
|
+
// Explicitly guarantee 0o600 on the final file — rename preserves perms
|
|
49
|
+
// on most systems but this is not guaranteed by POSIX.
|
|
50
|
+
fs.chmodSync(settingsPath, 0o600);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// Clean up .part file so it doesn't leak on failure
|
|
53
|
+
try {
|
|
54
|
+
fs.unlinkSync(tmp);
|
|
55
|
+
} catch {}
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
31
58
|
}
|
|
32
59
|
|
|
33
60
|
function safeUpdateSettings(patch, settingsPath = SETTINGS_PATH) {
|
|
@@ -65,6 +92,14 @@ function safeUpdateSettings(patch, settingsPath = SETTINGS_PATH) {
|
|
|
65
92
|
next.enabledPlugins = {};
|
|
66
93
|
}
|
|
67
94
|
for (const pluginKey of Object.keys(patch.enabledPlugins)) {
|
|
95
|
+
// Defense-in-depth: block reserved property names before regex check.
|
|
96
|
+
// The regex would also reject these, but an explicit guard is clearer
|
|
97
|
+
// and survives future regex changes.
|
|
98
|
+
if (RESERVED_KEYS.has(pluginKey)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`settings-writer: refusing to modify reserved key "${pluginKey}"`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
68
103
|
if (!ALLOWED_PLUGIN_KEYS.test(pluginKey)) {
|
|
69
104
|
throw new Error(
|
|
70
105
|
`settings-writer: refusing to modify plugin key "${pluginKey}"`,
|
|
@@ -105,4 +140,5 @@ module.exports = {
|
|
|
105
140
|
SETTINGS_PATH,
|
|
106
141
|
ALLOWED_TOP_LEVEL_KEYS,
|
|
107
142
|
ALLOWED_PLUGIN_KEYS,
|
|
143
|
+
RESERVED_KEYS,
|
|
108
144
|
};
|
package/bin/postinstall.js
CHANGED
|
@@ -3,33 +3,24 @@
|
|
|
3
3
|
const {
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
6
|
+
rmSync,
|
|
6
7
|
cpSync,
|
|
7
8
|
chmodSync,
|
|
8
9
|
readdirSync,
|
|
9
10
|
readFileSync,
|
|
10
11
|
writeFileSync,
|
|
12
|
+
accessSync,
|
|
13
|
+
constants: fsConstants,
|
|
11
14
|
} = require("fs");
|
|
12
|
-
const { join, resolve } = require("path");
|
|
15
|
+
const { join, resolve, dirname } = require("path");
|
|
13
16
|
const { homedir } = require("os");
|
|
14
|
-
const { execSync } = require("node:child_process");
|
|
15
17
|
|
|
16
18
|
const pkg = require("../package.json");
|
|
17
19
|
|
|
18
|
-
// ---
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
// package isn't published yet.
|
|
23
|
-
if (process.env.OT_SKIP_PROVENANCE !== "1") {
|
|
24
|
-
try {
|
|
25
|
-
execSync(`npm audit signatures --package=${pkg.name}@${pkg.version}`, {
|
|
26
|
-
stdio: "inherit",
|
|
27
|
-
});
|
|
28
|
-
} catch (e) {
|
|
29
|
-
console.error("refusing to install: npm audit signatures failed");
|
|
30
|
-
console.error("set OT_SKIP_PROVENANCE=1 to override (not recommended)");
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
20
|
+
// --- Skip postinstall entirely if requested ---
|
|
21
|
+
if (process.env.OT_SKIP_POSTINSTALL === "1") {
|
|
22
|
+
console.log("OT_SKIP_POSTINSTALL=1 — skipping plugin auto-install");
|
|
23
|
+
process.exit(0);
|
|
33
24
|
}
|
|
34
25
|
|
|
35
26
|
const { safeUpdateSettings } = require("./lib/settings-writer.js");
|
|
@@ -39,16 +30,78 @@ const MARKETPLACE_NAME = "openthread";
|
|
|
39
30
|
const pluginSrc = resolve(__dirname, "..");
|
|
40
31
|
|
|
41
32
|
// Marketplace lives alongside the official one
|
|
42
|
-
const
|
|
33
|
+
const claudeDir = join(homedir(), ".claude");
|
|
34
|
+
const marketplaceDir = join(claudeDir, "plugins", "marketplaces", MARKETPLACE_NAME);
|
|
43
35
|
const pluginDest = join(marketplaceDir, "plugins", PLUGIN_ID);
|
|
44
36
|
|
|
45
37
|
const DIRS_TO_COPY = [".claude-plugin", "commands", "skills", "scripts"];
|
|
46
38
|
const FILES_TO_COPY = ["icon.svg"];
|
|
47
39
|
|
|
40
|
+
// Required files that must exist after copy for the plugin to work.
|
|
41
|
+
// If any are missing, the install is considered incomplete.
|
|
42
|
+
const REQUIRED_FILES = [
|
|
43
|
+
".claude-plugin/plugin.json",
|
|
44
|
+
"commands/share.md",
|
|
45
|
+
"skills/share-thread/SKILL.md",
|
|
46
|
+
"scripts/share.sh",
|
|
47
|
+
"scripts/token.sh",
|
|
48
|
+
"scripts/auth.sh",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// --- Preflight checks ---
|
|
52
|
+
function preflight() {
|
|
53
|
+
const home = homedir();
|
|
54
|
+
try {
|
|
55
|
+
// Ensure ~/.claude exists or can be created
|
|
56
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
57
|
+
accessSync(claudeDir, fsConstants.W_OK);
|
|
58
|
+
} catch {
|
|
59
|
+
console.warn(
|
|
60
|
+
`Warning: Cannot write to ${claudeDir}. Check permissions (chmod 755 ~/.claude).`,
|
|
61
|
+
);
|
|
62
|
+
console.warn("Try: claude --plugin-dir " + pluginSrc);
|
|
63
|
+
process.exit(0); // graceful — don't block npm install
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Atomic JSON write (read → merge → write .part → rename) ---
|
|
68
|
+
function atomicJsonUpdate(filePath, mergeFn) {
|
|
69
|
+
let current = {};
|
|
70
|
+
if (existsSync(filePath)) {
|
|
71
|
+
try {
|
|
72
|
+
current = JSON.parse(readFileSync(filePath, "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
current = {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const next = mergeFn(current);
|
|
78
|
+
const tmp = filePath + ".part";
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
81
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n");
|
|
82
|
+
const { renameSync } = require("fs");
|
|
83
|
+
renameSync(tmp, filePath);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// Clean up .part file on failure
|
|
86
|
+
try {
|
|
87
|
+
const { unlinkSync } = require("fs");
|
|
88
|
+
unlinkSync(tmp);
|
|
89
|
+
} catch {}
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
try {
|
|
49
|
-
|
|
95
|
+
preflight();
|
|
96
|
+
|
|
97
|
+
// 1. Clean destination to prevent stale files from previous versions.
|
|
98
|
+
// cpSync does NOT delete files that exist in dest but not in src.
|
|
99
|
+
if (existsSync(pluginDest)) {
|
|
100
|
+
rmSync(pluginDest, { recursive: true, force: true });
|
|
101
|
+
}
|
|
50
102
|
mkdirSync(pluginDest, { recursive: true });
|
|
51
103
|
|
|
104
|
+
// 2. Copy plugin files into marketplace/plugins/ot/
|
|
52
105
|
for (const dir of DIRS_TO_COPY) {
|
|
53
106
|
const src = join(pluginSrc, dir);
|
|
54
107
|
if (existsSync(src)) {
|
|
@@ -63,17 +116,31 @@ try {
|
|
|
63
116
|
}
|
|
64
117
|
}
|
|
65
118
|
|
|
66
|
-
//
|
|
67
|
-
const
|
|
119
|
+
// 3. Validate required files exist after copy
|
|
120
|
+
const missing = REQUIRED_FILES.filter(
|
|
121
|
+
(f) => !existsSync(join(pluginDest, f)),
|
|
122
|
+
);
|
|
123
|
+
if (missing.length > 0) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`Warning: Plugin install incomplete — missing: ${missing.join(", ")}`,
|
|
126
|
+
);
|
|
127
|
+
console.warn("Try reinstalling: npm install @openthread/claude-code-plugin");
|
|
128
|
+
// Don't exit — partial install is better than no install
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 4. Sync version from package.json → plugin.json
|
|
68
132
|
const pluginJsonPath = join(pluginDest, ".claude-plugin", "plugin.json");
|
|
69
|
-
if (existsSync(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
133
|
+
if (existsSync(pluginJsonPath)) {
|
|
134
|
+
try {
|
|
135
|
+
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, "utf8"));
|
|
136
|
+
pluginJson.version = pkg.version;
|
|
137
|
+
writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2) + "\n");
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.warn(`Warning: Could not sync version to plugin.json: ${e.message}`);
|
|
140
|
+
}
|
|
74
141
|
}
|
|
75
142
|
|
|
76
|
-
// Ensure scripts are executable
|
|
143
|
+
// 5. Ensure scripts are executable
|
|
77
144
|
const scriptsDir = join(pluginDest, "scripts");
|
|
78
145
|
if (existsSync(scriptsDir)) {
|
|
79
146
|
for (const f of readdirSync(scriptsDir)) {
|
|
@@ -83,7 +150,7 @@ try {
|
|
|
83
150
|
}
|
|
84
151
|
}
|
|
85
152
|
|
|
86
|
-
//
|
|
153
|
+
// 6. Create marketplace.json (like the official marketplace has)
|
|
87
154
|
const marketplaceJsonDir = join(marketplaceDir, ".claude-plugin");
|
|
88
155
|
mkdirSync(marketplaceJsonDir, { recursive: true });
|
|
89
156
|
writeFileSync(
|
|
@@ -106,33 +173,27 @@ try {
|
|
|
106
173
|
) + "\n",
|
|
107
174
|
);
|
|
108
175
|
|
|
109
|
-
//
|
|
176
|
+
// 7. Register marketplace in known_marketplaces.json (atomic write)
|
|
110
177
|
const knownPath = join(homedir(), ".claude", "plugins", "known_marketplaces.json");
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
installLocation: marketplaceDir,
|
|
122
|
-
lastUpdated: new Date().toISOString(),
|
|
123
|
-
};
|
|
124
|
-
writeFileSync(knownPath, JSON.stringify(known, null, 2) + "\n");
|
|
125
|
-
|
|
126
|
-
// 4. Enable the plugin in settings.json via the guarded writer (G16).
|
|
178
|
+
atomicJsonUpdate(knownPath, (known) => {
|
|
179
|
+
known[MARKETPLACE_NAME] = {
|
|
180
|
+
source: { source: "local", path: marketplaceDir },
|
|
181
|
+
installLocation: marketplaceDir,
|
|
182
|
+
lastUpdated: new Date().toISOString(),
|
|
183
|
+
};
|
|
184
|
+
return known;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 8. Enable the plugin in settings.json via the guarded writer (G16).
|
|
127
188
|
// The writer only permits changes to enabledPlugins["ot@openthread"] and
|
|
128
189
|
// refuses any diff touching hooks, permissions, or unknown keys.
|
|
129
190
|
safeUpdateSettings({ enabledPlugins: { "ot@openthread": true } });
|
|
130
191
|
|
|
131
|
-
|
|
132
|
-
console.log(`\x1b[32m✓\x1b[0m OpenThread plugin v${version} installed`);
|
|
192
|
+
console.log(`\x1b[32m✓\x1b[0m OpenThread plugin v${pkg.version} installed`);
|
|
133
193
|
console.log(` Marketplace: ${marketplaceDir}`);
|
|
134
194
|
console.log(` Restart Claude Code, then use \x1b[1m/ot:share\x1b[0m to share conversations.`);
|
|
135
195
|
} catch (err) {
|
|
136
|
-
console.
|
|
137
|
-
console.
|
|
196
|
+
console.warn(`Warning: Could not auto-install plugin: ${err.message}`);
|
|
197
|
+
console.warn("Try: claude --plugin-dir " + pluginSrc);
|
|
198
|
+
// Exit 0 — never block npm install for a postinstall failure
|
|
138
199
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cleanup script that runs before `npm uninstall @openthread/claude-code-plugin`.
|
|
3
|
+
//
|
|
4
|
+
// Removes:
|
|
5
|
+
// 1. Plugin files from ~/.claude/plugins/marketplaces/openthread/
|
|
6
|
+
// 2. Marketplace registration from known_marketplaces.json
|
|
7
|
+
// 3. Plugin enable flag from settings.json
|
|
8
|
+
// 4. Stored credentials from ~/.config/openthread/
|
|
9
|
+
// 5. Keychain tokens (access_token, refresh_token) via keytar
|
|
10
|
+
//
|
|
11
|
+
// This script NEVER exits non-zero — npm uninstall must always succeed.
|
|
12
|
+
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const path = require("node:path");
|
|
15
|
+
const os = require("node:os");
|
|
16
|
+
const { execSync } = require("node:child_process");
|
|
17
|
+
|
|
18
|
+
const MARKETPLACE_NAME = "openthread";
|
|
19
|
+
const claudeDir = path.join(os.homedir(), ".claude");
|
|
20
|
+
const marketplaceDir = path.join(claudeDir, "plugins", "marketplaces", MARKETPLACE_NAME);
|
|
21
|
+
const knownPath = path.join(claudeDir, "plugins", "known_marketplaces.json");
|
|
22
|
+
const credentialsDir = path.join(os.homedir(), ".config", "openthread");
|
|
23
|
+
|
|
24
|
+
function tryOr(fn, label) {
|
|
25
|
+
try {
|
|
26
|
+
fn();
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// Silently continue — never block uninstall
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 1. Remove plugin files
|
|
33
|
+
tryOr(() => {
|
|
34
|
+
if (fs.existsSync(marketplaceDir)) {
|
|
35
|
+
fs.rmSync(marketplaceDir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}, "remove plugin files");
|
|
38
|
+
|
|
39
|
+
// 2. Deregister from known_marketplaces.json
|
|
40
|
+
tryOr(() => {
|
|
41
|
+
if (fs.existsSync(knownPath)) {
|
|
42
|
+
const known = JSON.parse(fs.readFileSync(knownPath, "utf8"));
|
|
43
|
+
delete known[MARKETPLACE_NAME];
|
|
44
|
+
const tmp = knownPath + ".part";
|
|
45
|
+
fs.writeFileSync(tmp, JSON.stringify(known, null, 2) + "\n");
|
|
46
|
+
fs.renameSync(tmp, knownPath);
|
|
47
|
+
}
|
|
48
|
+
}, "deregister marketplace");
|
|
49
|
+
|
|
50
|
+
// 3. Disable in settings.json
|
|
51
|
+
tryOr(() => {
|
|
52
|
+
const { safeUpdateSettings } = require("./lib/settings-writer.js");
|
|
53
|
+
safeUpdateSettings({ enabledPlugins: { "ot@openthread": false } });
|
|
54
|
+
}, "disable plugin in settings");
|
|
55
|
+
|
|
56
|
+
// 4. Clear keychain tokens
|
|
57
|
+
tryOr(() => {
|
|
58
|
+
const keychainJs = path.join(__dirname, "..", "scripts", "lib", "keychain.js");
|
|
59
|
+
if (fs.existsSync(keychainJs)) {
|
|
60
|
+
execSync(`node "${keychainJs}" delete access_token`, { stdio: "ignore" });
|
|
61
|
+
execSync(`node "${keychainJs}" delete refresh_token`, { stdio: "ignore" });
|
|
62
|
+
}
|
|
63
|
+
}, "clear keychain tokens");
|
|
64
|
+
|
|
65
|
+
// 5. Remove credentials file and directory
|
|
66
|
+
tryOr(() => {
|
|
67
|
+
if (fs.existsSync(credentialsDir)) {
|
|
68
|
+
fs.rmSync(credentialsDir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}, "remove credentials");
|
|
71
|
+
|
|
72
|
+
console.log("\x1b[32m✓\x1b[0m OpenThread plugin uninstalled and credentials cleared");
|
package/package.json
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openthread/claude-code-plugin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Share Claude Code conversations to OpenThread
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "Share Claude Code conversations to OpenThread \u2014 the StackOverflow for AI agents. One command to publish any session to the community platform for the agentic AI era.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"openthread-claude": "bin/cli.sh"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
|
-
"bin/",
|
|
9
|
+
"bin/cli.sh",
|
|
10
|
+
"bin/postinstall.js",
|
|
11
|
+
"bin/preuninstall.js",
|
|
12
|
+
"bin/lib/",
|
|
10
13
|
".claude-plugin/",
|
|
11
14
|
"commands/",
|
|
12
15
|
"skills/",
|
|
13
16
|
"scripts/auth.sh",
|
|
14
17
|
"scripts/share.sh",
|
|
18
|
+
"scripts/search.sh",
|
|
19
|
+
"scripts/import.sh",
|
|
20
|
+
"scripts/export.sh",
|
|
15
21
|
"scripts/token.sh",
|
|
16
22
|
"scripts/lib/",
|
|
17
23
|
"icon.svg"
|
|
18
24
|
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=16.7.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"registry": "https://registry.npmjs.org",
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
19
32
|
"scripts": {
|
|
20
33
|
"prepack": "node -e \"const fs=require('fs');const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;const p=JSON.parse(fs.readFileSync('.claude-plugin/plugin.json','utf8'));p.version=v;fs.writeFileSync('.claude-plugin/plugin.json',JSON.stringify(p,null,2)+'\\n')\"",
|
|
21
34
|
"postinstall": "node bin/postinstall.js",
|
|
35
|
+
"preuninstall": "node bin/preuninstall.js",
|
|
22
36
|
"test": "node bin/__tests__/settings-writer.test.js"
|
|
23
37
|
},
|
|
24
38
|
"dependencies": {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Export an OpenThread post as a local archive file.
|
|
3
|
+
#
|
|
4
|
+
# Unlike import.sh, the output file is intended for archival / sharing:
|
|
5
|
+
# it is written to the current working directory with mode 0644 and a
|
|
6
|
+
# plain provenance banner. It is NOT marked as untrusted and NOT loaded
|
|
7
|
+
# into Claude's context.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# bash export.sh <post-id-or-url> \
|
|
11
|
+
# [--format markdown|text|json] \
|
|
12
|
+
# [--out <path>] \
|
|
13
|
+
# [--stdout] \
|
|
14
|
+
# [--no-banner]
|
|
15
|
+
#
|
|
16
|
+
# Environment:
|
|
17
|
+
# OPENTHREAD_API_URL Base URL for the API (default https://openthread.me)
|
|
18
|
+
# OPENTHREAD_EXPORT_OVERWRITE=1 Allow overwriting an existing output file
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
22
|
+
API_BASE="${OPENTHREAD_API_URL:-https://openthread.me}"
|
|
23
|
+
|
|
24
|
+
FORMAT="markdown"
|
|
25
|
+
OUT=""
|
|
26
|
+
STDOUT=0
|
|
27
|
+
NO_BANNER=0
|
|
28
|
+
INPUT=""
|
|
29
|
+
|
|
30
|
+
if [ $# -eq 0 ]; then
|
|
31
|
+
echo '{"error":"MISSING_INPUT","message":"Usage: export.sh <post-id-or-url> [--format markdown|text|json] [--out <path>] [--stdout] [--no-banner]"}' >&2
|
|
32
|
+
exit 2
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
INPUT="$1"; shift
|
|
36
|
+
|
|
37
|
+
while [ $# -gt 0 ]; do
|
|
38
|
+
case "$1" in
|
|
39
|
+
--format)
|
|
40
|
+
if [ $# -lt 2 ]; then
|
|
41
|
+
echo '{"error":"UNKNOWN_FLAG","message":"--format requires an argument"}' >&2
|
|
42
|
+
exit 2
|
|
43
|
+
fi
|
|
44
|
+
FORMAT="$2"; shift 2;;
|
|
45
|
+
--out)
|
|
46
|
+
if [ $# -lt 2 ]; then
|
|
47
|
+
echo '{"error":"UNKNOWN_FLAG","message":"--out requires an argument"}' >&2
|
|
48
|
+
exit 2
|
|
49
|
+
fi
|
|
50
|
+
OUT="$2"; shift 2;;
|
|
51
|
+
--stdout) STDOUT=1; shift;;
|
|
52
|
+
--no-banner) NO_BANNER=1; shift;;
|
|
53
|
+
*)
|
|
54
|
+
printf '{"error":"UNKNOWN_FLAG","message":"Unknown flag: %s"}\n' "$1" >&2
|
|
55
|
+
exit 2
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
# Optional bearer token — public posts don't need it.
|
|
61
|
+
TOKEN=""
|
|
62
|
+
if TOKEN=$(bash "$SCRIPT_DIR/token.sh" get 2>/dev/null); then :; fi
|
|
63
|
+
|
|
64
|
+
PLUGIN_SCRIPTS_DIR="$SCRIPT_DIR" \
|
|
65
|
+
API_BASE="$API_BASE" \
|
|
66
|
+
INPUT="$INPUT" \
|
|
67
|
+
FORMAT="$FORMAT" \
|
|
68
|
+
OUT="$OUT" \
|
|
69
|
+
STDOUT="$STDOUT" \
|
|
70
|
+
NO_BANNER="$NO_BANNER" \
|
|
71
|
+
TOKEN="$TOKEN" \
|
|
72
|
+
OVERWRITE="${OPENTHREAD_EXPORT_OVERWRITE:-0}" \
|
|
73
|
+
python3 "$SCRIPT_DIR/lib/export_client.py"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fetch an OpenThread post into the current workspace.
|
|
3
|
+
#
|
|
4
|
+
# Default mode is --read: the masked thread is saved to disk but NOT
|
|
5
|
+
# injected into Claude's context. The --context flag additionally writes
|
|
6
|
+
# an <imported_thread trust="untrusted"> envelope file which the skill
|
|
7
|
+
# can then display to Claude. In either case, imported content is
|
|
8
|
+
# treated as UNTRUSTED DATA — never as instructions.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# bash import.sh <post-id-or-url> [--context|--read]
|
|
12
|
+
#
|
|
13
|
+
# Environment:
|
|
14
|
+
# OPENTHREAD_API_URL Base URL for the API (default https://openthread.me)
|
|
15
|
+
# OPENTHREAD_IMPORT_OVERWRITE=1 Allow overwriting an existing import
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
API_BASE="${OPENTHREAD_API_URL:-https://openthread.me}"
|
|
20
|
+
|
|
21
|
+
MODE="read" # read | context
|
|
22
|
+
INPUT=""
|
|
23
|
+
|
|
24
|
+
if [ $# -eq 0 ]; then
|
|
25
|
+
echo '{"error":"MISSING_INPUT","message":"Usage: import.sh <post-id-or-url> [--context|--read]"}' >&2
|
|
26
|
+
exit 2
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
INPUT="$1"; shift
|
|
30
|
+
|
|
31
|
+
while [ $# -gt 0 ]; do
|
|
32
|
+
case "$1" in
|
|
33
|
+
--context) MODE="context"; shift;;
|
|
34
|
+
--read) MODE="read"; shift;;
|
|
35
|
+
*)
|
|
36
|
+
printf '{"error":"UNKNOWN_FLAG","message":"Unknown flag: %s"}\n' "$1" >&2
|
|
37
|
+
exit 2
|
|
38
|
+
;;
|
|
39
|
+
esac
|
|
40
|
+
done
|
|
41
|
+
|
|
42
|
+
# Optional bearer token — public posts don't need it.
|
|
43
|
+
TOKEN=""
|
|
44
|
+
if command -v bash >/dev/null 2>&1; then
|
|
45
|
+
TOKEN=$(bash "$SCRIPT_DIR/token.sh" get 2>/dev/null || true)
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
PLUGIN_SCRIPTS_DIR="$SCRIPT_DIR" \
|
|
49
|
+
API_BASE="$API_BASE" \
|
|
50
|
+
INPUT="$INPUT" \
|
|
51
|
+
MODE="$MODE" \
|
|
52
|
+
TOKEN="$TOKEN" \
|
|
53
|
+
OVERWRITE="${OPENTHREAD_IMPORT_OVERWRITE:-0}" \
|
|
54
|
+
python3 "$SCRIPT_DIR/lib/import_client.py"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Search OpenThread from the Claude Code plugin.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# bash search.sh <query> [--type posts|comments|communities|users|all]
|
|
6
|
+
# [--community <name>]
|
|
7
|
+
# [--provider <provider>]
|
|
8
|
+
# [--time hour|day|week|month|year|all]
|
|
9
|
+
# [--limit 1-25]
|
|
10
|
+
#
|
|
11
|
+
# Delegates to lib/search_client.py, which calls GET /api/search and emits
|
|
12
|
+
# a plugin-consumable JSON response to stdout with sanitized strings.
|
|
13
|
+
#
|
|
14
|
+
# Works for both anonymous and authenticated users — the bearer token is
|
|
15
|
+
# fetched best-effort from token.sh. Read-only: never mutates server state.
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
API_BASE="${OPENTHREAD_API_URL:-https://openthread.me/api}"
|
|
20
|
+
|
|
21
|
+
QUERY=""
|
|
22
|
+
TYPE="posts"
|
|
23
|
+
COMMUNITY=""
|
|
24
|
+
PROVIDER=""
|
|
25
|
+
TIME_RANGE=""
|
|
26
|
+
LIMIT="10"
|
|
27
|
+
|
|
28
|
+
if [ $# -eq 0 ]; then
|
|
29
|
+
echo '{"error":"MISSING_QUERY","message":"Usage: search.sh <query> [--type ...] [--limit N]"}' >&2
|
|
30
|
+
exit 2
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
QUERY="$1"
|
|
34
|
+
shift
|
|
35
|
+
|
|
36
|
+
while [ $# -gt 0 ]; do
|
|
37
|
+
case "$1" in
|
|
38
|
+
--type)
|
|
39
|
+
[ $# -ge 2 ] || { echo '{"error":"MISSING_VALUE","message":"--type requires a value"}' >&2; exit 2; }
|
|
40
|
+
TYPE="$2"; shift 2;;
|
|
41
|
+
--community)
|
|
42
|
+
[ $# -ge 2 ] || { echo '{"error":"MISSING_VALUE","message":"--community requires a value"}' >&2; exit 2; }
|
|
43
|
+
COMMUNITY="$2"; shift 2;;
|
|
44
|
+
--provider)
|
|
45
|
+
[ $# -ge 2 ] || { echo '{"error":"MISSING_VALUE","message":"--provider requires a value"}' >&2; exit 2; }
|
|
46
|
+
PROVIDER="$2"; shift 2;;
|
|
47
|
+
--time)
|
|
48
|
+
[ $# -ge 2 ] || { echo '{"error":"MISSING_VALUE","message":"--time requires a value"}' >&2; exit 2; }
|
|
49
|
+
TIME_RANGE="$2"; shift 2;;
|
|
50
|
+
--limit)
|
|
51
|
+
[ $# -ge 2 ] || { echo '{"error":"MISSING_VALUE","message":"--limit requires a value"}' >&2; exit 2; }
|
|
52
|
+
LIMIT="$2"; shift 2;;
|
|
53
|
+
*)
|
|
54
|
+
printf '{"error":"UNKNOWN_FLAG","message":"Unknown flag: %s"}\n' "$1" >&2
|
|
55
|
+
exit 2
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
# Optional bearer token — search works anonymously, just with narrower visibility.
|
|
61
|
+
TOKEN=""
|
|
62
|
+
if T=$(bash "$SCRIPT_DIR/token.sh" get 2>/dev/null); then
|
|
63
|
+
TOKEN="$T"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
PLUGIN_SCRIPTS_DIR="$SCRIPT_DIR" \
|
|
67
|
+
API_BASE="$API_BASE" \
|
|
68
|
+
QUERY="$QUERY" \
|
|
69
|
+
TYPE="$TYPE" \
|
|
70
|
+
COMMUNITY="$COMMUNITY" \
|
|
71
|
+
PROVIDER="$PROVIDER" \
|
|
72
|
+
TIME_RANGE="$TIME_RANGE" \
|
|
73
|
+
LIMIT="$LIMIT" \
|
|
74
|
+
TOKEN="$TOKEN" \
|
|
75
|
+
exec python3 "$SCRIPT_DIR/lib/search_client.py"
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Unit tests for bin/lib/settings-writer.js
|
|
3
|
-
// Run with: node bin/__tests__/settings-writer.test.js
|
|
4
|
-
// Exit 0 on pass, non-zero on failure.
|
|
5
|
-
|
|
6
|
-
const fs = require("node:fs");
|
|
7
|
-
const os = require("node:os");
|
|
8
|
-
const path = require("node:path");
|
|
9
|
-
const assert = require("node:assert/strict");
|
|
10
|
-
|
|
11
|
-
const { safeUpdateSettings } = require("../lib/settings-writer.js");
|
|
12
|
-
|
|
13
|
-
let failures = 0;
|
|
14
|
-
let passed = 0;
|
|
15
|
-
|
|
16
|
-
function test(name, fn) {
|
|
17
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ot-settings-test-"));
|
|
18
|
-
const tmpFile = path.join(tmpDir, "settings.json");
|
|
19
|
-
try {
|
|
20
|
-
fn(tmpFile);
|
|
21
|
-
console.log(` ok ${name}`);
|
|
22
|
-
passed++;
|
|
23
|
-
} catch (e) {
|
|
24
|
-
console.error(` FAIL ${name}`);
|
|
25
|
-
console.error(" " + (e.stack || e.message));
|
|
26
|
-
failures++;
|
|
27
|
-
} finally {
|
|
28
|
-
try {
|
|
29
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
30
|
-
} catch {
|
|
31
|
-
/* ignore */
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
console.log("settings-writer tests:");
|
|
37
|
-
|
|
38
|
-
test("enables ot@openthread on empty file", (file) => {
|
|
39
|
-
const result = safeUpdateSettings(
|
|
40
|
-
{ enabledPlugins: { "ot@openthread": true } },
|
|
41
|
-
file,
|
|
42
|
-
);
|
|
43
|
-
assert.equal(result.enabledPlugins["ot@openthread"], true);
|
|
44
|
-
const onDisk = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
45
|
-
assert.equal(onDisk.enabledPlugins["ot@openthread"], true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("refuses to modify hooks", (file) => {
|
|
49
|
-
assert.throws(
|
|
50
|
-
() =>
|
|
51
|
-
safeUpdateSettings(
|
|
52
|
-
{ hooks: { preToolUse: "evil" } },
|
|
53
|
-
file,
|
|
54
|
-
),
|
|
55
|
-
/refusing to modify top-level key "hooks"/,
|
|
56
|
-
);
|
|
57
|
-
assert.equal(fs.existsSync(file), false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("refuses to modify permissions", (file) => {
|
|
61
|
-
assert.throws(
|
|
62
|
-
() => safeUpdateSettings({ permissions: { allow: ["*"] } }, file),
|
|
63
|
-
/refusing to modify top-level key "permissions"/,
|
|
64
|
-
);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("refuses unknown plugin keys", (file) => {
|
|
68
|
-
assert.throws(
|
|
69
|
-
() =>
|
|
70
|
-
safeUpdateSettings(
|
|
71
|
-
{ enabledPlugins: { "other@thing": true } },
|
|
72
|
-
file,
|
|
73
|
-
),
|
|
74
|
-
/refusing to modify plugin key "other@thing"/,
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("refuses unknown top-level keys", (file) => {
|
|
79
|
-
assert.throws(
|
|
80
|
-
() => safeUpdateSettings({ unknownKey: "x" }, file),
|
|
81
|
-
/refusing to modify top-level key "unknownKey"/,
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("preserves unrelated keys on update", (file) => {
|
|
86
|
-
fs.writeFileSync(
|
|
87
|
-
file,
|
|
88
|
-
JSON.stringify({
|
|
89
|
-
permissions: { allow: ["Bash"] },
|
|
90
|
-
hooks: { preToolUse: "existing" },
|
|
91
|
-
enabledPlugins: { "some@other": true },
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
const result = safeUpdateSettings(
|
|
95
|
-
{ enabledPlugins: { "ot@openthread": true } },
|
|
96
|
-
file,
|
|
97
|
-
);
|
|
98
|
-
assert.deepEqual(result.permissions, { allow: ["Bash"] });
|
|
99
|
-
assert.deepEqual(result.hooks, { preToolUse: "existing" });
|
|
100
|
-
assert.equal(result.enabledPlugins["some@other"], true);
|
|
101
|
-
assert.equal(result.enabledPlugins["ot@openthread"], true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("disables ot@openthread", (file) => {
|
|
105
|
-
const result = safeUpdateSettings(
|
|
106
|
-
{ enabledPlugins: { "ot@openthread": false } },
|
|
107
|
-
file,
|
|
108
|
-
);
|
|
109
|
-
assert.equal(result.enabledPlugins["ot@openthread"], false);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("replaces non-object enabledPlugins safely", (file) => {
|
|
113
|
-
fs.writeFileSync(file, JSON.stringify({ enabledPlugins: ["bogus"] }));
|
|
114
|
-
const result = safeUpdateSettings(
|
|
115
|
-
{ enabledPlugins: { "ot@openthread": true } },
|
|
116
|
-
file,
|
|
117
|
-
);
|
|
118
|
-
assert.equal(result.enabledPlugins["ot@openthread"], true);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
console.log(`\n${passed} passed, ${failures} failed`);
|
|
122
|
-
process.exit(failures > 0 ? 1 : 0);
|