@codefilabs/tq 0.0.1

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/tq ADDED
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Ensure homebrew binaries are available (cron has minimal PATH)
5
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
6
+
7
+ STATUS_MODE=0
8
+ PROMPT_MODE=0
9
+ PROMPT_TEXT=""
10
+ TASK_NAME="adhoc"
11
+ TASK_CWD=""
12
+ NOTIFY=""
13
+
14
+ while [[ $# -gt 0 ]]; do
15
+ case "${1:-}" in
16
+ --status) STATUS_MODE=1; shift ;;
17
+ --prompt) PROMPT_TEXT="${2:-}"; PROMPT_MODE=1; shift 2 ;;
18
+ --name) TASK_NAME="${2:-}"; shift 2 ;;
19
+ --cwd) TASK_CWD="${2:-}"; shift 2 ;;
20
+ --notify) NOTIFY="${2:-}"; shift 2 ;;
21
+ --) shift; break ;;
22
+ -*) echo "Unknown flag: $1" >&2; exit 1 ;;
23
+ *) break ;;
24
+ esac
25
+ done
26
+
27
+ QUEUE_FILE="${1:-}"
28
+
29
+ if [[ "$PROMPT_MODE" == "1" && -n "$QUEUE_FILE" ]]; then
30
+ echo "Error: --prompt and a queue file are mutually exclusive" >&2
31
+ exit 1
32
+ fi
33
+
34
+ if [[ "$PROMPT_MODE" == "0" && -z "$QUEUE_FILE" ]]; then
35
+ echo "Usage: tq [--status] <queue-yaml-file>" >&2
36
+ echo " tq [--status] --prompt <text> [--name <name>] [--cwd <dir>]" >&2
37
+ exit 1
38
+ fi
39
+
40
+ if [[ "$PROMPT_MODE" == "1" ]]; then
41
+ STATE_DIR="${HOME}/.tq/adhoc/${TASK_NAME}"
42
+ else
43
+ QUEUE_FILE="$(realpath "$QUEUE_FILE")"
44
+ if [[ ! -f "$QUEUE_FILE" ]]; then
45
+ echo "Error: queue file not found: $QUEUE_FILE" >&2
46
+ exit 1
47
+ fi
48
+ QUEUE_DIR="$(dirname "$QUEUE_FILE")"
49
+ QUEUE_BASENAME="$(basename "$QUEUE_FILE" .yaml)"
50
+ STATE_DIR="$QUEUE_DIR/.tq/$QUEUE_BASENAME"
51
+ fi
52
+
53
+ mkdir -p "$STATE_DIR"
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # --status mode: print status table and reap dead sessions
57
+ # ---------------------------------------------------------------------------
58
+ if [[ "$STATUS_MODE" == "1" ]]; then
59
+ if [[ ! -d "$STATE_DIR" ]]; then
60
+ if [[ "$PROMPT_MODE" == "1" ]]; then
61
+ echo "No state found for name: $TASK_NAME (run tq --prompt first)"
62
+ else
63
+ echo "No state found for $QUEUE_FILE (run tq first)"
64
+ fi
65
+ exit 0
66
+ fi
67
+
68
+ printf "%-10s %-25s %-22s %s\n" "STATUS" "SESSION" "STARTED" "PROMPT"
69
+ printf "%-10s %-25s %-22s %s\n" "----------" "-------------------------" "----------------------" "------"
70
+
71
+ TOTAL_TASKS=0
72
+ DONE_TASKS=0
73
+
74
+ for STATE_FILE in "$STATE_DIR"/*; do
75
+ [[ -f "$STATE_FILE" ]] || continue
76
+ [[ "$STATE_FILE" == *.prompt ]] && continue
77
+ [[ "$STATE_FILE" == *.launch.py ]] && continue
78
+ [[ "$(basename "$STATE_FILE")" == .queue-notified ]] && continue
79
+
80
+ STATUS="$(grep '^status=' "$STATE_FILE" | cut -d= -f2)"
81
+ SESSION="$(grep '^session=' "$STATE_FILE" | cut -d= -f2)"
82
+ STARTED="$(grep '^started=' "$STATE_FILE" | cut -d= -f2)"
83
+
84
+ HASH="$(basename "$STATE_FILE")"
85
+ PROMPT_FILE="$STATE_DIR/$HASH.prompt"
86
+ if [[ -f "$PROMPT_FILE" ]]; then
87
+ PROMPT="$(head -1 "$PROMPT_FILE")"
88
+ else
89
+ PROMPT="$(grep '^prompt=' "$STATE_FILE" | cut -d= -f2-)"
90
+ fi
91
+
92
+ if [[ "$STATUS" == "running" ]]; then
93
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
94
+ sed -i '' 's/^status=running/status=done/' "$STATE_FILE"
95
+ STATUS="done"
96
+ fi
97
+ fi
98
+
99
+ PROMPT_SHORT="${PROMPT:0:60}"
100
+ [[ ${#PROMPT} -gt 60 ]] && PROMPT_SHORT="${PROMPT_SHORT}..."
101
+
102
+ printf "%-10s %-25s %-22s %s\n" "$STATUS" "$SESSION" "$STARTED" "$PROMPT_SHORT"
103
+
104
+ TOTAL_TASKS=$(( TOTAL_TASKS + 1 ))
105
+ if [[ "$STATUS" == "done" ]]; then
106
+ DONE_TASKS=$(( DONE_TASKS + 1 ))
107
+ fi
108
+ done
109
+
110
+ # Queue completion notification
111
+ SENTINEL="$STATE_DIR/.queue-notified"
112
+ if [[ "$TOTAL_TASKS" -gt 0 && "$TOTAL_TASKS" -eq "$DONE_TASKS" && ! -f "$SENTINEL" ]]; then
113
+ touch "$SENTINEL"
114
+ if command -v tq-message &>/dev/null; then
115
+ tq-message --queue "$QUEUE_FILE"
116
+ fi
117
+ fi
118
+
119
+ exit 0
120
+ fi
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Step 1: Write Python parser to a temp file and run it.
124
+ # Outputs one JSON line per task: {"hash":"...", "first_line":"..."}
125
+ # (Using a temp file avoids bash 3.2 bug: single quotes inside <<'HEREDOC'
126
+ # inside $() are incorrectly scanned by the quote counter.)
127
+ # ---------------------------------------------------------------------------
128
+ PARSE_SCRIPT=$(mktemp /tmp/tq-parse-XXXXXX.py)
129
+ trap 'rm -f "$PARSE_SCRIPT"' EXIT
130
+
131
+ cat > "$PARSE_SCRIPT" <<'PYEOF'
132
+ import sys, os, hashlib, re, json, stat
133
+
134
+ sessions_dir = os.path.expanduser('~/.tq/sessions')
135
+ os.makedirs(sessions_dir, exist_ok=True)
136
+
137
+ # Support direct prompt mode: --prompt <text> <state_dir> [cwd]
138
+ if len(sys.argv) > 1 and sys.argv[1] == '--prompt':
139
+ tasks = [(sys.argv[2].strip(), sys.argv[4] if len(sys.argv) > 4 else '')]
140
+ state_dir = sys.argv[3]
141
+ else:
142
+ queue_file = sys.argv[1]
143
+ state_dir = sys.argv[2]
144
+
145
+ with open(queue_file) as f:
146
+ text = f.read()
147
+
148
+ lines = text.split('\n')
149
+
150
+ # Extract top-level cwd
151
+ cwd = ''
152
+ for line in lines:
153
+ m = re.match(r'^cwd:\s*(.+)$', line)
154
+ if m:
155
+ cwd = m.group(1).strip().strip('"\'')
156
+ break
157
+
158
+ # Parse tasks -- handle inline, block literal (|), block folded (>), quoted
159
+ tasks = []
160
+ i = 0
161
+ while i < len(lines):
162
+ m = re.match(r'^ - prompt:\s*(.*)', lines[i])
163
+ if not m:
164
+ i += 1
165
+ continue
166
+
167
+ inline = m.group(1).strip()
168
+
169
+ if inline in ('|', '>'):
170
+ # Block scalar: collect indented continuation lines
171
+ block_lines = []
172
+ j = i + 1
173
+ indent = None
174
+ while j < len(lines):
175
+ line = lines[j]
176
+ stripped = line.lstrip()
177
+ if not stripped:
178
+ if indent is not None:
179
+ block_lines.append('')
180
+ j += 1
181
+ continue
182
+ cur_indent = len(line) - len(stripped)
183
+ if indent is None:
184
+ indent = cur_indent
185
+ if cur_indent < indent:
186
+ break
187
+ block_lines.append(line[indent:])
188
+ j += 1
189
+ # Remove trailing blank lines
190
+ while block_lines and not block_lines[-1]:
191
+ block_lines.pop()
192
+ if inline == '>':
193
+ out = []
194
+ for bl in block_lines:
195
+ out.append('\n' if bl == '' else bl)
196
+ prompt = ' '.join(out).strip()
197
+ else:
198
+ prompt = '\n'.join(block_lines)
199
+ i = j
200
+ else:
201
+ # Inline prompt -- strip surrounding quotes if present
202
+ prompt = inline
203
+ if len(prompt) >= 2 and (
204
+ (prompt[0] == '"' and prompt[-1] == '"') or
205
+ (prompt[0] == "'" and prompt[-1] == "'")
206
+ ):
207
+ prompt = prompt[1:-1]
208
+ i += 1
209
+
210
+ prompt = prompt.strip()
211
+ if prompt:
212
+ tasks.append((prompt, cwd))
213
+
214
+ for (prompt, cwd) in tasks:
215
+ h = hashlib.sha256(prompt.encode()).hexdigest()[:8]
216
+ first_line = prompt.split('\n')[0][:80]
217
+
218
+ # Write prompt file
219
+ prompt_file = os.path.join(state_dir, h + '.prompt')
220
+ with open(prompt_file, 'w') as f:
221
+ f.write(prompt)
222
+
223
+ # --- Session directory: ~/.tq/sessions/<hash>/ ---
224
+ session_dir = os.path.join(sessions_dir, h)
225
+ hooks_dir = os.path.join(session_dir, 'hooks')
226
+ settings_file = os.path.join(session_dir, 'settings.json')
227
+ stop_hook = os.path.join(hooks_dir, 'on-stop.sh')
228
+ state_file = os.path.join(state_dir, h)
229
+ os.makedirs(hooks_dir, exist_ok=True)
230
+
231
+ # Write settings.json with Stop hook
232
+ settings = {
233
+ "hooks": {
234
+ "Stop": [
235
+ {
236
+ "hooks": [
237
+ {
238
+ "type": "command",
239
+ "command": stop_hook
240
+ }
241
+ ]
242
+ }
243
+ ]
244
+ }
245
+ }
246
+ with open(settings_file, 'w') as f:
247
+ json.dump(settings, f, indent=2)
248
+
249
+ # Write on-stop.sh -- marks task done, then runs optional notify action
250
+ notify = os.environ.get('TQ_NOTIFY', '')
251
+ stop_script = '#!/usr/bin/env bash\n'
252
+ stop_script += 'set -euo pipefail\n'
253
+ stop_script += '# Mark tq task done\n'
254
+ stop_script += 'STATE_FILE=' + json.dumps(state_file) + '\n'
255
+ stop_script += 'if [[ -f "$STATE_FILE" ]]; then\n'
256
+ stop_script += " sed -i '' 's/^status=running/status=done/' \"$STATE_FILE\"\n"
257
+ stop_script += 'fi\n'
258
+ if notify:
259
+ stop_script += '\n# Notification (--notify)\n'
260
+ stop_script += 'export TQ_PROMPT=' + json.dumps(first_line) + '\n'
261
+ stop_script += 'export TQ_HASH=' + json.dumps(h) + '\n'
262
+ stop_script += 'export TQ_STATE_FILE=' + json.dumps(state_file) + '\n'
263
+ if notify == 'macos':
264
+ stop_script += 'osascript <<\'APPLES\'\n'
265
+ stop_script += 'display notification ' + json.dumps(first_line) + ' with title "tq: task done"\n'
266
+ stop_script += 'APPLES\n'
267
+ elif notify == 'bell':
268
+ stop_script += "printf '\\a'\n"
269
+ elif '/' in notify or notify.endswith('.sh'):
270
+ # path/to/script -- called with TQ_PROMPT, TQ_HASH, TQ_STATE_FILE in env
271
+ stop_script += json.dumps(notify) + '\n'
272
+ else:
273
+ # inline shell snippet -- TQ_PROMPT, TQ_HASH, TQ_STATE_FILE available
274
+ stop_script += notify + '\n'
275
+ # Messaging via tq-message (always emitted; tq-message exits silently if unconfigured)
276
+ stop_script += '\n# tq-message notification\n'
277
+ stop_script += 'export TQ_HASH=' + json.dumps(h) + '\n'
278
+ if len(sys.argv) > 1 and sys.argv[1] != '--prompt':
279
+ stop_script += 'export TQ_QUEUE_FILE=' + json.dumps(queue_file) + '\n'
280
+ else:
281
+ stop_script += 'export TQ_QUEUE_FILE=""\n'
282
+ stop_script += 'if command -v tq-message &>/dev/null; then\n'
283
+ stop_script += ' SESSION="$(grep \'^session=\' "$STATE_FILE" | cut -d= -f2)"\n'
284
+ stop_script += ' tq-message --task "$TQ_HASH" --queue "$TQ_QUEUE_FILE" --state-file "$STATE_FILE" --session "$SESSION"\n'
285
+ stop_script += 'fi\n'
286
+ with open(stop_hook, 'w') as f:
287
+ f.write(stop_script)
288
+ os.chmod(stop_hook, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
289
+
290
+ # Capture Claude auth at tq-run time (keychain is accessible in this context).
291
+ # Priority: read live credentials from macOS keychain, fall back to env vars.
292
+ captured_env = {}
293
+ try:
294
+ import subprocess
295
+ result = subprocess.run(
296
+ ['security', 'find-generic-password', '-s', 'Claude Code-credentials', '-a', os.environ.get('USER', ''), '-w'],
297
+ capture_output=True, text=True
298
+ )
299
+ if result.returncode == 0 and result.stdout.strip():
300
+ creds = json.loads(result.stdout.strip())
301
+ oauth = creds.get('claudeAiOauth', {})
302
+ if oauth.get('accessToken'):
303
+ # Set CLAUDE_CODE_OAUTH_KEY to the current live token from keychain
304
+ captured_env['CLAUDE_CODE_OAUTH_KEY'] = oauth['accessToken']
305
+ except Exception:
306
+ pass
307
+ # Fall back to env vars if keychain read failed
308
+ for k in ['CLAUDE_CODE_OAUTH_KEY', 'ANTHROPIC_API_KEY']:
309
+ if k not in captured_env and k in os.environ:
310
+ captured_env[k] = os.environ[k]
311
+
312
+ # Write Python launcher -- execs claude with per-session settings, no shell quoting issues
313
+ launcher_file = os.path.join(state_dir, h + '.launch.py')
314
+ with open(launcher_file, 'w') as f:
315
+ f.write('#!/usr/bin/env python3\n')
316
+ f.write('import os\n')
317
+ f.write('cwd = ' + json.dumps(cwd) + '\n')
318
+ f.write('prompt_file = ' + json.dumps(prompt_file) + '\n')
319
+ f.write('settings_file = ' + json.dumps(settings_file) + '\n')
320
+ # Inject auth env vars captured at queue-run time
321
+ for k, v in captured_env.items():
322
+ f.write('os.environ[' + json.dumps(k) + '] = ' + json.dumps(v) + '\n')
323
+ f.write('if cwd:\n')
324
+ f.write(' os.chdir(cwd)\n')
325
+ f.write('import shutil, subprocess, time\n')
326
+ f.write('prompt = open(prompt_file).read()\n')
327
+ f.write('# Open Chrome with Profile 5 (halbotkirchner@gmail.com) before connecting\n')
328
+ f.write('subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--profile-directory=Profile 5"])\n')
329
+ f.write('time.sleep(2)\n')
330
+ f.write('# Use reattach-to-user-namespace if available (macOS tmux keychain fix)\n')
331
+ f.write('reattach = shutil.which("reattach-to-user-namespace")\n')
332
+ f.write('if reattach:\n')
333
+ f.write(" os.execvp(reattach, [reattach, 'claude', '--settings', settings_file, '--dangerously-skip-permissions', '--chrome', prompt])\n")
334
+ f.write('else:\n')
335
+ f.write(" os.execvp('claude', ['claude', '--settings', settings_file, '--dangerously-skip-permissions', '--chrome', prompt])\n")
336
+
337
+ print(json.dumps({'hash': h, 'first_line': first_line}))
338
+
339
+ PYEOF
340
+
341
+ export TQ_NOTIFY="$NOTIFY"
342
+
343
+ if [[ "$PROMPT_MODE" == "1" ]]; then
344
+ TASK_CWD="${TASK_CWD:-$HOME/.tq/workspace}"
345
+ mkdir -p "$TASK_CWD"
346
+ PARSE_OUTPUT=$(python3 "$PARSE_SCRIPT" "--prompt" "$PROMPT_TEXT" "$STATE_DIR" "$TASK_CWD")
347
+ else
348
+ PARSE_OUTPUT=$(python3 "$PARSE_SCRIPT" "$QUEUE_FILE" "$STATE_DIR")
349
+ fi
350
+
351
+ if [[ -z "$PARSE_OUTPUT" ]]; then
352
+ if [[ "$PROMPT_MODE" == "1" ]]; then
353
+ echo "Error: empty prompt" >&2
354
+ else
355
+ echo "No tasks found in $QUEUE_FILE" >&2
356
+ fi
357
+ exit 0
358
+ fi
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Step 2: For each parsed task, check state and spawn if pending.
362
+ # ---------------------------------------------------------------------------
363
+ while IFS= read -r JSON_LINE; do
364
+ HASH="$(python3 -c "import sys,json; print(json.loads(sys.argv[1])['hash'])" "$JSON_LINE")"
365
+ FIRST_LINE="$(python3 -c "import sys,json; print(json.loads(sys.argv[1])['first_line'])" "$JSON_LINE")"
366
+ STATE_FILE="$STATE_DIR/$HASH"
367
+ LAUNCHER="$STATE_DIR/$HASH.launch.py"
368
+
369
+ # Check existing state
370
+ if [[ -f "$STATE_FILE" ]]; then
371
+ STATUS="$(grep '^status=' "$STATE_FILE" | cut -d= -f2)"
372
+ if [[ "$STATUS" == "done" ]]; then
373
+ echo " [done] $FIRST_LINE"
374
+ continue
375
+ fi
376
+ if [[ "$STATUS" == "running" ]]; then
377
+ SESSION="$(grep '^session=' "$STATE_FILE" | cut -d= -f2)"
378
+ if tmux has-session -t "$SESSION" 2>/dev/null; then
379
+ echo " [running] $FIRST_LINE"
380
+ continue
381
+ else
382
+ # Session died -- mark done
383
+ sed -i '' 's/^status=running/status=done/' "$STATE_FILE"
384
+ echo " [done] $FIRST_LINE (session ended)"
385
+ continue
386
+ fi
387
+ fi
388
+ fi
389
+
390
+ # Generate session/window names from first line of prompt
391
+ EPOCH_SUFFIX="$(date +%s | tail -c 6)"
392
+ SESSION_BASE="$(echo "$FIRST_LINE" | awk '{print $1" "$2" "$3}' | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-20)"
393
+ SESSION="tq-${SESSION_BASE}-${EPOCH_SUFFIX}"
394
+ WINDOW="$(echo "$FIRST_LINE" | awk '{print $1" "$2}' | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-15)"
395
+
396
+ # Write state file
397
+ cat > "$STATE_FILE" <<EOF
398
+ status=running
399
+ session=$SESSION
400
+ window=$WINDOW
401
+ prompt=$FIRST_LINE
402
+ started=$(date -u +%Y-%m-%dT%H:%M:%S)
403
+ EOF
404
+
405
+ # Reset queue-notified sentinel so completion fires again after re-run
406
+ rm -f "$STATE_DIR/.queue-notified"
407
+
408
+ # Spawn tmux session.
409
+ # IMPORTANT: start the window WITHOUT a command so tmux uses the user's default
410
+ # shell (zsh). Zsh sources .zshrc and has CLAUDE_CODE_OAUTH_KEY + keychain access.
411
+ # Then send the launch command via send-keys so it runs inside that authenticated shell.
412
+ tmux start-server
413
+ tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION"
414
+ WIN_IDX=$(tmux new-window -P -F '#{window_index}' -t "$SESSION" -n "$WINDOW")
415
+ tmux send-keys -t "$SESSION:$WIN_IDX" "python3 '$LAUNCHER'" Enter
416
+ (sleep 10 && tmux send-keys -t "$SESSION:$WIN_IDX" "" Enter) &
417
+
418
+ echo " [spawned] $SESSION -- $FIRST_LINE"
419
+ done <<< "$PARSE_OUTPUT"
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # Step 1: Register marketplace and install Claude plugin
6
+ # ---------------------------------------------------------------------------
7
+ if command -v claude &>/dev/null; then
8
+ echo "Adding tq marketplace..."
9
+ claude plugin marketplace add kevnk/tq
10
+ echo "Installing tq plugin..."
11
+ claude plugin install tq@tq
12
+ else
13
+ echo "Warning: 'claude' CLI not found — skipping plugin registration." >&2
14
+ echo " Install Claude Code first: https://claude.ai/code" >&2
15
+ fi
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Step 2: Resolve plugin root for symlinking CLI tools
19
+ # ---------------------------------------------------------------------------
20
+ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
21
+ PLUGIN_ROOT="$CLAUDE_PLUGIN_ROOT"
22
+ elif [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
23
+ # Running directly from a cloned repo
24
+ PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ else
26
+ # Running via curl | bash — find the installed plugin cache
27
+ CACHE_DIR="$HOME/.claude/plugins/cache/tq/tq"
28
+ PLUGIN_ROOT="$(ls -d "$CACHE_DIR"/*/ 2>/dev/null | sort -V | tail -1)"
29
+ if [[ -z "${PLUGIN_ROOT:-}" ]]; then
30
+ echo "Error: could not locate tq plugin files in $CACHE_DIR" >&2
31
+ echo " Try running: claude plugin marketplace add kevnk/tq && claude plugin install tq@tq" >&2
32
+ exit 1
33
+ fi
34
+ fi
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Step 3: Symlink CLI tools into PATH
38
+ # ---------------------------------------------------------------------------
39
+ if [[ -n "${TQ_INSTALL_DIR:-}" ]]; then
40
+ INSTALL_DIR="$TQ_INSTALL_DIR"
41
+ elif [[ -d "/opt/homebrew/bin" ]]; then
42
+ INSTALL_DIR="/opt/homebrew/bin"
43
+ elif [[ -d "/usr/local/bin" ]]; then
44
+ INSTALL_DIR="/usr/local/bin"
45
+ else
46
+ echo "Error: no suitable install directory found (tried /opt/homebrew/bin, /usr/local/bin)" >&2
47
+ echo "Set TQ_INSTALL_DIR to override" >&2
48
+ exit 1
49
+ fi
50
+
51
+ for SCRIPT in tq tq-message tq-setup tq-telegram-poll tq-telegram-watchdog; do
52
+ SRC="$PLUGIN_ROOT/scripts/$SCRIPT"
53
+ DEST="$INSTALL_DIR/$SCRIPT"
54
+ if [[ -L "$DEST" ]]; then
55
+ rm "$DEST"
56
+ elif [[ -f "$DEST" ]]; then
57
+ echo "Warning: $DEST exists and is not a symlink — skipping (remove manually to update)" >&2
58
+ continue
59
+ fi
60
+ ln -s "$SRC" "$DEST"
61
+ echo " linked $DEST -> $SRC"
62
+ done
63
+
64
+ mkdir -p ~/.tq/queues ~/.tq/logs ~/.tq/config
65
+
66
+ echo ""
67
+ echo "tq installed. Crontab example (crontab -e):"
68
+ echo ""
69
+ echo " 0 9 * * * /opt/homebrew/bin/tq ~/.tq/queues/morning.yaml >> ~/.tq/logs/tq.log 2>&1"
70
+ echo " */30 * * * * /opt/homebrew/bin/tq --status ~/.tq/queues/morning.yaml >> ~/.tq/logs/tq.log 2>&1"
71
+ echo ""
72
+ echo "To configure Telegram notifications:"
73
+ echo " tq-setup"
74
+ echo ""
75
+ echo "Or from Claude Code: /setup-telegram"
76
+ echo ""
77
+ echo "To relay Telegram messages as tq tasks, add to crontab:"
78
+ echo " * * * * * /opt/homebrew/bin/tq-telegram-poll >> ~/.tq/logs/tq-telegram.log 2>&1"