@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 +47 -9
- package/package.json +1 -1
- package/scripts/auto-topic-hook.sh +238 -0
- package/scripts/statusline.sh +6 -1
- package/skills/auto-topic/SKILL.md +24 -12
- package/skills/set-topic/SKILL.md +14 -7
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/scripts/statusline.sh
CHANGED
|
@@ -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
|
|
4
|
-
version:
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
41
|
+
5. Confirm to the user that the topic has been set and will appear in the statusline.
|