tlc-claude-code 2.0.1 → 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.
Files changed (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,123 @@
1
+ #!/bin/bash
2
+ # TLC Enforcement Layer 2b: Session initialization
3
+ # 1. Inject TLC awareness
4
+ # 2. Ensure TLC server is running
5
+ # 3. Probe LLM providers and write persistent router state
6
+
7
+ if [ ! -f ".tlc.json" ]; then
8
+ exit 0
9
+ fi
10
+
11
+ echo "TLC project detected. All work goes through /tlc commands. Run /tlc for current status and next action."
12
+
13
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
14
+
15
+ # ─── TLC Server ───────────────────────────────────────────
16
+
17
+ TLC_PORT="${TLC_PORT:-3147}"
18
+ if curl -sf --max-time 1 "http://localhost:${TLC_PORT}/api/health" > /dev/null 2>&1; then
19
+ : # Server is running
20
+ else
21
+ PLIST="$HOME/Library/LaunchAgents/com.tlc.server.plist"
22
+
23
+ if [ -f "$PLIST" ]; then
24
+ launchctl kickstart -k "gui/$(id -u)/com.tlc.server" 2>/dev/null
25
+ elif [ -f "$PROJECT_DIR/server/index.js" ]; then
26
+ nohup node "$PROJECT_DIR/server/index.js" > "$HOME/.tlc/logs/server.log" 2>&1 &
27
+ fi
28
+
29
+ for i in 1 2 3; do
30
+ sleep 1
31
+ curl -sf --max-time 1 "http://localhost:${TLC_PORT}/api/health" > /dev/null 2>&1 && break
32
+ done
33
+ fi
34
+
35
+ # ─── LLM Router: Probe Providers ─────────────────────────
36
+ #
37
+ # Writes .tlc/.router-state.json with provider availability.
38
+ # Skills read this file instead of probing from scratch.
39
+ # State has a TTL — re-probed if older than 1 hour.
40
+
41
+ STATE_DIR="$PROJECT_DIR/.tlc"
42
+ STATE_FILE="$STATE_DIR/.router-state.json"
43
+ mkdir -p "$STATE_DIR"
44
+
45
+ # Check if state is fresh (less than 1 hour old)
46
+ STALE=true
47
+ if [ -f "$STATE_FILE" ]; then
48
+ STATE_AGE=$(( $(date +%s) - $(stat -f %m "$STATE_FILE" 2>/dev/null || stat -c %Y "$STATE_FILE" 2>/dev/null || echo 0) ))
49
+ if [ "$STATE_AGE" -lt 3600 ]; then
50
+ STALE=false
51
+ fi
52
+ fi
53
+
54
+ if [ "$STALE" = true ]; then
55
+ # Probe each provider
56
+ CLAUDE_PATH=$(which claude 2>/dev/null || echo "")
57
+ CODEX_PATH=$(which codex 2>/dev/null || echo "")
58
+ GEMINI_PATH=$(which gemini 2>/dev/null || echo "")
59
+
60
+ CLAUDE_OK="false"
61
+ CODEX_OK="false"
62
+ GEMINI_OK="false"
63
+
64
+ [ -n "$CLAUDE_PATH" ] && CLAUDE_OK="true"
65
+ [ -n "$CODEX_PATH" ] && CODEX_OK="true"
66
+ [ -n "$GEMINI_PATH" ] && GEMINI_OK="true"
67
+
68
+ # Count available
69
+ AVAILABLE=0
70
+ [ "$CLAUDE_OK" = "true" ] && AVAILABLE=$((AVAILABLE + 1))
71
+ [ "$CODEX_OK" = "true" ] && AVAILABLE=$((AVAILABLE + 1))
72
+ [ "$GEMINI_OK" = "true" ] && AVAILABLE=$((AVAILABLE + 1))
73
+
74
+ # Read configured providers from .tlc.json
75
+ CONFIGURED_PROVIDERS=""
76
+ if command -v jq >/dev/null 2>&1; then
77
+ CONFIGURED_PROVIDERS=$(jq -r '.router.providers // {} | keys[]' .tlc.json 2>/dev/null | tr '\n' ',' | sed 's/,$//')
78
+ fi
79
+
80
+ # Write state file
81
+ cat > "$STATE_FILE" <<STATEEOF
82
+ {
83
+ "probed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
84
+ "ttl_seconds": 3600,
85
+ "providers": {
86
+ "claude": {
87
+ "available": $CLAUDE_OK,
88
+ "path": "$CLAUDE_PATH"
89
+ },
90
+ "codex": {
91
+ "available": $CODEX_OK,
92
+ "path": "$CODEX_PATH"
93
+ },
94
+ "gemini": {
95
+ "available": $GEMINI_OK,
96
+ "path": "$GEMINI_PATH"
97
+ }
98
+ },
99
+ "summary": {
100
+ "available_count": $AVAILABLE,
101
+ "configured": "$CONFIGURED_PROVIDERS"
102
+ }
103
+ }
104
+ STATEEOF
105
+
106
+ # Report to Claude
107
+ if [ "$AVAILABLE" -gt 0 ]; then
108
+ PROVIDERS_LIST=""
109
+ [ "$CLAUDE_OK" = "true" ] && PROVIDERS_LIST="${PROVIDERS_LIST}claude, "
110
+ [ "$CODEX_OK" = "true" ] && PROVIDERS_LIST="${PROVIDERS_LIST}codex, "
111
+ [ "$GEMINI_OK" = "true" ] && PROVIDERS_LIST="${PROVIDERS_LIST}gemini, "
112
+ PROVIDERS_LIST=$(echo "$PROVIDERS_LIST" | sed 's/, $//')
113
+ echo "LLM Router: ${AVAILABLE} providers available (${PROVIDERS_LIST}). State written to .tlc/.router-state.json. All routing skills MUST read this file for provider availability — do not probe manually."
114
+ else
115
+ echo "LLM Router: No external providers detected. Running Claude-only mode. Install codex or gemini for multi-LLM reviews."
116
+ fi
117
+ else
118
+ # State is fresh — just report it
119
+ if command -v jq >/dev/null 2>&1 && [ -f "$STATE_FILE" ]; then
120
+ COUNT=$(jq -r '.summary.available_count' "$STATE_FILE" 2>/dev/null)
121
+ echo "LLM Router: ${COUNT} providers available (cached). State at .tlc/.router-state.json."
122
+ fi
123
+ fi
package/CLAUDE.md CHANGED
@@ -19,6 +19,7 @@ When the user says X → invoke `Skill(skill="tlc:...")`:
19
19
  | "plan", "break this down" | `/tlc:plan` |
20
20
  | "build", "implement", "add feature" | `/tlc:build` |
21
21
  | "review", "check code" | `/tlc:review` |
22
+ | "review plan", "check plan" | `/tlc:review-plan` |
22
23
  | "status", "what's next", "where are we" | `/tlc:progress` |
23
24
  | "discuss", "talk about approach" | `/tlc:discuss` |
24
25
  | "test", "run tests" | `/tlc:status` |
@@ -49,6 +50,10 @@ When the user says X → invoke `Skill(skill="tlc:...")`:
49
50
  | "quick task", "small fix" | `/tlc:quick` |
50
51
  | "dashboard" | `/tlc:dashboard` |
51
52
  | "review PR" | `/tlc:review-pr` |
53
+ | "watch ci", "fix ci", "ci failing" | `/tlc:watchci` |
54
+ | "e2e", "screenshot", "visual check" | `/tlc:e2e-verify` |
55
+ | "guard", "check process", "validate" | `/tlc:guard` |
56
+ | "preflight", "am I done", "check gaps" | `/tlc:preflight` |
52
57
 
53
58
  ## TLC File System
54
59
 
@@ -73,6 +78,14 @@ Use `Task` tool to spawn sub-agents for independent work. Keep main conversation
73
78
 
74
79
  Claim tasks before starting: `/tlc:claim`. Release if blocked: `/tlc:release`. Check team: `/tlc:who`. Pull before claiming, push after.
75
80
 
81
+ ## Memory Auto-Capture
82
+
83
+ Conversations are automatically captured via the Claude Code `Stop` hook. After each response, the hook POSTs the exchange to the TLC server's capture endpoint. The pattern detector classifies decisions, gotchas, and preferences into team memory files under `.tlc/memory/team/`.
84
+
85
+ - **Resilience:** If the server is unreachable, exchanges spool to `.tlc/memory/.spool.jsonl` and drain on the next successful capture.
86
+ - **Endpoint hardening:** Payloads are capped at 100KB, deduplicated within a 60s window, and rate-limited to 100 captures/minute per project.
87
+ - **Disable:** Remove the `Stop` hook entry from `.claude/settings.json`.
88
+
76
89
  ---
77
90
 
78
91
  <!-- TLC-STANDARDS -->
package/bin/install.js CHANGED
@@ -33,6 +33,7 @@ const COMMANDS = [
33
33
  'sync.md',
34
34
  'new-project.md',
35
35
  'init.md',
36
+ 'bootstrap.md',
36
37
  'import-project.md',
37
38
  'discuss.md',
38
39
  'plan.md',
@@ -71,6 +72,7 @@ const COMMANDS = [
71
72
  // Review
72
73
  'review.md',
73
74
  'review-pr.md',
75
+ 'review-plan.md',
74
76
  // Documentation
75
77
  'docs.md',
76
78
  // Multi-Tool & Deployment
@@ -78,10 +80,37 @@ const COMMANDS = [
78
80
  'deploy.md',
79
81
  // Multi-Model
80
82
  'llm.md',
83
+ // Plugins (auto-run via hooks)
84
+ 'watchci.md',
85
+ 'e2e-verify.md',
86
+ 'guard.md',
87
+ 'preflight.md',
88
+ // Memory
89
+ 'remember.md',
90
+ 'recall.md',
91
+ // Dashboard
92
+ 'dashboard.md',
81
93
  // Help
82
94
  'help.md'
83
95
  ];
84
96
 
97
+ // Hook scripts that power the plugin system
98
+ const HOOKS = [
99
+ 'tlc-block-tools.sh',
100
+ 'tlc-prompt-guard.sh',
101
+ 'tlc-session-init.sh',
102
+ 'tlc-post-push.sh',
103
+ 'tlc-post-build.sh',
104
+ 'tlc-capture-exchange.sh'
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
+
85
114
  function getGlobalDir() {
86
115
  const claudeConfig = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude');
87
116
  return path.join(claudeConfig, 'commands');
@@ -115,15 +144,20 @@ function info(msg) {
115
144
 
116
145
  function install(targetDir, installType) {
117
146
  const commandsDir = path.join(targetDir, 'tlc');
147
+ const packageRoot = path.join(__dirname, '..');
118
148
 
119
149
  // Create directory
120
150
  fs.mkdirSync(commandsDir, { recursive: true });
121
151
 
122
152
  // Copy command files with version injection
123
- const sourceDir = path.join(__dirname, '..');
153
+ // Try .claude/commands/tlc/ first (npm package structure), fall back to root (dev)
154
+ const commandsSrcDir = fs.existsSync(path.join(packageRoot, '.claude', 'commands', 'tlc'))
155
+ ? path.join(packageRoot, '.claude', 'commands', 'tlc')
156
+ : packageRoot;
157
+
124
158
  let installed = 0;
125
159
  for (const file of COMMANDS) {
126
- const src = path.join(sourceDir, file);
160
+ const src = path.join(commandsSrcDir, file);
127
161
  const dest = path.join(commandsDir, file);
128
162
  if (fs.existsSync(src)) {
129
163
  // Read, replace {{VERSION}}, write
@@ -135,6 +169,35 @@ function install(targetDir, installType) {
135
169
  }
136
170
 
137
171
  success(`Installed ${installed} commands to ${c.cyan}${commandsDir}${c.reset}`);
172
+
173
+ // Install hooks (plugin system)
174
+ const hooksInstalled = installHooks(targetDir, packageRoot);
175
+ if (hooksInstalled > 0) {
176
+ success(`Installed ${hooksInstalled} hooks to ${c.cyan}${path.dirname(targetDir)}/.claude/hooks/${c.reset}`);
177
+ }
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
+
185
+ // Install settings template (with hooks wiring)
186
+ const settingsInstalled = installSettings(targetDir, installType);
187
+ if (settingsInstalled) {
188
+ success(`Installed settings template with hook wiring`);
189
+ }
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
+
138
201
  log('');
139
202
  log(`${c.green}Done!${c.reset} Restart Claude Code to load commands.`);
140
203
  log('');
@@ -150,6 +213,203 @@ function install(targetDir, installType) {
150
213
  log('');
151
214
  }
152
215
 
216
+ function installHooks(targetDir, packageRoot) {
217
+ // For local install: .claude/commands -> go up to .claude/hooks
218
+ // For global install: ~/.claude/commands -> go up to ~/.claude/hooks
219
+ const claudeDir = path.dirname(targetDir);
220
+ const hooksDestDir = path.join(claudeDir, 'hooks');
221
+ fs.mkdirSync(hooksDestDir, { recursive: true });
222
+
223
+ // Try .claude/hooks/ first (npm package), fall back to root .claude/hooks/ (dev)
224
+ const hooksSrcDir = fs.existsSync(path.join(packageRoot, '.claude', 'hooks'))
225
+ ? path.join(packageRoot, '.claude', 'hooks')
226
+ : null;
227
+
228
+ if (!hooksSrcDir) return 0;
229
+
230
+ let copied = 0;
231
+ for (const file of HOOKS) {
232
+ const src = path.join(hooksSrcDir, file);
233
+ const dest = path.join(hooksDestDir, file);
234
+ if (fs.existsSync(src)) {
235
+ fs.copyFileSync(src, dest);
236
+ // Make executable
237
+ fs.chmodSync(dest, 0o755);
238
+ copied++;
239
+ }
240
+ }
241
+ return copied;
242
+ }
243
+
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) {
282
+ // For local install: .claude/commands -> go up to .claude/settings.json
283
+ // For global install: ~/.claude/commands -> go up to ~/.claude/settings.json
284
+ const claudeDir = path.dirname(targetDir);
285
+ const settingsPath = path.join(claudeDir, 'settings.json');
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
+
293
+ // The settings template with full hook wiring
294
+ const settingsTemplate = {
295
+ permissions: {
296
+ allow: [
297
+ "Bash(npm *)", "Bash(npx *)", "Bash(node *)", "Bash(git *)",
298
+ "Bash(gh *)", "Bash(ssh *)", "Bash(scp *)", "Bash(rsync *)",
299
+ "Bash(curl *)", "Bash(wget *)", "Bash(docker *)", "Bash(docker-compose *)",
300
+ "Bash(pytest*)", "Bash(python *)", "Bash(pip *)", "Bash(go *)",
301
+ "Bash(cargo *)", "Bash(make *)", "Bash(cat *)", "Bash(ls *)",
302
+ "Bash(pwd*)", "Bash(cd *)", "Bash(mkdir *)", "Bash(cp *)",
303
+ "Bash(mv *)", "Bash(which *)", "Bash(echo *)", "Bash(jq *)",
304
+ "Bash(wc *)", "Bash(head *)", "Bash(tail *)", "Bash(sort *)",
305
+ "Bash(uniq *)", "Bash(xargs *)"
306
+ ]
307
+ },
308
+ hooks: {
309
+ PreToolUse: [{
310
+ matcher: "EnterPlanMode|TaskCreate|TaskUpdate|TaskList|TaskGet|ExitPlanMode",
311
+ hooks: [{
312
+ type: "command",
313
+ command: `bash ${hooksBase}/tlc-block-tools.sh`,
314
+ timeout: 5
315
+ }]
316
+ }],
317
+ UserPromptSubmit: [{
318
+ hooks: [{
319
+ type: "command",
320
+ command: `bash ${hooksBase}/tlc-prompt-guard.sh`,
321
+ timeout: 5
322
+ }]
323
+ }],
324
+ SessionStart: [{
325
+ hooks: [{
326
+ type: "command",
327
+ command: `bash ${hooksBase}/tlc-session-init.sh`,
328
+ timeout: 5
329
+ }]
330
+ }],
331
+ PostToolUse: [
332
+ {
333
+ matcher: "Bash",
334
+ hooks: [{
335
+ type: "command",
336
+ command: `bash ${hooksBase}/tlc-post-push.sh`,
337
+ timeout: 5
338
+ }]
339
+ },
340
+ {
341
+ matcher: "Skill",
342
+ hooks: [{
343
+ type: "command",
344
+ command: `bash ${hooksBase}/tlc-post-build.sh`,
345
+ timeout: 5
346
+ }]
347
+ }
348
+ ],
349
+ Stop: [{
350
+ hooks: [{
351
+ type: "command",
352
+ command: `bash "${hooksBase}/tlc-capture-exchange.sh"`,
353
+ timeout: 30
354
+ }]
355
+ }]
356
+ }
357
+ };
358
+
359
+ if (fs.existsSync(settingsPath)) {
360
+ // Merge: preserve existing permissions, add missing hooks
361
+ try {
362
+ const existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
363
+ // Merge permissions (union)
364
+ if (existing.permissions && existing.permissions.allow) {
365
+ const existingSet = new Set(existing.permissions.allow);
366
+ for (const perm of settingsTemplate.permissions.allow) {
367
+ existingSet.add(perm);
368
+ }
369
+ existing.permissions.allow = [...existingSet];
370
+ } else {
371
+ existing.permissions = settingsTemplate.permissions;
372
+ }
373
+ // Add hooks if not present
374
+ if (!existing.hooks) {
375
+ existing.hooks = settingsTemplate.hooks;
376
+ }
377
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n');
378
+ return true;
379
+ } catch (err) {
380
+ // If we can't parse existing, don't overwrite
381
+ return false;
382
+ }
383
+ } else {
384
+ fs.writeFileSync(settingsPath, JSON.stringify(settingsTemplate, null, 2) + '\n');
385
+ return true;
386
+ }
387
+ }
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
+
153
413
  async function main() {
154
414
  const args = process.argv.slice(2);
155
415
 
@@ -161,6 +421,12 @@ async function main() {
161
421
 
162
422
  printBanner();
163
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
+
164
430
  if (args.includes('--global') || args.includes('-g')) {
165
431
  info(`Installing ${c.bold}globally${c.reset} to ~/.claude/commands/tlc`);
166
432
  log('');
@@ -4,44 +4,122 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
 
7
- // Get source and destination directories
8
- const srcDir = path.join(__dirname, '..', '.claude', 'commands', 'tlc');
9
- const destDir = path.join(os.homedir(), '.claude', 'commands', 'tlc');
7
+ const packageRoot = path.join(__dirname, '..');
8
+ const claudeHome = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
9
+
10
+ // Source directories (inside npm package)
11
+ const commandsSrcDir = path.join(packageRoot, '.claude', 'commands', 'tlc');
12
+ const hooksSrcDir = path.join(packageRoot, '.claude', 'hooks');
13
+ const agentsSrcDir = path.join(packageRoot, '.claude', 'agents');
14
+
15
+ // Destination directories (user's home)
16
+ const commandsDestDir = path.join(claudeHome, 'commands', 'tlc');
17
+ const hooksDestDir = path.join(claudeHome, 'hooks');
18
+ const agentsDestDir = path.join(claudeHome, 'agents');
10
19
 
11
- // Create destination directory if it doesn't exist
12
20
  function ensureDir(dir) {
13
21
  if (!fs.existsSync(dir)) {
14
22
  fs.mkdirSync(dir, { recursive: true });
15
23
  }
16
24
  }
17
25
 
18
- // Copy all .md files from source to destination
26
+ // Copy all .md command files
19
27
  function copyCommands() {
20
- try {
21
- // Ensure destination exists
22
- ensureDir(destDir);
28
+ if (!fs.existsSync(commandsSrcDir)) return 0;
29
+ ensureDir(commandsDestDir);
30
+
31
+ const files = fs.readdirSync(commandsSrcDir).filter(f => f.endsWith('.md'));
32
+ let copied = 0;
33
+ for (const file of files) {
34
+ fs.copyFileSync(path.join(commandsSrcDir, file), path.join(commandsDestDir, file));
35
+ copied++;
36
+ }
37
+ return copied;
38
+ }
23
39
 
24
- // Check if source exists
25
- if (!fs.existsSync(srcDir)) {
26
- // Silent exit if source doesn't exist (might be dev install)
27
- return;
40
+ // Copy all .sh hook files
41
+ function copyHooks() {
42
+ if (!fs.existsSync(hooksSrcDir)) return 0;
43
+ ensureDir(hooksDestDir);
44
+
45
+ const files = fs.readdirSync(hooksSrcDir).filter(f => f.endsWith('.sh'));
46
+ let copied = 0;
47
+ for (const file of files) {
48
+ const dest = path.join(hooksDestDir, file);
49
+ fs.copyFileSync(path.join(hooksSrcDir, file), dest);
50
+ fs.chmodSync(dest, 0o755);
51
+ copied++;
52
+ }
53
+ return copied;
54
+ }
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;
28
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
+ }
29
79
 
30
- // Get all .md files
31
- const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.md'));
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
+ }
32
100
 
33
- let copied = 0;
34
- for (const file of files) {
35
- const src = path.join(srcDir, file);
36
- const dest = path.join(destDir, file);
101
+ function postinstall() {
102
+ try {
103
+ const commands = copyCommands();
104
+ const hooks = copyHooks();
105
+ const agents = copyAgents();
37
106
 
38
- // Copy file (overwrite if exists)
39
- fs.copyFileSync(src, dest);
40
- copied++;
107
+ if (commands > 0) {
108
+ console.log(`\x1b[32m✓\x1b[0m TLC: Installed ${commands} commands to ~/.claude/commands/tlc/`);
109
+ }
110
+ if (hooks > 0) {
111
+ console.log(`\x1b[32m✓\x1b[0m TLC: Installed ${hooks} hooks to ~/.claude/hooks/`);
112
+ }
113
+ if (agents > 0) {
114
+ console.log(`\x1b[32m✓\x1b[0m TLC: Installed ${agents} agents to ~/.claude/agents/`);
41
115
  }
42
116
 
43
- if (copied > 0) {
44
- console.log(`\x1b[32m✓\x1b[0m TLC: Installed ${copied} commands to ~/.claude/commands/tlc/`);
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);
45
123
  }
46
124
  } catch (err) {
47
125
  // Silent fail - don't break npm install
@@ -51,4 +129,4 @@ function copyCommands() {
51
129
  }
52
130
  }
53
131
 
54
- copyCommands();
132
+ postinstall();