@kaydash9999/banker-plugins 0.23.3 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +14 -6
- package/codex/manifest.json +6 -2
- package/codex/transform-matrix.md +6 -2
- package/package.json +1 -1
- package/skills/build-testbox/SKILL.md +213 -0
- package/skills/build-testbox/scripts/args.mjs +156 -0
- package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
- package/skills/build-testbox/scripts/browsers.mjs +164 -0
- package/skills/build-testbox/scripts/containers.mjs +237 -0
- package/skills/build-testbox/scripts/detect.mjs +166 -0
- package/skills/build-testbox/scripts/proc.mjs +91 -0
- package/skills/build-testbox/scripts/report.mjs +109 -0
- package/skills/build-testbox/scripts/setup.mjs +144 -0
- package/skills/build-testbox/scripts/testbox.mjs +148 -0
- package/skills/git-init/SKILL.md +152 -0
- package/skills/git-init/scripts/git-init.mjs +440 -0
- package/skills/git-init/scripts/marker-block.mjs +311 -0
- package/skills/git-init/scripts/payload-scan.mjs +186 -0
- package/skills/init-dir/SKILL.md +172 -0
- package/skills/init-dir/scripts/init-dir.mjs +308 -0
- package/skills/init-dir/scripts/marker-block.mjs +311 -0
- package/skills/init-dir/scripts/templates.mjs +100 -0
- package/skills/init-dir/templates/KB/README.md +15 -0
- package/skills/init-dir/templates/docs/README.md +14 -0
- package/skills/init-dir/templates/logs/README.md +14 -0
- package/skills/init-dir/templates/logs/gitignore +3 -0
- package/skills/init-dir/templates/mockup/README.md +7 -0
- package/skills/init-dir/templates/scripts/README.md +36 -0
- package/skills/init-dir/templates/scripts/lib/service.mjs +540 -0
- package/skills/init-dir/templates/scripts/restart.cmd +13 -0
- package/skills/init-dir/templates/scripts/restart.sh +10 -0
- package/skills/init-dir/templates/scripts/service.conf +5 -0
- package/skills/init-dir/templates/scripts/start.cmd +13 -0
- package/skills/init-dir/templates/scripts/start.sh +10 -0
- package/skills/init-dir/templates/scripts/status.cmd +13 -0
- package/skills/init-dir/templates/scripts/status.sh +10 -0
- package/skills/init-dir/templates/scripts/stop.cmd +13 -0
- package/skills/init-dir/templates/scripts/stop.sh +10 -0
- package/skills/init-dir/templates/tests/README.md +15 -0
- package/skills/init-dir/templates/tests/run.cmd +13 -0
- package/skills/init-dir/templates/tests/run.mjs +160 -0
- package/skills/init-dir/templates/tests/run.sh +10 -0
- package/skills/init-dir/templates/tests/tests.conf +7 -0
- package/skills/init-dir/templates/work/README.md +8 -0
- package/skills/progress-mon/README.md +10 -8
- package/skills/progress-mon/SKILL.md +53 -16
- package/skills/progress-mon/assets/fixture/README.md +1 -1
- package/skills/progress-mon/assets/fixture/adr.json +35 -0
- package/skills/progress-mon/assets/fixture/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/flow.json +2 -2
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
- package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
- package/skills/progress-mon/references/adr.md +177 -0
- package/skills/progress-mon/references/authoring.md +13 -5
- package/skills/progress-mon/scripts/pm-adr.mjs +585 -0
- package/skills/progress-mon/scripts/pm-check.mjs +48 -20
- package/skills/progress-mon/scripts/pm-data.mjs +1 -1
- package/skills/progress-mon/scripts/pm-html.mjs +1 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +113 -28
- package/skills/progress-mon/scripts/pm-render.mjs +6 -4
- package/skills/progress-mon/scripts/pm-server.mjs +2 -1
- package/skills/progress-mon/scripts/pm-sources.mjs +1 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
- package/skills/tune-tmux/SKILL.md +235 -0
- package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
- package/skills/tune-tmux/scripts/marker-block.mjs +311 -0
- package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
- package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
- package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
- package/skills/ultra-chain/SKILL.md +52 -22
- package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
- package/skills/ultra-chain/references/chain-report.mjs +13 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-config: what tune-tmux knows about tmux and psmux. Tool and version detection, the clipboard command,
|
|
3
|
+
* which config files are read, the block lines, conflicting lines in other files and the commands that take
|
|
4
|
+
* block lines back out of a running server.
|
|
5
|
+
*
|
|
6
|
+
* Every function that runs a program takes `exec(cmd, args)` returning { status, stdout, stderr, error }, so the
|
|
7
|
+
* tests can stand in for tmux on every OS. No network is used.
|
|
8
|
+
*/
|
|
9
|
+
import { accessSync, constants, lstatSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
10
|
+
import { devNull, homedir } from "node:os";
|
|
11
|
+
import { basename, delimiter, join, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
/** The tool and version in `tmux -V` output. psmux says "tmux 3.3.8" on line 1 and "psmux" on line 2. */
|
|
14
|
+
export function parseVersion(text) {
|
|
15
|
+
const out = String(text ?? "");
|
|
16
|
+
const first = out.split(/\r?\n/)[0].trim();
|
|
17
|
+
if (!/^(tmux|psmux)\s/i.test(first)) return null;
|
|
18
|
+
const kind = /psmux/i.test(out) ? "psmux" : "tmux";
|
|
19
|
+
const m = /(\d+)\.(\d+)/.exec(first);
|
|
20
|
+
return m ? { kind, major: Number(m[1]), minor: Number(m[2]), raw: first } : { kind, major: 99, minor: 0, raw: first };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether version v is at least major.minor. */
|
|
24
|
+
export const atLeast = (v, major, minor) => v.major > major || (v.major === major && v.minor >= minor);
|
|
25
|
+
|
|
26
|
+
/** The tool behind bin, or null when it does not run. */
|
|
27
|
+
export function toolVersion(exec, bin) {
|
|
28
|
+
const r = exec(bin, ["-V"]);
|
|
29
|
+
if (r.error || r.status !== 0) return null;
|
|
30
|
+
const v = parseVersion(`${r.stdout ?? ""}\n${r.stderr ?? ""}`);
|
|
31
|
+
return v ? { ...v, bin } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The home folder psmux and tmux use: USERPROFILE first on Windows, HOME elsewhere. */
|
|
35
|
+
export function homeOf(env, platform) {
|
|
36
|
+
const first = platform === "win32" ? env.USERPROFILE || env.HOME : env.HOME;
|
|
37
|
+
return first || homedir();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether p is a file this user may run (Windows: any file).
|
|
41
|
+
function runnable(p, platform) {
|
|
42
|
+
try {
|
|
43
|
+
if (!statSync(p).isFile()) return false;
|
|
44
|
+
if (platform !== "win32") accessSync(p, constants.X_OK);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The path of an executable name in env.PATH, or null. */
|
|
52
|
+
export function which(name, env, platform = process.platform) {
|
|
53
|
+
const exts = platform === "win32" ? ["", ...String(env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
54
|
+
for (const dir of String(env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean)) {
|
|
55
|
+
for (const ext of exts) {
|
|
56
|
+
const p = join(dir, name + ext);
|
|
57
|
+
if (runnable(p, platform)) return p;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CLIPBOARDS = [
|
|
64
|
+
{ tool: "pbcopy", cmd: "pbcopy", when: (o) => o.platform === "darwin" },
|
|
65
|
+
{ tool: "wl-copy", cmd: "wl-copy", when: (o) => Boolean(o.env.WAYLAND_DISPLAY) },
|
|
66
|
+
{ tool: "xclip", cmd: "xclip -selection clipboard -in", when: (o) => Boolean(o.env.DISPLAY) },
|
|
67
|
+
{ tool: "xsel", cmd: "xsel -ib", when: (o) => Boolean(o.env.DISPLAY) },
|
|
68
|
+
{ tool: "clip.exe", cmd: "clip.exe", when: (o) => Boolean(o.env.WSL_DISTRO_NAME || o.env.WSL_INTEROP) },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** The OS clipboard command: macOS pbcopy, Wayland wl-copy, X11 xclip then xsel, WSL clip.exe; or null. */
|
|
72
|
+
export function pickClipboard({ platform, env, has }) {
|
|
73
|
+
const hit = CLIPBOARDS.find((c) => c.when({ platform, env }) && has(c.tool));
|
|
74
|
+
return hit ? { tool: hit.tool, cmd: hit.cmd } : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const exists = (p) => lstatSync(p, { throwIfNoEntry: false }) !== undefined;
|
|
78
|
+
|
|
79
|
+
/** The config files tmux reads by itself, in order. Measured: 3.6a reads every one there; 2.7 only ~/.tmux.conf. */
|
|
80
|
+
export function tmuxReads(v, env, home) {
|
|
81
|
+
const files = [join(home, ".tmux.conf")];
|
|
82
|
+
if (v && !atLeast(v, 3, 1)) return files;
|
|
83
|
+
if (env.XDG_CONFIG_HOME) files.push(join(env.XDG_CONFIG_HOME, "tmux", "tmux.conf"));
|
|
84
|
+
files.push(join(home, ".config", "tmux", "tmux.conf"));
|
|
85
|
+
return [...new Set(files.map((f) => resolve(f)))];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The tmux file for the block: --conf, else the first of tmuxReads that is there, else ~/.tmux.conf. */
|
|
89
|
+
export function chooseTmuxConf(v, env, home, conf) {
|
|
90
|
+
if (conf) return resolve(conf);
|
|
91
|
+
return tmuxReads(v, env, home).find(exists) ?? resolve(join(home, ".tmux.conf"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** psmux reads one file: -f, PSMUX_CONFIG_FILE, then the first there of these four (measured on psmux 3.3.8). */
|
|
95
|
+
function psmuxCandidates(home) {
|
|
96
|
+
return [join(home, ".psmux.conf"), join(home, ".psmuxrc"), join(home, ".tmux.conf"), join(home, ".config", "psmux", "psmux.conf")];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function choosePsmuxConf(env, home, conf) {
|
|
100
|
+
if (conf) return resolve(conf);
|
|
101
|
+
if (env.PSMUX_CONFIG_FILE) return resolve(env.PSMUX_CONFIG_FILE);
|
|
102
|
+
return resolve(psmuxCandidates(home).find(exists) ?? join(home, ".psmux.conf"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const PAGE_UP = 'bind -n PPage if -F "#{||:#{alternate_on},#{pane_in_mode}}" "send-keys PPage" "copy-mode -eu"';
|
|
106
|
+
export const FORCE_DRAG = 'bind -n MouseDrag1Pane if -F "#{pane_in_mode}" "send-keys -M" "copy-mode -M"';
|
|
107
|
+
export const HEADER = "# banker tune-tmux: mouse drag copy and PageUp scroll. Remove with: tune-tmux off";
|
|
108
|
+
|
|
109
|
+
// The lines that send a finished mouse selection to the OS clipboard, for this version.
|
|
110
|
+
function copyLines(v, clipboard) {
|
|
111
|
+
if (!clipboard) return { lines: [], omitted: [{ what: "copy-command", reason: "외부 클립보드 도구 없음, 터미널의 OSC 52 허용 필요" }] };
|
|
112
|
+
if (atLeast(v, 3, 2)) return { lines: [`set -s copy-command '${clipboard.cmd}'`], omitted: [] };
|
|
113
|
+
if (atLeast(v, 2, 4)) {
|
|
114
|
+
const pipe = (t) => `bind -T ${t} MouseDragEnd1Pane send -X copy-pipe-and-cancel '${clipboard.cmd}'`;
|
|
115
|
+
return { lines: [pipe("copy-mode"), pipe("copy-mode-vi")], omitted: [] };
|
|
116
|
+
}
|
|
117
|
+
return { lines: [], omitted: [{ what: "copy-pipe", reason: `tmux ${v.major}.${v.minor} 는 send -X 가 없음(2.4 이상 필요)` }] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The tmux block: { lines, omitted }. conditional is whether #{||:} works (probeConditional). */
|
|
121
|
+
export function tmuxBody({ version, clipboard, conditional, forceDrag }) {
|
|
122
|
+
const copy = copyLines(version, clipboard);
|
|
123
|
+
const lines = [HEADER, "set -g mouse on", "set -s set-clipboard on", ...copy.lines];
|
|
124
|
+
const omitted = [...copy.omitted];
|
|
125
|
+
if (conditional) lines.push(PAGE_UP);
|
|
126
|
+
else omitted.push({ what: "PageUp", reason: "이 tmux 는 #{||:} 조건식을 읽지 못함. PageUp 줄을 넣으면 전체 화면 앱의 PageUp 을 빼앗으므로 넣지 않음" });
|
|
127
|
+
if (forceDrag) lines.push(FORCE_DRAG);
|
|
128
|
+
return { lines, omitted };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The psmux block from the caps table: verified lines psmux does not already have. Empty means no block. */
|
|
132
|
+
export function psmuxBody(caps, forceDrag) {
|
|
133
|
+
const wanted = caps.options.filter((o) => !o.forceDrag || forceDrag);
|
|
134
|
+
const lines = wanted.filter((o) => o.verified === true && o.default !== true).map((o) => o.line);
|
|
135
|
+
const omitted = wanted
|
|
136
|
+
.filter((o) => !(o.verified === true && o.default !== true))
|
|
137
|
+
.map((o) => ({ what: o.id, reason: o.default === true ? `psmux 기본값과 같음: ${o.note}` : `psmux ${caps.psmux} 에서 확인 안 됨: ${o.note}` }));
|
|
138
|
+
return { lines: lines.length > 0 ? [HEADER, ...lines] : [], omitted, behavior: psmuxBehavior(caps) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What psmux does with its defaults: { default, unverified, broken } lists of text (empty when caps has none). */
|
|
142
|
+
function psmuxBehavior(caps) {
|
|
143
|
+
const b = caps.behavior ?? {};
|
|
144
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
145
|
+
return { default: list(b.default), unverified: list(b.unverified), broken: list(b.broken) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The socket file of a private server, removed after kill-server (tmux leaves it behind; measured 2.7 and 3.6a).
|
|
149
|
+
function removeSocket(path, sock) {
|
|
150
|
+
if (!path || basename(path) !== sock) return;
|
|
151
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isSocket()) rmSync(path, { force: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Runs fn(sock) against a private server started with no config, then kills it: { value } or { error }. */
|
|
155
|
+
function withPrivateServer(exec, bin, fn) {
|
|
156
|
+
const sock = `banker-tune-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
157
|
+
const up = exec(bin, ["-L", sock, "-f", devNull, "new-session", "-d", "-s", "probe", "-x", "80", "-y", "24"]);
|
|
158
|
+
if (up.error || up.status !== 0) return { error: `사설 서버를 띄우지 못함: ${firstLine(up)}` };
|
|
159
|
+
const path = String(exec(bin, ["-L", sock, "display-message", "-p", "-t", "probe", "#{socket_path}"]).stdout ?? "").trim();
|
|
160
|
+
try {
|
|
161
|
+
return { value: fn(sock) };
|
|
162
|
+
} finally {
|
|
163
|
+
exec(bin, ["-L", sock, "kill-server"]);
|
|
164
|
+
removeSocket(path, sock);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether #{||:} works, asked of a private server: { ok, reason? }. */
|
|
169
|
+
export function probeConditional(exec, bin) {
|
|
170
|
+
const r = withPrivateServer(exec, bin, (sock) => exec(bin, ["-L", sock, "display-message", "-p", "-t", "probe", "#{||:0,1}#{||:0,0}"]));
|
|
171
|
+
if (r.error) return { ok: false, reason: r.error };
|
|
172
|
+
return { ok: r.value.status === 0 && String(r.value.stdout).trim() === "10" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const firstLine = (r) => String(r.error?.message || r.stderr || r.stdout || "").trim().split(/\r?\n/)[0];
|
|
176
|
+
|
|
177
|
+
const CONFLICTS = [
|
|
178
|
+
{ what: "mouse off", test: (t) => isSet(t, "mouse", "off") },
|
|
179
|
+
{ what: "set-clipboard off", test: (t) => isSet(t, "set-clipboard", "off") },
|
|
180
|
+
{ what: "루트 PPage 바인딩", test: (t) => isRootBind(t, "PPage") },
|
|
181
|
+
];
|
|
182
|
+
const isSet = (t, name, value) => ["set", "set-option"].includes(t[0]) && t.indexOf(name) > 0 && t[t.indexOf(name) + 1] === value;
|
|
183
|
+
const isRootBind = (t, key) => ["bind", "bind-key"].includes(t[0]) && (t.includes("-n") || t[t.indexOf("-T") + 1] === "root") && t.includes(key);
|
|
184
|
+
|
|
185
|
+
/** Warnings for lines in other files tmux reads that undo the block (mouse off, set-clipboard off, PPage). */
|
|
186
|
+
export function conflictsIn(files) {
|
|
187
|
+
const warnings = [];
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
let text;
|
|
190
|
+
try {
|
|
191
|
+
if (!statSync(file).isFile() || statSync(file).size > 1024 * 1024) continue;
|
|
192
|
+
text = readFileSync(file, "utf8");
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
197
|
+
const t = line.trim().split(/\s+/);
|
|
198
|
+
for (const c of CONFLICTS) if (c.test(t)) warnings.push(`경고: ${file}:${i + 1} 에 ${c.what}. tmux 가 이 파일도 읽으므로 블록보다 나중에 읽히면 블록 설정을 덮음`);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return warnings;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The body lines of the block in text (marker lines excluded), or [] when there is none. */
|
|
205
|
+
export function blockLinesOf(text, mk) {
|
|
206
|
+
const lines = String(text).replace(/^\u{FEFF}/u, "").split(/\r?\n/);
|
|
207
|
+
const first = lines.findIndex((l) => l.trimEnd() === mk.start);
|
|
208
|
+
const last = lines.findIndex((l) => l.trimEnd() === mk.end);
|
|
209
|
+
return first !== -1 && last > first ? lines.slice(first + 1, last) : [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// What a block line changes: an option ({ scope, name }) or a key ({ table, key }), or null for a comment.
|
|
213
|
+
function effectOf(line) {
|
|
214
|
+
const set = /^set\s+-([gs])\s+(\S+)\s/.exec(line);
|
|
215
|
+
if (set) return { id: `opt:${set[1]}:${set[2]}`, undo: `set -${set[1]}u ${set[2]}` };
|
|
216
|
+
const bind = /^bind\s+(?:-n|-T\s+(\S+))\s+(\S+)\s/.exec(line);
|
|
217
|
+
if (bind) return { id: `key:${bind[1] ?? "root"}:${bind[2]}`, table: bind[1] ?? "root", key: bind[2] };
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The list-keys lines of the keys asked, from tables of a private server.
|
|
222
|
+
function readKeys(exec, bin, sock, keys) {
|
|
223
|
+
const found = new Map(keys.map((k) => [`${k.table} ${k.key}`, null]));
|
|
224
|
+
for (const table of new Set(keys.map((k) => k.table))) {
|
|
225
|
+
for (const line of String(exec(bin, ["-L", sock, "list-keys", "-T", table]).stdout ?? "").split(/\r?\n/)) {
|
|
226
|
+
const m = /^bind-key\s+(?:-r\s+)?-T\s+(\S+)\s+(\S+)\s/.exec(line);
|
|
227
|
+
if (m && found.has(`${m[1]} ${m[2]}`)) found.set(`${m[1]} ${m[2]}`, line.trim());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return found;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The default bindings of a fresh private server for the keys asked: Map "table key" -> list-keys line or null. */
|
|
234
|
+
export function defaultKeys(exec, bin, keys) {
|
|
235
|
+
if (keys.length === 0) return new Map();
|
|
236
|
+
const r = withPrivateServer(exec, bin, (sock) => readKeys(exec, bin, sock, keys));
|
|
237
|
+
return r.value ?? new Map();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The commands that take the effect of oldLines out of a server when newLines replace them: options are unset,
|
|
242
|
+
* keys get their default binding back (from defaults) or are unbound. Lines also in newLines are left alone.
|
|
243
|
+
*/
|
|
244
|
+
export function undoCommands(oldLines, newLines, defaults) {
|
|
245
|
+
const kept = new Set(newLines.map(effectOf).filter(Boolean).map((e) => e.id));
|
|
246
|
+
const gone = oldLines.map(effectOf).filter((e) => e && !kept.has(e.id));
|
|
247
|
+
return gone.map((e) => e.undo ?? defaults.get(`${e.table} ${e.key}`) ?? `unbind -T ${e.table} ${e.key}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The keys among block lines, for defaultKeys. */
|
|
251
|
+
export const keysOf = (lines) => lines.map(effectOf).filter((e) => e?.key);
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tune-tmux: puts one marked block into the tmux (or psmux) config so mouse drag copies to the OS clipboard and
|
|
4
|
+
* PageUp scrolls tmux history, while Claude Code and Codex full-screen views keep their own keys.
|
|
5
|
+
*
|
|
6
|
+
* node tune-tmux.mjs [status|on|off] [--target auto|tmux|psmux] [--conf <path>] [--follow-symlink]
|
|
7
|
+
* [--force-drag] [--reload] [--socket <name>] [--dry-run] [--json]
|
|
8
|
+
*
|
|
9
|
+
* - tmux: the block sets mouse on, set-clipboard on, the clipboard command (copy-command on 3.2 and later,
|
|
10
|
+
* copy-pipe on older) and a PageUp binding that enters copy-mode only on the normal screen. The root
|
|
11
|
+
* MouseDrag1Pane default (an app that grabs the mouse gets the drag) is kept unless --force-drag.
|
|
12
|
+
* - psmux: only lines psmux-caps.json marks verified and not already psmux defaults. Today that is none, so psmux
|
|
13
|
+
* gets no block. on says so with the counts of unverified and broken behaviors (exit 0 is not "all works"), and
|
|
14
|
+
* takes out an old block left in that file, as off does.
|
|
15
|
+
* - marker-block.mjs refuses links (unless --follow-symlink), hard links, other owners, broken markers and files
|
|
16
|
+
* that are not UTF-8; a refusal changes nothing. The first original is kept in <conf>.tune-tmux.bak.
|
|
17
|
+
* - Claude Code and Codex settings are never read or written.
|
|
18
|
+
*
|
|
19
|
+
* Exit status: 0 done (nothing to change included), 1 refused, write or reload failed, 2 usage error,
|
|
20
|
+
* 3 environment (on or off with neither tmux nor psmux). No network is used.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
import { pathToFileURL } from "node:url";
|
|
27
|
+
import { HASH, commitBlock, markers, planBlock } from "./marker-block.mjs";
|
|
28
|
+
import * as tc from "./tmux-config.mjs";
|
|
29
|
+
import { appRows } from "./app-notes.mjs";
|
|
30
|
+
|
|
31
|
+
const USAGE = [
|
|
32
|
+
"tune-tmux: tmux, psmux 설정에 표지 블록을 넣고 빼서 마우스 드래그 복사와 PageUp/PageDown 스크롤을 맞춤",
|
|
33
|
+
"사용법: node tune-tmux.mjs [status|on|off] [--target auto|tmux|psmux] [--conf <경로>] [--follow-symlink] [--force-drag] [--reload] [--socket <이름>] [--dry-run] [--json]",
|
|
34
|
+
"종료 코드: 0 성공(바뀐 것 없음 포함), 1 거부나 쓰기, 반영 실패, 2 사용 오류, 3 환경(on, off 인데 tmux, psmux 둘 다 없음)",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
const ID = "banker:tune-tmux";
|
|
38
|
+
const SKILL = "tune-tmux";
|
|
39
|
+
const MK = markers(HASH, ID);
|
|
40
|
+
const FLAGS = { "--follow-symlink": "follow", "--force-drag": "forceDrag", "--reload": "reload", "--dry-run": "dryRun", "--json": "json", "--help": "help", "-h": "help" };
|
|
41
|
+
const VALUES = { "--target": "target", "--conf": "conf", "--socket": "socket" };
|
|
42
|
+
const SUBCOMMANDS = new Set(["status", "on", "off"]);
|
|
43
|
+
const fail = (code, error) => ({ code, error });
|
|
44
|
+
|
|
45
|
+
function splitArg(a) {
|
|
46
|
+
const eq = a.indexOf("=");
|
|
47
|
+
return a.startsWith("--") && eq > 0 ? [a.slice(0, eq), a.slice(eq + 1)] : [a, undefined];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The options in argv, or { error } (exit 2). */
|
|
51
|
+
export function parseArgs(argv) {
|
|
52
|
+
const o = { cmd: "status", target: "auto", conf: null, socket: null, follow: false, forceDrag: false, reload: false, dryRun: false, json: false, help: false };
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const [name, inline] = splitArg(argv[i]);
|
|
55
|
+
if (FLAGS[name] && inline === undefined) o[FLAGS[name]] = true;
|
|
56
|
+
else if (VALUES[name]) {
|
|
57
|
+
const value = inline ?? argv[++i];
|
|
58
|
+
if (!value) return { error: `${name} 에 값이 없음` };
|
|
59
|
+
o[VALUES[name]] = value;
|
|
60
|
+
} else if (i === 0 && SUBCOMMANDS.has(name)) o.cmd = name;
|
|
61
|
+
else return { error: `모르는 인자: ${argv[i]}` };
|
|
62
|
+
}
|
|
63
|
+
if (!["auto", "tmux", "psmux"].includes(o.target)) return { error: `--target 은 auto, tmux, psmux: ${o.target}` };
|
|
64
|
+
return o;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const loadCaps = () => JSON.parse(readFileSync(new URL("./psmux-caps.json", import.meta.url), "utf8"));
|
|
68
|
+
|
|
69
|
+
function defaults(deps) {
|
|
70
|
+
const env = deps.env ?? process.env;
|
|
71
|
+
const exec = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", env, timeout: 15000, windowsHide: true });
|
|
72
|
+
return { env, platform: process.platform, exec, caps: null, ...deps };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// tmux and psmux as installed. On Windows the tmux command may be psmux (its -V says so on line 2).
|
|
76
|
+
function detectTools(want, platform, exec) {
|
|
77
|
+
const tmux = tc.toolVersion(exec, "tmux");
|
|
78
|
+
const psmux = platform === "win32" || want === "psmux" ? tc.toolVersion(exec, "psmux") : null;
|
|
79
|
+
return { tmux, psmux: psmux ?? (tmux?.kind === "psmux" ? tmux : null) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The target and its tool: auto is psmux on Windows (tmux when there is no psmux), tmux elsewhere. */
|
|
83
|
+
function resolveTarget(want, platform, exec) {
|
|
84
|
+
const { tmux, psmux } = detectTools(want, platform, exec);
|
|
85
|
+
if (want === "psmux" || (want === "auto" && platform === "win32" && psmux !== null)) return { target: "psmux", tool: psmux, warnings: [] };
|
|
86
|
+
if (tmux?.kind === "psmux") return { target: "psmux", tool: tmux, warnings: ["경고: tmux 명령이 psmux 라 psmux 로 처리"] };
|
|
87
|
+
return { target: "tmux", tool: tmux, warnings: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The block lines this machine wants for tmux, after a probe of #{||:} on a private server.
|
|
91
|
+
function tmuxWanted(tool, o, d) {
|
|
92
|
+
const clipboard = tc.pickClipboard({ platform: d.platform, env: d.env, has: (n) => tc.which(n, d.env, d.platform) });
|
|
93
|
+
const probe = tc.probeConditional(d.exec, tool.bin);
|
|
94
|
+
const body = tc.tmuxBody({ version: tool, clipboard, conditional: probe.ok, forceDrag: o.forceDrag });
|
|
95
|
+
if (probe.reason) body.omitted.push({ what: "PageUp", reason: probe.reason });
|
|
96
|
+
return { ...body, clipboard };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Everything worked out before a write: target, tool, config file, block lines, warnings.
|
|
100
|
+
function context(o, d) {
|
|
101
|
+
const t = resolveTarget(o.target, d.platform, d.exec);
|
|
102
|
+
if (!t.tool && o.cmd !== "status") return fail(3, `${t.target} 미설치. banker setup-tmux 로 먼저 설치`);
|
|
103
|
+
const home = tc.homeOf(d.env, d.platform);
|
|
104
|
+
const toolPath = t.tool ? tc.which(t.tool.bin, d.env, d.platform) : null;
|
|
105
|
+
const ctx = { ...t, home, toolPath, warnings: [...t.warnings] };
|
|
106
|
+
if (t.target === "psmux") {
|
|
107
|
+
const body = tc.psmuxBody(d.caps ?? loadCaps(), o.forceDrag);
|
|
108
|
+
return { ...ctx, conf: tc.choosePsmuxConf(d.env, home, o.conf), ...body, clipboard: null };
|
|
109
|
+
}
|
|
110
|
+
const conf = tc.chooseTmuxConf(t.tool, d.env, home, o.conf);
|
|
111
|
+
const reads = tc.tmuxReads(t.tool, d.env, home);
|
|
112
|
+
if (o.conf && !reads.includes(conf)) ctx.warnings.push(`경고: tmux 가 스스로 읽지 않는 파일: ${conf}. tmux -f 나 source-file 로 불러야 적용됨`);
|
|
113
|
+
ctx.warnings.push(...tc.conflictsIn(reads.filter((f) => f !== conf)));
|
|
114
|
+
const body = t.tool ? tmuxWanted(t.tool, o, d) : { lines: [], omitted: [], clipboard: null };
|
|
115
|
+
return { ...ctx, conf, ...body };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const planOf = (ctx, body, o) => planBlock(ctx.conf, { comment: HASH, id: ID, body, skill: SKILL, followSymlink: o.follow });
|
|
119
|
+
|
|
120
|
+
// absent, current, stale or refused:<code>, from two dry plans (take out, put in).
|
|
121
|
+
function blockState(ctx, o) {
|
|
122
|
+
const out = planOf(ctx, null, o);
|
|
123
|
+
if (!out.ok) return `refused:${out.refused}`;
|
|
124
|
+
if (!out.changed) return "absent";
|
|
125
|
+
if (ctx.lines.length === 0) return "present";
|
|
126
|
+
return planOf(ctx, ctx.lines.join("\n"), o).changed ? "stale" : "current";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function status(ctx, o) {
|
|
130
|
+
const state = ctx.tool ? blockState(ctx, o) : "unknown";
|
|
131
|
+
return { code: 0, report: { ...reportOf(ctx, "status", o), state, apps: appRows(ctx.target) } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function reportOf(ctx, command, o) {
|
|
135
|
+
const tool = ctx.tool ? { kind: ctx.tool.kind, version: ctx.tool.raw, bin: ctx.tool.bin, path: ctx.toolPath } : null;
|
|
136
|
+
return { ok: true, command, target: ctx.target, tool, conf: ctx.conf, dryRun: o.dryRun, lines: ctx.lines, omitted: ctx.omitted, clipboard: ctx.clipboard, warnings: ctx.warnings };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// on with zero lines (psmux): what the defaults do, what is unverified, what does not work. Exit 0 means the file
|
|
140
|
+
// was handled, not that every behavior works, so the counts stay on the result.
|
|
141
|
+
function noLines(ctx) {
|
|
142
|
+
const b = ctx.behavior ?? { default: [], unverified: [], broken: [] };
|
|
143
|
+
return { psmux: b, note: `넣은 줄 0: psmux 기본값 유지. 미확인 ${b.unverified.length}, 안 됨 ${b.broken.length}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The report of an on with zero lines that took out an old block: the counts note, and what was taken out.
|
|
147
|
+
function removedOld(out, ctx) {
|
|
148
|
+
const removed = `넣을 줄이 0개라 옛 tune-tmux 블록을 ${out.dryRun ? "뺄 예정" : "뺌"}(off 와 같음)${out.note ? `. ${out.note}` : ""}`;
|
|
149
|
+
return { ...out, ...noLines(ctx), removed };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// on or off: plan, write unless dry-run, then reload when asked. on with zero lines takes out an old block as off.
|
|
153
|
+
function change(ctx, o, d) {
|
|
154
|
+
const none = o.cmd === "on" && ctx.lines.length === 0;
|
|
155
|
+
const plan = planOf(ctx, o.cmd === "off" || none ? null : ctx.lines.join("\n"), o);
|
|
156
|
+
if (!plan.ok) return fail(1, `${plan.message}. 아무것도 바꾸지 않음`);
|
|
157
|
+
if (none && !plan.changed) return { code: 0, report: { ...reportOf(ctx, o.cmd, o), changed: false, ...noLines(ctx) } };
|
|
158
|
+
return reloaded(ctx, plan, o, d, none ? removedOld(written(ctx, o, plan), ctx) : written(ctx, o, plan));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The written report, applied to a running server when --reload asks (not on a dry run).
|
|
162
|
+
function reloaded(ctx, plan, o, d, out) {
|
|
163
|
+
if (!o.reload || o.dryRun) return { code: 0, report: out };
|
|
164
|
+
const reload = reloadServer(ctx, plan, o, d);
|
|
165
|
+
if (reload.error) return fail(1, `파일은 ${plan.changed ? "바꿨음" : "그대로"}. 떠 있는 서버 반영 실패: ${reload.error}`);
|
|
166
|
+
return { code: 0, report: { ...out, reload } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Writes the plan unless dry-run and says what happened.
|
|
170
|
+
function written(ctx, o, plan) {
|
|
171
|
+
const done = o.dryRun ? plan : commitBlock(plan, SKILL);
|
|
172
|
+
const empty = (o.cmd === "off" || ctx.lines.length === 0) && plan.changed && plan.after?.length === 0;
|
|
173
|
+
const note = empty ? "블록만 있던 파일이라 빈 파일로 남김" : null;
|
|
174
|
+
return { ...reportOf(ctx, o.cmd, o), changed: plan.changed, created: done.created === true, backup: done.backup ?? null, note };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Sources each file into the server in turn; { error } at the first that fails.
|
|
178
|
+
function sourceAll(d, bin, base, files) {
|
|
179
|
+
for (const f of files) {
|
|
180
|
+
const r = d.exec(bin, [...base, "source-file", f]);
|
|
181
|
+
if (r.error || r.status !== 0) return { error: `source-file ${f}: ${tc.firstLine(r)}` };
|
|
182
|
+
}
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Applies the file to a running server: the effect of old block lines not in the new block is taken out (options
|
|
188
|
+
* unset, keys back to the defaults of a fresh server), then the whole config file is sourced again.
|
|
189
|
+
*/
|
|
190
|
+
function reloadServer(ctx, plan, o, d) {
|
|
191
|
+
const bin = ctx.tool.bin;
|
|
192
|
+
const base = o.socket ? ["-L", o.socket] : [];
|
|
193
|
+
const oldLines = plan.before ? tc.blockLinesOf(plan.before.toString("utf8"), MK) : [];
|
|
194
|
+
const newLines = o.cmd === "off" ? [] : ctx.lines;
|
|
195
|
+
if (oldLines.length === 0 && newLines.length === 0) return { state: "nothing", message: "넣은 줄이 없어 반영할 것 없음" };
|
|
196
|
+
if (d.exec(bin, [...base, "list-sessions"]).status !== 0) return { state: "no-server", message: "떠 있는 서버 없음. 다음 시작 때 적용" };
|
|
197
|
+
const undo = tc.undoCommands(oldLines, newLines, tc.defaultKeys(d.exec, bin, tc.keysOf(oldLines)));
|
|
198
|
+
const dir = mkdtempSync(join(tmpdir(), "tune-tmux-"));
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(join(dir, "undo.conf"), `${undo.join("\n")}\n`);
|
|
201
|
+
const r = sourceAll(d, bin, base, undo.length ? [join(dir, "undo.conf"), ctx.conf] : [ctx.conf]);
|
|
202
|
+
if (r.error) return r;
|
|
203
|
+
} finally {
|
|
204
|
+
rmSync(dir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
return { state: "reloaded", undone: undo, message: undo.length ? `반영함(되돌린 설정 ${undo.length}개)` : "반영함" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const STATE_WORDS = { absent: "없음", current: "최신", stale: "낡음(on 으로 갱신)", present: "있음(넣을 줄 없음, off 로 뺄 수 있음)", unknown: "미설치라 확인 안 함" };
|
|
210
|
+
|
|
211
|
+
function headLines(rep) {
|
|
212
|
+
const tool = rep.tool ? `${rep.tool.version}${rep.tool.kind === "psmux" ? " (psmux)" : ""} (${rep.tool.path ?? rep.tool.bin})` : `${rep.target} 미설치(banker setup-tmux 로 설치)`;
|
|
213
|
+
const clip = rep.target === "psmux" ? "psmux 기본(set-clipboard on)" : rep.clipboard ? rep.clipboard.cmd : "외부 클립보드 도구 없음, 터미널의 OSC 52 허용 필요";
|
|
214
|
+
return [`tune-tmux ${rep.command}${rep.dryRun ? " (dry-run, 바꾸지 않음)" : ""}: ${tool}`, `설정 파일: ${rep.conf}`, `클립보드: ${clip}`];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function bodyLines(rep) {
|
|
218
|
+
const lines = rep.lines.length ? rep.lines.map((l) => ` ${l}`) : [" (없음)"];
|
|
219
|
+
const omitted = rep.omitted.map((x) => ` - ${x.what}: ${x.reason}`);
|
|
220
|
+
return ["블록 줄:", ...lines, ...(omitted.length ? ["넣지 않은 줄:", ...omitted] : [])];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resultLine(rep) {
|
|
224
|
+
if (rep.state) return `블록: ${STATE_WORDS[rep.state] ?? `거부(${rep.state.replace("refused:", "")})`}`;
|
|
225
|
+
const verb = rep.dryRun ? "바꿀 예정" : rep.created ? "새로 만듦" : "바꿈";
|
|
226
|
+
const what = rep.changed ? `${verb}: ${rep.conf}${rep.backup ? ` (첫 원본 ${rep.backup})` : ""}` : `변경 없음: ${rep.conf}`;
|
|
227
|
+
return `결과: ${what}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The psmux behavior lists under an on with zero lines.
|
|
231
|
+
function behaviorLines(rep) {
|
|
232
|
+
if (!rep.psmux) return [];
|
|
233
|
+
const list = (head, xs) => (xs.length ? [head, ...xs.map((x) => ` - ${x}`)] : []);
|
|
234
|
+
const removed = rep.removed ? [rep.removed] : [];
|
|
235
|
+
return [...removed, ...list("psmux 기본값으로 되는 것:", rep.psmux.default), ...list("확인 못 한 것:", rep.psmux.unverified), ...list("안 되는 것:", rep.psmux.broken)];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function textOf(rep) {
|
|
239
|
+
const out = [...headLines(rep), resultLine(rep), ...(rep.note ? [rep.note] : []), ...behaviorLines(rep), ...(rep.reload ? [`반영: ${rep.reload.message}`] : [])];
|
|
240
|
+
out.push(...(rep.command === "off" ? [] : bodyLines(rep)), ...rep.warnings);
|
|
241
|
+
if (rep.apps) out.push("", "앱별 동작", "", "| 앱 상태 | PageUp/PageDown | 드래그 |", "|---|---|---|", ...rep.apps.map((a) => `| ${a.state} | ${a.keys} | ${a.drag} |`));
|
|
242
|
+
return out.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function main(o, d) {
|
|
246
|
+
const ctx = context(o, d);
|
|
247
|
+
if (ctx.error) return ctx;
|
|
248
|
+
return o.cmd === "status" ? status(ctx, o) : change(ctx, o, d);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Runs tune-tmux with argv; returns { code, stdout, stderr }. deps: { env, platform, exec, caps } for tests. */
|
|
252
|
+
export function run(argv, deps = {}) {
|
|
253
|
+
const o = parseArgs(argv);
|
|
254
|
+
if (o.help) return { code: 0, stdout: USAGE, stderr: "" };
|
|
255
|
+
if (o.error) return finish(o, fail(2, `${o.error}\n${USAGE}`));
|
|
256
|
+
try {
|
|
257
|
+
return finish(o, main(o, defaults(deps)));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return finish(o, fail(1, `쓰기 실패: ${e.message}`));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function finish(o, r) {
|
|
264
|
+
if (r.error) {
|
|
265
|
+
const out = o.json ? JSON.stringify({ ok: false, exit: r.code, error: r.error.split("\n")[0] }) : "";
|
|
266
|
+
return { code: r.code, stdout: out, stderr: `tune-tmux: ${r.error}` };
|
|
267
|
+
}
|
|
268
|
+
return { code: r.code, stdout: o.json ? JSON.stringify(r.report) : textOf(r.report), stderr: "" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const real = (p) => {
|
|
272
|
+
try {
|
|
273
|
+
return realpathSync(p);
|
|
274
|
+
} catch {
|
|
275
|
+
return resolve(p);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(real(process.argv[1])).href) {
|
|
280
|
+
const r = run(process.argv.slice(2));
|
|
281
|
+
if (r.stdout) process.stdout.write(`${r.stdout}\n`);
|
|
282
|
+
if (r.stderr) process.stderr.write(`${r.stderr}\n`);
|
|
283
|
+
process.exitCode = r.code;
|
|
284
|
+
}
|