@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/README.md +296 -0
- package/package.json +41 -0
- package/scripts/tq +419 -0
- package/scripts/tq-install.sh +78 -0
- package/scripts/tq-message +318 -0
- package/scripts/tq-setup +147 -0
- package/scripts/tq-telegram-poll +129 -0
- package/scripts/tq-telegram-watchdog +23 -0
- package/skills/tq/SKILL.md +108 -0
- package/skills/tq/references/cron-expressions.md +67 -0
- package/skills/tq/references/session-naming.md +50 -0
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"
|