tlc-claude-code 2.3.0 → 2.4.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.
@@ -1,30 +1,230 @@
1
1
  /**
2
2
  * TLC Auto-Update Setup
3
- * Creates scheduled jobs to keep Claude Code and Codex CLI updated daily.
3
+ *
4
+ * Generates a self-contained bash script that keeps Claude, Codex, and Gemini
5
+ * CLIs updated daily. The script detects each CLI's install method (Homebrew,
6
+ * npm global, standalone binary) at runtime and uses the correct update command.
7
+ * macOS: scheduled via launchd (runs missed jobs after wake).
8
+ * Linux: scheduled via cron.
9
+ * No Node.js dependency at runtime — pure bash.
4
10
  */
5
11
 
6
12
  import os from 'os';
7
13
  import path from 'path';
8
14
 
9
- const LAUNCH_AGENT_LABEL = 'com.tlc.autoupdate';
10
- const LAUNCH_AGENT_PLIST = `${os.homedir()}/Library/LaunchAgents/${LAUNCH_AGENT_LABEL}.plist`;
11
- const TLC_DIR = `${os.homedir()}/.tlc`;
12
- const SCRIPT_PATH = `${TLC_DIR}/autoupdate.sh`;
13
- const LOG_PATH = `${TLC_DIR}/logs/autoupdate.log`;
15
+ const TLC_DIR = path.join(os.homedir(), '.tlc');
16
+ const SCRIPT_PATH = path.join(TLC_DIR, 'autoupdate.sh');
17
+ const CRON_MARKER = '# tlc-autoupdate';
14
18
  const TIMESTAMP_FILE = '.last-update';
19
+ const LAUNCHD_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.tlc.autoupdate.plist');
20
+ const UPDATE_AVAILABLE_FILE = '.update-available';
21
+
22
+ /** @type {string} npm package name for TLC */
23
+ export const TLC_PACKAGE = 'tlc-claude-code';
24
+
25
+ /**
26
+ * Package names per install method for each supported CLI.
27
+ * @type {Record<string, { npm: string, brew: string, selfUpdate: string|null }>}
28
+ */
29
+ export const CLI_PACKAGES = {
30
+ claude: {
31
+ npm: '@anthropic-ai/claude-code',
32
+ brew: 'claude-code',
33
+ selfUpdate: 'claude update',
34
+ },
35
+ codex: {
36
+ npm: '@openai/codex',
37
+ brew: 'codex',
38
+ selfUpdate: null,
39
+ },
40
+ gemini: {
41
+ npm: '@google/gemini-cli',
42
+ brew: 'gemini-cli',
43
+ selfUpdate: null,
44
+ },
45
+ };
46
+
47
+ /**
48
+ * Generates a self-contained bash script that detects install methods at
49
+ * runtime and updates all three CLIs. Designed to run from cron with a
50
+ * minimal environment.
51
+ * @returns {string} Complete bash script content.
52
+ */
53
+ export function generateUpdateScript() {
54
+ return `#!/usr/bin/env bash
55
+ set -euo pipefail
56
+
57
+ # ── PATH setup (cron has minimal PATH) ──────────────────────────────
58
+ # Homebrew paths
59
+ if [ -d "/opt/homebrew/bin" ]; then
60
+ export PATH="/opt/homebrew/bin:\$PATH"
61
+ fi
62
+ if [ -d "/usr/local/bin" ]; then
63
+ export PATH="/usr/local/bin:\$PATH"
64
+ fi
65
+ # Version managers (nvm, volta, asdf)
66
+ [ -s "\$HOME/.nvm/nvm.sh" ] && . "\$HOME/.nvm/nvm.sh" 2>/dev/null
67
+ [ -d "\$HOME/.volta/bin" ] && export PATH="\$HOME/.volta/bin:\$PATH"
68
+ [ -d "\$HOME/.asdf/shims" ] && export PATH="\$HOME/.asdf/shims:\$PATH"
69
+ # npm global bin (fallback for non-managed installs)
70
+ NPM_BIN="\$(npm config get prefix 2>/dev/null)/bin" || true
71
+ if [ -d "\$NPM_BIN" ]; then
72
+ export PATH="\$NPM_BIN:\$PATH"
73
+ fi
74
+
75
+ # ── Directories ─────────────────────────────────────────────────────
76
+ TLC_DIR="\$HOME/.tlc"
77
+ LOG_DIR="\$TLC_DIR/logs"
78
+ LOG_FILE="\$LOG_DIR/autoupdate.log"
79
+ TIMESTAMP_FILE="\$TLC_DIR/.last-update"
80
+
81
+ mkdir -p "\$LOG_DIR"
82
+
83
+ log() {
84
+ echo "\$(date -u +%Y-%m-%dT%H:%M:%SZ) \$1" >> "\$LOG_FILE"
85
+ }
86
+
87
+ # ── Install method detection ────────────────────────────────────────
88
+ # Returns: "brew", "npm", "standalone", or "" (not installed)
89
+ detect_install_method() {
90
+ local cli_name="\$1"
91
+ local brew_pkg="\$2"
92
+ local npm_pkg="\$3"
93
+
94
+ if ! command -v "\$cli_name" &>/dev/null; then
95
+ echo ""
96
+ return
97
+ fi
98
+
99
+ # Check Homebrew first
100
+ if command -v brew &>/dev/null && brew list --formula 2>/dev/null | grep -q "^\${brew_pkg}\$"; then
101
+ echo "brew"
102
+ return
103
+ fi
104
+
105
+ # Check npm global
106
+ if command -v npm &>/dev/null && npm list -g "\$npm_pkg" --depth=0 2>/dev/null | grep -q "\$npm_pkg"; then
107
+ echo "npm"
108
+ return
109
+ fi
110
+
111
+ echo "standalone"
112
+ }
113
+
114
+ # ── Update dispatcher ───────────────────────────────────────────────
115
+ update_cli() {
116
+ local cli_name="\$1"
117
+ local method="\$2"
118
+ local brew_pkg="\$3"
119
+ local npm_pkg="\$4"
120
+ local self_update="\${5:-}"
121
+
122
+ case "\$method" in
123
+ brew)
124
+ log "Updating \$cli_name via brew upgrade \$brew_pkg"
125
+ brew upgrade "\$brew_pkg" >> "\$LOG_FILE" 2>&1 || log "\$cli_name brew upgrade failed"
126
+ ;;
127
+ npm)
128
+ log "Updating \$cli_name via npm update -g \$npm_pkg"
129
+ npm update -g "\$npm_pkg" >> "\$LOG_FILE" 2>&1 || log "\$cli_name npm update failed"
130
+ ;;
131
+ standalone)
132
+ if [ -n "\$self_update" ]; then
133
+ log "Updating \$cli_name via \$self_update"
134
+ \$self_update >> "\$LOG_FILE" 2>&1 || log "\$cli_name self-update failed"
135
+ else
136
+ log "Skipping \$cli_name — standalone install with no self-update command"
137
+ fi
138
+ ;;
139
+ *)
140
+ log "Skipping \$cli_name — not installed"
141
+ ;;
142
+ esac
143
+ }
144
+
145
+ # ── Main ────────────────────────────────────────────────────────────
146
+ log "Starting TLC auto-update"
147
+
148
+ # Claude Code
149
+ if command -v claude &>/dev/null; then
150
+ CLAUDE_METHOD=\$(detect_install_method "claude" "claude-code" "@anthropic-ai/claude-code")
151
+ update_cli "claude" "\$CLAUDE_METHOD" "claude-code" "@anthropic-ai/claude-code" "claude update"
152
+ fi
153
+
154
+ # Codex CLI
155
+ if command -v codex &>/dev/null; then
156
+ CODEX_METHOD=\$(detect_install_method "codex" "codex" "@openai/codex")
157
+ update_cli "codex" "\$CODEX_METHOD" "codex" "@openai/codex"
158
+ fi
159
+
160
+ # Gemini CLI
161
+ if command -v gemini &>/dev/null; then
162
+ GEMINI_METHOD=\$(detect_install_method "gemini" "gemini-cli" "@google/gemini-cli")
163
+ update_cli "gemini" "\$GEMINI_METHOD" "gemini-cli" "@google/gemini-cli"
164
+ fi
165
+
166
+ # TLC itself
167
+ if command -v npm &>/dev/null; then
168
+ log "Updating TLC (tlc-claude-code) via npm"
169
+ npm update -g tlc-claude-code >> "\$LOG_FILE" 2>&1 || log "TLC npm update failed"
170
+
171
+ # Re-read installed version AFTER update to get current state
172
+ INSTALLED=\$(npm list -g tlc-claude-code --depth=0 --json 2>/dev/null | grep -o '"version": *"[^"]*"' | head -1 | grep -o '[0-9][^"]*') || true
173
+ LATEST=\$(npm show tlc-claude-code version 2>/dev/null) || true
174
+ UPDATE_FILE="\$TLC_DIR/.update-available"
175
+ if [ -n "\$LATEST" ] && [ -n "\$INSTALLED" ] && [ "\$LATEST" != "\$INSTALLED" ]; then
176
+ # POSIX-compatible version compare (no sort -V which is GNU-only)
177
+ ver_gt() {
178
+ local IFS=.
179
+ local i a=(\$1) b=(\$2)
180
+ for ((i=0; i<\${#a[@]}; i++)); do
181
+ [ "\${a[i]:-0}" -gt "\${b[i]:-0}" ] && return 0
182
+ [ "\${a[i]:-0}" -lt "\${b[i]:-0}" ] && return 1
183
+ done
184
+ return 1
185
+ }
186
+ if ver_gt "\$LATEST" "\$INSTALLED"; then
187
+ printf 'current=%s\\nlatest=%s\\ncommand=npm update -g tlc-claude-code\\n' "\$INSTALLED" "\$LATEST" > "\$UPDATE_FILE"
188
+ log "TLC update available: \$INSTALLED → \$LATEST"
189
+ else
190
+ rm -f "\$UPDATE_FILE"
191
+ fi
192
+ else
193
+ rm -f "\$UPDATE_FILE"
194
+ fi
195
+ fi
196
+
197
+ # Write completion timestamp
198
+ date -u +%Y-%m-%dT%H:%M:%SZ > "\$TIMESTAMP_FILE"
199
+
200
+ log "TLC auto-update complete"
201
+ `;
202
+ }
203
+
204
+ /**
205
+ * Generates a crontab entry string for daily auto-update at 4am.
206
+ * Includes a marker comment so we can identify and remove it later.
207
+ * @param {string} scriptPath - Absolute path to the update shell script.
208
+ * @returns {string} A single crontab line with trailing marker.
209
+ */
210
+ export function generateCronEntry(scriptPath) {
211
+ return `0 4 * * * ${scriptPath} ${CRON_MARKER}`;
212
+ }
15
213
 
16
214
  /**
17
- * Generates a macOS LaunchAgent plist XML string for daily auto-update.
215
+ * Generates a macOS LaunchAgent plist for daily auto-update.
216
+ * launchd runs missed jobs after wake, unlike cron which skips them.
18
217
  * @param {string} scriptPath - Absolute path to the update shell script.
19
218
  * @returns {string} XML plist content.
20
219
  */
21
220
  export function generateLaunchdPlist(scriptPath) {
221
+ const logPath = path.join(TLC_DIR, 'logs', 'autoupdate.log');
22
222
  return `<?xml version="1.0" encoding="UTF-8"?>
23
223
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
24
224
  <plist version="1.0">
25
225
  <dict>
26
226
  <key>Label</key>
27
- <string>${LAUNCH_AGENT_LABEL}</string>
227
+ <string>com.tlc.autoupdate</string>
28
228
  <key>ProgramArguments</key>
29
229
  <array>
30
230
  <string>/bin/bash</string>
@@ -35,136 +235,111 @@ export function generateLaunchdPlist(scriptPath) {
35
235
  <key>RunAtLoad</key>
36
236
  <false/>
37
237
  <key>StandardOutPath</key>
38
- <string>${LOG_PATH}</string>
238
+ <string>${logPath}</string>
39
239
  <key>StandardErrorPath</key>
40
- <string>${LOG_PATH}</string>
240
+ <string>${logPath}</string>
41
241
  </dict>
42
242
  </plist>
43
243
  `;
44
244
  }
45
245
 
46
246
  /**
47
- * Generates a crontab entry string for daily auto-update at 4am.
48
- * @param {string} scriptPath - Absolute path to the update shell script.
49
- * @returns {string} A single crontab line.
50
- */
51
- export function generateCronEntry(scriptPath) {
52
- return `0 4 * * * ${scriptPath}`;
53
- }
54
-
55
- /**
56
- * Generates a bash update script that runs `claude update` and
57
- * `npm update -g @openai/codex`, logs output, and writes a timestamp.
58
- * @returns {string} Bash script content.
59
- */
60
- export function generateUpdateScript() {
61
- return `#!/usr/bin/env bash
62
- set -euo pipefail
63
-
64
- TLC_DIR="$HOME/.tlc"
65
- LOG_DIR="$TLC_DIR/logs"
66
- LOG_FILE="$LOG_DIR/autoupdate.log"
67
- TIMESTAMP_FILE="$TLC_DIR/.last-update"
68
-
69
- mkdir -p "$LOG_DIR"
70
-
71
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) Starting TLC auto-update" >> "$LOG_FILE"
72
-
73
- # Update Claude Code
74
- if command -v claude &>/dev/null; then
75
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) Running: claude update" >> "$LOG_FILE"
76
- claude update >> "$LOG_FILE" 2>&1 || echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) claude update failed" >> "$LOG_FILE"
77
- fi
78
-
79
- # Update Codex CLI
80
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) Running: npm update -g @openai/codex" >> "$LOG_FILE"
81
- npm update -g @openai/codex >> "$LOG_FILE" 2>&1 || echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) npm update failed" >> "$LOG_FILE"
82
-
83
- # Write timestamp
84
- date -u +%Y-%m-%dT%H:%M:%SZ > "$TIMESTAMP_FILE"
85
-
86
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) TLC auto-update complete" >> "$LOG_FILE"
87
- `;
88
- }
89
-
90
- /**
91
- * Enables auto-update scheduled job for the current platform.
247
+ * Writes the update script and installs the platform scheduler.
248
+ * macOS: launchd (runs missed jobs after wake)
249
+ * Linux: cron
92
250
  * @param {object} opts
93
251
  * @param {string} opts.platform - 'darwin' or 'linux'
94
252
  * @param {object} opts.fs - fs module (injected for testability)
95
- * @param {Function} [opts.execSync] - child_process.execSync (injected for testability)
96
- * @returns {{ type: string, scriptPath: string, jobPath?: string }}
253
+ * @param {Function} [opts.execSync] - child_process.execSync (required for Linux)
254
+ * @returns {{ type: 'launchd'|'cron', scriptPath: string, plistPath?: string }}
97
255
  */
98
256
  export function enable({ platform, fs, execSync }) {
99
- // Ensure TLC directories exist
100
257
  fs.mkdirSync(TLC_DIR, { recursive: true });
101
- fs.mkdirSync(`${TLC_DIR}/logs`, { recursive: true });
258
+ fs.mkdirSync(path.join(TLC_DIR, 'logs'), { recursive: true });
102
259
 
103
- // Write the update script
104
260
  const scriptContent = generateUpdateScript();
105
261
  fs.writeFileSync(SCRIPT_PATH, scriptContent, 'utf8');
106
262
  fs.chmodSync(SCRIPT_PATH, 0o755);
107
263
 
108
264
  if (platform === 'darwin') {
109
- const launchAgentDir = path.dirname(LAUNCH_AGENT_PLIST);
110
- fs.mkdirSync(launchAgentDir, { recursive: true });
265
+ const plistDir = path.dirname(LAUNCHD_PLIST);
266
+ fs.mkdirSync(plistDir, { recursive: true });
111
267
  const plist = generateLaunchdPlist(SCRIPT_PATH);
112
- fs.writeFileSync(LAUNCH_AGENT_PLIST, plist, 'utf8');
113
- return { type: 'launchd', scriptPath: SCRIPT_PATH, jobPath: LAUNCH_AGENT_PLIST };
268
+ fs.writeFileSync(LAUNCHD_PLIST, plist, 'utf8');
269
+ return { type: 'launchd', scriptPath: SCRIPT_PATH, plistPath: LAUNCHD_PLIST };
114
270
  }
115
271
 
116
- // Linux — use cron
117
- const entry = generateCronEntry(SCRIPT_PATH);
272
+ // Linux: cron
118
273
  let existing = '';
119
274
  try {
120
275
  existing = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf8' });
121
- } catch (_) {
276
+ } catch {
122
277
  existing = '';
123
278
  }
124
- const newCrontab = existing.trimEnd() + (existing.trim() ? '\n' : '') + entry + '\n';
125
- // Write via heredoc to preserve newlines correctly
126
- execSync(`crontab - <<'CRONTAB_EOF'\n${newCrontab}CRONTAB_EOF`);
279
+
280
+ if (existing.includes(CRON_MARKER)) {
281
+ return { type: 'cron', scriptPath: SCRIPT_PATH };
282
+ }
283
+
284
+ // Strip legacy unmarked TLC autoupdate entries (match our specific path)
285
+ const cleaned = existing
286
+ .split('\n')
287
+ .filter(line => !line.includes(SCRIPT_PATH))
288
+ .join('\n');
289
+
290
+ const entry = generateCronEntry(SCRIPT_PATH);
291
+ const newCrontab = cleaned.trimEnd() + (cleaned.trim() ? '\n' : '') + entry + '\n';
292
+ execSync('crontab -', { input: newCrontab, encoding: 'utf8' });
127
293
 
128
294
  return { type: 'cron', scriptPath: SCRIPT_PATH };
129
295
  }
130
296
 
131
297
  /**
132
- * Disables the auto-update scheduled job.
298
+ * Disables the auto-update scheduler.
299
+ * macOS: removes launchd plist
300
+ * Linux: removes cron entry
133
301
  * @param {object} opts
134
302
  * @param {string} opts.platform - 'darwin' or 'linux'
135
- * @param {object} opts.fs - fs module (injected for testability)
136
- * @param {Function} [opts.execSync] - child_process.execSync (injected for testability)
303
+ * @param {object} [opts.fs] - fs module (required for macOS)
304
+ * @param {Function} [opts.execSync] - child_process.execSync (required for Linux)
137
305
  * @returns {{ removed: boolean }}
138
306
  */
139
307
  export function disable({ platform, fs, execSync }) {
140
308
  if (platform === 'darwin') {
141
- if (fs.existsSync(LAUNCH_AGENT_PLIST)) {
142
- fs.unlinkSync(LAUNCH_AGENT_PLIST);
309
+ if (fs && fs.existsSync(LAUNCHD_PLIST)) {
310
+ fs.unlinkSync(LAUNCHD_PLIST);
143
311
  return { removed: true };
144
312
  }
145
313
  return { removed: false };
146
314
  }
147
315
 
148
- // Linux — strip TLC cron entry
316
+ // Linux: cron
149
317
  let existing = '';
150
318
  try {
151
319
  existing = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf8' });
152
- } catch (_) {
153
- existing = '';
320
+ } catch {
321
+ return { removed: false };
154
322
  }
323
+
324
+ const hasMarked = existing.includes(CRON_MARKER);
325
+ const hasLegacy = !hasMarked && existing.includes(SCRIPT_PATH);
326
+
327
+ if (!hasMarked && !hasLegacy) {
328
+ return { removed: false };
329
+ }
330
+
155
331
  const filtered = existing
156
332
  .split('\n')
157
- .filter(line => !line.includes(TLC_DIR) && !line.includes('autoupdate'))
333
+ .filter(line => !line.includes(CRON_MARKER) && !line.includes(SCRIPT_PATH))
158
334
  .join('\n');
159
- const changed = filtered.trim() !== existing.trim();
160
- if (changed) {
161
- try {
162
- const cleanCrontab = filtered.trim() + '\n';
163
- execSync(`crontab - <<'CRONTAB_EOF'\n${cleanCrontab}CRONTAB_EOF`);
164
- } catch (_) {
165
- // best effort
166
- }
335
+
336
+ const cleanCrontab = filtered.trim() ? filtered.trim() + '\n' : '';
337
+ if (cleanCrontab) {
338
+ execSync('crontab -', { input: cleanCrontab, encoding: 'utf8' });
339
+ } else {
340
+ execSync('crontab -r 2>/dev/null || true');
167
341
  }
342
+
168
343
  return { removed: true };
169
344
  }
170
345
 
@@ -204,3 +379,57 @@ export function isStale(timestamp) {
204
379
  const age = Date.now() - new Date(timestamp).getTime();
205
380
  return age > 24 * 60 * 60 * 1000;
206
381
  }
382
+
383
+ /**
384
+ * Compares installed TLC version against latest on npm.
385
+ * Writes a notification file if an update is available, removes it if up to date.
386
+ * Designed to be called from the SessionStart hook for instant feedback.
387
+ * @param {object} opts
388
+ * @param {Function} opts.execSync - child_process.execSync
389
+ * @param {string} opts.installedVersion - current TLC version
390
+ * @param {object} [opts.fs] - fs module (for writing notification file)
391
+ * @param {string} [opts.dir] - directory for the notification file
392
+ * @returns {{ current: string, latest: string } | null}
393
+ */
394
+ export function checkForUpdate({ execSync, installedVersion, fs, dir }) {
395
+ let latest;
396
+ try {
397
+ latest = execSync(`npm show ${TLC_PACKAGE} version 2>/dev/null`, { encoding: 'utf8' }).trim();
398
+ } catch {
399
+ return null;
400
+ }
401
+
402
+ if (!latest || latest === installedVersion) {
403
+ // Up to date — remove stale notification if present
404
+ if (fs && dir) {
405
+ const notifPath = path.join(dir, UPDATE_AVAILABLE_FILE);
406
+ if (fs.existsSync && fs.existsSync(notifPath)) {
407
+ fs.unlinkSync(notifPath);
408
+ }
409
+ }
410
+ return null;
411
+ }
412
+
413
+ // Simple semver compare: split and compare major.minor.patch
414
+ const parse = (v) => v.split('.').map(Number);
415
+ const [cMaj, cMin, cPat] = parse(installedVersion);
416
+ const [lMaj, lMin, lPat] = parse(latest);
417
+ const isNewer = lMaj > cMaj || (lMaj === cMaj && lMin > cMin) || (lMaj === cMaj && lMin === cMin && lPat > cPat);
418
+
419
+ if (!isNewer) {
420
+ return null;
421
+ }
422
+
423
+ // Write notification file for SessionStart hook to display
424
+ if (fs && dir) {
425
+ fs.mkdirSync(dir, { recursive: true });
426
+ const msg = [
427
+ `current=${installedVersion}`,
428
+ `latest=${latest}`,
429
+ `command=npm update -g ${TLC_PACKAGE}`,
430
+ ].join('\n');
431
+ fs.writeFileSync(path.join(dir, UPDATE_AVAILABLE_FILE), msg, 'utf8');
432
+ }
433
+
434
+ return { current: installedVersion, latest };
435
+ }