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.
- package/README.md +1 -1
- package/lib/cli.js +14 -0
- package/lib/engagement-server-setup.js +5 -1
- package/lib/metadata.js +9 -2
- package/lib/mux-setup.js +117 -1
- package/lib/settings-merge.js +5 -0
- package/package.json +4 -2
- package/templates/cabinet/_cabinet-member-template.md +4 -2
- package/templates/mux/bin/mux +31 -6
- package/templates/mux/config/context-help.py +6 -0
- package/templates/mux/config/help.txt +16 -0
- package/templates/mux/config/mux.tmux.conf +27 -0
- package/templates/mux/config/muxlib.py +22 -3
- package/templates/mux/config/screenshot-to-clipboard.sh +45 -0
- package/templates/mux/config/unwrap-copy.py +72 -0
- package/templates/scripts/watchtower-build-context.mjs +27 -12
- package/templates/scripts/watchtower-queue.mjs +8 -2
- package/templates/scripts/watchtower-ring2.mjs +99 -5
- package/templates/scripts/watchtower-ring3-close.mjs +43 -14
- package/templates/scripts/watchtower-status.sh +6 -0
- package/templates/skills/cabinet-accessibility/SKILL.md +4 -2
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +4 -2
- package/templates/skills/cabinet-anti-confirmation/SKILL.md +4 -2
- package/templates/skills/cabinet-architecture/SKILL.md +4 -2
- package/templates/skills/cabinet-automation/SKILL.md +4 -2
- package/templates/skills/cabinet-boundary-man/SKILL.md +4 -2
- package/templates/skills/cabinet-cc-health/SKILL.md +4 -2
- package/templates/skills/cabinet-data-integrity/SKILL.md +4 -2
- package/templates/skills/cabinet-debugger/SKILL.md +4 -2
- package/templates/skills/cabinet-elegance/SKILL.md +4 -2
- package/templates/skills/cabinet-framework-quality/SKILL.md +4 -2
- package/templates/skills/cabinet-goal-alignment/SKILL.md +4 -2
- package/templates/skills/cabinet-historian/SKILL.md +4 -2
- package/templates/skills/cabinet-information-design/SKILL.md +4 -2
- package/templates/skills/cabinet-interactive-storyteller/SKILL.md +4 -2
- package/templates/skills/cabinet-mantine-quality/SKILL.md +4 -2
- package/templates/skills/cabinet-narrative-architect/SKILL.md +4 -2
- package/templates/skills/cabinet-organized-mind/SKILL.md +4 -2
- package/templates/skills/cabinet-process-therapist/SKILL.md +4 -2
- package/templates/skills/cabinet-qa/SKILL.md +4 -2
- package/templates/skills/cabinet-record-keeper/SKILL.md +4 -2
- package/templates/skills/cabinet-roster-check/SKILL.md +4 -2
- package/templates/skills/cabinet-security/SKILL.md +4 -2
- package/templates/skills/cabinet-small-screen/SKILL.md +4 -2
- package/templates/skills/cabinet-speed-freak/SKILL.md +4 -2
- package/templates/skills/cabinet-system-advocate/SKILL.md +4 -2
- package/templates/skills/cabinet-technical-debt/SKILL.md +4 -2
- package/templates/skills/cabinet-ui-experimentalist/SKILL.md +4 -2
- package/templates/skills/cabinet-usability/SKILL.md +4 -2
- package/templates/skills/cabinet-user-advocate/SKILL.md +4 -2
- package/templates/skills/cabinet-vision/SKILL.md +4 -2
- package/templates/skills/cabinet-workflow-cop/SKILL.md +4 -2
- package/templates/skills/inbox/SKILL.md +17 -4
- package/templates/watchtower/queue/items/item.json.schema +30 -3
- 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
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/settings-merge.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
132
|
-
patterns from prior audits
|
|
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
|
```
|
package/templates/mux/bin/mux
CHANGED
|
@@ -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
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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${
|
|
290
|
+
content: `--- Inbox ---\n${headline}\n${breakdown}`,
|
|
276
291
|
priority: 1,
|
|
277
292
|
});
|
|
278
293
|
}
|