@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ot",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Share Claude Code conversations to OpenThread — the StackOverflow for AI agents. One command to publish any session.",
5
5
  "icon": "icon.svg",
6
6
  "author": {
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
- echo "✓ OpenThread plugin removed and deregistered"
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
- return JSON.parse(fs.readFileSync(settingsPath, "utf8"));
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
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
30
- fs.renameSync(tmp, settingsPath);
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
  };
@@ -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
- // --- SLSA provenance verification (G22) ---
19
- // Before touching the filesystem, verify that the installed package tarball
20
- // was produced by our trusted publish workflow. Setting OT_SKIP_PROVENANCE=1
21
- // bypasses the check — needed for local `npm link` / dev installs where the
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 marketplaceDir = join(homedir(), ".claude", "plugins", "marketplaces", MARKETPLACE_NAME);
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
- // 1. Copy plugin files into marketplace/plugins/ot/
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
- // Sync version from package.json plugin.json
67
- const pkgJsonPath = join(pluginSrc, "package.json");
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(pkgJsonPath) && existsSync(pluginJsonPath)) {
70
- const version = JSON.parse(readFileSync(pkgJsonPath, "utf8")).version;
71
- const pluginJson = JSON.parse(readFileSync(pluginJsonPath, "utf8"));
72
- pluginJson.version = version;
73
- writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2) + "\n");
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
- // 2. Create marketplace.json (like the official marketplace has)
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
- // 3. Register marketplace in known_marketplaces.json
176
+ // 7. Register marketplace in known_marketplaces.json (atomic write)
110
177
  const knownPath = join(homedir(), ".claude", "plugins", "known_marketplaces.json");
111
- let known = {};
112
- if (existsSync(knownPath)) {
113
- try {
114
- known = JSON.parse(readFileSync(knownPath, "utf8"));
115
- } catch {
116
- known = {};
117
- }
118
- }
119
- known[MARKETPLACE_NAME] = {
120
- source: { source: "local", path: marketplaceDir },
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
- const version = JSON.parse(readFileSync(join(pluginSrc, "package.json"), "utf8")).version;
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.error(`Warning: Could not auto-install plugin: ${err.message}`);
137
- console.error("Try: claude --plugin-dir " + pluginSrc);
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.8",
4
- "description": "Share Claude Code conversations to OpenThread the StackOverflow for AI agents. One command to publish any session to the community platform for the agentic AI era.",
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);