@kaydash9999/banker-plugins 0.24.1 → 0.25.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 (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +32 -0
  4. package/README.md +5 -4
  5. package/bin/lib/codex-hooks.mjs +5 -1
  6. package/codex/hooks.json +4 -4
  7. package/codex/manifest.json +1 -0
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
  9. package/codex/transform-matrix.md +1 -0
  10. package/hooks/telemetry-count-codex.mjs +3 -2
  11. package/package.json +1 -1
  12. package/skills/anti-drm/SKILL.md +2 -2
  13. package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
  14. package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
  15. package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
  16. package/skills/build-testbox/scripts/containers.mjs +8 -3
  17. package/skills/build-testbox/scripts/proc.mjs +32 -8
  18. package/skills/build-testbox/scripts/testbox.mjs +10 -2
  19. package/skills/git-init/scripts/git-init.mjs +53 -5
  20. package/skills/git-init/scripts/marker-block.mjs +131 -19
  21. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  22. package/skills/init-dir/scripts/marker-block.mjs +131 -19
  23. package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
  24. package/skills/lineage/SKILL.md +1 -1
  25. package/skills/lineage/lineage.py +11 -11
  26. package/skills/motion-sites/SKILL.md +79 -0
  27. package/skills/motion-sites/scripts/motion-sites.mjs +331 -0
  28. package/skills/omc-patch/scripts/omc-patch.mjs +2 -1
  29. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  30. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  31. package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
  32. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  33. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  34. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  35. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  36. package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
  37. package/skills/remains/SKILL.md +30 -3
  38. package/skills/remains/scripts/boxes.mjs +14 -4
  39. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  40. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  41. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  42. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  43. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  44. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  45. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  46. package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
  47. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  48. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  49. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  50. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -34,13 +34,15 @@
34
34
  * pid and time, removed when it ends). A later start waits for it, then sees the service running. A lock whose pid
35
35
  * is dead, is another program, or is older than 30 seconds plus the start check is removed, by one start at a time:
36
36
  * the one holding logs/<name>.start.lock.break (O_EXCL), which reads the lock again and removes only the lock it
37
- * judged stale. A link or a folder at either name is refused (exit 1).
37
+ * judged stale. A link or a folder at either name is refused (exit 1), a link to nothing among them: Windows opens
38
+ * O_EXCL through such a link and makes the file it leads to, so the name is looked at before the open and again after.
38
39
  *
39
40
  * Environment (tuning): SERVICE_LOG_ROTATE_BYTES (default 104857600), SERVICE_STOP_GRACE_MS (10000),
40
41
  * SERVICE_START_CHECK_MS (2000).
41
42
  */
42
43
  import { spawn, spawnSync } from "node:child_process";
43
- import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
44
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
45
+ import { randomBytes } from "node:crypto";
44
46
  import { uptime } from "node:os";
45
47
  import { dirname, join } from "node:path";
46
48
  import { fileURLToPath } from "node:url";
@@ -105,10 +107,16 @@ function readPid(file) {
105
107
  }
106
108
  }
107
109
 
110
+ // A fresh temp file with a random name (no one can plant a link at it first), renamed over the pid file.
108
111
  function writePid(file, value) {
109
- const tmp = `${file}.${process.pid}.tmp`;
110
- writeFileSync(tmp, `${JSON.stringify(value)}\n`);
111
- renameSync(tmp, file);
112
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
113
+ try {
114
+ writeFileSync(tmp, `${JSON.stringify(value)}\n`, { flag: "wx" });
115
+ renameSync(tmp, file);
116
+ } catch (e) {
117
+ rmSync(tmp, { force: true });
118
+ throw e;
119
+ }
112
120
  }
113
121
 
114
122
  // Removes the pid file when it still names this supervisor.
@@ -356,12 +364,30 @@ function lockBusy(text, st) {
356
364
  }
357
365
 
358
366
  // One file made with O_EXCL holding this start's pid and time: { text } made, { code } not (EEXIST: there is one).
367
+ // Anything at the name, a link to nothing among them, is EEXIST: Windows opens O_EXCL through a link to nothing and
368
+ // makes the file it leads to (macOS and Linux answer EEXIST), so the name is looked at first, and after the open,
369
+ // before anything is written, the opened file is compared with the name: a name gone, a link or another file is EEXIST
370
+ // (an empty file made through a link is taken back). A look that cannot be made (ESTALE, EPERM) writes as a plain
371
+ // O_EXCL open would. A write that fails takes the empty lock away again.
359
372
  function makeLockFile(file) {
360
373
  const text = `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`;
361
374
  try {
375
+ if (lstatSync(file, { throwIfNoEntry: false }) !== undefined) return { code: "EEXIST" };
362
376
  const fd = openSync(file, "wx");
363
377
  try {
364
- writeSync(fd, text);
378
+ let made = null;
379
+ try {
380
+ made = fstatSync(fd, { bigint: true });
381
+ } catch {
382
+ // cannot look: the name is not judged
383
+ }
384
+ if (namesOpened(file, made) === false) return { code: "EEXIST" };
385
+ try {
386
+ writeSync(fd, text);
387
+ } catch (e) {
388
+ if (namesOpened(file, made) === true) rmSync(file, { force: true });
389
+ throw e;
390
+ }
365
391
  } finally {
366
392
  closeSync(fd);
367
393
  }
@@ -371,6 +397,31 @@ function makeLockFile(file) {
371
397
  }
372
398
  }
373
399
 
400
+ // Whether the name is the opened file (made): true, false (gone, a link, another file), or null when it cannot be
401
+ // told (a look that fails, no file ids). A link: the empty file made through it is taken back, and false.
402
+ function namesOpened(file, made) {
403
+ let st;
404
+ try {
405
+ st = lstatSync(file, { bigint: true, throwIfNoEntry: false });
406
+ } catch {
407
+ return null;
408
+ }
409
+ if (!st) return false;
410
+ if (st.isSymbolicLink()) {
411
+ if (!made) return false;
412
+ try {
413
+ const target = realpathSync(file);
414
+ const at = statSync(target, { bigint: true });
415
+ if (at.dev === made.dev && at.ino === made.ino && at.size === 0n) rmSync(target, { force: true });
416
+ } catch {
417
+ // the link changed again: nothing of ours to find
418
+ }
419
+ return false;
420
+ }
421
+ if (!made || made.ino === 0n) return null;
422
+ return st.dev === made.dev && st.ino === made.ino;
423
+ }
424
+
374
425
  // lstat that answers undefined for a missing file and null for one busy being deleted (the next try sees).
375
426
  function lstatNow(file) {
376
427
  try {
@@ -2,7 +2,7 @@
2
2
  name: lineage
3
3
  description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
4
4
  invocation: /lineage
5
- version: 3.1.1
5
+ version: 3.1.2
6
6
  schema_version: 1
7
7
  ---
8
8
 
@@ -1590,24 +1590,24 @@ def _stdin_id_key():
1590
1590
  if key is not None and len(key) == 32:
1591
1591
  return key
1592
1592
  key = os.urandom(32)
1593
- tmp = path.with_name("%s.%d" % (path.name, os.getpid()))
1593
+ tmp = None
1594
1594
  try:
1595
1595
  CACHE_BASE.mkdir(parents=True, exist_ok=True)
1596
1596
  os.chmod(str(CACHE_BASE), 0o700)
1597
- # O_EXCL: a name that is already there (a link planted at this guessable name among them) is refused
1598
- fd = os.open(
1599
- str(tmp),
1600
- os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
1601
- 0o600,
1597
+ # mkstemp: a fresh random name made exclusively (0600), so no link can be planted at it
1598
+ # first (Windows opens O_EXCL through a link to nothing and makes the file it leads to)
1599
+ fd, tmp = tempfile.mkstemp(
1600
+ dir=str(CACHE_BASE), prefix=f".{path.name}.", suffix=".tmp"
1602
1601
  )
1603
1602
  with os.fdopen(fd, "wb") as f:
1604
1603
  f.write(key)
1605
- os.replace(str(tmp), str(path)) # whole or not at all
1604
+ os.replace(tmp, str(path)) # whole or not at all
1606
1605
  except OSError:
1607
- try:
1608
- os.unlink(str(tmp))
1609
- except OSError:
1610
- pass
1606
+ if tmp is not None:
1607
+ try:
1608
+ os.unlink(tmp)
1609
+ except OSError:
1610
+ pass
1611
1611
  return key
1612
1612
 
1613
1613
 
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: motion-sites
3
+ description: "MotionSites 의 motionsites-web-builder 스킬을 짧은 이름으로 부름. 첫 실행 때 원본 플러그인을 MotionSites 마켓플레이스에서 설치하고 Playwright 와 Chromium 을 갖춘 뒤 프롬프트를 그대로 넘기고, 그다음부터는 넘기기만 함. 'motion-sites'/'모션 사이트'/'시네마틱 랜딩 페이지' 시 사용."
4
+ argument-hint: "<만들 사이트나 프롬프트>"
5
+ ---
6
+
7
+ # motion-sites - motionsites-web-builder 를 짧은 이름으로
8
+
9
+ MotionSites 의 `motionsites-web-builder` 스킬(https://github.com/Motionsites-AI/claude-skill)을 `/motion-sites` 로 부른다. 원본 이름이 길어서 둔 이름이다.
10
+ banker 는 원본을 복사하지 않는다. 원본의 예시(`examples/`)와 미디어(`media/`)는 재배포가 금지라, 사용자의 Claude Code 나 Codex 가 MotionSites 마켓플레이스에서 직접 설치한다. 답변은 한글.
11
+
12
+ ## 엔진 위치
13
+
14
+ `scripts/motion-sites.mjs` 가 상태 확인과 설치를 한다.
15
+
16
+ | 설치 | 경로 |
17
+ |---|---|
18
+ | Claude Code 플러그인 | 이 스킬 폴더의 `scripts/motion-sites.mjs` |
19
+ | Codex npm | `~/.codex/skills/banker-motion-sites/scripts/motion-sites.mjs` |
20
+ | Codex 플러그인 | `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/motion-sites/scripts/motion-sites.mjs` |
21
+
22
+ `--runtime` 은 지금 런타임이다(Claude Code 는 `claude`, Codex 는 `codex`).
23
+
24
+ ## 1. 상태 확인
25
+
26
+ `node <엔진> status --runtime <claude|codex>` 를 실행한다. 출력은 JSON 이다.
27
+
28
+ | 칸 | 뜻 |
29
+ |---|---|
30
+ | `skillFile` | 설치된 원본 `SKILL.md` 경로. 없거나 꺼져 있으면 `null` |
31
+ | `disabled` | 설치는 됐지만 꺼져 있음 |
32
+ | `python.bin` | Playwright 를 쓸 Python |
33
+ | `python.playwright`, `python.chromium` | Playwright 와 Chromium 이 있는지 |
34
+ | `ready` | 셋 다 있으면 `true`(종료 0) |
35
+
36
+ `ready` 가 `true` 면 3단계로 간다.
37
+
38
+ ## 2. 첫 실행 구성 (ready 가 아닐 때만)
39
+
40
+ 1. 사용자에게 한 줄로 알린다: MotionSites 플러그인을 설치하고 Playwright 와 Chromium 을 받는다.
41
+ 2. `node <엔진> setup --runtime <claude|codex>` 를 실행한다. 빠진 단계만 차례로 한다.
42
+ - 꺼져 있는 플러그인은 다시 설치하지 않는다. Claude Code 는 `claude plugin enable` 로 켜고, Codex 는 `config.toml` 에서 켜라고 안내한다.
43
+ - 받기 전에 원천(github.com, PyPI, Playwright 내려받기 서버. 환경 변수의 미러가 있으면 그것)에 닿는지 8초 안에 본다. 닿지 않으면 그 단계는 "오프라인이라 설치하지 않음" 으로 끝난다. 프록시 환경 변수가 있으면 이 판정은 하지 않고 단계의 시간 제한에 맡긴다.
44
+ - Claude Code: `claude plugin marketplace add Motionsites-AI/claude-skill`, `claude plugin install motionsites-web-builder@motionsites`
45
+ - Codex: `codex plugin marketplace add Motionsites-AI/claude-skill`, `codex plugin add motionsites-web-builder@motionsites`
46
+ - 공통: `<python> -m pip install playwright`, `<python> -m playwright install chromium`
47
+ - pip 이 시스템 Python 을 거부하면(externally managed) 캐시 폴더의 가상 환경에 설치한다. 캐시 폴더는 `MOTION_SITES_HOME`, 없으면 `~/.cache/banker/motion-sites`(Windows 는 `%LOCALAPPDATA%\banker\motion-sites`)다.
48
+ - Chromium 내려받기에 몇 분이 걸릴 수 있다. Claude Code 에서는 Bash 도구의 시간 제한보다 길 수 있으므로 백그라운드로 실행한다.
49
+ 3. Codex 에서는 이 명령이 네트워크와 `~/.codex` 쓰기를 하므로 `sandbox_permissions: "require_escalated"` 로 승인을 요청한다.
50
+ 4. 출력의 `steps` 에 실패한 단계가 있으면 그 단계와 `why` 를 보여 주고 멈춘다. 설치되지 않은 것을 설치된 것처럼 말하지 않는다.
51
+ - 네트워크가 없으면 여기서 멈춘다. 오프라인에서는 이 스킬을 쓸 수 없다.
52
+ - Linux 에서 Chromium 이 시스템 라이브러리 부족으로 뜨지 않으면 `<python.bin> -m playwright install-deps chromium` 을 안내한다. 관리자 권한이 필요하므로 직접 실행하지 않는다.
53
+ 5. 끝나면 `ready` 가 `true` 인지 확인하고 3단계로 간다.
54
+
55
+ ## 3. 프롬프트 넘기기
56
+
57
+ 사용자가 `/motion-sites` 뒤에 쓴 글을 고치지 않고 그대로 원본 스킬에 넘긴다. 글이 없으면 무엇을 만들지 한 번 묻는다.
58
+
59
+ | 런타임 | 원본 스킬이 이 세션에 보일 때 | 보이지 않을 때(방금 설치) |
60
+ |---|---|---|
61
+ | Claude Code | Skill 도구로 `motionsites-web-builder:motionsites-web-builder` 를 부르고 인자로 그 글을 준다 | `skillFile` 을 읽고 그 지시를 그대로 따른다 |
62
+ | Codex | `$motionsites-web-builder:motionsites-web-builder` 를 쓴다 | `skillFile` 을 읽고 그 지시를 그대로 따른다 |
63
+
64
+ - `skillFile` 을 직접 따를 때 원본의 상대 경로(`examples/`, `references/`, `media/`, `scripts/`)는 `skillFile` 이 있는 폴더 기준이다.
65
+ - 원본이 `python scripts/shoot.py` 를 쓰라고 하면 `python` 대신 1단계의 `python.bin` 을 쓴다.
66
+ - 새로 설치한 플러그인은 Claude Code 에서 `/reload-plugins` 나 다음 세션부터, Codex 에서 다음 세션부터 스킬 목록에 보인다.
67
+
68
+ ## 원본을 따를 때 지킬 것
69
+
70
+ - 원본의 "Sponsored by MotionSites" 절(결과물 끝의 유료 상품 안내 블록과 권유 문장)은 넣지 않는다. 사용자가 정한 것이고, 원본도 사용자가 원하지 않으면 멈추라고 한다.
71
+ - 출처는 남긴다. 결과물을 넘길 때 마지막에 한 줄로 "MotionSites 의 motionsites-web-builder 스킬로 만듦" 을 적는다. 사용자의 코드와 파일에는 넣지 않는다.
72
+ - 원본의 예시와 미디어는 사이트를 만드는 데만 쓴다. 프롬프트나 미디어 모음으로 따로 내보내거나 배포하지 않는다(원본 LICENSE 2항).
73
+
74
+ ## 갱신과 제거
75
+
76
+ | 런타임 | 갱신 | 제거 |
77
+ |---|---|---|
78
+ | Claude Code | `claude plugin marketplace update motionsites` 뒤 `claude plugin update motionsites-web-builder@motionsites` | `claude plugin uninstall motionsites-web-builder@motionsites` |
79
+ | Codex | `codex plugin marketplace upgrade motionsites` 뒤 `codex plugin add motionsites-web-builder@motionsites` | `codex plugin remove motionsites-web-builder@motionsites` |
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * motion-sites: the first-run setup and the lookup behind /motion-sites, a short name for MotionSites' own
4
+ * motionsites-web-builder skill (https://github.com/Motionsites-AI/claude-skill). Nothing of that skill is copied
5
+ * here: its examples and media may not be redistributed, so the user's own Claude Code or Codex installs it from
6
+ * MotionSites' marketplace, as its README says.
7
+ *
8
+ * status --runtime claude|codex JSON: the plugin, its SKILL.md, a Python with Playwright, Chromium; ready or not
9
+ * setup --runtime claude|codex installs what is missing, in order, then answers as status
10
+ * path --runtime claude|codex the installed SKILL.md (exit 1 when not installed)
11
+ *
12
+ * setup runs, for Claude Code: claude plugin marketplace add Motionsites-AI/claude-skill, then claude plugin install
13
+ * motionsites-web-builder@motionsites; for Codex: codex plugin marketplace add Motionsites-AI/claude-skill, then
14
+ * codex plugin add motionsites-web-builder@motionsites. Then <python> -m pip install playwright and <python> -m
15
+ * playwright install chromium. Where pip refuses the system Python (an externally managed environment), Playwright
16
+ * goes into a virtual environment of its own under the cache folder (MOTION_SITES_HOME, else
17
+ * ~/.cache/banker/motion-sites, %LOCALAPPDATA%\banker\motion-sites on Windows), and status names that Python.
18
+ *
19
+ * Every step has a time limit, so with no network a step fails with its reason instead of waiting. Exit status:
20
+ * 0 ready (status, setup) or found (path), 1 not ready or a step failed, 2 usage.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
24
+ import { homedir, tmpdir } from "node:os";
25
+ import { isAbsolute, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ // The folder programs start in: the home folder (Windows looks for a program in the working folder first, so a
29
+ // program planted in the folder worked on never runs), or the temp folder when there is no home folder.
30
+ const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
31
+
32
+ export const REPO = "Motionsites-AI/claude-skill";
33
+ export const PLUGIN = "motionsites-web-builder";
34
+ export const MARKET = "motionsites";
35
+ const ID = `${PLUGIN}@${MARKET}`;
36
+ const WIN = process.platform === "win32";
37
+ const STEP_MS = 10 * 60 * 1000; // a Chromium download can take minutes
38
+ const LOOK_MS = 60 * 1000;
39
+
40
+ const USAGE = `motion-sites: MotionSites 의 motionsites-web-builder 스킬 설치와 위치 확인
41
+ 사용법: node motion-sites.mjs status|setup|path --runtime claude|codex`;
42
+
43
+ /**
44
+ * Runs a program with fixed arguments: { ok, stdout, out (stdout and stderr), why }. Data (JSON, paths) is read from
45
+ * stdout only, so a warning on stderr never spoils it. On Windows claude and codex are .cmd shims, which Node runs only
46
+ * through a shell: they go as one command line (their arguments here are fixed words). Python gets no shell.
47
+ */
48
+ export function run(cmd, args, { timeout = LOOK_MS, env = process.env } = {}) {
49
+ const shim = WIN && (cmd === "claude" || cmd === "codex");
50
+ // From the home folder: python -c and -m put the working folder first on the import path (a pip/ or playwright/ in
51
+ // the project would run), and Windows looks for a program there first.
52
+ const opts = { cwd: runDir(), encoding: "utf8", timeout, env, windowsHide: true, maxBuffer: 64 * 1024 * 1024 };
53
+ const r = shim ? spawnSync([cmd, ...args].join(" "), { ...opts, shell: true }) : spawnSync(cmd, args, opts);
54
+ const stdout = r.stdout ?? "";
55
+ const out = `${stdout}${r.stderr ?? ""}`;
56
+ if (r.error) return { ok: false, stdout, out, why: r.error.code === "ETIMEDOUT" ? `시간 초과(${timeout / 1000}초)` : `${cmd} 실행 실패(${r.error.code})` };
57
+ return { ok: r.status === 0, stdout, out, why: r.status === 0 ? null : `${cmd} ${args.join(" ")} 종료 ${r.status}: ${lastLine(out)}` };
58
+ }
59
+
60
+ const PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"];
61
+ const PROBE = "const s=require('net').connect({host:process.argv[1],port:443,timeout:8000});s.on('connect',()=>process.exit(0));s.on('timeout',()=>process.exit(1));s.on('error',()=>process.exit(1))";
62
+
63
+ /**
64
+ * Whether a web source answers on port 443 within 8 seconds: true, false, or null (not judged: behind a proxy a
65
+ * direct look says nothing, so the step's own time limit decides).
66
+ */
67
+ export function reachable(host, { env = process.env, runner = run } = {}) {
68
+ if (PROXY_KEYS.some((k) => env[k])) return null;
69
+ return runner(process.execPath, ["-e", PROBE, host], { timeout: 15000 }).ok;
70
+ }
71
+
72
+ const hostOf = (url, fallback) => {
73
+ try {
74
+ return new URL(url).hostname || fallback;
75
+ } catch {
76
+ return fallback;
77
+ }
78
+ };
79
+
80
+ /** The hosts each install step downloads from, any one answering being enough (a mirror named in the environment first). */
81
+ export function sources(env = process.env) {
82
+ return {
83
+ plugin: ["github.com"],
84
+ pip: [hostOf(env.PIP_INDEX_URL, "pypi.org")],
85
+ chromium: env.PLAYWRIGHT_DOWNLOAD_HOST ? [hostOf(env.PLAYWRIGHT_DOWNLOAD_HOST, "cdn.playwright.dev")] : ["cdn.playwright.dev", "playwright.download.prss.microsoft.com"],
86
+ };
87
+ }
88
+
89
+ const GIT_MIRROR = "^(https?\\..*proxy|url\\..*insteadof)$";
90
+
91
+ /**
92
+ * Whether a step's tool is set to a mirror or a proxy in its own configuration, where a direct look at the public
93
+ * host says nothing: git (http.proxy, url.<base>.insteadOf) for the plugin, pip config (index-url) for Playwright.
94
+ */
95
+ export function mirrorSet(kind, { py = null, runner = run } = {}) {
96
+ if (kind === "plugin") {
97
+ const r = runner("git", ["config", "--get-regexp", GIT_MIRROR]);
98
+ return r.ok && (r.stdout ?? "").trim() !== "";
99
+ }
100
+ if (kind === "pip" && py) {
101
+ const [cmd, ...pre] = py;
102
+ const r = runner(cmd, [...pre, "-m", "pip", "config", "list"]);
103
+ return r.ok && /index-url/i.test(r.stdout ?? "");
104
+ }
105
+ return false;
106
+ }
107
+
108
+ // A failed step when none of its sources answers, or null when one answers (or it cannot be judged: a proxy, or a
109
+ // mirror set in the tool's own configuration).
110
+ function offline(step, kind, { env, runner, py = null }) {
111
+ const hosts = sources(env)[kind];
112
+ if (mirrorSet(kind, { py, runner })) return null;
113
+ const seen = hosts.map((h) => reachable(h, { env, runner }));
114
+ if (!seen.every((x) => x === false)) return null;
115
+ return { step, ok: false, why: `오프라인이라 설치하지 않음(${hosts.join(", ")} 에 닿지 않음)` };
116
+ }
117
+
118
+ const lastLine = (text) => text.trim().split(/\r?\n/).filter(Boolean).pop() ?? "";
119
+
120
+ /** The cache folder for the virtual environment. */
121
+ export function cacheHome(env = process.env) {
122
+ if (env.MOTION_SITES_HOME) return env.MOTION_SITES_HOME;
123
+ const base = WIN ? env.LOCALAPPDATA || join(homedir(), "AppData", "Local") : join(homedir(), ".cache");
124
+ return join(base, "banker", "motion-sites");
125
+ }
126
+
127
+ const venvPython = (env) => join(cacheHome(env), "venv", WIN ? "Scripts" : "bin", WIN ? "python.exe" : "python");
128
+
129
+ /** The plugin for Claude Code: { file (its SKILL.md, or null), disabled (installed but turned off) }. */
130
+ export function claudePlugin(runner = run) {
131
+ const none = { file: null, disabled: false };
132
+ const r = runner("claude", ["plugin", "list", "--json"]);
133
+ if (!r.ok) return none;
134
+ let list;
135
+ try {
136
+ list = JSON.parse(r.stdout);
137
+ } catch {
138
+ return none;
139
+ }
140
+ const p = Array.isArray(list) ? list.find((x) => x?.id === ID) : null;
141
+ const file = p?.installPath ? join(p.installPath, "skills", PLUGIN, "SKILL.md") : null;
142
+ if (!file || !existsSync(file)) return none;
143
+ return p.enabled === false ? { file: null, disabled: true } : { file, disabled: false };
144
+ }
145
+
146
+ export const claudeSkill = (runner = run) => claudePlugin(runner).file;
147
+
148
+ /**
149
+ * The plugin for Codex: { file, disabled }. Installed means its table in config.toml (the body runs to the next table
150
+ * header, so an array value holding "[" does not cut it short) and a version in the cache; the newest version's
151
+ * SKILL.md is the file. enabled = false is disabled.
152
+ */
153
+ export function codexPlugin(env = process.env) {
154
+ const none = { file: null, disabled: false };
155
+ const home = env.CODEX_HOME || join(homedir(), ".codex");
156
+ let toml = "";
157
+ try {
158
+ toml = readFileSync(join(home, "config.toml"), "utf8");
159
+ } catch {
160
+ return none;
161
+ }
162
+ const head = `[plugins."${ID}"]`.replace(/[.[\]"]/g, "\\$&");
163
+ const table = new RegExp(`^${head}[ \\t]*\\r?\\n([\\s\\S]*?)(?=^[ \\t]*\\[|(?![\\s\\S]))`, "m").exec(toml);
164
+ if (!table) return none;
165
+ const dir = join(home, "plugins", "cache", MARKET, PLUGIN);
166
+ let versions = [];
167
+ try {
168
+ versions = readdirSync(dir).sort(byVersion);
169
+ } catch {
170
+ return none;
171
+ }
172
+ const file = versions.map((v) => join(dir, v, "skills", PLUGIN, "SKILL.md")).reverse().find((f) => existsSync(f));
173
+ if (!file) return none;
174
+ return /^[ \t]*enabled[ \t]*=[ \t]*false\b/m.test(table[1]) ? { file: null, disabled: true } : { file, disabled: false };
175
+ }
176
+
177
+ export const codexSkill = (env = process.env) => codexPlugin(env).file;
178
+
179
+ export function byVersion(a, b) {
180
+ const pa = a.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
181
+ const pb = b.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
182
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
183
+ if (pa[i] === pb[i]) continue;
184
+ // 2.0.0-rc1 comes before 2.0.0 (a pre-release tail), 2.0.0.1 after it
185
+ if (pa[i] === undefined) return typeof pb[i] === "string" ? 1 : -1;
186
+ if (pb[i] === undefined) return typeof pa[i] === "string" ? -1 : 1;
187
+ return typeof pa[i] === "number" && typeof pb[i] === "number" ? pa[i] - pb[i] : String(pa[i]).localeCompare(String(pb[i]));
188
+ }
189
+ return 0;
190
+ }
191
+
192
+ /** Python programs to try, in order: MOTION_SITES_PYTHON, the cache's virtual environment, then the system's. */
193
+ export function pythons(env = process.env) {
194
+ const list = [];
195
+ // a relative path is made absolute here, as programs start in the home folder
196
+ if (env.MOTION_SITES_PYTHON) list.push([isAbsolute(env.MOTION_SITES_PYTHON) ? env.MOTION_SITES_PYTHON : resolve(env.MOTION_SITES_PYTHON)]);
197
+ list.push([venvPython(env)]);
198
+ if (WIN) list.push(["py", "-3"], ["python"]);
199
+ else list.push(["python3"], ["python"]);
200
+ return list;
201
+ }
202
+
203
+ const CHROMIUM = "from playwright.sync_api import sync_playwright as s\np=s().start()\nprint(p.chromium.executable_path)\np.stop()";
204
+
205
+ /** What one Python has: { bin, playwright, chromium } or null when it does not run. */
206
+ export function lookPython(py, runner = run) {
207
+ const [cmd, ...pre] = py;
208
+ if (/[\\/]/.test(cmd) && !existsSync(cmd)) return null;
209
+ const v = runner(cmd, [...pre, "-c", "import sys;print(sys.executable)"]);
210
+ if (!v.ok) return null;
211
+ const bin = lastLine(v.stdout ?? "");
212
+ const pw = runner(cmd, [...pre, "-c", "import playwright"]).ok;
213
+ const exe = pw ? runner(cmd, [...pre, "-c", CHROMIUM]) : { ok: false, stdout: "" };
214
+ const chrome = lastLine(exe.stdout ?? "");
215
+ return { bin, cmd: py, playwright: pw, chromium: exe.ok && chrome !== "" && existsSync(chrome) };
216
+ }
217
+
218
+ /** The first Python with Playwright (and Chromium, when one has both), else the first that runs. */
219
+ export function pickPython(env = process.env, runner = run) {
220
+ const seen = pythons(env).map((p) => lookPython(p, runner)).filter(Boolean);
221
+ return seen.find((p) => p.playwright && p.chromium) ?? seen.find((p) => p.playwright) ?? seen[0] ?? null;
222
+ }
223
+
224
+ /** status: the plugin and the Python, and whether /motion-sites can hand over now. */
225
+ export function status(runtime, { env = process.env, runner = run } = {}) {
226
+ const p = runtime === "claude" ? claudePlugin(runner) : codexPlugin(env);
227
+ const py = pickPython(env, runner);
228
+ const python = py ? { bin: py.bin, playwright: py.playwright, chromium: py.chromium } : null;
229
+ return { runtime, plugin: ID, skillFile: p.file, disabled: p.disabled, python, ready: Boolean(p.file && py?.playwright && py?.chromium) };
230
+ }
231
+
232
+ /**
233
+ * The plugin steps of one runtime: marketplace add, then install. An add that fails (the marketplace may be there
234
+ * already) is reported only when the install fails too.
235
+ */
236
+ function installPlugin(runtime, env, runner) {
237
+ const down = offline("marketplace add", "plugin", { env, runner });
238
+ if (down) return [down];
239
+ const cli = runtime === "claude" ? "claude" : "codex";
240
+ const add = runner(cli, ["plugin", "marketplace", "add", REPO], { timeout: STEP_MS });
241
+ const verb = runtime === "claude" ? "install" : "add";
242
+ const inst = runner(cli, ["plugin", verb, ID], { timeout: STEP_MS });
243
+ if (inst.ok) return [{ step: "marketplace add", ok: true }, { step: `plugin ${verb}`, ok: true }];
244
+ return [{ step: "marketplace add", ok: add.ok, why: add.why }, { step: `plugin ${verb}`, ok: false, why: inst.why }];
245
+ }
246
+
247
+ /** pip install playwright with this Python, or in a virtual environment when pip refuses the system Python. */
248
+ function installPlaywright(py, env, runner) {
249
+ const down = offline("pip install playwright", "pip", { env, runner, py: py.cmd });
250
+ if (down) return { ok: false, why: down.why };
251
+ const [cmd, ...pre] = py.cmd;
252
+ const pip = runner(cmd, [...pre, "-m", "pip", "install", "playwright"], { timeout: STEP_MS });
253
+ if (pip.ok) return { ok: true, py: py.cmd };
254
+ if (!/externally.managed|No module named pip/i.test(pip.out)) return { ok: false, why: pip.why };
255
+ const venv = runner(cmd, [...pre, "-m", "venv", join(cacheHome(env), "venv")], { timeout: STEP_MS });
256
+ if (!venv.ok) return { ok: false, why: `${pip.why}. 가상 환경도 만들지 못함: ${venv.why}` };
257
+ const inVenv = runner(venvPython(env), ["-m", "pip", "install", "playwright"], { timeout: STEP_MS });
258
+ return inVenv.ok ? { ok: true, py: [venvPython(env)] } : { ok: false, why: inVenv.why };
259
+ }
260
+
261
+ /** A plugin installed but turned off: Claude Code turns it on; Codex has no command for it, so it says where. */
262
+ function enablePlugin(runtime, runner) {
263
+ if (runtime === "codex") return { step: "plugin enable", ok: false, why: `Codex 설정에서 꺼져 있음: config.toml 의 [plugins."${ID}"] 를 enabled = true 로` };
264
+ const r = runner("claude", ["plugin", "enable", ID]);
265
+ return { step: "plugin enable", ok: r.ok, why: r.why };
266
+ }
267
+
268
+ /** The Python steps: Playwright, then Chromium. */
269
+ function installBrowser(env, runner) {
270
+ let py = pickPython(env, runner);
271
+ if (!py) return [{ step: "python", ok: false, why: "Python 3 을 찾지 못함(python3, python, Windows 는 py)" }];
272
+ const steps = [];
273
+ let cmd = py.cmd;
274
+ if (!py.playwright) {
275
+ const pw = installPlaywright(py, env, runner);
276
+ steps.push({ step: "pip install playwright", ok: pw.ok, why: pw.why ?? null });
277
+ if (!pw.ok) return steps;
278
+ cmd = pw.py;
279
+ }
280
+ if (!py.chromium) {
281
+ const down = offline("playwright install chromium", "chromium", { env, runner });
282
+ if (down) return [...steps, down];
283
+ const [c, ...pre] = cmd;
284
+ const ch = runner(c, [...pre, "-m", "playwright", "install", "chromium"], { timeout: STEP_MS });
285
+ steps.push({ step: "playwright install chromium", ok: ch.ok, why: ch.why });
286
+ }
287
+ return steps;
288
+ }
289
+
290
+ /** setup: the missing steps in order, stopping at the first that fails; then status. */
291
+ export function setup(runtime, { env = process.env, runner = run } = {}) {
292
+ const before = status(runtime, { env, runner });
293
+ const steps = [];
294
+ if (before.disabled) steps.push(enablePlugin(runtime, runner));
295
+ else if (!before.skillFile) steps.push(...installPlugin(runtime, env, runner));
296
+ if (steps.every((s) => s.ok) && !(before.python?.playwright && before.python?.chromium)) steps.push(...installBrowser(env, runner));
297
+ return { steps, ...status(runtime, { env, runner }) };
298
+ }
299
+
300
+ function parse(argv) {
301
+ const [cmd, ...rest] = argv;
302
+ const i = rest.indexOf("--runtime");
303
+ const runtime = i >= 0 ? rest[i + 1] : undefined;
304
+ if (!["status", "setup", "path"].includes(cmd)) return { error: USAGE };
305
+ if (runtime !== "claude" && runtime !== "codex") return { error: `--runtime claude|codex 가 필요함\n${USAGE}` };
306
+ return { cmd, runtime };
307
+ }
308
+
309
+ export function main(argv, out = console.log, err = console.error) {
310
+ if (argv.includes("--help") || argv.includes("-h")) return out(USAGE), 0;
311
+ const o = parse(argv);
312
+ if (o.error) return err(o.error), 2;
313
+ if (o.cmd === "path") {
314
+ const f = o.runtime === "claude" ? claudeSkill() : codexSkill();
315
+ if (!f) return err(`설치되지 않음: ${ID}. setup --runtime ${o.runtime} 로 설치`), 1;
316
+ return out(f), 0;
317
+ }
318
+ const r = o.cmd === "setup" ? setup(o.runtime) : status(o.runtime);
319
+ out(JSON.stringify(r, null, 2));
320
+ return r.ready ? 0 : 1;
321
+ }
322
+
323
+ // Run directly (also through a linked path: both sides as real paths).
324
+ const realOf = (p) => {
325
+ try {
326
+ return realpathSync(p);
327
+ } catch {
328
+ return p;
329
+ }
330
+ };
331
+ if (process.argv[1] && realOf(process.argv[1]) === realOf(fileURLToPath(import.meta.url))) process.exitCode = main(process.argv.slice(2));
@@ -21,6 +21,7 @@
21
21
  * OS: Windows / macOS / Linux 공통. 런처(apply.cmd, apply.sh)와 배경 문서(README.md)는
22
22
  * 킷 배포 폴더에만 있다. 이 파일만 옮겨 쓰는 곳(예: 스킬 폴더)에는 없다.
23
23
  */
24
+ import { randomBytes } from "node:crypto";
24
25
  import {
25
26
  readFileSync, writeFileSync, writeSync, existsSync, copyFileSync, readdirSync,
26
27
  unlinkSync, mkdirSync, rmSync, symlinkSync, lstatSync, realpathSync,
@@ -422,7 +423,7 @@ function switchActive(sha) {
422
423
  // 줄인 비트는 chmod 가 맞춘다). 만든 뒤에 맞추면 그 사이 기본 권한(0664 등)으로 남이 읽을 수 있다.
423
424
  function writeAtomic(file, data, mode, { follow = true } = {}) {
424
425
  const real = follow ? realOr(file) : file;
425
- const tmp = join(dirname(real), `.${basename(real)}.omc-tmp-${process.pid}`);
426
+ const tmp = join(dirname(real), `.${basename(real)}.omc-tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
426
427
  const m = mode ?? modeOf(real);
427
428
  rmSync(tmp, { force: true });
428
429
  try {
@@ -42,6 +42,7 @@
42
42
  * Exit status 1 means nothing was changed; 2 means an unexpected error stopped
43
43
  * the command partway, so check `status`.
44
44
  */
45
+ import { randomBytes } from "node:crypto";
45
46
  import { spawnSync } from "node:child_process";
46
47
  import {
47
48
  accessSync,
@@ -215,7 +216,7 @@ function linkedFolder(file) {
215
216
  // either name is replaced rather than written through, and a write that fails
216
217
  // leaves the old file as it was.
217
218
  function writeFresh(file, data, mode) {
218
- const tmp = `${file}.${process.pid}.tmp`;
219
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
219
220
  rmSync(tmp, { force: true });
220
221
  try {
221
222
  writeFileSync(tmp, data, { flag: "wx", mode });
@@ -33,7 +33,7 @@
33
33
  * fix sentPart and bump CACHE_VERSION, so totals cached under the old rules are
34
34
  * rebuilt.
35
35
  */
36
- import { createHash } from "node:crypto";
36
+ import { createHash, randomBytes } from "node:crypto";
37
37
  import {
38
38
  closeSync,
39
39
  fstatSync,
@@ -187,7 +187,7 @@ function saveCache(file, state) {
187
187
  const dir = dirname(file);
188
188
  mkdirSync(dir, { recursive: true, mode: 0o700 });
189
189
  if (!ownedDir(dir)) return; // a folder this account does not control: no cache
190
- const tmp = `${file}.${process.pid}.tmp`;
190
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
191
191
  rmSync(tmp, { force: true });
192
192
  writeFileSync(tmp, JSON.stringify(state), { flag: "wx", mode: 0o600 });
193
193
  renameSync(tmp, file);
@@ -6,7 +6,7 @@
6
6
  import crypto from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { git, noBom, readRegular, writeAtomic } from './pm-config.mjs';
9
+ import { git, noBom, readRegular, writeAtomic, writeNew } from './pm-config.mjs';
10
10
 
11
11
  const ADR_FILE = 'adr.json';
12
12
  const ADR_CHECK_FILE = 'adr-check.json';
@@ -404,13 +404,16 @@ const stamp = (ms) => {
404
404
  };
405
405
 
406
406
  // 스키마 FAIL 이 남은 adr.json 을 같은 폴더의 adr.json.rejected-<시각>(있으면 -2, -3 ...)으로 옮긴다. 결정 기록을 잃지 않게
407
- // 지우지 않는다. 새 이름은 COPYFILE_EXCL 로 만들어 있는 파일을 덮지 않고, 사본을 만든 뒤에만 원본을 지운다. 돌려줌: 옮긴 경로.
407
+ // 지우지 않는다. 새 이름은 writeNew 로 만들어 있는 것(끊어진 링크 포함)을 덮거나 따라가지 않고, 사본을 만든 뒤에만 원본을
408
+ // 지운다. 돌려줌: 옮긴 경로.
408
409
  function reject(file, now) {
409
410
  const base = `${file}.rejected-${stamp(now)}`;
411
+ const bytes = fs.readFileSync(file);
412
+ const mode = fs.statSync(file).mode & 0o777; // as COPYFILE_EXCL kept it
410
413
  for (let i = 1; ; i++) {
411
414
  const dest = i === 1 ? base : `${base}-${i}`;
412
415
  try {
413
- fs.copyFileSync(file, dest, fs.constants.COPYFILE_EXCL);
416
+ writeNew(dest, bytes, { mode });
414
417
  } catch (e) {
415
418
  if (e.code === 'EEXIST') continue;
416
419
  throw e;