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/.claude/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +4 -0
- package/.claude/commands/tlc/review-plan.md +363 -0
- package/.claude/commands/tlc/review.md +155 -53
- package/CLAUDE.md +1 -0
- package/bin/install.js +105 -8
- package/bin/postinstall.js +60 -1
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- package/package.json +2 -2
- package/scripts/project-docs.js +1 -1
- package/server/lib/cost-tracker.test.js +49 -12
- package/server/lib/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -0
- package/server/lib/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -0
- package/server/setup.sh +271 -271
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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('');
|
package/bin/postinstall.js
CHANGED
|
@@ -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.
|
|
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/",
|
package/scripts/project-docs.js
CHANGED