@alexismunozdev/claude-session-topics 2.0.1 → 2.1.0

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/bin/install.js CHANGED
@@ -50,7 +50,7 @@ const WRAPPER_CMD = `bash "$HOME/.claude/session-topics/wrapper-statusline.sh"`;
50
50
 
51
51
  // ─── Permission rule ─────────────────────────────────────────────────────────
52
52
 
53
- const PERMISSION_RULE = 'Bash(*session-topics*)';
53
+ const PERMISSION_RULE = 'Bash(*/.claude/session-topics/*)';
54
54
 
55
55
  // ─── Wrapper script content ──────────────────────────────────────────────────
56
56
 
@@ -59,7 +59,27 @@ input=$(cat)
59
59
  TOPIC_OUTPUT=$(echo "$input" | bash "$HOME/.claude/session-topics/statusline.sh" 2>/dev/null || echo "")
60
60
  ORIG_CMD=$(cat "$HOME/.claude/session-topics/.original-statusline-cmd" 2>/dev/null || echo "")
61
61
  ORIG_OUTPUT=""
62
- [ -n "$ORIG_CMD" ] && ORIG_OUTPUT=$(echo "$input" | eval "$ORIG_CMD" 2>/dev/null || echo "")
62
+
63
+ # Validate the original command before executing it
64
+ validate_cmd() {
65
+ local cmd="\$1"
66
+ # Reject dangerous patterns: command substitution, backticks, chaining,
67
+ # process substitution, and /dev/tcp|udp redirection
68
+ if echo "\$cmd" | grep -qF '\$(' ; then return 1; fi
69
+ if echo "\$cmd" | grep -qF '\`' ; then return 1; fi
70
+ if echo "\$cmd" | grep -q '[;&|]' ; then return 1; fi
71
+ if echo "\$cmd" | grep -qE '>\\(' ; then return 1; fi
72
+ if echo "\$cmd" | grep -qE '<\\(' ; then return 1; fi
73
+ if echo "\$cmd" | grep -qE '/dev/(tcp|udp)' ; then return 1; fi
74
+ # Must start with an allowed command pattern (bash <path> or absolute path)
75
+ if ! echo "\$cmd" | grep -qE '^(bash |/[a-zA-Z0-9._/-]+)' ; then return 1; fi
76
+ return 0
77
+ }
78
+
79
+ if [ -n "$ORIG_CMD" ] && validate_cmd "$ORIG_CMD"; then
80
+ ORIG_OUTPUT=$(echo "$input" | bash -c "$ORIG_CMD" 2>/dev/null || echo "")
81
+ fi
82
+
63
83
  if [ -n "$TOPIC_OUTPUT" ] && [ -n "$ORIG_OUTPUT" ]; then
64
84
  echo -e "\${TOPIC_OUTPUT} | \${ORIG_OUTPUT}"
65
85
  elif [ -n "$TOPIC_OUTPUT" ]; then
@@ -83,7 +103,10 @@ function readSettings() {
83
103
  function writeSettings(obj) {
84
104
  const dir = path.dirname(SETTINGS_FILE);
85
105
  fs.mkdirSync(dir, { recursive: true });
86
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(obj, null, 2) + '\n', 'utf8');
106
+ // Atomic write: write to temp file then rename to avoid TOCTOU race condition
107
+ const tmpFile = SETTINGS_FILE + '.tmp.' + process.pid;
108
+ fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
109
+ fs.renameSync(tmpFile, SETTINGS_FILE);
87
110
  }
88
111
 
89
112
  function copyDirRecursive(src, dest) {
@@ -110,6 +133,15 @@ function hasJq() {
110
133
 
111
134
  // ─── CLI argument parsing ────────────────────────────────────────────────────
112
135
 
136
+ const VALID_NAMED_COLORS = ['green', 'blue', 'cyan', 'magenta', 'yellow', 'red', 'white', 'orange', 'grey'];
137
+ const VALID_ANSI_CODE_RE = /^[0-9;]{1,15}$/;
138
+
139
+ function validateColor(value) {
140
+ if (VALID_NAMED_COLORS.includes(value.toLowerCase())) return true;
141
+ if (VALID_ANSI_CODE_RE.test(value)) return true;
142
+ return false;
143
+ }
144
+
113
145
  function parseArgs(argv) {
114
146
  const args = argv.slice(2);
115
147
  const result = { action: 'install', color: null };
@@ -126,7 +158,12 @@ function parseArgs(argv) {
126
158
  }
127
159
  if (arg === '--color') {
128
160
  if (i + 1 < args.length) {
129
- result.color = args[i + 1];
161
+ const colorValue = args[i + 1];
162
+ if (!validateColor(colorValue)) {
163
+ err(`Invalid color: "${colorValue}". Use a named color (${VALID_NAMED_COLORS.join(', ')}) or a numeric ANSI code (max 15 chars).`);
164
+ process.exit(1);
165
+ }
166
+ result.color = colorValue;
130
167
  i++;
131
168
  } else {
132
169
  err('--color requires a value (e.g., --color cyan)');
@@ -222,12 +259,12 @@ function install(color) {
222
259
  // Another command exists — create wrapper
223
260
  const origCmd = settings.statusLine.command;
224
261
 
225
- // Backup original command
226
- fs.writeFileSync(ORIG_CMD_FILE, origCmd, 'utf8');
262
+ // Backup original command (read-only: 0400 to prevent tampering)
263
+ fs.writeFileSync(ORIG_CMD_FILE, origCmd, { encoding: 'utf8', mode: 0o400 });
227
264
  info(`Backed up original statusLine command to .original-statusline-cmd`);
228
265
 
229
266
  // Write wrapper
230
- fs.writeFileSync(DEST_WRAPPER, WRAPPER_SCRIPT, 'utf8');
267
+ fs.writeFileSync(DEST_WRAPPER, WRAPPER_SCRIPT, { encoding: 'utf8', mode: 0o600 });
231
268
  fs.chmodSync(DEST_WRAPPER, 0o755);
232
269
  info('Created wrapper-statusline.sh');
233
270
 
@@ -282,7 +319,7 @@ function install(color) {
282
319
  // ── Step 7: Configure color ──────────────────────────────────────────
283
320
 
284
321
  if (color) {
285
- fs.writeFileSync(COLOR_CONFIG, color, 'utf8');
322
+ fs.writeFileSync(COLOR_CONFIG, color, { encoding: 'utf8', mode: 0o600 });
286
323
  ok(`Topic color set to: ${BOLD}${color}${RESET}`);
287
324
  }
288
325
 
@@ -374,8 +411,9 @@ function uninstall() {
374
411
  Array.isArray(settings.permissions.allow)
375
412
  ) {
376
413
  const before = settings.permissions.allow.length;
414
+ const OLD_PERMISSION_RULE = 'Bash(*session-topics*)';
377
415
  settings.permissions.allow = settings.permissions.allow.filter(
378
- (rule) => rule !== PERMISSION_RULE
416
+ (rule) => rule !== PERMISSION_RULE && rule !== OLD_PERMISSION_RULE
379
417
  );
380
418
  if (settings.permissions.allow.length < before) {
381
419
  writeSettings(settings);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexismunozdev/claude-session-topics",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Session topics for Claude Code — auto-set and display a topic in the statusline, change anytime with /set-topic",
5
5
  "bin": {
6
6
  "claude-session-topics": "bin/install.js"
@@ -0,0 +1,238 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # ── Stop hook: automatically set session topic from first user message
5
+ # Receives Stop event JSON on stdin: {"session_id": "...", "transcript_path": "..."}
6
+ # Fast-path: exits immediately if topic already exists for this session.
7
+
8
+ input=$(cat)
9
+
10
+ # ── Parse JSON fields
11
+ SESSION_ID=$(echo "$input" | python3 -c "
12
+ import sys, json
13
+ try:
14
+ d = json.load(sys.stdin)
15
+ print(d.get('session_id', ''))
16
+ except:
17
+ print('')
18
+ " 2>/dev/null || echo "")
19
+
20
+ TRANSCRIPT_PATH=$(echo "$input" | python3 -c "
21
+ import sys, json
22
+ try:
23
+ d = json.load(sys.stdin)
24
+ print(d.get('transcript_path', ''))
25
+ except:
26
+ print('')
27
+ " 2>/dev/null || echo "")
28
+
29
+ # ── Sanitize session ID (only allow alphanumeric, hyphens, underscores)
30
+ SESSION_ID=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
31
+ if [ -z "$SESSION_ID" ]; then
32
+ exit 0
33
+ fi
34
+
35
+ # ── Ensure topics directory exists and write active session
36
+ mkdir -p "$HOME/.claude/session-topics"
37
+ echo "$SESSION_ID" > "$HOME/.claude/session-topics/.active-session"
38
+
39
+ # ── Fast path: topic already exists — nothing to do
40
+ TOPIC_FILE="$HOME/.claude/session-topics/${SESSION_ID}"
41
+ if [ -f "$TOPIC_FILE" ] && [ -s "$TOPIC_FILE" ]; then
42
+ exit 0
43
+ fi
44
+
45
+ # ── No topic yet — extract one from the transcript
46
+ if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
47
+ exit 0
48
+ fi
49
+
50
+ # ── Use python3 for robust JSON + text processing
51
+ TOPIC=$(python3 -c "
52
+ import sys, json, re
53
+
54
+ def extract_user_text(transcript_path):
55
+ \"\"\"Read JSONL transcript and return the first user/human message text.\"\"\"
56
+ try:
57
+ with open(transcript_path, 'r', encoding='utf-8') as f:
58
+ for line in f:
59
+ line = line.strip()
60
+ if not line:
61
+ continue
62
+ try:
63
+ obj = json.loads(line)
64
+ except json.JSONDecodeError:
65
+ continue
66
+
67
+ text = None
68
+
69
+ # Format 1: {\"role\": \"user\", \"content\": \"message text\"}
70
+ if obj.get('role') == 'user':
71
+ content = obj.get('content', '')
72
+ if isinstance(content, str):
73
+ text = content
74
+ elif isinstance(content, list):
75
+ # Format 2: {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"...\"}]}
76
+ for item in content:
77
+ if isinstance(item, dict) and item.get('type') == 'text':
78
+ text = item.get('text', '')
79
+ break
80
+
81
+ # Format 3: {\"type\": \"human\", \"message\": {\"role\": \"user\", \"content\": \"text\"}}
82
+ elif obj.get('type') == 'human':
83
+ msg = obj.get('message', {})
84
+ if isinstance(msg, dict):
85
+ content = msg.get('content', '')
86
+ if isinstance(content, str):
87
+ text = content
88
+ elif isinstance(content, list):
89
+ for item in content:
90
+ if isinstance(item, dict) and item.get('type') == 'text':
91
+ text = item.get('text', '')
92
+ break
93
+
94
+ if text and text.strip():
95
+ return text.strip()
96
+ except Exception:
97
+ pass
98
+ return ''
99
+
100
+ def extract_topic(text):
101
+ \"\"\"Extract a concise 2-4 word topic from user text.\"\"\"
102
+ if not text:
103
+ return ''
104
+
105
+ # Remove markdown formatting
106
+ text = re.sub(r'\`\`\`[\s\S]*?\`\`\`', '', text) # code blocks
107
+ text = re.sub(r'\`[^\`]+\`', '', text) # inline code
108
+ text = re.sub(r'[#*_~>\[\]()!]', '', text) # markdown chars
109
+ text = re.sub(r'https?://\S+', '', text) # URLs
110
+ text = re.sub(r'\s+', ' ', text).strip()
111
+
112
+ # Take only the first sentence/line for topic extraction
113
+ first_line = text.split('\n')[0].strip()
114
+ first_sentence = re.split(r'[.!?]', first_line)[0].strip()
115
+ if first_sentence:
116
+ text = first_sentence
117
+
118
+ # Strip common prefixes (English and Spanish)
119
+ prefixes = [
120
+ # Greetings first (often followed by other prefixes)
121
+ r'^hey\b',
122
+ r'^hi\b',
123
+ r'^hello\b',
124
+ r'^hola\b',
125
+ # Polite qualifiers
126
+ r'^please\b',
127
+ r'^por favor\b',
128
+ # English request patterns (longer patterns first)
129
+ r'^i would like to\b',
130
+ r'^i\'d like to\b',
131
+ r'^i need to\b',
132
+ r'^i want to\b',
133
+ r'^i need\b',
134
+ r'^i want\b',
135
+ r'^can you\b',
136
+ r'^could you\b',
137
+ r'^would you\b',
138
+ r'^help me\b',
139
+ r'^we need to\b',
140
+ r'^we should\b',
141
+ r'^let\'s\b',
142
+ # Spanish request patterns
143
+ r'^me gustaria\b',
144
+ r'^ayudame a\b',
145
+ r'^ayudame\b',
146
+ r'^necesito\b',
147
+ r'^quiero\b',
148
+ r'^puedes\b',
149
+ r'^podrias\b',
150
+ r'^podr.as\b',
151
+ r'^vamos a\b',
152
+ ]
153
+ # Multi-pass: keep stripping until no more prefixes match
154
+ changed = True
155
+ while changed:
156
+ changed = False
157
+ for prefix in prefixes:
158
+ old = text
159
+ text = re.sub(prefix, '', text, flags=re.IGNORECASE).strip()
160
+ text = re.sub(r'^[,;:\s]+', '', text).strip()
161
+ if text != old:
162
+ changed = True
163
+
164
+ # Stop words (English + Spanish)
165
+ stop_words = {
166
+ # English
167
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
168
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
169
+ 'should', 'may', 'might', 'shall', 'can', 'to', 'of', 'in', 'for',
170
+ 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
171
+ 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
172
+ 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
173
+ 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more',
174
+ 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own',
175
+ 'same', 'so', 'than', 'too', 'very', 'just', 'about', 'up', 'it',
176
+ 'its', 'this', 'that', 'these', 'those', 'my', 'your', 'his', 'her',
177
+ 'our', 'their', 'me', 'him', 'us', 'them', 'what', 'which', 'who',
178
+ 'whom', 'and', 'but', 'or', 'if', 'while', 'because', 'until',
179
+ 'also', 'still', 'get', 'got', 'make', 'made',
180
+ # Spanish
181
+ 'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'de', 'del',
182
+ 'en', 'con', 'por', 'para', 'al', 'es', 'son', 'ser', 'estar',
183
+ 'este', 'esta', 'estos', 'estas', 'ese', 'esa', 'esos', 'esas',
184
+ 'que', 'como', 'mas', 'pero', 'si', 'ya', 'se', 'le', 'lo',
185
+ 'nos', 'les', 'su', 'sus', 'mi', 'mis', 'tu', 'tus',
186
+ }
187
+
188
+ words = text.split()
189
+ # Filter stop words but keep words that look important (capitalized, technical)
190
+ meaningful = []
191
+ for w in words:
192
+ clean = re.sub(r'[^a-zA-Z0-9\u00C0-\u024F-]', '', w)
193
+ if not clean:
194
+ continue
195
+ if clean.lower() in stop_words and not clean[0].isupper():
196
+ continue
197
+ meaningful.append(clean)
198
+
199
+ if not meaningful:
200
+ # Fallback: just take first few words if all were stop words
201
+ meaningful = [re.sub(r'[^a-zA-Z0-9\u00C0-\u024F-]', '', w) for w in words[:4]]
202
+ meaningful = [w for w in meaningful if w]
203
+
204
+ # Take 2-4 words
205
+ topic_words = meaningful[:4]
206
+ if not topic_words:
207
+ return ''
208
+
209
+ # Title-case each word
210
+ topic = ' '.join(w.capitalize() if not w[0].isupper() else w for w in topic_words)
211
+
212
+ # Truncate to max 40 chars
213
+ if len(topic) > 40:
214
+ # Try to fit within 40 chars by reducing words
215
+ while len(topic) > 40 and len(topic_words) > 2:
216
+ topic_words.pop()
217
+ topic = ' '.join(w.capitalize() if not w[0].isupper() else w for w in topic_words)
218
+ if len(topic) > 40:
219
+ topic = topic[:40].rstrip()
220
+
221
+ return topic
222
+
223
+ transcript_path = sys.argv[1]
224
+ text = extract_user_text(transcript_path)
225
+ topic = extract_topic(text)
226
+ print(topic)
227
+ " "$TRANSCRIPT_PATH" 2>/dev/null || echo "")
228
+
229
+ # ── Write topic if we got one
230
+ if [ -n "$TOPIC" ]; then
231
+ # Sanitize topic: remove shell metacharacters and non-printable chars, keep letters (incl. accented), digits, spaces, basic punctuation
232
+ TOPIC=$(printf '%s' "$TOPIC" | sed 's/[^a-zA-Z0-9àáâãäåèéêëìíîïòóôõöùúûüýÿñçÀÁÂÃÄÅÈÉÊËÌÍÎÏÒÓÔÕÖÙÚÛÜÝÑÇ .,:!?'"'"'-]//g' | cut -c1-40)
233
+ if [ -n "$TOPIC" ]; then
234
+ printf '%s\n' "$TOPIC" > "$TOPIC_FILE"
235
+ fi
236
+ fi
237
+
238
+ exit 0
@@ -5,6 +5,11 @@ input=$(cat)
5
5
 
6
6
  # ── Parse JSON
7
7
  SESSION_ID=$(echo "$input" | jq -r '.session_id // ""')
8
+ SESSION_ID=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
9
+ if [ -z "$SESSION_ID" ]; then
10
+ # No valid session ID — skip session-dependent logic, just run original statusline
11
+ exit 0
12
+ fi
8
13
 
9
14
  # ── Write active session file
10
15
  if [ -n "$SESSION_ID" ]; then
@@ -63,7 +68,7 @@ C_RESET='\033[0m'
63
68
  CLEANUP_LOCK="/tmp/.claude-topic-cleanup-lock"
64
69
  if mkdir "$CLEANUP_LOCK" 2>/dev/null; then
65
70
  trap "rmdir '$CLEANUP_LOCK' 2>/dev/null || true" EXIT
66
- find "$HOME/.claude/session-topics" -type f -mtime +7 -delete 2>/dev/null || true
71
+ find "$HOME/.claude/session-topics" -type f -mtime +7 -not -name '.*' -delete 2>/dev/null || true
67
72
  rmdir "$CLEANUP_LOCK" 2>/dev/null
68
73
  fi
69
74
 
@@ -1,18 +1,23 @@
1
1
  ---
2
2
  name: auto-topic
3
- description: Use on EVERY user message to automatically set or update a short session topic (2-4 words) in the statusline. Activates continuously throughout the conversation to keep the topic current as the discussion evolves.
4
- version: 1.2.0
3
+ description: Use on EVERY user message to refine or update the session topic (2-4 words) when the conversation shifts to a different subject. The initial topic is set automatically by the Stop hook this skill handles ongoing topic evolution.
4
+ version: 2.0.0
5
5
  allowed-tools: [Bash]
6
6
  ---
7
7
 
8
8
  # Auto Topic
9
9
 
10
- Automatically set and update a short, descriptive topic for the current Claude Code session as the conversation evolves.
10
+ Refine and update the session topic as the conversation evolves. The initial topic is set automatically by a Stop hook after the first response this skill's role is to **update the topic when the conversation shifts** to a different subject.
11
11
 
12
12
  ## When to Activate
13
13
 
14
14
  This skill activates on **every user message**. Run it silently — do not mention to the user that you are setting or updating the topic.
15
15
 
16
+ ## How It Works
17
+
18
+ 1. **Initial topic**: Handled automatically by the Stop hook (`auto-topic-hook.sh`). It extracts a 2-4 word topic from the first user message using heuristics. No action needed from this skill for the first message.
19
+ 2. **Ongoing updates**: This skill monitors the conversation and updates the topic when the user shifts to a genuinely different area of work.
20
+
16
21
  ## Rules
17
22
 
18
23
  1. After reading each user message, infer a topic of **2-4 words** (max 20 characters) that reflects what the user is currently working on
@@ -28,11 +33,14 @@ This skill activates on **every user message**. Run it silently — do not menti
28
33
 
29
34
  ```bash
30
35
  SESSION_ID=$(cat "$HOME/.claude/session-topics/.active-session" 2>/dev/null)
31
- if [ -n "$SESSION_ID" ]; then
32
- TOPIC_FILE="$HOME/.claude/session-topics/${SESSION_ID}"
33
- CURRENT_TOPIC=$(cat "$TOPIC_FILE" 2>/dev/null || echo "")
34
- echo "Current topic: '$CURRENT_TOPIC'"
36
+ SESSION_ID=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
37
+ if [ -z "$SESSION_ID" ]; then
38
+ echo "No active session found. Skipping."
39
+ exit 0
35
40
  fi
41
+ TOPIC_FILE="$HOME/.claude/session-topics/${SESSION_ID}"
42
+ CURRENT_TOPIC=$(cat "$TOPIC_FILE" 2>/dev/null || echo "")
43
+ echo "Current topic: '$CURRENT_TOPIC'"
36
44
  ```
37
45
 
38
46
  ### Step 2: Decide whether to update
@@ -41,11 +49,12 @@ Compare the inferred new topic with the current topic. **Only write a new topic
41
49
  - Minor variations of the same topic (e.g., "Auth Refactor" → "Refactor Auth")
42
50
  - Subtasks within the same broader topic (e.g., "Auth Refactor" → "Auth Tests" if still working on auth)
43
51
  - Rewording without a real subject change
52
+ - The first message (the Stop hook already handles initial topic setting)
44
53
 
45
54
  DO update when:
46
55
  - The user has moved to a genuinely different area of work (e.g., "Auth Refactor" → "DB Migration")
47
56
  - The original topic no longer describes what the conversation is about
48
- - There is no current topic yet (first message)
57
+ - The hook-generated topic is too generic and you can infer a better one from context
49
58
 
50
59
  ### Step 3: Write the new topic (only if changed)
51
60
 
@@ -53,18 +62,21 @@ If the topic has meaningfully changed, run:
53
62
 
54
63
  ```bash
55
64
  SESSION_ID=$(cat "$HOME/.claude/session-topics/.active-session" 2>/dev/null)
56
- if [ -n "$SESSION_ID" ]; then
57
- mkdir -p "$HOME/.claude/session-topics"
58
- echo "Your New Topic" > "$HOME/.claude/session-topics/${SESSION_ID}"
65
+ SESSION_ID=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
66
+ if [ -z "$SESSION_ID" ]; then
67
+ exit 0
59
68
  fi
69
+ mkdir -p "$HOME/.claude/session-topics"
70
+ printf '%s\n' "Your New Topic" > "$HOME/.claude/session-topics/${SESSION_ID}"
60
71
  ```
61
72
 
62
- Replace `Your New Topic` with the inferred 2-4 word topic.
73
+ Replace `Your New Topic` with the inferred 2-4 word topic. The topic must contain only safe display text (letters, numbers, spaces, basic punctuation).
63
74
 
64
75
  If the topic has NOT meaningfully changed, **do nothing** — skip the write entirely.
65
76
 
66
77
  ## Important
67
78
 
79
+ - The Stop hook sets the initial topic automatically — this skill complements it by handling topic evolution
68
80
  - Run this on **every** user message, but only write when the topic has genuinely changed
69
81
  - Do NOT mention the topic to the user — ever
70
82
  - Keep topics short and descriptive (2-4 words, max 20 characters)
@@ -18,17 +18,24 @@ Set or change the topic displayed in the Claude Code statusline.
18
18
 
19
19
  1. The topic text is: $ARGUMENTS
20
20
  2. If the topic text is empty, inform the user they need to provide a topic (e.g., `/set-topic Auth Refactor`)
21
- 3. Run this bash command to discover the session ID and write the topic file:
21
+ 3. **Sanitize the arguments:** Before writing, the topic must be cleaned to contain only safe display text — letters, numbers, spaces, and basic punctuation (`.,-:!?'`). Strip any shell metacharacters or non-printable characters, and truncate to a maximum of 100 characters.
22
+ 4. Run this bash command to discover the session ID, sanitize inputs, and write the topic file:
22
23
 
23
24
  ```bash
24
25
  SESSION_ID=$(cat "$HOME/.claude/session-topics/.active-session" 2>/dev/null)
25
- if [ -n "$SESSION_ID" ]; then
26
- mkdir -p "$HOME/.claude/session-topics"
27
- echo "$ARGUMENTS" > "$HOME/.claude/session-topics/${SESSION_ID}"
28
- echo "Topic set to: $ARGUMENTS"
29
- else
26
+ SESSION_ID=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
27
+ if [ -z "$SESSION_ID" ]; then
30
28
  echo "Error: No active session found. The statusline must run at least once before setting a topic."
29
+ exit 1
31
30
  fi
31
+ TOPIC=$(printf '%s' "$ARGUMENTS" | tr -cd 'a-zA-Z0-9 .,:!?'"'"'-' | cut -c1-100)
32
+ if [ -z "$TOPIC" ]; then
33
+ echo "Error: Topic text is empty after sanitization."
34
+ exit 1
35
+ fi
36
+ mkdir -p "$HOME/.claude/session-topics"
37
+ printf '%s\n' "$TOPIC" > "$HOME/.claude/session-topics/${SESSION_ID}"
38
+ echo "Topic set to: $TOPIC"
32
39
  ```
33
40
 
34
- 4. Confirm to the user that the topic has been set and will appear in the statusline.
41
+ 5. Confirm to the user that the topic has been set and will appear in the statusline.