@openthread/claude-code-plugin 0.1.4 → 0.1.8
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 +2 -2
- package/README.md +111 -17
- package/bin/__tests__/settings-writer.test.js +122 -0
- package/bin/cli.sh +77 -102
- package/bin/lib/settings-writer.js +108 -0
- package/bin/postinstall.js +80 -34
- package/commands/export.md +22 -0
- package/commands/import.md +26 -0
- package/commands/search.md +15 -0
- package/commands/share.md +24 -3
- package/package.json +23 -5
- package/scripts/auth.sh +21 -3
- package/scripts/lib/__init__.py +1 -0
- package/scripts/lib/export_client.py +666 -0
- package/scripts/lib/import_client.py +510 -0
- package/scripts/lib/jsonl.py +88 -0
- package/scripts/lib/keychain.js +59 -0
- package/scripts/lib/mask.py +669 -0
- package/scripts/lib/sanitize.py +92 -0
- package/scripts/lib/search_client.py +218 -0
- package/scripts/lib/thread_to_md.py +156 -0
- package/scripts/share.sh +230 -47
- package/scripts/token.sh +215 -23
- package/skills/export-thread/SKILL.md +166 -0
- package/skills/import-thread/SKILL.md +171 -0
- package/skills/search-threads/SKILL.md +103 -0
- package/skills/share-thread/SKILL.md +25 -43
package/scripts/share.sh
CHANGED
|
@@ -9,6 +9,46 @@ set -euo pipefail
|
|
|
9
9
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
10
|
API_BASE="${OPENTHREAD_API_URL:-https://openthread.me/api}"
|
|
11
11
|
|
|
12
|
+
# --- Require CLAUDE_SESSION_ID (G15) ---
|
|
13
|
+
# /ot:share must be invoked inside a real Claude Code session so we can
|
|
14
|
+
# locate the exact JSONL transcript. Without the UUID we cannot reliably
|
|
15
|
+
# pick the right file, and ranked size fallback risks uploading the wrong
|
|
16
|
+
# thread — refuse to run instead.
|
|
17
|
+
require_session_id() {
|
|
18
|
+
local sid="${CLAUDE_SESSION_ID:-}"
|
|
19
|
+
if [ -z "$sid" ]; then
|
|
20
|
+
echo "error: CLAUDE_SESSION_ID environment variable not set" >&2
|
|
21
|
+
echo " /ot:share must be invoked from a Claude Code session." >&2
|
|
22
|
+
exit 2
|
|
23
|
+
fi
|
|
24
|
+
# Must look like a UUID (8-4-4-4-12 hex).
|
|
25
|
+
if ! echo "$sid" | grep -Eq '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; then
|
|
26
|
+
echo "error: CLAUDE_SESSION_ID is not a valid UUID: $sid" >&2
|
|
27
|
+
exit 2
|
|
28
|
+
fi
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# --- Validate session file path matches the current session UUID ---
|
|
32
|
+
# The caller passes a path; we enforce that its basename is
|
|
33
|
+
# "<CLAUDE_SESSION_ID>.jsonl" so a stale/ranked path cannot be smuggled in.
|
|
34
|
+
validate_session_file() {
|
|
35
|
+
local session_file="$1"
|
|
36
|
+
local sid="${CLAUDE_SESSION_ID:-}"
|
|
37
|
+
if [ ! -f "$session_file" ]; then
|
|
38
|
+
echo "error: session file not found: $session_file" >&2
|
|
39
|
+
echo " expected ~/.claude/projects/<project-slug>/${sid}.jsonl" >&2
|
|
40
|
+
exit 2
|
|
41
|
+
fi
|
|
42
|
+
local base
|
|
43
|
+
base=$(basename "$session_file")
|
|
44
|
+
if [ "$base" != "${sid}.jsonl" ]; then
|
|
45
|
+
echo "error: session file basename does not match CLAUDE_SESSION_ID" >&2
|
|
46
|
+
echo " got: $base" >&2
|
|
47
|
+
echo " expected: ${sid}.jsonl" >&2
|
|
48
|
+
exit 2
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
|
|
12
52
|
# --- Parse JSONL session file into compact markdown body ---
|
|
13
53
|
# Extracts only text blocks from assistant messages, skips thinking/tool_use/tool_result/etc.
|
|
14
54
|
# Formats as markdown with ## User / ## Assistant headings separated by ---
|
|
@@ -20,16 +60,33 @@ parse_session() {
|
|
|
20
60
|
return 1
|
|
21
61
|
fi
|
|
22
62
|
|
|
23
|
-
python3 - "$session_file" <<'PYEOF'
|
|
63
|
+
PLUGIN_SCRIPTS_DIR="$SCRIPT_DIR" python3 - "$session_file" <<'PYEOF'
|
|
64
|
+
"""Parse a Claude Code JSONL session file into masked markdown.
|
|
65
|
+
|
|
66
|
+
This inline script delegates all privacy / sanitization work to the
|
|
67
|
+
shared helpers under ``scripts/lib/``. Import order: ``sanitize`` is
|
|
68
|
+
pulled in implicitly by ``mask``; ``jsonl`` provides the sidechain /
|
|
69
|
+
memory / cwd helpers.
|
|
70
|
+
"""
|
|
71
|
+
|
|
24
72
|
import json
|
|
73
|
+
import os
|
|
74
|
+
import pathlib
|
|
25
75
|
import sys
|
|
26
76
|
|
|
77
|
+
_SCRIPTS_DIR = os.environ.get("PLUGIN_SCRIPTS_DIR")
|
|
78
|
+
if _SCRIPTS_DIR:
|
|
79
|
+
sys.path.insert(0, _SCRIPTS_DIR)
|
|
80
|
+
|
|
81
|
+
from lib import jsonl, mask # noqa: E402
|
|
82
|
+
|
|
27
83
|
session_file = sys.argv[1]
|
|
28
|
-
parts = []
|
|
29
|
-
SKIP_TYPES = {"thinking", "tool_use", "tool_result", "web_fetch", "web_search", "error", "image"}
|
|
30
84
|
|
|
31
|
-
|
|
32
|
-
|
|
85
|
+
# ---- Load all entries ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
entries: list[dict] = []
|
|
88
|
+
with open(session_file, "r", encoding="utf-8", errors="replace") as fh:
|
|
89
|
+
for line in fh:
|
|
33
90
|
line = line.strip()
|
|
34
91
|
if not line:
|
|
35
92
|
continue
|
|
@@ -37,52 +94,93 @@ with open(session_file, 'r') as f:
|
|
|
37
94
|
entry = json.loads(line)
|
|
38
95
|
except json.JSONDecodeError:
|
|
39
96
|
continue
|
|
97
|
+
if isinstance(entry, dict):
|
|
98
|
+
entries.append(entry)
|
|
40
99
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
100
|
+
by_uuid = jsonl.build_uuid_index(entries)
|
|
101
|
+
cwds = jsonl.collect_cwds(entries)
|
|
102
|
+
home = str(pathlib.Path.home())
|
|
44
103
|
|
|
45
|
-
|
|
46
|
-
if entry_type == "user":
|
|
47
|
-
role = "User"
|
|
48
|
-
elif entry_type == "assistant":
|
|
49
|
-
role = "Assistant"
|
|
50
|
-
else:
|
|
51
|
-
continue
|
|
104
|
+
# ---- Walk user / assistant entries, stripping sidechains & memory ---------
|
|
52
105
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
106
|
+
SKIP_BLOCK_TYPES = {
|
|
107
|
+
"thinking",
|
|
108
|
+
"tool_use",
|
|
109
|
+
"tool_result",
|
|
110
|
+
"web_fetch",
|
|
111
|
+
"web_search",
|
|
112
|
+
"error",
|
|
113
|
+
"image",
|
|
114
|
+
}
|
|
58
115
|
|
|
59
|
-
|
|
116
|
+
parts: list[str] = []
|
|
117
|
+
dropped_memory = 0
|
|
118
|
+
dropped_sidechain = 0
|
|
60
119
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
120
|
+
for entry in entries:
|
|
121
|
+
entry_type = entry.get("type")
|
|
122
|
+
if entry_type not in ("user", "assistant"):
|
|
123
|
+
continue
|
|
124
|
+
if jsonl.is_under_sidechain(entry, by_uuid):
|
|
125
|
+
dropped_sidechain += 1
|
|
126
|
+
continue
|
|
67
127
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
128
|
+
role = "User" if entry_type == "user" else "Assistant"
|
|
129
|
+
message = entry.get("message", {})
|
|
130
|
+
|
|
131
|
+
raw_texts: list[str] = []
|
|
132
|
+
if isinstance(message, str):
|
|
133
|
+
if message.strip():
|
|
134
|
+
raw_texts.append(message)
|
|
135
|
+
elif isinstance(message, dict):
|
|
136
|
+
msg_content = message.get("content", "")
|
|
137
|
+
if isinstance(msg_content, str):
|
|
138
|
+
if msg_content.strip():
|
|
139
|
+
raw_texts.append(msg_content)
|
|
140
|
+
elif isinstance(msg_content, list):
|
|
71
141
|
for block in msg_content:
|
|
72
142
|
if isinstance(block, str):
|
|
73
|
-
|
|
143
|
+
if block.strip():
|
|
144
|
+
raw_texts.append(block)
|
|
74
145
|
elif isinstance(block, dict):
|
|
75
146
|
btype = block.get("type", "")
|
|
76
|
-
if btype in
|
|
147
|
+
if btype in SKIP_BLOCK_TYPES:
|
|
77
148
|
continue
|
|
78
|
-
if btype == "text"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
149
|
+
if btype == "text":
|
|
150
|
+
text_val = block.get("text", "")
|
|
151
|
+
if isinstance(text_val, str) and text_val.strip():
|
|
152
|
+
raw_texts.append(text_val)
|
|
153
|
+
|
|
154
|
+
# Drop any memory / system-reminder blocks.
|
|
155
|
+
kept_texts: list[str] = []
|
|
156
|
+
for text in raw_texts:
|
|
157
|
+
if jsonl.looks_like_memory(text):
|
|
158
|
+
dropped_memory += 1
|
|
159
|
+
continue
|
|
160
|
+
kept_texts.append(text)
|
|
161
|
+
|
|
162
|
+
if not kept_texts:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Mask each kept block through the shared library.
|
|
166
|
+
masked_texts = [
|
|
167
|
+
mask.mask(t, cwds=cwds, home=home).strip() for t in kept_texts
|
|
168
|
+
]
|
|
169
|
+
masked_texts = [t for t in masked_texts if t]
|
|
170
|
+
if not masked_texts:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
parts.append(f"## {role}\n\n" + "\n\n".join(masked_texts))
|
|
174
|
+
|
|
175
|
+
if dropped_memory or dropped_sidechain:
|
|
176
|
+
print(
|
|
177
|
+
f"[share.sh] dropped {dropped_sidechain} sidechain entries, "
|
|
178
|
+
f"{dropped_memory} memory blocks",
|
|
179
|
+
file=sys.stderr,
|
|
180
|
+
)
|
|
83
181
|
|
|
84
182
|
body = "\n\n---\n\n".join(parts)
|
|
85
|
-
# Truncate to 500000 chars
|
|
183
|
+
# Truncate to 500000 chars to match the server's body limit.
|
|
86
184
|
print(body[:500000])
|
|
87
185
|
PYEOF
|
|
88
186
|
}
|
|
@@ -105,6 +203,50 @@ import_post() {
|
|
|
105
203
|
return 1
|
|
106
204
|
fi
|
|
107
205
|
|
|
206
|
+
# --- Editor preview (G17) ---
|
|
207
|
+
# Default: open the masked body in $EDITOR for user review before upload.
|
|
208
|
+
# Modelines are disabled in vim/nvim/emacs so a malicious modeline in the
|
|
209
|
+
# transcript cannot execute editor commands. `--yes` skips the preview.
|
|
210
|
+
if [ "${PREVIEW:-1}" = "1" ] && [ -t 0 ] && [ -t 1 ]; then
|
|
211
|
+
local preview_file
|
|
212
|
+
preview_file=$(mktemp -t ot-preview.XXXXXX) || {
|
|
213
|
+
echo "ERROR: failed to create preview tempfile" >&2
|
|
214
|
+
return 1
|
|
215
|
+
}
|
|
216
|
+
# Ensure .md suffix so editors syntax-highlight properly.
|
|
217
|
+
mv "$preview_file" "${preview_file}.md"
|
|
218
|
+
preview_file="${preview_file}.md"
|
|
219
|
+
printf '%s' "$compact_body" > "$preview_file"
|
|
220
|
+
chmod 600 "$preview_file"
|
|
221
|
+
|
|
222
|
+
local editor="${EDITOR:-${VISUAL:-vi}}"
|
|
223
|
+
case "$editor" in
|
|
224
|
+
*vim*|*nvim*)
|
|
225
|
+
"$editor" -c 'set nomodeline nomodelineexpr' "$preview_file" </dev/tty >/dev/tty 2>&1 || true
|
|
226
|
+
;;
|
|
227
|
+
*emacs*)
|
|
228
|
+
"$editor" --no-site-file --eval '(setq enable-local-eval nil)' "$preview_file" </dev/tty >/dev/tty 2>&1 || true
|
|
229
|
+
;;
|
|
230
|
+
*)
|
|
231
|
+
"$editor" "$preview_file" </dev/tty >/dev/tty 2>&1 || true
|
|
232
|
+
;;
|
|
233
|
+
esac
|
|
234
|
+
|
|
235
|
+
printf 'Upload this content? [y/N] ' >/dev/tty
|
|
236
|
+
local confirm=""
|
|
237
|
+
if [ -r /dev/tty ]; then
|
|
238
|
+
read -r confirm </dev/tty || confirm=""
|
|
239
|
+
fi
|
|
240
|
+
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
|
241
|
+
echo "cancelled." >&2
|
|
242
|
+
rm -f "$preview_file"
|
|
243
|
+
return 0
|
|
244
|
+
fi
|
|
245
|
+
# Re-read in case the user edited the body.
|
|
246
|
+
compact_body=$(cat "$preview_file")
|
|
247
|
+
rm -f "$preview_file"
|
|
248
|
+
fi
|
|
249
|
+
|
|
108
250
|
# Build tags array from comma-separated string
|
|
109
251
|
local tags_json="[]"
|
|
110
252
|
if [ -n "$tags_csv" ] && [ "$tags_csv" != "-" ]; then
|
|
@@ -115,19 +257,40 @@ print(json.dumps(tags))
|
|
|
115
257
|
" "$tags_csv")
|
|
116
258
|
fi
|
|
117
259
|
|
|
118
|
-
# Build the import payload — prepend summary to body if provided
|
|
260
|
+
# Build the import payload — prepend summary to body if provided.
|
|
261
|
+
# Title / summary are run through the same shared masking library so
|
|
262
|
+
# nothing unmasked leaks via the meta fields. The body was already
|
|
263
|
+
# masked by parse_session() but we run it through again to handle any
|
|
264
|
+
# user-authored title / summary that the caller injected above it.
|
|
119
265
|
local payload
|
|
120
|
-
payload=$(python3 -c "
|
|
121
|
-
import json, sys
|
|
266
|
+
payload=$(PLUGIN_SCRIPTS_DIR="$SCRIPT_DIR" python3 -c "
|
|
267
|
+
import json, os, pathlib, sys
|
|
268
|
+
|
|
269
|
+
_SCRIPTS_DIR = os.environ.get('PLUGIN_SCRIPTS_DIR')
|
|
270
|
+
if _SCRIPTS_DIR:
|
|
271
|
+
sys.path.insert(0, _SCRIPTS_DIR)
|
|
272
|
+
from lib import mask # noqa: E402
|
|
273
|
+
|
|
122
274
|
body_text = sys.argv[1]
|
|
275
|
+
title = sys.argv[2]
|
|
276
|
+
community_id = sys.argv[3]
|
|
277
|
+
tags = json.loads(sys.argv[4])
|
|
123
278
|
summary = sys.argv[5] if len(sys.argv) > 5 and sys.argv[5] else ''
|
|
279
|
+
|
|
280
|
+
home = str(pathlib.Path.home())
|
|
281
|
+
title = mask.mask(title, home=home)
|
|
282
|
+
summary = mask.mask(summary, home=home)
|
|
283
|
+
# Body was masked in parse_session but is idempotent — safe to re-run.
|
|
284
|
+
body_text = mask.mask(body_text, home=home)
|
|
285
|
+
|
|
124
286
|
if summary:
|
|
125
287
|
body_text = '> ' + summary.replace('\n', '\n> ') + '\n\n---\n\n' + body_text
|
|
288
|
+
|
|
126
289
|
payload = {
|
|
127
|
-
'title':
|
|
128
|
-
'communityId':
|
|
290
|
+
'title': title,
|
|
291
|
+
'communityId': community_id,
|
|
129
292
|
'body': body_text,
|
|
130
|
-
'tags':
|
|
293
|
+
'tags': tags,
|
|
131
294
|
'source': 'claude-code',
|
|
132
295
|
'provider': 'claude',
|
|
133
296
|
}
|
|
@@ -219,30 +382,50 @@ print(json.dumps(payload))
|
|
|
219
382
|
}
|
|
220
383
|
|
|
221
384
|
# --- CLI interface ---
|
|
385
|
+
# Parse global flags (before the subcommand).
|
|
386
|
+
PREVIEW=1
|
|
387
|
+
while [ $# -gt 0 ]; do
|
|
388
|
+
case "${1:-}" in
|
|
389
|
+
--yes) PREVIEW=0; shift;;
|
|
390
|
+
--preview) PREVIEW=1; shift;;
|
|
391
|
+
--) shift; break;;
|
|
392
|
+
*) break;;
|
|
393
|
+
esac
|
|
394
|
+
done
|
|
395
|
+
export PREVIEW
|
|
396
|
+
|
|
222
397
|
case "${1:-}" in
|
|
223
398
|
parse)
|
|
224
399
|
if [ -z "${2:-}" ]; then
|
|
225
400
|
echo "Usage: share.sh parse <session_file>" >&2
|
|
226
401
|
exit 1
|
|
227
402
|
fi
|
|
403
|
+
require_session_id
|
|
404
|
+
validate_session_file "$2"
|
|
228
405
|
parse_session "$2"
|
|
229
406
|
;;
|
|
230
407
|
import)
|
|
231
408
|
if [ -z "${6:-}" ]; then
|
|
232
|
-
echo "Usage: share.sh import <session_file> <title> <community_id> <tags> <token> [summary]" >&2
|
|
409
|
+
echo "Usage: share.sh [--yes] import <session_file> <title> <community_id> <tags> <token> [summary]" >&2
|
|
233
410
|
exit 1
|
|
234
411
|
fi
|
|
412
|
+
require_session_id
|
|
413
|
+
validate_session_file "$2"
|
|
235
414
|
import_post "$2" "$3" "$4" "${5:--}" "$6" "${7:-}"
|
|
236
415
|
;;
|
|
237
416
|
import-export)
|
|
238
417
|
if [ -z "${6:-}" ]; then
|
|
239
|
-
echo "Usage: share.sh import-export <export_file> <title> <community_id> <tags> <token> [summary]" >&2
|
|
418
|
+
echo "Usage: share.sh [--yes] import-export <export_file> <title> <community_id> <tags> <token> [summary]" >&2
|
|
240
419
|
exit 1
|
|
241
420
|
fi
|
|
421
|
+
if [ ! -f "$2" ]; then
|
|
422
|
+
echo "error: export file not found: $2" >&2
|
|
423
|
+
exit 2
|
|
424
|
+
fi
|
|
242
425
|
import_export_post "$2" "$3" "$4" "${5:--}" "$6" "${7:-}"
|
|
243
426
|
;;
|
|
244
427
|
*)
|
|
245
|
-
echo "Usage: share.sh {parse|import|import-export} ..." >&2
|
|
428
|
+
echo "Usage: share.sh [--yes|--preview] {parse|import|import-export} ..." >&2
|
|
246
429
|
exit 1
|
|
247
430
|
;;
|
|
248
431
|
esac
|
package/scripts/token.sh
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Token management for OpenThread Claude Code plugin
|
|
3
|
+
#
|
|
4
|
+
# Secrets (access_token, refresh_token) are stored in the OS keychain via
|
|
5
|
+
# scripts/lib/keychain.js (keytar). The credentials.json file now only holds
|
|
6
|
+
# { "expiresAt": <unix seconds> } so the shell can check expiry without
|
|
7
|
+
# hitting the keychain on every call.
|
|
8
|
+
#
|
|
9
|
+
# If keytar is not installed (e.g. dev clone without `npm install`), we fall
|
|
10
|
+
# back to plaintext storage in credentials.json and print a warning.
|
|
11
|
+
#
|
|
3
12
|
# Usage:
|
|
4
13
|
# source token.sh — import functions
|
|
5
14
|
# bash token.sh refresh — refresh the access token
|
|
@@ -7,12 +16,67 @@
|
|
|
7
16
|
# bash token.sh check — exit 0 if authenticated, 1 if not
|
|
8
17
|
set -euo pipefail
|
|
9
18
|
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
20
|
API_BASE="${OPENTHREAD_API_URL:-https://openthread.me/api}"
|
|
11
21
|
EXTENSION_ID="claude-code"
|
|
12
22
|
CREDENTIALS_DIR="$HOME/.config/openthread"
|
|
13
23
|
CREDENTIALS_FILE="$CREDENTIALS_DIR/credentials.json"
|
|
24
|
+
KEYCHAIN_JS="$SCRIPT_DIR/lib/keychain.js"
|
|
14
25
|
|
|
15
|
-
# ---
|
|
26
|
+
# --- Keychain availability probe ---
|
|
27
|
+
# Returns 0 if node + keytar are usable, 1 otherwise. Cached per process.
|
|
28
|
+
_KEYCHAIN_AVAILABLE=""
|
|
29
|
+
keychain_available() {
|
|
30
|
+
if [ -n "$_KEYCHAIN_AVAILABLE" ]; then
|
|
31
|
+
[ "$_KEYCHAIN_AVAILABLE" = "yes" ] && return 0 || return 1
|
|
32
|
+
fi
|
|
33
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
34
|
+
_KEYCHAIN_AVAILABLE="no"
|
|
35
|
+
return 1
|
|
36
|
+
fi
|
|
37
|
+
if [ ! -f "$KEYCHAIN_JS" ]; then
|
|
38
|
+
_KEYCHAIN_AVAILABLE="no"
|
|
39
|
+
return 1
|
|
40
|
+
fi
|
|
41
|
+
# Probe by resolving keytar. `node -e "require('keytar')"` exits 0 if ok.
|
|
42
|
+
if node -e "require('keytar')" >/dev/null 2>&1; then
|
|
43
|
+
_KEYCHAIN_AVAILABLE="yes"
|
|
44
|
+
return 0
|
|
45
|
+
fi
|
|
46
|
+
# keytar may be installed in the plugin's own node_modules.
|
|
47
|
+
if (cd "$SCRIPT_DIR/.." && node -e "require('keytar')" >/dev/null 2>&1); then
|
|
48
|
+
_KEYCHAIN_AVAILABLE="yes"
|
|
49
|
+
return 0
|
|
50
|
+
fi
|
|
51
|
+
_KEYCHAIN_AVAILABLE="no"
|
|
52
|
+
return 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_warn_plaintext_once() {
|
|
56
|
+
if [ -z "${_PLAINTEXT_WARNED:-}" ]; then
|
|
57
|
+
echo "warning: keytar not available — falling back to plaintext credentials at $CREDENTIALS_FILE" >&2
|
|
58
|
+
_PLAINTEXT_WARNED=1
|
|
59
|
+
fi
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# --- Low-level keychain helpers (when available) ---
|
|
63
|
+
kc_set() {
|
|
64
|
+
local account="$1" value="$2"
|
|
65
|
+
(cd "$SCRIPT_DIR/.." && printf '%s' "$value" | node "$KEYCHAIN_JS" set "$account")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
kc_get() {
|
|
69
|
+
local account="$1"
|
|
70
|
+
(cd "$SCRIPT_DIR/.." && node "$KEYCHAIN_JS" get "$account")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
kc_delete() {
|
|
74
|
+
local account="$1"
|
|
75
|
+
(cd "$SCRIPT_DIR/.." && node "$KEYCHAIN_JS" delete "$account") >/dev/null 2>&1 || true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# --- Save tokens ---
|
|
79
|
+
# save_tokens <access_token> <refresh_token> <expires_at>
|
|
16
80
|
save_tokens() {
|
|
17
81
|
local access_token="$1"
|
|
18
82
|
local refresh_token="$2"
|
|
@@ -20,54 +84,170 @@ save_tokens() {
|
|
|
20
84
|
|
|
21
85
|
mkdir -p "$CREDENTIALS_DIR"
|
|
22
86
|
|
|
23
|
-
|
|
87
|
+
if keychain_available; then
|
|
88
|
+
kc_set "access_token" "$access_token"
|
|
89
|
+
kc_set "refresh_token" "$refresh_token"
|
|
90
|
+
cat > "$CREDENTIALS_FILE" <<EOF
|
|
91
|
+
{
|
|
92
|
+
"expiresAt": ${expires_at}
|
|
93
|
+
}
|
|
94
|
+
EOF
|
|
95
|
+
else
|
|
96
|
+
_warn_plaintext_once
|
|
97
|
+
cat > "$CREDENTIALS_FILE" <<EOF
|
|
24
98
|
{
|
|
25
99
|
"access_token": "${access_token}",
|
|
26
100
|
"refresh_token": "${refresh_token}",
|
|
27
|
-
"
|
|
101
|
+
"expiresAt": ${expires_at}
|
|
28
102
|
}
|
|
29
103
|
EOF
|
|
104
|
+
fi
|
|
30
105
|
|
|
31
106
|
chmod 600 "$CREDENTIALS_FILE"
|
|
32
107
|
}
|
|
33
108
|
|
|
34
|
-
# ---
|
|
35
|
-
|
|
109
|
+
# --- Migrate legacy plaintext credentials into keychain ---
|
|
110
|
+
# Idempotent: if the file already has only { expiresAt }, this is a no-op.
|
|
111
|
+
migrate_credentials_if_needed() {
|
|
112
|
+
[ -f "$CREDENTIALS_FILE" ] || return 0
|
|
113
|
+
keychain_available || return 0
|
|
114
|
+
|
|
115
|
+
# Detect if the file still has access_token / refresh_token fields.
|
|
116
|
+
local needs_migration
|
|
117
|
+
needs_migration=$(python3 - "$CREDENTIALS_FILE" <<'PYEOF'
|
|
118
|
+
import json, sys
|
|
119
|
+
try:
|
|
120
|
+
with open(sys.argv[1]) as f:
|
|
121
|
+
data = json.load(f)
|
|
122
|
+
except Exception:
|
|
123
|
+
print("no")
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
if isinstance(data, dict) and ("access_token" in data or "refresh_token" in data):
|
|
126
|
+
print("yes")
|
|
127
|
+
else:
|
|
128
|
+
print("no")
|
|
129
|
+
PYEOF
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if [ "$needs_migration" != "yes" ]; then
|
|
133
|
+
return 0
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
local legacy_access legacy_refresh legacy_expires
|
|
137
|
+
legacy_access=$(python3 -c "import json; d=json.load(open('$CREDENTIALS_FILE')); print(d.get('access_token',''))")
|
|
138
|
+
legacy_refresh=$(python3 -c "import json; d=json.load(open('$CREDENTIALS_FILE')); print(d.get('refresh_token',''))")
|
|
139
|
+
legacy_expires=$(python3 -c "import json; d=json.load(open('$CREDENTIALS_FILE')); print(d.get('expiresAt', d.get('expires_at', 0)))")
|
|
140
|
+
|
|
141
|
+
if [ -n "$legacy_access" ]; then
|
|
142
|
+
kc_set "access_token" "$legacy_access"
|
|
143
|
+
fi
|
|
144
|
+
if [ -n "$legacy_refresh" ]; then
|
|
145
|
+
kc_set "refresh_token" "$legacy_refresh"
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
cat > "$CREDENTIALS_FILE" <<EOF
|
|
149
|
+
{
|
|
150
|
+
"expiresAt": ${legacy_expires:-0}
|
|
151
|
+
}
|
|
152
|
+
EOF
|
|
153
|
+
chmod 600 "$CREDENTIALS_FILE"
|
|
154
|
+
echo "token.sh: migrated plaintext credentials into OS keychain" >&2
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# --- Read expiresAt from the credentials file (supports legacy key name) ---
|
|
158
|
+
_read_expires_at() {
|
|
36
159
|
if [ ! -f "$CREDENTIALS_FILE" ]; then
|
|
37
|
-
|
|
160
|
+
echo "0"
|
|
161
|
+
return
|
|
38
162
|
fi
|
|
163
|
+
python3 -c "
|
|
164
|
+
import json, sys
|
|
165
|
+
try:
|
|
166
|
+
with open('$CREDENTIALS_FILE') as f:
|
|
167
|
+
d = json.load(f)
|
|
168
|
+
print(d.get('expiresAt', d.get('expires_at', 0)))
|
|
169
|
+
except Exception:
|
|
170
|
+
print(0)
|
|
171
|
+
" 2>/dev/null || echo "0"
|
|
172
|
+
}
|
|
39
173
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
174
|
+
# --- Check if token is expired (60s buffer) ---
|
|
175
|
+
is_expired() {
|
|
176
|
+
if [ ! -f "$CREDENTIALS_FILE" ]; then
|
|
177
|
+
return 0
|
|
178
|
+
fi
|
|
179
|
+
local expires_at now
|
|
180
|
+
expires_at=$(_read_expires_at)
|
|
43
181
|
now=$(date +%s)
|
|
44
|
-
|
|
45
|
-
# Consider expired if within 60 seconds of expiry
|
|
46
182
|
if [ "$now" -ge "$((expires_at - 60))" ]; then
|
|
47
|
-
return 0
|
|
183
|
+
return 0
|
|
48
184
|
fi
|
|
49
|
-
|
|
50
|
-
return 1 # Still valid
|
|
185
|
+
return 1
|
|
51
186
|
}
|
|
52
187
|
|
|
53
|
-
# --- Get access token from
|
|
188
|
+
# --- Get access token from storage ---
|
|
54
189
|
get_access_token() {
|
|
55
190
|
if [ ! -f "$CREDENTIALS_FILE" ]; then
|
|
56
191
|
echo "ERROR: Not authenticated. Run auth.sh first." >&2
|
|
57
192
|
return 1
|
|
58
193
|
fi
|
|
59
194
|
|
|
60
|
-
|
|
195
|
+
migrate_credentials_if_needed
|
|
196
|
+
|
|
197
|
+
if keychain_available; then
|
|
198
|
+
if ! kc_get "access_token"; then
|
|
199
|
+
# Fall back to legacy field if a stale file still contains it.
|
|
200
|
+
python3 -c "
|
|
201
|
+
import json, sys
|
|
202
|
+
try:
|
|
203
|
+
d = json.load(open('$CREDENTIALS_FILE'))
|
|
204
|
+
v = d.get('access_token', '')
|
|
205
|
+
if not v:
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
print(v)
|
|
208
|
+
except Exception:
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
" 2>/dev/null || {
|
|
211
|
+
echo "ERROR: access_token not found in keychain or credentials file" >&2
|
|
212
|
+
return 1
|
|
213
|
+
}
|
|
214
|
+
fi
|
|
215
|
+
else
|
|
216
|
+
_warn_plaintext_once
|
|
217
|
+
python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE')).get('access_token',''))" 2>/dev/null
|
|
218
|
+
fi
|
|
61
219
|
}
|
|
62
220
|
|
|
63
|
-
# --- Get refresh token from
|
|
221
|
+
# --- Get refresh token from storage ---
|
|
64
222
|
get_refresh_token() {
|
|
65
223
|
if [ ! -f "$CREDENTIALS_FILE" ]; then
|
|
66
224
|
echo "ERROR: Not authenticated. Run auth.sh first." >&2
|
|
67
225
|
return 1
|
|
68
226
|
fi
|
|
69
227
|
|
|
70
|
-
|
|
228
|
+
migrate_credentials_if_needed
|
|
229
|
+
|
|
230
|
+
if keychain_available; then
|
|
231
|
+
if ! kc_get "refresh_token"; then
|
|
232
|
+
python3 -c "
|
|
233
|
+
import json, sys
|
|
234
|
+
try:
|
|
235
|
+
d = json.load(open('$CREDENTIALS_FILE'))
|
|
236
|
+
v = d.get('refresh_token', '')
|
|
237
|
+
if not v:
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
print(v)
|
|
240
|
+
except Exception:
|
|
241
|
+
sys.exit(1)
|
|
242
|
+
" 2>/dev/null || {
|
|
243
|
+
echo "ERROR: refresh_token not found in keychain or credentials file" >&2
|
|
244
|
+
return 1
|
|
245
|
+
}
|
|
246
|
+
fi
|
|
247
|
+
else
|
|
248
|
+
_warn_plaintext_once
|
|
249
|
+
python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE')).get('refresh_token',''))" 2>/dev/null
|
|
250
|
+
fi
|
|
71
251
|
}
|
|
72
252
|
|
|
73
253
|
# --- Refresh the access token ---
|
|
@@ -91,14 +271,12 @@ refresh_token() {
|
|
|
91
271
|
return 1
|
|
92
272
|
fi
|
|
93
273
|
|
|
94
|
-
local new_access_token
|
|
274
|
+
local new_access_token expires_in new_expires_at
|
|
95
275
|
new_access_token=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])")
|
|
96
|
-
local expires_in
|
|
97
276
|
expires_in=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['expires_in'])")
|
|
98
|
-
local new_expires_at
|
|
99
277
|
new_expires_at=$(( $(date +%s) + expires_in ))
|
|
100
278
|
|
|
101
|
-
#
|
|
279
|
+
# Persist new access token; keep the refresh token we already had.
|
|
102
280
|
save_tokens "$new_access_token" "$current_refresh" "$new_expires_at"
|
|
103
281
|
|
|
104
282
|
echo "$new_access_token"
|
|
@@ -106,6 +284,7 @@ refresh_token() {
|
|
|
106
284
|
|
|
107
285
|
# --- Get a valid token (refresh if needed) ---
|
|
108
286
|
get_valid_token() {
|
|
287
|
+
migrate_credentials_if_needed
|
|
109
288
|
if is_expired; then
|
|
110
289
|
echo "Token expired, refreshing..." >&2
|
|
111
290
|
refresh_token
|
|
@@ -114,6 +293,15 @@ get_valid_token() {
|
|
|
114
293
|
fi
|
|
115
294
|
}
|
|
116
295
|
|
|
296
|
+
# --- Clear all stored credentials ---
|
|
297
|
+
clear_tokens() {
|
|
298
|
+
if keychain_available; then
|
|
299
|
+
kc_delete "access_token"
|
|
300
|
+
kc_delete "refresh_token"
|
|
301
|
+
fi
|
|
302
|
+
rm -f "$CREDENTIALS_FILE"
|
|
303
|
+
}
|
|
304
|
+
|
|
117
305
|
# --- CLI interface ---
|
|
118
306
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
119
307
|
case "${1:-}" in
|
|
@@ -132,8 +320,12 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
|
132
320
|
exit 1
|
|
133
321
|
fi
|
|
134
322
|
;;
|
|
323
|
+
clear|logout)
|
|
324
|
+
clear_tokens
|
|
325
|
+
echo "Credentials cleared"
|
|
326
|
+
;;
|
|
135
327
|
*)
|
|
136
|
-
echo "Usage: token.sh {refresh|get|check}" >&2
|
|
328
|
+
echo "Usage: token.sh {refresh|get|check|clear}" >&2
|
|
137
329
|
exit 1
|
|
138
330
|
;;
|
|
139
331
|
esac
|