tlc-claude-code 2.1.0 → 2.2.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
@@ -72,6 +72,7 @@ const COMMANDS = [
72
72
  // Review
73
73
  'review.md',
74
74
  'review-pr.md',
75
+ 'review-plan.md',
75
76
  // Documentation
76
77
  'docs.md',
77
78
  // Multi-Tool & Deployment
@@ -103,6 +104,13 @@ const HOOKS = [
103
104
  'tlc-capture-exchange.sh'
104
105
  ];
105
106
 
107
+ // Claude Code agent definitions (managed by TLC — identified by '# TLC Agent:' header)
108
+ const AGENTS = [
109
+ 'reviewer.md',
110
+ 'builder.md',
111
+ 'planner.md'
112
+ ];
113
+
106
114
  function getGlobalDir() {
107
115
  const claudeConfig = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude');
108
116
  return path.join(claudeConfig, 'commands');
@@ -168,12 +176,28 @@ function install(targetDir, installType) {
168
176
  success(`Installed ${hooksInstalled} hooks to ${c.cyan}${path.dirname(targetDir)}/.claude/hooks/${c.reset}`);
169
177
  }
170
178
 
179
+ // Install agent definitions (Claude Code sub-agents)
180
+ const agentsInstalled = installAgents(targetDir, packageRoot);
181
+ if (agentsInstalled > 0) {
182
+ success(`Installed ${agentsInstalled} agents to ${c.cyan}${path.dirname(targetDir)}/.claude/agents/${c.reset}`);
183
+ }
184
+
171
185
  // Install settings template (with hooks wiring)
172
- const settingsInstalled = installSettings(targetDir);
186
+ const settingsInstalled = installSettings(targetDir, installType);
173
187
  if (settingsInstalled) {
174
188
  success(`Installed settings template with hook wiring`);
175
189
  }
176
190
 
191
+ // Fix ownership if running under sudo
192
+ if (isRunningAsSudo()) {
193
+ const claudeDir = path.dirname(targetDir);
194
+ fixOwnership(commandsDir);
195
+ fixOwnership(path.join(claudeDir, 'hooks'));
196
+ fixOwnership(path.join(claudeDir, 'agents'));
197
+ fixOwnership(path.join(claudeDir, 'settings.json'));
198
+ success(`Fixed file ownership for ${c.cyan}${process.env.SUDO_USER}${c.reset}`);
199
+ }
200
+
177
201
  log('');
178
202
  log(`${c.green}Done!${c.reset} Restart Claude Code to load commands.`);
179
203
  log('');
@@ -217,12 +241,55 @@ function installHooks(targetDir, packageRoot) {
217
241
  return copied;
218
242
  }
219
243
 
220
- function installSettings(targetDir) {
244
+ function installAgents(targetDir, packageRoot) {
245
+ // targetDir is .claude/commands (or ~/.claude/commands)
246
+ // agents go into the sibling .claude/agents/ directory
247
+ const claudeDir = path.dirname(targetDir);
248
+ const agentsDestDir = path.join(claudeDir, 'agents');
249
+ fs.mkdirSync(agentsDestDir, { recursive: true });
250
+
251
+ // Try .claude/agents/ first (npm package structure), fall back to nothing
252
+ const agentsSrcDir = fs.existsSync(path.join(packageRoot, '.claude', 'agents'))
253
+ ? path.join(packageRoot, '.claude', 'agents')
254
+ : null;
255
+
256
+ if (!agentsSrcDir) return 0;
257
+
258
+ const TLC_AGENT_MARKER = '# TLC Agent:';
259
+
260
+ let copied = 0;
261
+ for (const file of AGENTS) {
262
+ const src = path.join(agentsSrcDir, file);
263
+ const dest = path.join(agentsDestDir, file);
264
+ if (!fs.existsSync(src)) continue;
265
+
266
+ // Only overwrite if the destination either doesn't exist or is TLC-managed
267
+ if (fs.existsSync(dest)) {
268
+ const existing = fs.readFileSync(dest, 'utf8');
269
+ if (!existing.startsWith(TLC_AGENT_MARKER)) {
270
+ // User has customized this agent — leave it alone
271
+ continue;
272
+ }
273
+ }
274
+
275
+ fs.copyFileSync(src, dest);
276
+ copied++;
277
+ }
278
+ return copied;
279
+ }
280
+
281
+ function installSettings(targetDir, installType) {
221
282
  // For local install: .claude/commands -> go up to .claude/settings.json
222
283
  // For global install: ~/.claude/commands -> go up to ~/.claude/settings.json
223
284
  const claudeDir = path.dirname(targetDir);
224
285
  const settingsPath = path.join(claudeDir, 'settings.json');
225
286
 
287
+ // Global installs put hooks in ~/.claude/hooks/ (or $CLAUDE_CONFIG_DIR/hooks/ if set)
288
+ // Local installs put hooks in .claude/hooks/ (use $CLAUDE_PROJECT_DIR)
289
+ const hooksBase = installType === 'global'
290
+ ? '${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks'
291
+ : '$CLAUDE_PROJECT_DIR/.claude/hooks';
292
+
226
293
  // The settings template with full hook wiring
227
294
  const settingsTemplate = {
228
295
  permissions: {
@@ -243,21 +310,21 @@ function installSettings(targetDir) {
243
310
  matcher: "EnterPlanMode|TaskCreate|TaskUpdate|TaskList|TaskGet|ExitPlanMode",
244
311
  hooks: [{
245
312
  type: "command",
246
- command: "bash $CLAUDE_PROJECT_DIR/.claude/hooks/tlc-block-tools.sh",
313
+ command: `bash ${hooksBase}/tlc-block-tools.sh`,
247
314
  timeout: 5
248
315
  }]
249
316
  }],
250
317
  UserPromptSubmit: [{
251
318
  hooks: [{
252
319
  type: "command",
253
- command: "bash $CLAUDE_PROJECT_DIR/.claude/hooks/tlc-prompt-guard.sh",
320
+ command: `bash ${hooksBase}/tlc-prompt-guard.sh`,
254
321
  timeout: 5
255
322
  }]
256
323
  }],
257
324
  SessionStart: [{
258
325
  hooks: [{
259
326
  type: "command",
260
- command: "bash $CLAUDE_PROJECT_DIR/.claude/hooks/tlc-session-init.sh",
327
+ command: `bash ${hooksBase}/tlc-session-init.sh`,
261
328
  timeout: 5
262
329
  }]
263
330
  }],
@@ -266,7 +333,7 @@ function installSettings(targetDir) {
266
333
  matcher: "Bash",
267
334
  hooks: [{
268
335
  type: "command",
269
- command: "bash $CLAUDE_PROJECT_DIR/.claude/hooks/tlc-post-push.sh",
336
+ command: `bash ${hooksBase}/tlc-post-push.sh`,
270
337
  timeout: 5
271
338
  }]
272
339
  },
@@ -274,7 +341,7 @@ function installSettings(targetDir) {
274
341
  matcher: "Skill",
275
342
  hooks: [{
276
343
  type: "command",
277
- command: "bash $CLAUDE_PROJECT_DIR/.claude/hooks/tlc-post-build.sh",
344
+ command: `bash ${hooksBase}/tlc-post-build.sh`,
278
345
  timeout: 5
279
346
  }]
280
347
  }
@@ -282,7 +349,7 @@ function installSettings(targetDir) {
282
349
  Stop: [{
283
350
  hooks: [{
284
351
  type: "command",
285
- command: "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tlc-capture-exchange.sh",
352
+ command: `bash "${hooksBase}/tlc-capture-exchange.sh"`,
286
353
  timeout: 30
287
354
  }]
288
355
  }]
@@ -319,6 +386,30 @@ function installSettings(targetDir) {
319
386
  }
320
387
  }
321
388
 
389
+ function isRunningAsSudo() {
390
+ return process.getuid && process.getuid() === 0 && process.env.SUDO_USER;
391
+ }
392
+
393
+ function fixOwnership(targetPath) {
394
+ const sudoUid = parseInt(process.env.SUDO_UID, 10);
395
+ const sudoGid = parseInt(process.env.SUDO_GID, 10);
396
+ if (isNaN(sudoUid) || isNaN(sudoGid)) return;
397
+
398
+ try {
399
+ const stat = fs.statSync(targetPath);
400
+ if (stat.isDirectory()) {
401
+ fs.chownSync(targetPath, sudoUid, sudoGid);
402
+ for (const entry of fs.readdirSync(targetPath)) {
403
+ fixOwnership(path.join(targetPath, entry));
404
+ }
405
+ } else {
406
+ fs.chownSync(targetPath, sudoUid, sudoGid);
407
+ }
408
+ } catch (err) {
409
+ // Best effort
410
+ }
411
+ }
412
+
322
413
  async function main() {
323
414
  const args = process.argv.slice(2);
324
415
 
@@ -330,6 +421,12 @@ async function main() {
330
421
 
331
422
  printBanner();
332
423
 
424
+ // Warn if running under sudo — files will be owned by root
425
+ if (isRunningAsSudo()) {
426
+ log(`${c.yellow}⚠ Running under sudo — will fix file ownership for ${process.env.SUDO_USER}${c.reset}`);
427
+ log('');
428
+ }
429
+
333
430
  if (args.includes('--global') || args.includes('-g')) {
334
431
  info(`Installing ${c.bold}globally${c.reset} to ~/.claude/commands/tlc`);
335
432
  log('');
@@ -5,15 +5,17 @@ const path = require('path');
5
5
  const os = require('os');
6
6
 
7
7
  const packageRoot = path.join(__dirname, '..');
8
- const claudeHome = path.join(os.homedir(), '.claude');
8
+ const claudeHome = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
9
9
 
10
10
  // Source directories (inside npm package)
11
11
  const commandsSrcDir = path.join(packageRoot, '.claude', 'commands', 'tlc');
12
12
  const hooksSrcDir = path.join(packageRoot, '.claude', 'hooks');
13
+ const agentsSrcDir = path.join(packageRoot, '.claude', 'agents');
13
14
 
14
15
  // Destination directories (user's home)
15
16
  const commandsDestDir = path.join(claudeHome, 'commands', 'tlc');
16
17
  const hooksDestDir = path.join(claudeHome, 'hooks');
18
+ const agentsDestDir = path.join(claudeHome, 'agents');
17
19
 
18
20
  function ensureDir(dir) {
19
21
  if (!fs.existsSync(dir)) {
@@ -51,10 +53,56 @@ function copyHooks() {
51
53
  return copied;
52
54
  }
53
55
 
56
+ // Copy all .md agent files (only overwrite TLC-managed ones)
57
+ function copyAgents() {
58
+ if (!fs.existsSync(agentsSrcDir)) return 0;
59
+ ensureDir(agentsDestDir);
60
+
61
+ const files = fs.readdirSync(agentsSrcDir).filter(f => f.endsWith('.md'));
62
+ let copied = 0;
63
+ for (const file of files) {
64
+ const dest = path.join(agentsDestDir, file);
65
+ // Don't overwrite user-customized agents
66
+ if (fs.existsSync(dest)) {
67
+ const content = fs.readFileSync(dest, 'utf8');
68
+ if (!content.startsWith('# TLC Agent:')) continue;
69
+ }
70
+ fs.copyFileSync(path.join(agentsSrcDir, file), dest);
71
+ copied++;
72
+ }
73
+ return copied;
74
+ }
75
+
76
+ function isRunningAsSudo() {
77
+ return process.getuid && process.getuid() === 0 && process.env.SUDO_USER;
78
+ }
79
+
80
+ function fixOwnership(targetPath) {
81
+ // After writing files as root, fix ownership to the actual user
82
+ const sudoUid = parseInt(process.env.SUDO_UID, 10);
83
+ const sudoGid = parseInt(process.env.SUDO_GID, 10);
84
+ if (isNaN(sudoUid) || isNaN(sudoGid)) return;
85
+
86
+ try {
87
+ const stat = fs.statSync(targetPath);
88
+ if (stat.isDirectory()) {
89
+ fs.chownSync(targetPath, sudoUid, sudoGid);
90
+ for (const entry of fs.readdirSync(targetPath)) {
91
+ fixOwnership(path.join(targetPath, entry));
92
+ }
93
+ } else {
94
+ fs.chownSync(targetPath, sudoUid, sudoGid);
95
+ }
96
+ } catch (err) {
97
+ // Best effort
98
+ }
99
+ }
100
+
54
101
  function postinstall() {
55
102
  try {
56
103
  const commands = copyCommands();
57
104
  const hooks = copyHooks();
105
+ const agents = copyAgents();
58
106
 
59
107
  if (commands > 0) {
60
108
  console.log(`\x1b[32m✓\x1b[0m TLC: Installed ${commands} commands to ~/.claude/commands/tlc/`);
@@ -62,6 +110,17 @@ function postinstall() {
62
110
  if (hooks > 0) {
63
111
  console.log(`\x1b[32m✓\x1b[0m TLC: Installed ${hooks} hooks to ~/.claude/hooks/`);
64
112
  }
113
+ if (agents > 0) {
114
+ console.log(`\x1b[32m✓\x1b[0m TLC: Installed ${agents} agents to ~/.claude/agents/`);
115
+ }
116
+
117
+ // Fix ownership if running under sudo
118
+ if (isRunningAsSudo()) {
119
+ console.log(`\x1b[33m⚠\x1b[0m TLC: Detected sudo — fixing file ownership for ${process.env.SUDO_USER}`);
120
+ fixOwnership(commandsDestDir);
121
+ fixOwnership(hooksDestDir);
122
+ fixOwnership(agentsDestDir);
123
+ }
65
124
  } catch (err) {
66
125
  // Silent fail - don't break npm install
67
126
  if (process.env.DEBUG) {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * TLC Auto-Update Setup
3
+ * Creates scheduled jobs to keep Claude Code and Codex CLI updated daily.
4
+ */
5
+
6
+ import os from 'os';
7
+ import path from 'path';
8
+
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`;
14
+ const TIMESTAMP_FILE = '.last-update';
15
+
16
+ /**
17
+ * Generates a macOS LaunchAgent plist XML string for daily auto-update.
18
+ * @param {string} scriptPath - Absolute path to the update shell script.
19
+ * @returns {string} XML plist content.
20
+ */
21
+ export function generateLaunchdPlist(scriptPath) {
22
+ return `<?xml version="1.0" encoding="UTF-8"?>
23
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
24
+ <plist version="1.0">
25
+ <dict>
26
+ <key>Label</key>
27
+ <string>${LAUNCH_AGENT_LABEL}</string>
28
+ <key>ProgramArguments</key>
29
+ <array>
30
+ <string>/bin/bash</string>
31
+ <string>${scriptPath}</string>
32
+ </array>
33
+ <key>StartInterval</key>
34
+ <integer>86400</integer>
35
+ <key>RunAtLoad</key>
36
+ <false/>
37
+ <key>StandardOutPath</key>
38
+ <string>${LOG_PATH}</string>
39
+ <key>StandardErrorPath</key>
40
+ <string>${LOG_PATH}</string>
41
+ </dict>
42
+ </plist>
43
+ `;
44
+ }
45
+
46
+ /**
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.
92
+ * @param {object} opts
93
+ * @param {string} opts.platform - 'darwin' or 'linux'
94
+ * @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 }}
97
+ */
98
+ export function enable({ platform, fs, execSync }) {
99
+ // Ensure TLC directories exist
100
+ fs.mkdirSync(TLC_DIR, { recursive: true });
101
+ fs.mkdirSync(`${TLC_DIR}/logs`, { recursive: true });
102
+
103
+ // Write the update script
104
+ const scriptContent = generateUpdateScript();
105
+ fs.writeFileSync(SCRIPT_PATH, scriptContent, 'utf8');
106
+ fs.chmodSync(SCRIPT_PATH, 0o755);
107
+
108
+ if (platform === 'darwin') {
109
+ const launchAgentDir = path.dirname(LAUNCH_AGENT_PLIST);
110
+ fs.mkdirSync(launchAgentDir, { recursive: true });
111
+ const plist = generateLaunchdPlist(SCRIPT_PATH);
112
+ fs.writeFileSync(LAUNCH_AGENT_PLIST, plist, 'utf8');
113
+ return { type: 'launchd', scriptPath: SCRIPT_PATH, jobPath: LAUNCH_AGENT_PLIST };
114
+ }
115
+
116
+ // Linux — use cron
117
+ const entry = generateCronEntry(SCRIPT_PATH);
118
+ let existing = '';
119
+ try {
120
+ existing = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf8' });
121
+ } catch (_) {
122
+ existing = '';
123
+ }
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`);
127
+
128
+ return { type: 'cron', scriptPath: SCRIPT_PATH };
129
+ }
130
+
131
+ /**
132
+ * Disables the auto-update scheduled job.
133
+ * @param {object} opts
134
+ * @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)
137
+ * @returns {{ removed: boolean }}
138
+ */
139
+ export function disable({ platform, fs, execSync }) {
140
+ if (platform === 'darwin') {
141
+ if (fs.existsSync(LAUNCH_AGENT_PLIST)) {
142
+ fs.unlinkSync(LAUNCH_AGENT_PLIST);
143
+ return { removed: true };
144
+ }
145
+ return { removed: false };
146
+ }
147
+
148
+ // Linux — strip TLC cron entry
149
+ let existing = '';
150
+ try {
151
+ existing = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf8' });
152
+ } catch (_) {
153
+ existing = '';
154
+ }
155
+ const filtered = existing
156
+ .split('\n')
157
+ .filter(line => !line.includes(TLC_DIR) && !line.includes('autoupdate'))
158
+ .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
+ }
167
+ }
168
+ return { removed: true };
169
+ }
170
+
171
+ /**
172
+ * Writes the current ISO timestamp to `{dir}/.last-update`.
173
+ * @param {object} opts
174
+ * @param {object} opts.fs - fs module
175
+ * @param {string} opts.dir - directory path
176
+ */
177
+ export function writeTimestamp({ fs, dir }) {
178
+ fs.mkdirSync(dir, { recursive: true });
179
+ fs.writeFileSync(path.join(dir, TIMESTAMP_FILE), new Date().toISOString(), 'utf8');
180
+ }
181
+
182
+ /**
183
+ * Reads the timestamp from `{dir}/.last-update`.
184
+ * @param {object} opts
185
+ * @param {object} opts.fs - fs module
186
+ * @param {string} opts.dir - directory path
187
+ * @returns {string|null} ISO timestamp string or null if file missing
188
+ */
189
+ export function readTimestamp({ fs, dir }) {
190
+ const filePath = path.join(dir, TIMESTAMP_FILE);
191
+ if (!fs.existsSync(filePath)) {
192
+ return null;
193
+ }
194
+ return fs.readFileSync(filePath, 'utf8').trim();
195
+ }
196
+
197
+ /**
198
+ * Returns true if the timestamp is null or older than 24 hours.
199
+ * @param {string|null} timestamp - ISO date string or null
200
+ * @returns {boolean}
201
+ */
202
+ export function isStale(timestamp) {
203
+ if (timestamp === null) return true;
204
+ const age = Date.now() - new Date(timestamp).getTime();
205
+ return age > 24 * 60 * 60 * 1000;
206
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ generateLaunchdPlist,
4
+ generateCronEntry,
5
+ generateUpdateScript,
6
+ enable,
7
+ disable,
8
+ writeTimestamp,
9
+ readTimestamp,
10
+ isStale,
11
+ } from './setup-autoupdate.js';
12
+
13
+ describe('setup-autoupdate', () => {
14
+ describe('generateLaunchdPlist', () => {
15
+ it('returns valid XML plist with daily interval', () => {
16
+ const plist = generateLaunchdPlist('/path/to/update-script.sh');
17
+ expect(plist).toContain('<?xml');
18
+ expect(plist).toContain('com.tlc.autoupdate');
19
+ expect(plist).toContain('<integer>86400</integer>');
20
+ expect(plist).toContain('/path/to/update-script.sh');
21
+ });
22
+ });
23
+
24
+ describe('generateCronEntry', () => {
25
+ it('returns valid crontab line running at 4am daily', () => {
26
+ const entry = generateCronEntry('/path/to/update-script.sh');
27
+ expect(entry).toMatch(/^0 4 \* \* \*/);
28
+ expect(entry).toContain('/path/to/update-script.sh');
29
+ });
30
+ });
31
+
32
+ describe('generateUpdateScript', () => {
33
+ it('includes claude update command', () => {
34
+ const script = generateUpdateScript();
35
+ expect(script).toContain('claude update');
36
+ });
37
+
38
+ it('includes codex update command', () => {
39
+ const script = generateUpdateScript();
40
+ expect(script).toContain('npm update -g @openai/codex');
41
+ });
42
+
43
+ it('logs to autoupdate.log', () => {
44
+ const script = generateUpdateScript();
45
+ expect(script).toContain('autoupdate.log');
46
+ });
47
+
48
+ it('writes timestamp to .last-update', () => {
49
+ const script = generateUpdateScript();
50
+ expect(script).toContain('.last-update');
51
+ });
52
+ });
53
+
54
+ describe('enable', () => {
55
+ it('creates launchd plist on macOS', () => {
56
+ const fs = { writeFileSync: vi.fn(), mkdirSync: vi.fn(), existsSync: vi.fn(() => false), chmodSync: vi.fn() };
57
+ const result = enable({ platform: 'darwin', fs });
58
+ expect(result.type).toBe('launchd');
59
+ expect(fs.writeFileSync).toHaveBeenCalled();
60
+ });
61
+
62
+ it('creates cron entry on Linux', () => {
63
+ const execSync = vi.fn(() => '');
64
+ const fs = { writeFileSync: vi.fn(), mkdirSync: vi.fn(), existsSync: vi.fn(() => false), chmodSync: vi.fn() };
65
+ const result = enable({ platform: 'linux', fs, execSync });
66
+ expect(result.type).toBe('cron');
67
+ });
68
+ });
69
+
70
+ describe('disable', () => {
71
+ it('removes launchd plist on macOS', () => {
72
+ const fs = { unlinkSync: vi.fn(), existsSync: vi.fn(() => true) };
73
+ const result = disable({ platform: 'darwin', fs });
74
+ expect(result.removed).toBe(true);
75
+ expect(fs.unlinkSync).toHaveBeenCalled();
76
+ });
77
+
78
+ it('removes cron entry on Linux', () => {
79
+ const execSync = vi.fn(() => '0 4 * * * /path/to/script\nother job');
80
+ const fs = { existsSync: vi.fn(() => false) };
81
+ const result = disable({ platform: 'linux', fs, execSync });
82
+ expect(result.removed).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe('writeTimestamp / readTimestamp', () => {
87
+ it('writes ISO timestamp to .last-update', () => {
88
+ const fs = { writeFileSync: vi.fn(), mkdirSync: vi.fn() };
89
+ writeTimestamp({ fs, dir: '/tmp/tlc' });
90
+ const args = fs.writeFileSync.mock.calls[0];
91
+ expect(args[0]).toContain('.last-update');
92
+ expect(args[1]).toMatch(/^\d{4}-\d{2}-\d{2}T/);
93
+ });
94
+
95
+ it('reads timestamp from .last-update', () => {
96
+ const now = new Date().toISOString();
97
+ const fs = { readFileSync: vi.fn(() => now), existsSync: vi.fn(() => true) };
98
+ const ts = readTimestamp({ fs, dir: '/tmp/tlc' });
99
+ expect(ts).toBe(now);
100
+ });
101
+
102
+ it('returns null when file missing', () => {
103
+ const fs = { existsSync: vi.fn(() => false) };
104
+ const ts = readTimestamp({ fs, dir: '/tmp/tlc' });
105
+ expect(ts).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe('isStale', () => {
110
+ it('returns false for fresh timestamp (<24h)', () => {
111
+ const recent = new Date(Date.now() - 3600_000).toISOString(); // 1h ago
112
+ expect(isStale(recent)).toBe(false);
113
+ });
114
+
115
+ it('returns true for stale timestamp (>24h)', () => {
116
+ const old = new Date(Date.now() - 90_000_000).toISOString(); // 25h ago
117
+ expect(isStale(old)).toBe(true);
118
+ });
119
+
120
+ it('returns true for null timestamp', () => {
121
+ expect(isStale(null)).toBe(true);
122
+ });
123
+ });
124
+ });
package/bin/tlc.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
- "tlc": "./bin/tlc.js",
7
6
  "tlc-claude-code": "./bin/install.js",
8
7
  "tlc-docs": "./scripts/project-docs.js"
9
8
  },
10
9
  "files": [
11
10
  "bin/",
11
+ ".claude/agents/",
12
12
  ".claude/commands/",
13
13
  ".claude/hooks/",
14
14
  "dashboard/dist/",
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Project Documentation Maintenance
4
4
  *