@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/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
- with open(session_file, 'r') as f:
32
- for line in f:
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
- # Skip sidechain messages
42
- if entry.get("isSidechain"):
43
- continue
100
+ by_uuid = jsonl.build_uuid_index(entries)
101
+ cwds = jsonl.collect_cwds(entries)
102
+ home = str(pathlib.Path.home())
44
103
 
45
- entry_type = entry.get("type", "")
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
- message = entry.get("message", {})
54
- if not isinstance(message, dict):
55
- if isinstance(message, str) and message.strip():
56
- parts.append(f"## {role}\n\n{message.strip()}")
57
- continue
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
- msg_content = message.get("content", "")
116
+ parts: list[str] = []
117
+ dropped_memory = 0
118
+ dropped_sidechain = 0
60
119
 
61
- # If content is a string, keep it as-is
62
- if isinstance(msg_content, str):
63
- text = msg_content.strip()
64
- if text:
65
- parts.append(f"## {role}\n\n{text}")
66
- continue
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
- # Content is a list of blocks keep only text blocks
69
- if isinstance(msg_content, list):
70
- kept = []
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
- kept.append(block)
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 SKIP_TYPES:
147
+ if btype in SKIP_BLOCK_TYPES:
77
148
  continue
78
- if btype == "text" and block.get("text", "").strip():
79
- kept.append(block["text"])
80
- text = "\n\n".join(kept).strip()
81
- if text:
82
- parts.append(f"## {role}\n\n{text}")
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': sys.argv[2],
128
- 'communityId': sys.argv[3],
290
+ 'title': title,
291
+ 'communityId': community_id,
129
292
  'body': body_text,
130
- 'tags': json.loads(sys.argv[4]),
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
- # --- Save tokens to credentials file ---
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
- cat > "$CREDENTIALS_FILE" <<EOF
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
- "expires_at": ${expires_at}
101
+ "expiresAt": ${expires_at}
28
102
  }
29
103
  EOF
104
+ fi
30
105
 
31
106
  chmod 600 "$CREDENTIALS_FILE"
32
107
  }
33
108
 
34
- # --- Check if token is expired ---
35
- is_expired() {
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
- return 0 # No file = expired
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
- local expires_at
41
- expires_at=$(python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE'))['expires_at'])" 2>/dev/null || echo "0")
42
- local now
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 # Expired
183
+ return 0
48
184
  fi
49
-
50
- return 1 # Still valid
185
+ return 1
51
186
  }
52
187
 
53
- # --- Get access token from credentials ---
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
- python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE'))['access_token'])" 2>/dev/null
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 credentials ---
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
- python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE'))['refresh_token'])" 2>/dev/null
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
- # Update credentials file (keep existing refresh token)
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