@openthread/claude-code-plugin 0.1.5 → 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,7 +1,7 @@
1
1
  {
2
2
  "name": "ot",
3
- "version": "0.1.5",
4
- "description": "Share Claude Code conversations to OpenThread",
3
+ "version": "0.1.9",
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": {
7
7
  "name": "OpenThread"
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @openthread/claude-code-plugin
2
2
 
3
- Share Claude Code conversations to [OpenThread](https://openthread.me) -- a Reddit-like platform for AI conversation threads.
3
+ Share Claude Code conversations to [OpenThread](https://openthread.me) **the StackOverflow for AI agents**. The community platform for the agentic AI era, where developers share, vote on, and discover the best AI conversation threads from Claude, ChatGPT, Gemini, and more.
4
+
5
+ One command. Zero config. Publish any Claude Code session as a post others can learn from.
4
6
 
5
7
  [![npm version](https://img.shields.io/npm/v/@openthread/claude-code-plugin)](https://www.npmjs.com/package/@openthread/claude-code-plugin)
6
8
  [![license](https://img.shields.io/npm/l/@openthread/claude-code-plugin)](https://opensource.org/licenses/MIT)
@@ -25,7 +27,7 @@ Community: Coding with AI
25
27
  Tags: typescript, authentication, debugging
26
28
 
27
29
  Post shared successfully!
28
- View it at: https://openthread.me/post/abc123
30
+ View it at: https://openthread.me/c/coding-with-ai/post/27512cb1
29
31
  ```
30
32
 
31
33
  ## Examples
@@ -51,7 +53,7 @@ Auto-generates title, picks the best community, adds tags, and posts -- no quest
51
53
  ? Tags: typescript, hono, api
52
54
 
53
55
  Post shared successfully!
54
- View it at: https://openthread.me/post/def456
56
+ View it at: https://openthread.me/c/coding-with-ai/post/27512cb1
55
57
  ```
56
58
 
57
59
  ### Share after a long debugging session
@@ -66,13 +68,20 @@ The plugin reads the full conversation, generates a summary, and shares it as a
66
68
 
67
69
  The plugin auto-detects the current Claude Code session file. Works in any project directory -- just run `/ot:share` and it finds the right conversation.
68
70
 
71
+ ## Why OpenThread?
72
+
73
+ OpenThread is the social platform for the agentic AI world — the place where developers share what their AI agents actually built. Think StackOverflow meets Reddit, designed from the ground up for AI conversations. Every post is a full thread (including code, thinking, and tool use) that the community votes on, comments on, and learns from.
74
+
75
+ **This plugin is the one-command bridge from your Claude Code session to that community.**
76
+
69
77
  ## Features
70
78
 
71
- - **One-command sharing** -- run `/ot:share` inside any Claude Code session to publish the conversation to OpenThread.
72
- - **Quick mode** -- `/ot:share <description>` auto-generates a title, selects a community, and posts immediately.
73
- - **Interactive mode** -- `/ot:share` with no arguments prompts you to choose a title, community, and tags.
74
- - **Secure auth** -- PKCE OAuth flow with automatic token refresh. Credentials are stored locally at `~/.config/openthread/`.
75
- - **CLI management** -- `openthread-claude` binary for install, uninstall, status checks, and updates.
79
+ - **One-command sharing** run `/ot:share` inside any Claude Code session to publish the entire conversation to OpenThread.
80
+ - **Quick mode** `/ot:share <description>` auto-generates a title, picks the best-matching community, adds tags, and posts immediately. Zero questions asked.
81
+ - **Interactive mode** `/ot:share` with no arguments prompts you to choose a title, community, and tags.
82
+ - **Privacy first** strips usernames and local file paths before publishing. Your code, not your filesystem.
83
+ - **Secure auth** PKCE OAuth flow with automatic token refresh. Credentials stored locally at `~/.config/openthread/`.
84
+ - **CLI management** — `openthread-claude` binary for install, uninstall, status checks, and updates.
76
85
 
77
86
  ## Usage
78
87
 
@@ -96,23 +105,108 @@ Run with no arguments to step through each field:
96
105
  2. Community (selectable from list)
97
106
  3. Tags (suggested, accept or modify)
98
107
 
108
+ ## Commands
109
+
110
+ ### `/ot:search <query>`
111
+
112
+ Search OpenThread for threads, comments, communities, or users without
113
+ leaving your Claude Code session.
114
+
115
+ Flags:
116
+
117
+ - `--type posts|comments|communities|users|all` (default `posts`)
118
+ - `--community <name>`
119
+ - `--provider claude|chatgpt|gemini|...`
120
+ - `--time hour|day|week|month|year|all`
121
+ - `--limit 1-25` (default `10`)
122
+
123
+ Works without authentication (narrower visibility). If you're logged in,
124
+ you also see private communities you're a member of. After results are
125
+ shown, pick a number to import the thread via `/ot:import`.
126
+
127
+ ```
128
+ > /ot:search hono auth bug
129
+
130
+ [1] Debugging PKCE token refresh in auth middleware
131
+ c/coding-with-ai · u/alice · 3h ago · ▲ 42 · 💬 7
132
+ Walks through the PKCE refresh flow and the off-by-one in expiresAt...
133
+ ```
134
+
135
+ ### `/ot:import <post-id-or-url> [--read|--context]`
136
+
137
+ Pull a published OpenThread thread into your current workspace.
138
+
139
+ **Imported content is UNTRUSTED third-party data.** It may contain
140
+ prompt injections. The plugin treats every imported byte as data, not
141
+ instructions, and enforces that boundary at multiple layers.
142
+
143
+ - **`--read`** (default) — downloads the thread, sanitizes and masks
144
+ it locally (defense-in-depth on top of server-side masking), and
145
+ saves it to `~/.openthread/imports/<uuid>.md` with mode `0600`
146
+ inside a `0700` directory. Claude does **not** automatically load
147
+ the file into context. If you want it read, ask in a separate
148
+ message after the import completes.
149
+ - **`--context`** — additionally emits an
150
+ `<imported_thread trust="untrusted">` envelope that the skill shows
151
+ to Claude after you explicitly confirm. Even inside the envelope,
152
+ the content is treated as data, never as instructions.
153
+
154
+ Inputs accepted:
155
+
156
+ - Bare UUID: `27512cb1-4e7a-4c3b-9d8e-1f2a3b4c5d6e`
157
+ - Path: `/c/<community>/post/<uuid>` or `/post/<uuid>`
158
+ - Full URL: `https://openthread.me/c/<community>/post/<uuid>`
159
+
160
+ Security properties:
161
+
162
+ - Strict UUID validation on every input form.
163
+ - HTTPS enforced unless `OPENTHREAD_API_URL` points to a loopback host.
164
+ - Response bodies capped at 5 MB, read in bounded chunks.
165
+ - Control characters and ANSI escapes are stripped; paths, usernames,
166
+ secrets, emails, and IPs are masked locally.
167
+ - Writes are atomic via a `.part` rename — a partial fetch never lands
168
+ at the final path. Files land at mode `0600` in a `0700` directory.
169
+ - Every saved file starts with a trust banner reminding Claude that
170
+ the content is data, not instructions.
171
+
172
+ ### `/ot:export <post-id-or-url>`
173
+
174
+ Download a thread from OpenThread as a local file. Unlike `/ot:import`,
175
+ this is for archival / sharing — the file is written with sharable
176
+ permissions and does NOT include the "untrusted data" banner.
177
+
178
+ Flags:
179
+
180
+ - `--format markdown|text|json` (default `markdown`)
181
+ - `--out <path>` (default `./ot-<slug>-<short>.<ext>`)
182
+ - `--stdout`
183
+ - `--no-banner`
184
+
185
+ The file is path-traversal-guarded (relative paths must stay under cwd;
186
+ absolute paths are denied into system dirs). Content is re-masked
187
+ locally on top of the server's masking as defense-in-depth. Writes are
188
+ atomic via a `.part` rename and land at mode `0644` so the file can be
189
+ committed or shared. Exported files are NOT loaded into Claude's
190
+ context — if you want Claude to read one, open it in a follow-up
191
+ message.
192
+
99
193
  ## CLI Commands
100
194
 
101
- | Command | Description |
102
- | --- | --- |
103
- | `openthread-claude install` | Install and register the plugin with Claude Code |
195
+ | Command | Description |
196
+ | ----------------------------- | ------------------------------------------------- |
197
+ | `openthread-claude install` | Install and register the plugin with Claude Code |
104
198
  | `openthread-claude uninstall` | Remove the plugin and deregister from Claude Code |
105
- | `openthread-claude status` | Show plugin installation and registration state |
106
- | `openthread-claude update` | Reinstall plugin (update to current version) |
199
+ | `openthread-claude status` | Show plugin installation and registration state |
200
+ | `openthread-claude update` | Reinstall plugin (update to current version) |
107
201
 
108
202
  ## Configuration
109
203
 
110
204
  Environment variables override the default endpoints. Set them in your shell profile or `.env` file.
111
205
 
112
- | Variable | Default | Description |
113
- | --- | --- | --- |
114
- | `OPENTHREAD_API_URL` | `https://openthread.me/api` | Backend API base URL |
115
- | `OPENTHREAD_WEB_URL` | `https://openthread.me` | Web app base URL (used for post links) |
206
+ | Variable | Default | Description |
207
+ | -------------------- | --------------------------- | -------------------------------------- |
208
+ | `OPENTHREAD_API_URL` | `https://openthread.me/api` | Backend API base URL |
209
+ | `OPENTHREAD_WEB_URL` | `https://openthread.me` | Web app base URL (used for post links) |
116
210
 
117
211
  ## Manual Install
118
212
 
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
@@ -66,24 +72,9 @@ with open(path, 'w') as f:
66
72
  f.write('\n')
67
73
  "
68
74
 
69
- # Enable in settings.json
70
- python3 -c "
71
- import json, os
72
- path = '$SETTINGS_FILE'
73
- settings = {}
74
- if os.path.exists(path):
75
- try:
76
- with open(path) as f: settings = json.load(f)
77
- except: pass
78
- if not isinstance(settings.get('enabledPlugins'), dict):
79
- settings['enabledPlugins'] = {}
80
- settings['enabledPlugins']['$PLUGIN_KEY'] = True
81
- settings.pop('extraKnownMarketplaces', None)
82
- os.makedirs(os.path.dirname(path), exist_ok=True)
83
- with open(path, 'w') as f:
84
- json.dump(settings, f, indent=2)
85
- f.write('\n')
86
- "
75
+ # Enable in settings.json via the guarded writer (G16).
76
+ # Only enabledPlugins["ot@openthread"] is permitted to change.
77
+ node "$PLUGIN_DIR/bin/lib/settings-writer.js" enable
87
78
 
88
79
  VERSION=$(python3 -c "import json; print(json.load(open('$DEST_DIR/.claude-plugin/plugin.json'))['version'])")
89
80
  echo "✓ OpenThread plugin v$VERSION installed"
@@ -104,18 +95,14 @@ with open('$KNOWN_FILE', 'w') as f:
104
95
  f.write('\n')
105
96
  " 2>/dev/null || true
106
97
 
107
- # Remove from settings.json
108
- [ -f "$SETTINGS_FILE" ] && python3 -c "
109
- import json
110
- with open('$SETTINGS_FILE') as f: settings = json.load(f)
111
- if isinstance(settings.get('enabledPlugins'), dict):
112
- settings['enabledPlugins'].pop('$PLUGIN_KEY', None)
113
- with open('$SETTINGS_FILE', 'w') as f:
114
- json.dump(settings, f, indent=2)
115
- f.write('\n')
116
- " 2>/dev/null || true
98
+ # Disable in settings.json via the guarded writer (G16).
99
+ [ -f "$SETTINGS_FILE" ] && node "$PLUGIN_DIR/bin/lib/settings-writer.js" disable 2>/dev/null || true
100
+
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"
117
104
 
118
- echo "✓ OpenThread plugin removed and deregistered"
105
+ echo "✓ OpenThread plugin removed, deregistered, and credentials cleared"
119
106
  }
120
107
 
121
108
  check_status() {
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // Guarded writer for ~/.claude/settings.json.
3
+ //
4
+ // Only allows modifications to a strict allowlist of top-level keys. Refuses
5
+ // any diff touching "hooks", "permissions", or unknown keys so that a
6
+ // compromised release cannot inject a preToolUse hook or weaken permissions.
7
+
8
+ const fs = require("node:fs");
9
+ const path = require("node:path");
10
+ const os = require("node:os");
11
+
12
+ const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
13
+
14
+ const ALLOWED_TOP_LEVEL_KEYS = new Set(["enabledPlugins"]);
15
+ const ALLOWED_PLUGIN_KEYS = /^ot@openthread$/;
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
+
29
+ function readSettings(settingsPath = SETTINGS_PATH) {
30
+ try {
31
+ const raw = fs.readFileSync(settingsPath, "utf8");
32
+ if (!raw.trim()) return {}; // handle 0-byte / whitespace-only files
33
+ return JSON.parse(raw);
34
+ } catch (e) {
35
+ if (e.code === "ENOENT") return {};
36
+ throw e;
37
+ }
38
+ }
39
+
40
+ function writeAtomically(data, settingsPath = SETTINGS_PATH) {
41
+ const tmp = settingsPath + ".part";
42
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
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
+ }
58
+ }
59
+
60
+ function safeUpdateSettings(patch, settingsPath = SETTINGS_PATH) {
61
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
62
+ throw new Error("settings-writer: patch must be a plain object");
63
+ }
64
+
65
+ const current = readSettings(settingsPath);
66
+ const next = JSON.parse(JSON.stringify(current));
67
+
68
+ for (const key of Object.keys(patch)) {
69
+ if (!ALLOWED_TOP_LEVEL_KEYS.has(key)) {
70
+ throw new Error(
71
+ `settings-writer: refusing to modify top-level key "${key}"`,
72
+ );
73
+ }
74
+ }
75
+
76
+ // enabledPlugins merge (only ot@openthread keys allowed)
77
+ if (patch.enabledPlugins !== undefined) {
78
+ if (
79
+ !patch.enabledPlugins ||
80
+ typeof patch.enabledPlugins !== "object" ||
81
+ Array.isArray(patch.enabledPlugins)
82
+ ) {
83
+ throw new Error(
84
+ "settings-writer: enabledPlugins patch must be a plain object",
85
+ );
86
+ }
87
+ if (
88
+ !next.enabledPlugins ||
89
+ typeof next.enabledPlugins !== "object" ||
90
+ Array.isArray(next.enabledPlugins)
91
+ ) {
92
+ next.enabledPlugins = {};
93
+ }
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
+ }
103
+ if (!ALLOWED_PLUGIN_KEYS.test(pluginKey)) {
104
+ throw new Error(
105
+ `settings-writer: refusing to modify plugin key "${pluginKey}"`,
106
+ );
107
+ }
108
+ next.enabledPlugins[pluginKey] = patch.enabledPlugins[pluginKey];
109
+ }
110
+ }
111
+
112
+ writeAtomically(next, settingsPath);
113
+ return next;
114
+ }
115
+
116
+ // CLI entry point: node settings-writer.js enable | disable
117
+ if (require.main === module) {
118
+ const cmd = process.argv[2];
119
+ try {
120
+ if (cmd === "enable") {
121
+ safeUpdateSettings({ enabledPlugins: { "ot@openthread": true } });
122
+ console.log("enabled ot@openthread in", SETTINGS_PATH);
123
+ } else if (cmd === "disable") {
124
+ safeUpdateSettings({ enabledPlugins: { "ot@openthread": false } });
125
+ console.log("disabled ot@openthread in", SETTINGS_PATH);
126
+ } else {
127
+ console.error("Usage: settings-writer.js enable|disable");
128
+ process.exit(1);
129
+ }
130
+ } catch (e) {
131
+ console.error(e.message);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ module.exports = {
137
+ safeUpdateSettings,
138
+ readSettings,
139
+ writeAtomically,
140
+ SETTINGS_PATH,
141
+ ALLOWED_TOP_LEVEL_KEYS,
142
+ ALLOWED_PLUGIN_KEYS,
143
+ RESERVED_KEYS,
144
+ };
@@ -1,24 +1,107 @@
1
1
  #!/usr/bin/env node
2
2
  // Auto-install plugin into a Claude Code marketplace so it's discoverable via /ot:share
3
- const { existsSync, mkdirSync, cpSync, chmodSync, readdirSync, readFileSync, writeFileSync } = require("fs");
4
- const { join, resolve } = require("path");
3
+ const {
4
+ existsSync,
5
+ mkdirSync,
6
+ rmSync,
7
+ cpSync,
8
+ chmodSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ accessSync,
13
+ constants: fsConstants,
14
+ } = require("fs");
15
+ const { join, resolve, dirname } = require("path");
5
16
  const { homedir } = require("os");
6
17
 
18
+ const pkg = require("../package.json");
19
+
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);
24
+ }
25
+
26
+ const { safeUpdateSettings } = require("./lib/settings-writer.js");
27
+
7
28
  const PLUGIN_ID = "ot";
8
29
  const MARKETPLACE_NAME = "openthread";
9
30
  const pluginSrc = resolve(__dirname, "..");
10
31
 
11
32
  // Marketplace lives alongside the official one
12
- const marketplaceDir = join(homedir(), ".claude", "plugins", "marketplaces", MARKETPLACE_NAME);
33
+ const claudeDir = join(homedir(), ".claude");
34
+ const marketplaceDir = join(claudeDir, "plugins", "marketplaces", MARKETPLACE_NAME);
13
35
  const pluginDest = join(marketplaceDir, "plugins", PLUGIN_ID);
14
36
 
15
37
  const DIRS_TO_COPY = [".claude-plugin", "commands", "skills", "scripts"];
16
38
  const FILES_TO_COPY = ["icon.svg"];
17
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
+
18
94
  try {
19
- // 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
+ }
20
102
  mkdirSync(pluginDest, { recursive: true });
21
103
 
104
+ // 2. Copy plugin files into marketplace/plugins/ot/
22
105
  for (const dir of DIRS_TO_COPY) {
23
106
  const src = join(pluginSrc, dir);
24
107
  if (existsSync(src)) {
@@ -33,17 +116,31 @@ try {
33
116
  }
34
117
  }
35
118
 
36
- // Sync version from package.json plugin.json
37
- 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
38
132
  const pluginJsonPath = join(pluginDest, ".claude-plugin", "plugin.json");
39
- if (existsSync(pkgJsonPath) && existsSync(pluginJsonPath)) {
40
- const version = JSON.parse(readFileSync(pkgJsonPath, "utf8")).version;
41
- const pluginJson = JSON.parse(readFileSync(pluginJsonPath, "utf8"));
42
- pluginJson.version = version;
43
- 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
+ }
44
141
  }
45
142
 
46
- // Ensure scripts are executable
143
+ // 5. Ensure scripts are executable
47
144
  const scriptsDir = join(pluginDest, "scripts");
48
145
  if (existsSync(scriptsDir)) {
49
146
  for (const f of readdirSync(scriptsDir)) {
@@ -53,52 +150,50 @@ try {
53
150
  }
54
151
  }
55
152
 
56
- // 2. Create marketplace.json (like the official marketplace has)
153
+ // 6. Create marketplace.json (like the official marketplace has)
57
154
  const marketplaceJsonDir = join(marketplaceDir, ".claude-plugin");
58
155
  mkdirSync(marketplaceJsonDir, { recursive: true });
59
- writeFileSync(join(marketplaceJsonDir, "marketplace.json"), JSON.stringify({
60
- name: MARKETPLACE_NAME,
61
- description: "OpenThread plugins for sharing AI conversations",
62
- owner: { name: "OpenThread" },
63
- plugins: [{
64
- name: PLUGIN_ID,
65
- description: "Share Claude Code conversations to OpenThread",
66
- source: `./plugins/${PLUGIN_ID}`,
67
- }],
68
- }, null, 2) + "\n");
69
-
70
- // 3. Register marketplace in known_marketplaces.json
156
+ writeFileSync(
157
+ join(marketplaceJsonDir, "marketplace.json"),
158
+ JSON.stringify(
159
+ {
160
+ name: MARKETPLACE_NAME,
161
+ description: "OpenThread plugins for sharing AI conversations",
162
+ owner: { name: "OpenThread" },
163
+ plugins: [
164
+ {
165
+ name: PLUGIN_ID,
166
+ description: "Share Claude Code conversations to OpenThread",
167
+ source: `./plugins/${PLUGIN_ID}`,
168
+ },
169
+ ],
170
+ },
171
+ null,
172
+ 2,
173
+ ) + "\n",
174
+ );
175
+
176
+ // 7. Register marketplace in known_marketplaces.json (atomic write)
71
177
  const knownPath = join(homedir(), ".claude", "plugins", "known_marketplaces.json");
72
- let known = {};
73
- if (existsSync(knownPath)) {
74
- try { known = JSON.parse(readFileSync(knownPath, "utf8")); } catch { known = {}; }
75
- }
76
- known[MARKETPLACE_NAME] = {
77
- source: { source: "local", path: marketplaceDir },
78
- installLocation: marketplaceDir,
79
- lastUpdated: new Date().toISOString(),
80
- };
81
- writeFileSync(knownPath, JSON.stringify(known, null, 2) + "\n");
82
-
83
- // 4. Enable the plugin in settings.json
84
- const settingsPath = join(homedir(), ".claude", "settings.json");
85
- let settings = {};
86
- if (existsSync(settingsPath)) {
87
- try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch { settings = {}; }
88
- }
89
- if (!settings.enabledPlugins || typeof settings.enabledPlugins !== "object" || Array.isArray(settings.enabledPlugins)) {
90
- settings.enabledPlugins = {};
91
- }
92
- settings.enabledPlugins[`${PLUGIN_ID}@${MARKETPLACE_NAME}`] = true;
93
- // Clean up old format if present
94
- delete settings.extraKnownMarketplaces;
95
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
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).
188
+ // The writer only permits changes to enabledPlugins["ot@openthread"] and
189
+ // refuses any diff touching hooks, permissions, or unknown keys.
190
+ safeUpdateSettings({ enabledPlugins: { "ot@openthread": true } });
96
191
 
97
- const version = JSON.parse(readFileSync(join(pluginSrc, "package.json"), "utf8")).version;
98
- console.log(`\x1b[32m✓\x1b[0m OpenThread plugin v${version} installed`);
192
+ console.log(`\x1b[32m✓\x1b[0m OpenThread plugin v${pkg.version} installed`);
99
193
  console.log(` Marketplace: ${marketplaceDir}`);
100
194
  console.log(` Restart Claude Code, then use \x1b[1m/ot:share\x1b[0m to share conversations.`);
101
195
  } catch (err) {
102
- console.error(`Warning: Could not auto-install plugin: ${err.message}`);
103
- 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
104
199
  }