create-claude-cabinet 0.40.0 → 0.41.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 (55) hide show
  1. package/README.md +1 -1
  2. package/lib/cli.js +14 -0
  3. package/lib/engagement-server-setup.js +5 -1
  4. package/lib/metadata.js +9 -2
  5. package/lib/mux-setup.js +117 -1
  6. package/lib/settings-merge.js +5 -0
  7. package/package.json +4 -2
  8. package/templates/cabinet/_cabinet-member-template.md +4 -2
  9. package/templates/mux/bin/mux +31 -6
  10. package/templates/mux/config/context-help.py +6 -0
  11. package/templates/mux/config/help.txt +16 -0
  12. package/templates/mux/config/mux.tmux.conf +27 -0
  13. package/templates/mux/config/muxlib.py +22 -3
  14. package/templates/mux/config/screenshot-to-clipboard.sh +45 -0
  15. package/templates/mux/config/unwrap-copy.py +72 -0
  16. package/templates/scripts/watchtower-build-context.mjs +27 -12
  17. package/templates/scripts/watchtower-queue.mjs +8 -2
  18. package/templates/scripts/watchtower-ring2.mjs +99 -5
  19. package/templates/scripts/watchtower-ring3-close.mjs +43 -14
  20. package/templates/scripts/watchtower-status.sh +6 -0
  21. package/templates/skills/cabinet-accessibility/SKILL.md +4 -2
  22. package/templates/skills/cabinet-anthropic-insider/SKILL.md +4 -2
  23. package/templates/skills/cabinet-anti-confirmation/SKILL.md +4 -2
  24. package/templates/skills/cabinet-architecture/SKILL.md +4 -2
  25. package/templates/skills/cabinet-automation/SKILL.md +4 -2
  26. package/templates/skills/cabinet-boundary-man/SKILL.md +4 -2
  27. package/templates/skills/cabinet-cc-health/SKILL.md +4 -2
  28. package/templates/skills/cabinet-data-integrity/SKILL.md +4 -2
  29. package/templates/skills/cabinet-debugger/SKILL.md +4 -2
  30. package/templates/skills/cabinet-elegance/SKILL.md +4 -2
  31. package/templates/skills/cabinet-framework-quality/SKILL.md +4 -2
  32. package/templates/skills/cabinet-goal-alignment/SKILL.md +4 -2
  33. package/templates/skills/cabinet-historian/SKILL.md +4 -2
  34. package/templates/skills/cabinet-information-design/SKILL.md +4 -2
  35. package/templates/skills/cabinet-interactive-storyteller/SKILL.md +4 -2
  36. package/templates/skills/cabinet-mantine-quality/SKILL.md +4 -2
  37. package/templates/skills/cabinet-narrative-architect/SKILL.md +4 -2
  38. package/templates/skills/cabinet-organized-mind/SKILL.md +4 -2
  39. package/templates/skills/cabinet-process-therapist/SKILL.md +4 -2
  40. package/templates/skills/cabinet-qa/SKILL.md +4 -2
  41. package/templates/skills/cabinet-record-keeper/SKILL.md +4 -2
  42. package/templates/skills/cabinet-roster-check/SKILL.md +4 -2
  43. package/templates/skills/cabinet-security/SKILL.md +4 -2
  44. package/templates/skills/cabinet-small-screen/SKILL.md +4 -2
  45. package/templates/skills/cabinet-speed-freak/SKILL.md +4 -2
  46. package/templates/skills/cabinet-system-advocate/SKILL.md +4 -2
  47. package/templates/skills/cabinet-technical-debt/SKILL.md +4 -2
  48. package/templates/skills/cabinet-ui-experimentalist/SKILL.md +4 -2
  49. package/templates/skills/cabinet-usability/SKILL.md +4 -2
  50. package/templates/skills/cabinet-user-advocate/SKILL.md +4 -2
  51. package/templates/skills/cabinet-vision/SKILL.md +4 -2
  52. package/templates/skills/cabinet-workflow-cop/SKILL.md +4 -2
  53. package/templates/skills/inbox/SKILL.md +17 -4
  54. package/templates/watchtower/queue/items/item.json.schema +30 -3
  55. package/templates/mux/config/__pycache__/muxlib.cpython-314.pyc +0 -0
package/README.md CHANGED
@@ -230,7 +230,7 @@ customization. You can pass multiple modules: `--modules verify,audit`.
230
230
  | **engagement** | Client engagement management — packets, billing, feedback loops |
231
231
  | **engagement-server** | Central multi-engagement API server (Railway/Fly deploy) |
232
232
  | **watchtower** | Continuous background state management replacing orient/debrief |
233
- | **mux** | Multi-project terminal manager — desks, auto-worktrees with shared identity, trail logging, DX captures, portal color-switching |
233
+ | **mux** | Multi-project terminal manager — desks, auto-worktrees with shared identity, trail logging, DX captures, portal color-switching, durable tmux bindings, clipboard copy with hard-wrap removal, screenshot-to-clipboard launchd watcher |
234
234
 
235
235
  ## CLI Options
236
236
 
package/lib/cli.js CHANGED
@@ -1315,6 +1315,11 @@ async function run() {
1315
1315
  if (fs.existsSync(mcpJsonPath)) {
1316
1316
  existing = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
1317
1317
  }
1318
+ // Guard shape drift: .mcpServers set on a top-level array would be
1319
+ // silently dropped on serialize.
1320
+ if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
1321
+ existing = {};
1322
+ }
1318
1323
  if (!existing.mcpServers) existing.mcpServers = {};
1319
1324
  Object.assign(existing.mcpServers, mcpConfig.mcpServers);
1320
1325
  fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2) + '\n');
@@ -1567,6 +1572,15 @@ async function run() {
1567
1572
  if (fs.existsSync(registryPath)) {
1568
1573
  registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
1569
1574
  }
1575
+ // Normalize shape drift: a bare top-level array has been observed
1576
+ // (ad-hoc edit dropped the {"projects": ...} wrapper). Re-wrap so
1577
+ // the update below also heals the file on disk.
1578
+ if (Array.isArray(registry)) {
1579
+ registry = { projects: registry };
1580
+ }
1581
+ if (!Array.isArray(registry.projects)) {
1582
+ registry.projects = [];
1583
+ }
1570
1584
  const existingIdx = registry.projects.findIndex(p => p.path === projectDir);
1571
1585
  const entry = {
1572
1586
  path: projectDir,
@@ -57,7 +57,11 @@ function compareVersions(a, b) {
57
57
  function readGlobalManifest() {
58
58
  if (!fs.existsSync(GLOBAL_MANIFEST_PATH)) return { files: {} };
59
59
  try {
60
- return JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
60
+ const m = JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
61
+ // Guard shape drift: manifest.files is indexed unconditionally below.
62
+ if (typeof m !== 'object' || m === null || Array.isArray(m)) return { files: {} };
63
+ if (typeof m.files !== 'object' || m.files === null || Array.isArray(m.files)) m.files = {};
64
+ return m;
61
65
  } catch {
62
66
  return { files: {} };
63
67
  }
package/lib/metadata.js CHANGED
@@ -10,13 +10,20 @@ function metadataPath(projectDir) {
10
10
 
11
11
  function read(projectDir) {
12
12
  const file = metadataPath(projectDir);
13
- if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf8'));
13
+ if (fs.existsSync(file)) return normalize(JSON.parse(fs.readFileSync(file, 'utf8')));
14
14
  // Fall back to legacy manifest from pre-v0.6.0 installs
15
15
  const legacyFile = path.join(projectDir, LEGACY_METADATA_FILE);
16
- if (fs.existsSync(legacyFile)) return JSON.parse(fs.readFileSync(legacyFile, 'utf8'));
16
+ if (fs.existsSync(legacyFile)) return normalize(JSON.parse(fs.readFileSync(legacyFile, 'utf8')));
17
17
  return null;
18
18
  }
19
19
 
20
+ // Guard shape drift: callers key into .modules/.manifest/.version — a
21
+ // non-object top level must read as "no metadata", not crash the installer.
22
+ function normalize(data) {
23
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return null;
24
+ return data;
25
+ }
26
+
20
27
  function write(projectDir, data) {
21
28
  const file = metadataPath(projectDir);
22
29
  fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
package/lib/mux-setup.js CHANGED
@@ -45,6 +45,9 @@ const MANAGED_FILES = [
45
45
  { src: 'config/worktree-session-health.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-session-health.sh'), mode: 0o755 },
46
46
  { src: 'config/worktree-health-popup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-health-popup.sh'), mode: 0o755 },
47
47
  { src: 'config/worktree-cleanup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-cleanup.sh'), mode: 0o755 },
48
+ { src: 'config/mux.tmux.conf', dest: path.join(os.homedir(), '.config', 'mux', 'mux.tmux.conf') },
49
+ { src: 'config/unwrap-copy.py', dest: path.join(os.homedir(), '.config', 'mux', 'unwrap-copy.py'), mode: 0o755 },
50
+ { src: 'config/screenshot-to-clipboard.sh', dest: path.join(os.homedir(), '.config', 'mux', 'screenshot-to-clipboard.sh'), mode: 0o755 },
48
51
  ];
49
52
 
50
53
  const DATA_DIRS = [
@@ -75,7 +78,11 @@ function compareVersions(a, b) {
75
78
  function readGlobalManifest() {
76
79
  if (!fs.existsSync(GLOBAL_MANIFEST_PATH)) return { files: {} };
77
80
  try {
78
- return JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
81
+ const m = JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
82
+ // Guard shape drift: manifest.files is indexed unconditionally below.
83
+ if (typeof m !== 'object' || m === null || Array.isArray(m)) return { files: {} };
84
+ if (typeof m.files !== 'object' || m.files === null || Array.isArray(m.files)) m.files = {};
85
+ return m;
79
86
  } catch {
80
87
  return { files: {} };
81
88
  }
@@ -187,7 +194,116 @@ function setupMux(opts = {}) {
187
194
 
188
195
  results.push(` ${copiedCount} file${copiedCount !== 1 ? 's' : ''} installed to user paths`);
189
196
 
197
+ if (process.platform === 'darwin') {
198
+ setupDarwinIntegration({ dryRun, results });
199
+ }
200
+
190
201
  return { results, status: installedVersion ? 'upgraded' : 'installed' };
191
202
  }
192
203
 
204
+ /**
205
+ * macOS-specific wiring: tmux.conf source line, live binding reload,
206
+ * and the screenshot-to-clipboard launchd watcher. All steps are
207
+ * idempotent and individually fault-tolerant — a failure in one is
208
+ * reported but never aborts the install.
209
+ */
210
+ function setupDarwinIntegration({ dryRun, results }) {
211
+ const { execSync } = require('child_process');
212
+ const run = (cmd) => execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
213
+
214
+ // 1. ~/.tmux.conf sources mux.tmux.conf so mux bindings survive tmux
215
+ // server restarts (inline bind-key calls from bin/mux evaporate).
216
+ const tmuxConf = path.join(os.homedir(), '.tmux.conf');
217
+ const sourceLine = 'source-file -q ~/.config/mux/mux.tmux.conf';
218
+ try {
219
+ const existing = fs.existsSync(tmuxConf) ? fs.readFileSync(tmuxConf, 'utf8') : '';
220
+ if (!existing.includes('mux.tmux.conf')) {
221
+ if (dryRun) {
222
+ results.push(` [dry-run] append "${sourceLine}" to ~/.tmux.conf`);
223
+ } else {
224
+ const block = `\n# mux-managed bindings (CC) — keep this line; mux upgrades edit the sourced file\n${sourceLine}\n`;
225
+ fs.appendFileSync(tmuxConf, block);
226
+ results.push(' ~/.tmux.conf now sources mux.tmux.conf');
227
+ }
228
+ }
229
+ } catch (err) {
230
+ results.push(` ⚠ Could not update ~/.tmux.conf: ${err.message}`);
231
+ }
232
+
233
+ // 2. Apply bindings to a running tmux server immediately.
234
+ if (!dryRun) {
235
+ try {
236
+ run('tmux source-file ~/.config/mux/mux.tmux.conf');
237
+ results.push(' mux.tmux.conf applied to running tmux server');
238
+ } catch {
239
+ // No server running — bindings load at next server start via ~/.tmux.conf.
240
+ }
241
+ }
242
+
243
+ // 3. Screenshot-to-clipboard launchd watcher. Watches the macOS
244
+ // screenshot folder and puts each new screenshot on the clipboard
245
+ // as a file reference.
246
+ const label = 'com.mux.screenshot-to-clipboard';
247
+ const agentDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
248
+ const plistPath = path.join(agentDir, `${label}.plist`);
249
+ const legacyLabel = 'com.orenmagid.screenshot-to-clipboard';
250
+ const legacyPlist = path.join(agentDir, `${legacyLabel}.plist`);
251
+ const scriptPath = path.join(os.homedir(), '.config', 'mux', 'screenshot-to-clipboard.sh');
252
+
253
+ let shotDir = path.join(os.homedir(), 'Desktop');
254
+ try {
255
+ const loc = run('defaults read com.apple.screencapture location');
256
+ if (loc) shotDir = loc.replace(/^~/, os.homedir());
257
+ } catch {
258
+ /* default location */
259
+ }
260
+
261
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
262
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
263
+ <plist version="1.0">
264
+ <dict>
265
+ <key>Label</key>
266
+ <string>${label}</string>
267
+ <key>ProgramArguments</key>
268
+ <array>
269
+ <string>/bin/bash</string>
270
+ <string>${scriptPath}</string>
271
+ </array>
272
+ <key>WatchPaths</key>
273
+ <array>
274
+ <string>${shotDir}</string>
275
+ </array>
276
+ </dict>
277
+ </plist>
278
+ `;
279
+
280
+ if (dryRun) {
281
+ results.push(` [dry-run] install launchd watcher ${label} (watching ${shotDir})`);
282
+ return;
283
+ }
284
+
285
+ try {
286
+ const uid = run('id -u');
287
+
288
+ // Migrate: unload + remove a pre-CC hand-rolled watcher so the two
289
+ // never double-fire. The old script file is left in place.
290
+ if (fs.existsSync(legacyPlist)) {
291
+ try { run(`launchctl bootout gui/${uid}/${legacyLabel}`); } catch { /* not loaded */ }
292
+ fs.unlinkSync(legacyPlist);
293
+ results.push(` migrated legacy watcher (${legacyLabel} removed)`);
294
+ }
295
+
296
+ fs.mkdirSync(agentDir, { recursive: true });
297
+ const hadPlist = fs.existsSync(plistPath);
298
+ fs.writeFileSync(plistPath, plist);
299
+ if (hadPlist) {
300
+ try { run(`launchctl bootout gui/${uid}/${label}`); } catch { /* not loaded */ }
301
+ }
302
+ run(`launchctl bootstrap gui/${uid} ${plistPath}`);
303
+ results.push(` screenshot-to-clipboard watcher loaded (watching ${shotDir})`);
304
+ } catch (err) {
305
+ results.push(` ⚠ launchd watcher setup failed: ${err.message}`);
306
+ }
307
+ }
308
+
193
309
  module.exports = { setupMux };
@@ -153,6 +153,11 @@ function mergeSettings(projectDir, { includeDb = true } = {}) {
153
153
  if (fs.existsSync(settingsPath)) {
154
154
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
155
155
  }
156
+ // Guard shape drift: setting .hooks on a top-level array would silently
157
+ // drop it on serialize (JSON.stringify ignores non-index array props).
158
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
159
+ settings = {};
160
+ }
156
161
 
157
162
  if (!settings.hooks) settings.hooks = {};
158
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -8,7 +8,9 @@
8
8
  "files": [
9
9
  "bin/",
10
10
  "lib/",
11
- "templates/"
11
+ "templates/",
12
+ "!**/__pycache__",
13
+ "!**/*.pyc"
12
14
  ],
13
15
  "keywords": [
14
16
  "claude",
@@ -128,8 +128,10 @@ to anchor the boundaries.
128
128
  ```markdown
129
129
  ## Historically Problematic Patterns
130
130
 
131
- Read `patterns-project.md` in this skill directory for project-specific
132
- patterns from prior audits. Apply alongside universal patterns below.
131
+ If `patterns-project.md` exists in this skill directory, read it for
132
+ project-specific patterns from prior audits and apply alongside the
133
+ universal patterns below. Absent is normal — it is seeded by debrief
134
+ when recurring findings accumulate.
133
135
 
134
136
  <!-- Universal patterns below this line -->
135
137
  ```
@@ -167,12 +167,11 @@ setup_session_hooks() {
167
167
  tmux set-option -t "$project" window-status-current-format \
168
168
  '#[fg=#ffffff,bg=#4a4a8a,bold] #I:#W#{?@mux_worktree,#{?@wt_healthy,#[fg=#88ddcc],#[fg=#ff6666]}·wt#[default],} ' 2>/dev/null || true
169
169
 
170
- # Global binding (root table, idempotent) lives here because mux has
171
- # no one-time init path; re-setting on each session open is harmless.
172
- # select-window -t = switches to the clicked tab before the popup queries tmux.
173
- tmux bind-key -T root DoubleClick1Status \
174
- select-window -t = \; \
175
- display-popup -E -h 14 -w 70 "${HOME}/.config/mux/worktree-health-popup.sh" 2>/dev/null || true
170
+ # Global bindings live in mux.tmux.conf (single source of truth), which
171
+ # ~/.tmux.conf sources at server start. Re-sourcing here applies upgrades
172
+ # to a running server bindings no longer evaporate on server restart
173
+ # the way inline bind-key calls did.
174
+ tmux source-file "${HOME}/.config/mux/mux.tmux.conf" 2>/dev/null || true
176
175
  }
177
176
 
178
177
  MUX_WORKTREES_DIR="${HOME}/.mux/worktrees"
@@ -1099,6 +1098,7 @@ cmd_setup() {
1099
1098
  │ mux ls List all desks │
1100
1099
  │ mux note "..." Leave a note │
1101
1100
  │ mux dx "..." DX idea capture │
1101
+ │ mux copy Copy w/o wraps │
1102
1102
  │ mux close / done Park this desk │
1103
1103
  │ mux where Where am I? │
1104
1104
  │ mux help This screen │
@@ -1131,6 +1131,7 @@ HELPFILE
1131
1131
  python3 -c "
1132
1132
  import json, os
1133
1133
  reg = json.load(open(os.path.expanduser('~/.claude/cc-registry.json')))
1134
+ if isinstance(reg, list): reg = {'projects': reg} # tolerate shape drift
1134
1135
  colors = ['#1a2744','#1a3a2a','#3a2a1a','#2a1a3a','#1a1a2e','#2a3a1a','#3a1a2a']
1135
1136
  projects = {}
1136
1137
  for i, p in enumerate(reg.get('projects', [])):
@@ -1235,6 +1236,29 @@ cmd_help() {
1235
1236
  fi
1236
1237
  }
1237
1238
 
1239
+ cmd_copy() {
1240
+ # Copy text to the system clipboard with hard-wrap removal — prose
1241
+ # produced in a Claude pane lands paste-ready for Gmail, D2L, docs.
1242
+ # Input: stdin when piped, otherwise the most recent tmux buffer
1243
+ # (i.e., the last copy-mode selection).
1244
+ local input
1245
+ if [[ ! -t 0 ]]; then
1246
+ input=$(cat)
1247
+ elif in_tmux; then
1248
+ input=$(tmux show-buffer 2>/dev/null) || die "Nothing to copy — select text first, or pipe: cmd | mux copy"
1249
+ else
1250
+ die "Pipe text in: some-command | mux copy"
1251
+ fi
1252
+ [[ -n "$input" ]] || die "Nothing to copy."
1253
+
1254
+ command -v pbcopy >/dev/null 2>&1 || die "pbcopy not found — mux copy requires macOS."
1255
+ printf '%s' "$input" | "${HOME}/.config/mux/unwrap-copy.py" | pbcopy
1256
+
1257
+ local chars
1258
+ chars=$(pbpaste | wc -c | tr -d ' ')
1259
+ printf '\033[32m✓\033[0m copied %s chars to clipboard (hard wraps removed)\n' "$chars"
1260
+ }
1261
+
1238
1262
  cmd_resume() {
1239
1263
  local session_id="${1:-}"
1240
1264
  require_tmux
@@ -1278,6 +1302,7 @@ main() {
1278
1302
  split) shift; cmd_split "${1:-}" ;;
1279
1303
  where) cmd_where ;;
1280
1304
  note) shift; cmd_note "$@" ;;
1305
+ copy) shift; cmd_copy "$@" ;;
1281
1306
  dx) shift; cmd_dx "$@" ;;
1282
1307
  portal) shift; cmd_portal "${1:-}" ;;
1283
1308
  worktree) shift; cmd_worktree "$@" ;;
@@ -27,6 +27,9 @@ def main():
27
27
  if not is_shell:
28
28
  print(f" {CYAN}Claude is running here.{R}")
29
29
  print(f" F3 = quick shell F2 = dashboard")
30
+ print(f" {D}Opt-B / Opt-F word back / forward{R}")
31
+ print(f" {D}Ctrl-A/E line start/end · Ctrl-W del{R}")
32
+ print(f" {D}Shift-Enter newline · ↑↓ history{R}")
30
33
  print()
31
34
  else:
32
35
  print(f" {D}Shell window.{R}")
@@ -41,7 +44,10 @@ def main():
41
44
  print(f" F1 Help F2 Dashboard F3 Shell")
42
45
  print(f" F4 Trail F5 Sessions")
43
46
  print(f" Ctrl-Space d Detach s Pick desk")
47
+ print(f" Ctrl-Space [ scroll back q = live")
48
+ print(f" drag = copy Y = copy w/o wraps")
44
49
  print()
50
+ print(f" {D}Full list: mux help{R}")
45
51
  print(f" {D}q = close{R}")
46
52
  print()
47
53
 
@@ -14,6 +14,7 @@
14
14
  │ mux note rm <#> Remove a note │
15
15
  │ mux dx "..." DX idea capture │
16
16
  │ mux dx done <#> Clear DX item │
17
+ │ mux copy Copy w/o wraps │
17
18
  │ mux split Split pane │
18
19
  │ mux portal on/off Toggle voice │
19
20
  │ mux worktree ls List worktrees │
@@ -32,6 +33,21 @@
32
33
  │ ·wt tab marker = worktree window │
33
34
  │ green = healthy, red = issues │
34
35
  │ │
36
+ │ Terminal text (scroll + copy): │
37
+ │ wheel or Ctrl-Space [ scroll back │
38
+ │ PgUp/PgDn move q = back to live │
39
+ │ Ctrl-r / Ctrl-s search back / fwd │
40
+ │ drag = copy Y = copy w/o wraps │
41
+ │ F3 popup: Shift-drag to select │
42
+ │ │
43
+ │ Typing a message (Claude prompt): │
44
+ │ Opt-B / Opt-F word back / fwd │
45
+ │ Ctrl-A / Ctrl-E line start / end │
46
+ │ Ctrl-W del word Ctrl-K del to end │
47
+ │ Ctrl-U del to start Ctrl-Y paste │
48
+ │ Shift-Enter newline (no send) │
49
+ │ ↑ / ↓ message history │
50
+ │ │
35
51
  │ Ctrl-Space shortcuts: │
36
52
  │ d Detach (leave tmux) │
37
53
  │ s Session picker │
@@ -0,0 +1,27 @@
1
+ # mux.tmux.conf — mux-owned tmux bindings (CC-managed, do not edit).
2
+ #
3
+ # Sourced from ~/.tmux.conf (line appended by mux-setup) so these survive
4
+ # tmux server restarts, and re-sourced by mux on each desk open so upgrades
5
+ # apply live. Single source of truth for mux's tmux bindings — bin/mux
6
+ # delegates here instead of setting bindings inline.
7
+
8
+ # --- Worktree health popup ---------------------------------------------
9
+ # Double-click a window tab: switch to it, then show its health popup.
10
+ bind-key -T root DoubleClick1Status \
11
+ select-window -t = \; \
12
+ display-popup -E -h 14 -w 70 "~/.config/mux/worktree-health-popup.sh"
13
+
14
+ # --- Clipboard ----------------------------------------------------------
15
+ # Copies land on the macOS clipboard, not just tmux's internal buffer.
16
+ set -g set-clipboard on
17
+
18
+ # Mouse-drag or y: copy selection verbatim to the system clipboard.
19
+ bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"
20
+ bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"
21
+ bind-key -T copy-mode y send-keys -X copy-pipe-and-cancel "pbcopy"
22
+ bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
23
+
24
+ # Y: unwrap-copy — joins hard-wrapped prose lines (Claude output → Gmail,
25
+ # D2L, docs) while leaving code blocks and lists intact.
26
+ bind-key -T copy-mode Y send-keys -X copy-pipe-and-cancel "~/.config/mux/unwrap-copy.py | pbcopy"
27
+ bind-key -T copy-mode-vi Y send-keys -X copy-pipe-and-cancel "~/.config/mux/unwrap-copy.py | pbcopy"
@@ -34,6 +34,17 @@ CYAN = "\033[36m"
34
34
 
35
35
 
36
36
  def load_json(path):
37
+ # Shape-coercing by design: every caller treats the result as a dict
38
+ # (.get / key access), so a file whose top level drifted to a list or
39
+ # scalar must degrade to {} here — not crash 20 call sites downstream
40
+ # (the cc-registry bare-array incident took out F2/F4/F5 and the
41
+ # picker at once). Callers that can rescue a non-dict shape should
42
+ # use load_json_any and normalize themselves.
43
+ data = load_json_any(path)
44
+ return data if isinstance(data, dict) else {}
45
+
46
+
47
+ def load_json_any(path):
37
48
  path = os.path.expanduser(path)
38
49
  if not os.path.exists(path):
39
50
  return {}
@@ -180,9 +191,17 @@ AUTO_COLORS = [
180
191
 
181
192
  def load_projects():
182
193
  manual = load_json(MUX_PROJECTS).get("projects", {})
183
- registry = load_json(CC_REGISTRY)
184
-
185
- if not isinstance(registry.get("projects"), list):
194
+ if not isinstance(manual, dict):
195
+ manual = {}
196
+ registry = load_json_any(CC_REGISTRY)
197
+
198
+ # Tolerate registry shape drift: canonical is {"projects": [...]}, but a
199
+ # bare top-level list has been observed in the wild (ad-hoc cleanup
200
+ # rewrote the file without the wrapper, crashing every load_projects
201
+ # caller — F2 dashboard, picker, sidebars). Degrade, never crash.
202
+ if isinstance(registry, list):
203
+ registry = {"projects": registry}
204
+ if not isinstance(registry, dict) or not isinstance(registry.get("projects"), list):
186
205
  return manual
187
206
 
188
207
  known_paths = {p.get("path") for p in manual.values() if isinstance(p, dict)}
@@ -0,0 +1,45 @@
1
+ #!/bin/bash
2
+ # screenshot-to-clipboard.sh — copy the newest screenshot to the clipboard
3
+ # as BOTH representations on a single pasteboard item: PNG image data
4
+ # (paste-able into Claude Code via Ctrl+V, Gmail, Slack) AND a file
5
+ # reference (paste-able as a file in Finder / attach dialogs). Each paste
6
+ # target picks the form it understands. Falls back to PNG-data-only if
7
+ # the JXA dual-write fails for any reason.
8
+ #
9
+ # Triggered by launchd WatchPaths on the screenshots folder (plist written
10
+ # by lib/mux-setup.js). Three guards keep it from misfiring:
11
+ # - `ls -t` glob: sorted newest-first, never matches macOS's in-progress
12
+ # hidden temp file (.Screenshot…), so we never copy a path that's
13
+ # about to be renamed out from under us
14
+ # - age guard: only fires for a file created in the last 15 seconds, so
15
+ # folder cleanups / syncs don't clobber the clipboard with an old shot
16
+ # - existence re-check after the settle sleep
17
+
18
+ DIR=$(defaults read com.apple.screencapture location 2>/dev/null || echo "$HOME/Desktop")
19
+ DIR="${DIR/#\~/$HOME}"
20
+
21
+ sleep 1 # let macOS finish writing/renaming the file
22
+
23
+ NEWEST=$(ls -t "$DIR"/*.png 2>/dev/null | head -1)
24
+ [ -n "$NEWEST" ] || exit 0
25
+ [ -f "$NEWEST" ] || exit 0
26
+
27
+ NOW=$(date +%s)
28
+ MTIME=$(stat -f %m "$NEWEST" 2>/dev/null || echo 0)
29
+ AGE=$((NOW - MTIME))
30
+ [ "$AGE" -le 15 ] || exit 0
31
+
32
+ osascript -l JavaScript -e '
33
+ function run(argv) {
34
+ ObjC.import("AppKit");
35
+ const path = argv[0];
36
+ const data = $.NSData.dataWithContentsOfFile(path);
37
+ if (data.isNil()) throw new Error("unreadable: " + path);
38
+ const item = $.NSPasteboardItem.alloc.init;
39
+ item.setDataForType(data, "public.png");
40
+ item.setStringForType($.NSURL.fileURLWithPath(path).absoluteString, "public.file-url");
41
+ const pb = $.NSPasteboard.generalPasteboard;
42
+ pb.clearContents;
43
+ if (!pb.writeObjects($([item]))) throw new Error("writeObjects failed");
44
+ }' "$NEWEST" 2>/dev/null || \
45
+ osascript -e "set the clipboard to (read (POSIX file \"$NEWEST\") as «class PNGf»)"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """unwrap-copy.py — join hard-wrapped prose lines, preserve structure.
3
+
4
+ Pure filter: stdin → unwrapped text → stdout. Callers pipe to pbcopy
5
+ (`mux copy`, and the copy-mode Y binding in mux.tmux.conf).
6
+
7
+ Heuristic: a line is "wrap-broken" when it runs close to the longest
8
+ line in the input — terminal-width wrapping leaves no short lines
9
+ mid-paragraph. Blank lines (paragraph breaks), fenced code blocks,
10
+ indented blocks, and list-item starts pass through untouched, so code
11
+ survives while prose unwraps. List items join their own wrapped
12
+ continuation lines.
13
+ """
14
+
15
+ import re
16
+ import sys
17
+
18
+
19
+ LIST_RE = re.compile(r"^\s*([-*+•]|\d+[.)]|>)\s")
20
+
21
+
22
+ def breaks_join(line):
23
+ """A line that must start fresh, never be appended to the previous."""
24
+ s = line.strip()
25
+ return (
26
+ not s
27
+ or s.startswith("```")
28
+ or line.startswith((" ", "\t"))
29
+ or bool(LIST_RE.match(line))
30
+ )
31
+
32
+
33
+ def unwrap(text):
34
+ lines = [l.rstrip() for l in text.split("\n")]
35
+
36
+ # Lines near the longest one were likely wrapped at that width.
37
+ maxlen = max((len(l) for l in lines), default=0)
38
+ threshold = max(40, maxlen - 15)
39
+
40
+ out = []
41
+ in_fence = False
42
+ i = 0
43
+ while i < len(lines):
44
+ line = lines[i]
45
+ if line.strip().startswith("```"):
46
+ in_fence = not in_fence
47
+ out.append(line)
48
+ i += 1
49
+ continue
50
+ # Pass through verbatim — except list items, which fall through so
51
+ # their wrapped continuation lines can join them.
52
+ if in_fence or (breaks_join(line) and not LIST_RE.match(line)):
53
+ out.append(line)
54
+ i += 1
55
+ continue
56
+
57
+ buf = line
58
+ j = i + 1
59
+ while j < len(lines) and len(lines[j - 1]) >= threshold:
60
+ nxt = lines[j]
61
+ if breaks_join(nxt):
62
+ break
63
+ buf += " " + nxt.strip()
64
+ j += 1
65
+ out.append(buf)
66
+ i = j
67
+
68
+ return "\n".join(out)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ sys.stdout.write(unwrap(sys.stdin.read()))
@@ -69,10 +69,12 @@ function fileAge(filePath) {
69
69
 
70
70
  function countQueueItems() {
71
71
  const queueDir = join(WATCHTOWER_DIR, 'queue', 'items');
72
- if (!existsSync(queueDir)) return { total: 0, urgent: 0 };
72
+ if (!existsSync(queueDir)) return { total: 0, urgent: 0, byCategory: {}, draftsReady: 0 };
73
73
 
74
74
  let total = 0;
75
75
  let urgent = 0;
76
+ let draftsReady = 0;
77
+ const byCategory = {};
76
78
 
77
79
  try {
78
80
  const entries = readdirSync(queueDir, { withFileTypes: true });
@@ -82,12 +84,27 @@ function countQueueItems() {
82
84
  if (!item || item.status !== 'pending') continue;
83
85
  total++;
84
86
  if (item.urgency === 'urgent') urgent++;
87
+ const cat = item.category || 'uncategorized';
88
+ byCategory[cat] = (byCategory[cat] || 0) + 1;
89
+ if (cat === 'knowledge-extraction' && item.draft_artifact) draftsReady++;
85
90
  }
86
91
  } catch {
87
92
  // Queue unreadable — degrade gracefully
88
93
  }
89
94
 
90
- return { total, urgent };
95
+ return { total, urgent, byCategory, draftsReady };
96
+ }
97
+
98
+ // A bare count is a scary number; a category breakdown is a work plan.
99
+ // "33 knowledge-extraction (drafts ready), 9 worktree-unmerged, 6 routing-decision"
100
+ function renderCategoryBreakdown(byCategory, draftsReady) {
101
+ const parts = Object.entries(byCategory)
102
+ .sort((a, b) => b[1] - a[1])
103
+ .map(([cat, n]) => {
104
+ const annotation = cat === 'knowledge-extraction' && draftsReady > 0 ? ' (drafts ready)' : '';
105
+ return `${n} ${cat}${annotation}`;
106
+ });
107
+ return parts.join(', ');
91
108
  }
92
109
 
93
110
  // --- Thread / focal zoom helpers ---
@@ -261,18 +278,16 @@ function main() {
261
278
  }
262
279
  }
263
280
 
264
- // Step 6: Inbox summary
265
- const { total, urgent } = countQueueItems();
266
- if (urgent > 0) {
267
- sections.push({
268
- key: 'queue',
269
- content: `--- Inbox ---\n⚡ ${urgent} urgent item(s) waiting — run /inbox. ${total > urgent ? `${total - urgent} more at normal priority.` : ''}`,
270
- priority: 1,
271
- });
272
- } else if (total > 0) {
281
+ // Step 6: Inbox summary — one number, decomposed by category
282
+ const { total, urgent, byCategory, draftsReady } = countQueueItems();
283
+ if (total > 0) {
284
+ const breakdown = renderCategoryBreakdown(byCategory, draftsReady);
285
+ const headline = urgent > 0
286
+ ? `⚡ ${total} pending (${urgent} urgent) — run /inbox`
287
+ : `${total} pending — run /inbox when ready`;
273
288
  sections.push({
274
289
  key: 'queue',
275
- content: `--- Inbox ---\n${total} item(s) waiting — run /inbox when ready.`,
290
+ content: `--- Inbox ---\n${headline}\n${breakdown}`,
276
291
  priority: 1,
277
292
  });
278
293
  }