@kaydash9999/banker-plugins 0.7.1 → 0.9.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.7.1"
9
+ "version": "0.9.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
5
5
  "author": {
6
6
  "name": "smallOpenSource",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] - 2026-07-18
4
+
5
+ ### Added
6
+ - **개인화 업데이트 알림**: 새 버전이 나왔을 때, 그 버전에서 바뀐 스킬 중 이 설치에서 실제로 써 본 스킬이 있으면 알림에 그 스킬 이름을 넣어 보여준다("자주 쓰는 스킬이 이번 업데이트에서 바뀌었습니다: ..."). 교집합이 없으면 기존 일반 알림으로 폴백한다.
7
+ - **로컬 "써 본 스킬" 집합**: 카운팅 훅(PostToolUse Skill / UserPromptExpansion)이 스킬 이름을 config 폴더의 `used-skills.json` 에 union 으로 모은다. 이 집합은 절대 전송하지 않는다 — 체크인 페이로드는 여전히 `{version, os, counts}` 뿐이다. 상한(300)·중복 무기록·원자적 기록.
8
+ - **바뀐 스킬 목록(공개 GitHub raw)**: 버전별 바뀐 스킬을 담은 `skill-changes.json` 을 저장소에서 GET 으로 조회한다(카운팅 활성 경로의 update-checkin 이 best-effort; 개인화 표시가 카운팅에 게이트되므로 카운팅 opt-out 경로의 update-fetch 는 조회하지 않는다). 무페이로드 GET 이라 식별자 전송이 없고, 실패하면 일반 알림으로 폴백한다. 릴리스마다 `scripts/gen-skill-changes.js`(git diff 기반·배포 제외)로 채운다.
9
+ - 두 opt-out(`BANKER_NO_UPDATE_CHECK`·`BANKER_NO_TELEMETRY`) 중 어느 쪽을 켜도 개인화는 동작하지 않는다. Codex CLI는 네트워크 차단으로 배제된다.
10
+ - `PRIVACY.md` 에 개인화 항목을 추가했다(로컬 집합 미전송·매니페스트 GET 수신자=GitHub).
11
+
12
+ ### Fixed
13
+ - `PRIVACY.md`·CHANGELOG 의 "0.8.0 아직 게시 안 됨" 상태 문구를 실제(게시됨)에 맞게 정정했다. EU ePrivacy opt-in 전환 여부는 열린 검토 항목으로 표기를 유지한다.
14
+
15
+ ## [0.8.0] - 2026-07-17
16
+
17
+ ### Added
18
+ - README 에 npm 월간 다운로드 배지와 GitHub stars 배지를 추가했다.
19
+ - `banker setup` 최초 실행 시 GitHub 저장소 별(star)을 요청하는 프롬프트를 추가했다(양 런타임 공통·홍보 목적·카운팅 opt-out 여부와 무관).
20
+ - **업데이트-체크 서비스**: 이전 초안(opt-in 익명 텔레메트리 클라이언트, 기본값 미수집)은 동의자가 거의 없어 사용량 가시성이라는 목표를 달성하지 못한다고 판단해 두 기능으로 재구성(피벗)했다.
21
+ - **업데이트 알림**: 공개 npm 레지스트리(`registry.npmjs.org/@kaydash9999/banker-plugins/latest`)를 조회해 세션 시작 시 새 버전이 있으면 알린다. 조회 요청의 수신자는 npm(GitHub/Microsoft)이고, banker 유지보수자는 이 조회를 받지 않는다. 기본값 켜짐, `BANKER_NO_UPDATE_CHECK` 환경변수 또는 `config.updateCheck=false` 로 끌 수 있다.
22
+ - **사용량 카운팅(count-default-on)**: 하루 1회 스킬/커맨드별 호출 수를 시간대(hour-of-day)별로 버킷화해 플러그인 버전·OS 종류와 함께 유지보수자가 운영하는 외부 엔드포인트로 익명 체크인한다. 집계는 사용자가 직접 입력한 `/banker:*` 커맨드와 모델이 자동 호출한 스킬 양쪽을 서로 다른 두 훅으로 겹침 없이 포착한다. 기본값 켜짐, `BANKER_NO_TELEMETRY` 환경변수·`config.telemetry=false`·`banker telemetry off` 로 끌 수 있다.
23
+ - **기본 카운팅 엔드포인트 내장**: 배포에 기본 엔드포인트(`banker.banker-plugins.workers.dev`, 유지보수자의 Cloudflare Worker)를 박아 `endpoint()` 가 env·config 미설정이어도 이를 반환한다. 그 결과 `countingActive()` 가 실제로 default-on 이 되어 opt-out 하지 않은 모든 설치에서 카운팅이 활성이다(이전엔 엔드포인트 미설정이라 사실상 유지보수자 셸만 잡혔다). 자가호스팅/override 는 `BANKER_TELEMETRY_ENDPOINT` 환경변수로 한다.
24
+ - 두 기능 모두 Claude Code 전용이다. Codex CLI는 샌드박스가 네트워크 접근을 차단하므로 알림·카운팅 둘 다 배제된다.
25
+ - `PRIVACY.md` 를 이 설계에 맞게 재작성했다: 알림/카운팅을 분리 기술하고 각각의 수신자를 이름으로 명시했다(알림=npm, 카운팅=유지보수자). 익명성이 페이로드의 속성이지 시스템 전체의 속성은 아니라는 점(카운팅 서버는 소스 IP를 수신하되 기록하지 않도록 설계)을 정직하게 밝혔다. EU ePrivacy Art.5(3)·Planet49 판례는 표준 관행 근거와 반대 근거를 양면으로 제시했고, 미국 CCPA·한국 PIPA는 이 익명 체크인에 재적용하지 않는다는 점(실무 판단 기준인 IP 지속 보유·프로파일링·판매에 해당하지 않음)을 명기했다.
26
+
27
+ ### Notes
28
+ - **정직 명시**: 이 릴리스는 npm·GitHub·마켓플레이스에 게시되었다. 초안 당시 계획했던 "EU ePrivacy opt-in 전환 검토 통과 후 게시" 게이트는 게시 결정으로 우회되었고, EU ePrivacy opt-in 전환 여부는 `PRIVACY.md` 의 열린 검토 항목으로 남아 있다.
29
+
3
30
  ## [0.7.1] - 2026-07-16
4
31
 
5
32
  ### Changed
package/PRIVACY.md ADDED
@@ -0,0 +1,106 @@
1
+ # 프라이버시 고지
2
+
3
+ > banker 플러그인의 업데이트-체크 서비스(업데이트 알림 + 익명 사용량 카운팅)에 대한 프라이버시 고지입니다.
4
+
5
+ ## 개요
6
+
7
+ 이 문서는 두 개의 독립된 기능을 설명합니다.\
8
+ 하나는 새 버전이 나왔을 때 알려주는 **업데이트 알림**이고, 다른 하나는 유지보수자가 사용량을 파악하기 위한 **익명 사용량 카운팅**입니다.\
9
+ 두 기능 모두 기본값은 켜짐(default-on)이며, 각각 독립적으로 끌 수 있습니다.\
10
+ 이 기능은 Claude Code 런타임에서만 동작합니다. Codex CLI는 샌드박스가 네트워크 접근을 차단하므로 알림과 카운팅 둘 다 동작하지 않습니다.
11
+
12
+ ## 현재 상태
13
+
14
+ 이 문서가 기술하는 업데이트-체크 서비스(업데이트 알림 + 익명 사용량 카운팅 + 개인화)는 게시된 버전에 포함되어 동작합니다.\
15
+ 각 버전의 실제 게시 여부는 npm 레지스트리(`@kaydash9999/banker-plugins`)가 정본입니다. 아래 "법적 상태"에서 다루는 EU ePrivacy opt-in 검토는 여전히 열린 항목이며, 현재 배포 방침은 사전 고지 + opt-out 입니다.\
16
+ 이 문구는 추가 검토 결과에 따라 더 정교해질 수 있습니다.
17
+
18
+ ## 업데이트 알림
19
+
20
+ 공개 npm 레지스트리(`https://registry.npmjs.org/@kaydash9999/banker-plugins/latest`)에서 최신 버전을 조회하고, 설치된 버전보다 최신 버전이 있으면 세션 시작 시 알립니다.\
21
+ 이 조회 요청의 수신자는 **npm(GitHub/Microsoft가 운영)**입니다. banker 유지보수자는 이 조회를 전혀 수신하지 않습니다.\
22
+ update-notifier·Homebrew 등 다른 CLI 도구가 쓰는 것과 같은 방식입니다.
23
+
24
+ 기본값은 켜짐입니다.\
25
+ 환경변수 `BANKER_NO_UPDATE_CHECK` 를 설정하거나 config의 `updateCheck` 를 `false` 로 두면 끌 수 있습니다.
26
+
27
+ ### 개인화 (선택적 강화)
28
+
29
+ 새 버전이 있을 때, 그 버전에서 바뀐 스킬 중 이 설치에서 실제로 써 본 스킬이 있으면 알림에 그 스킬 이름을 넣어 보여줍니다("자주 쓰는 스킬이 이번 업데이트에서 바뀌었습니다: ...").\
30
+ 이를 위해 두 가지가 쓰이며, 어느 것도 새로운 개인식별정보를 서버로 보내지 않습니다.
31
+
32
+ - **로컬 "써 본 스킬" 집합**: 이 기기의 config 폴더에 `used-skills.json` 로 스킬 이름만 모아 둡니다. 이 파일은 **절대 전송하지 않습니다**. 사용량 카운팅 체크인의 페이로드는 여전히 `{version, os, counts}` 세 키뿐이며 이 집합을 포함하지 않습니다. 오직 세션 시작 시 로컬에서 읽어 알림 문구를 개인화하는 데만 씁니다.
33
+ - **바뀐 스킬 목록 조회**: 어떤 스킬이 바뀌었는지는 공개 GitHub raw(저장소의 `skill-changes.json`)에서 GET 으로 조회합니다. 이 요청의 수신자는 **GitHub(Microsoft가 운영)**이고, GET 이라 페이로드나 식별자를 보내지 않습니다. 업데이트 버전 조회(npm)와 같은 성격의 무페이로드 조회입니다.
34
+
35
+ 개인화는 두 opt-out 중 어느 쪽으로도 완전히 꺼집니다.\
36
+ `BANKER_NO_UPDATE_CHECK`(또는 config `updateCheck=false`)는 업데이트 알림 자체를 끄므로 개인화도 함께 사라집니다.\
37
+ `BANKER_NO_TELEMETRY`(또는 config `telemetry=false`·`banker telemetry off`)는 개인화가 기반하는 사용량 카운팅을 끄므로, 로컬 "써 본 스킬" 집합의 **기록**과 알림에서의 **표시**가 모두 중단됩니다(이미 쌓인 로컬 집합이 있어도 표시하지 않으며, 이 경우 일반 업데이트 알림은 계속 표시됩니다).
38
+
39
+ ## 사용량 카운팅
40
+
41
+ 하루 1회, 유지보수자가 운영하는 외부 엔드포인트(banker 패키지 밖에서 별도로 운영되는 serverless)로 익명 체크인을 전송합니다.\
42
+ 플러그인은 기본 카운팅 엔드포인트(`banker.banker-plugins.workers.dev`)를 내장하므로, count-default-on 이라 설치 즉시 활성입니다.\
43
+ `BANKER_TELEMETRY_ENDPOINT` 환경변수로 자가호스팅 엔드포인트를 지정하거나 override 할 수 있습니다.
44
+
45
+ ### 수집 항목
46
+
47
+ - 스킬/커맨드별 호출 횟수를 시간대(hour-of-day, 0시~23시)별로 버킷화한 값
48
+ - 플러그인 버전
49
+ - OS 종류(linux/darwin/win32 수준)
50
+
51
+ 개별 호출의 시각은 전송하지 않고 시간대 버킷까지만 뭉쳐서 보냅니다.\
52
+ 집계 대상은 사용자가 직접 입력한 `/banker:*` 커맨드와 모델이 자동으로 호출한 스킬 양쪽 모두이며, 서로 다른 두 훅이 겹치지 않게 나누어 포착합니다.
53
+
54
+ ### 수집하지 않는 항목
55
+
56
+ - 이름, 이메일 등 개인식별정보(PII)
57
+ - 코드 및 파일 내용, 커맨드 인자
58
+ - 저장소·계정 정보
59
+ - 지속적인 사용자 또는 기기 식별자(UUID·머신ID 등을 생성하지 않습니다)
60
+ - 페이로드 안의 IP 주소
61
+
62
+ 기본값은 켜짐(count-default-on)입니다.\
63
+ 환경변수 `BANKER_NO_TELEMETRY` 를 설정하거나, config의 `telemetry` 를 `false` 로 두거나, `banker telemetry off` 를 실행하면 끌 수 있습니다.\
64
+ `banker setup` 첫 실행 시 이 기본값과 끄는 방법을 투명하게 고지합니다. 진행을 막는 opt-in 동의 프롬프트가 아니라 정보 제공입니다.
65
+
66
+ ## 익명성에 대한 정직한 고지
67
+
68
+ 익명성은 전송되는 데이터, 즉 페이로드 자체의 속성이지 시스템 전체의 속성이 아닙니다.\
69
+ 카운팅 엔드포인트는 네트워크 요청의 시스템 경계에서 TCP 소스 IP 주소를 봅니다. IP 주소는 GDPR과 한국 개인정보보호법(PIPA) 모두에서 개인정보로 취급됩니다.\
70
+ 이 엔드포인트는 banker 유지보수자가 운영하며, 수신 IP를 기록하지 않도록 설계되어 있습니다. banker 플러그인(클라이언트)과 카운팅 서버는 같은 운영자가 관리합니다.\
71
+ 반면 업데이트 알림 조회의 IP는 npm만 보며, banker 유지보수자는 어느 경로로도 보지 않습니다.\
72
+ 클라이언트인 banker가 보장하는 범위는 페이로드 안에 IP나 그 밖의 식별자를 넣지 않는다는 것까지이며, 서버가 수신 IP를 기록하지 않는 것은 서버를 운영하는 유지보수자의 설계 책임입니다.
73
+
74
+ ## 상시 훅 비용
75
+
76
+ 알림과 카운팅이 기본값으로 켜져 있기 때문에 다음 훅이 banker 활성화 중 항상 등록되어 있습니다.
77
+
78
+ - `SessionStart`: 세션 시작 시 1회, 캐시된 업데이트 알림을 확인합니다(이 훅 자체는 네트워크 요청을 하지 않습니다).
79
+ - `PostToolUse`(Skill 매처): 모델이 자동으로 호출한 스킬을 집계하고, 개인화용 로컬 "써 본 스킬" 집합을 갱신합니다(집계는 전송, 로컬 집합은 미전송).
80
+ - `UserPromptExpansion`: 사용자가 직접 입력한 `/banker:*` 커맨드를 집계하고, 개인화용 로컬 "써 본 스킬" 집합을 갱신합니다.
81
+
82
+ 세 훅 모두 트리거될 때마다 짧게 실행되는 node 프로세스를 하나씩 기동합니다(수십 ms 수준).\
83
+ opt-out 하더라도 훅의 등록 자체는 남고, 각 스크립트 내부의 게이트가 즉시 무동작으로 반환합니다.
84
+
85
+ ## 전송 안전
86
+
87
+ - 업데이트 알림 조회는 GET 요청이며 페이로드가 없습니다.
88
+ - 카운팅 체크인은 HTTPS만 사용합니다. 평문 http는 로컬 테스트용 loopback(localhost/127.0.0.1/::1) 엔드포인트에만 허용되고, 그 외 엔드포인트에는 거부됩니다.
89
+ - 네트워크 조회·전송은 세션을 막지 않도록 분리 실행되며, 포그라운드 훅은 캐시 읽기만 하고 즉시 반환합니다.
90
+ - 어떤 네트워크 오류도 삼키며(fail-closed), 항상 정상 종료합니다.
91
+
92
+ ## 법적 상태
93
+
94
+ **EU ePrivacy는 양면을 그대로 남깁니다.**
95
+
96
+ 표준 관행이라는 근거: 익명 체크인, 요청 카운트, IP 미보유, 사전 고지와 opt-out의 조합은 사실상 업계 표준 관행에 해당합니다. 지속 식별자나 사용자별 프로파일을 만들지 않는 무상태 체크인이라는 점이 이 판단의 근거입니다.
97
+
98
+ 반대 근거: EU ePrivacy Directive Art.5(3)은 개인식별정보 여부와 무관하게 단말기(device) 접근 자체에 이용자 동의를 요구할 수 있습니다. Planet49 판례에 따르면 분석 목적의 opt-out은 유효한 동의로 인정되지 않을 수 있습니다. 즉 무상태 체크인이라도 접근 자체가 EU에서는 동의를 유발할 가능성이 있습니다.
99
+
100
+ 실무 결론: 실제로 EU 사용자를 대상으로 배포하기 전에는 opt-in 전환 여부를 별도로 검토해야 합니다. 현재 배포는 사전 고지 + opt-out 방식으로 운영되고 있으며, 이 EU ePrivacy opt-in 전환 여부는 열린 검토 항목으로 남아 있습니다.
101
+
102
+ **미국 CCPA·한국 PIPA는 이 익명 업데이트-체크에 재적용하지 않습니다.** 두 법의 실무 판단 기준은 IP 주소의 지속적 보유, 사용자별 프로파일 구축, 데이터 판매 여부입니다.\
103
+ 이 기능의 익명 체크인은 이 중 어느 것도 하지 않습니다. IP를 기록하지 않고, 지속 식별자가 없고, 데이터를 판매하지 않습니다.\
104
+ 따라서 CCPA·PIPA를 이 기능의 별도 미해결 법적 쟁점으로 표기하지 않습니다. 위에서 다룬 EU ePrivacy는 이와 무관한 별건입니다.
105
+
106
+ 이 문구는 향후 더 정교해질 수 있습니다.
package/README.md CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@kaydash9999/banker-plugins)](https://www.npmjs.com/package/@kaydash9999/banker-plugins)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@kaydash9999/banker-plugins)](https://www.npmjs.com/package/@kaydash9999/banker-plugins) [![GitHub stars](https://img.shields.io/github/stars/smallOpenSource/banker_plugins)](https://github.com/smallOpenSource/banker_plugins)
7
8
 
8
- [빠른 시작](#빠른-시작) · [구성](#구성) · [설치 상세](#설치-상세-npm--codex) · [요구사항](#요구사항) · [업데이트 / 제거](#업데이트--제거) · [라이선스 / 서드파티](#라이선스--서드파티)
9
+ [빠른 시작](#빠른-시작) · [구성](#구성) · [설치 상세](#설치-상세-npm--codex) · [요구사항](#요구사항) · [업데이트 / 제거](#업데이트--제거) · [업데이트 확인](#업데이트-확인-및-사용량-카운팅) · [라이선스 / 서드파티](#라이선스--서드파티)
9
10
 
10
11
  banker는 QA·보안 감사·문서·아키텍처·위키 작업과 의존성·개발환경(OS별) 설치를 아우르는 **스킬 48개 + 커맨드 2개**(총 50개 구성요소)를 묶은 Claude Code 플러그인입니다.\
11
12
  설치하면 스킬과 커맨드가 `/banker:<이름>` 네임스페이스로 노출됩니다.\
@@ -174,6 +175,30 @@ banker uninstall # 제거
174
175
 
175
176
  Codex는 재설치할 때마다 기존 `banker-*` 를 먼저 정리하므로 옛 버전이 중복으로 남지 않습니다.
176
177
 
178
+ ## 업데이트 확인 및 사용량 카운팅
179
+
180
+ banker는 기본값(default-on)으로 두 가지를 합니다.\
181
+ 새 버전이 나오면 세션 시작 시 알려주는 **업데이트 알림**과, 유지보수자가 사용량을 파악하는 **익명 사용량 카운팅**입니다.\
182
+ 이 때문에 `SessionStart`(알림 확인)·`PostToolUse`(모델이 자동 호출한 스킬 집계)·`UserPromptExpansion`(사용자가 직접 입력한 `/banker:*` 집계) 훅이 항상 등록되어 있고, 트리거될 때마다 짧게 node 프로세스를 하나씩 띄웁니다(수십 ms 수준).
183
+
184
+ 업데이트 알림은 **개인화**됩니다.\
185
+ 새 버전에서 바뀐 스킬 중 당신이 실제로 써 본 스킬이 있으면 그 이름을 알림에 넣어 보여줍니다("자주 쓰는 스킬이 이번 업데이트에서 바뀌었습니다: ...").\
186
+ 이를 위해 써 본 스킬 이름만 로컬 파일(`used-skills.json`)에 모으며, 이 목록은 전송하지 않습니다(바뀐 스킬 목록만 공개 GitHub raw 에서 무페이로드 GET 으로 조회).\
187
+ 개인화는 아래 두 opt-out 중 어느 쪽으로도 함께 꺼집니다.
188
+
189
+ 끄기:
190
+
191
+ ```bash
192
+ export BANKER_NO_UPDATE_CHECK=1 # 업데이트 알림만 끄기
193
+ export BANKER_NO_TELEMETRY=1 # 사용량 카운팅만 끄기
194
+ ```
195
+
196
+ 카운팅은 `banker telemetry off` 로도 끌 수 있습니다.\
197
+ 플러그인은 기본 카운팅 엔드포인트(`banker.banker-plugins.workers.dev`)를 내장하므로 count-default-on 이라 설치 즉시 활성이며, `BANKER_TELEMETRY_ENDPOINT` 환경변수로 자가호스팅/override 할 수 있습니다.\
198
+ Codex CLI는 샌드박스가 네트워크를 차단하므로 알림·카운팅 둘 다 동작하지 않습니다.
199
+
200
+ 무엇을 수집하고 누가 수신하는지는 [PRIVACY.md](PRIVACY.md)에 정리했습니다.
201
+
177
202
  ## 라이선스 / 서드파티
178
203
 
179
204
  banker 자체는 **MIT** ([LICENSE](LICENSE)). Owner: [smallOpenSource](https://github.com/smallOpenSource).
package/bin/banker.js CHANGED
@@ -11,12 +11,14 @@ const fs = require('fs');
11
11
  const os = require('os');
12
12
  const path = require('path');
13
13
  const cp = require('child_process');
14
+ const { pathToFileURL } = require('url');
14
15
 
15
16
  const PKG_ROOT = path.resolve(__dirname, '..');
16
17
  const MANIFEST = path.join(PKG_ROOT, 'codex', 'manifest.json');
17
18
  const PLUGIN_JSON = path.join(PKG_ROOT, '.claude-plugin', 'plugin.json');
18
19
  const MARKETPLACE = 'banker-plugins';
19
20
  const PLUGIN_NAME = 'banker';
21
+ const REPO_URL = 'https://github.com/smallOpenSource/banker_plugins';
20
22
 
21
23
  function version() {
22
24
  try { return require(path.join(PKG_ROOT, 'package.json')).version; } catch { return '0.0.0'; }
@@ -25,9 +27,10 @@ function log(...a) { console.log(...a); }
25
27
  function warn(...a) { console.error(...a); }
26
28
 
27
29
  function parseArgs(argv) {
28
- const a = { cmd: null, claude: false, codex: false, scope: 'user', scopeExplicit: false, dryRun: false, help: false, version: false };
30
+ const a = { cmd: null, sub: null, claude: false, codex: false, scope: 'user', scopeExplicit: false, dryRun: false, help: false, version: false };
29
31
  for (const t of argv) {
30
- if (t === 'setup' || t === 'doctor' || t === 'uninstall' || t === 'help') { if (!a.cmd) a.cmd = t; }
32
+ if (t === 'setup' || t === 'doctor' || t === 'uninstall' || t === 'help' || t === 'telemetry') { if (!a.cmd) a.cmd = t; }
33
+ else if (t === 'on' || t === 'off' || t === 'status') { if (!a.sub) a.sub = t; } // telemetry subcommand
31
34
  else if (t === '--claude') a.claude = true;
32
35
  else if (t === '--codex') a.codex = true;
33
36
  else if (t === '--dry-run' || t === '-n') a.dryRun = true;
@@ -228,11 +231,111 @@ function uninstallCodex(dryRun, scopes) {
228
231
  }
229
232
  }
230
233
 
234
+ /* ---------- telemetry config bridge ---------- */
235
+ // bin/banker.js is CommonJS; telemetry-config is ESM (.mjs). Load it lazily via dynamic import
236
+ // (pathToFileURL keeps the specifier valid on Windows). Only the interactive setup path and the
237
+ // telemetry subcommand call this, so the non-TTY / dry-run setup path never imports it.
238
+ async function loadTelemetry() {
239
+ return import(pathToFileURL(path.join(__dirname, 'lib', 'telemetry-config.mjs')).href);
240
+ }
241
+
242
+ /* ---------- setup first-run prompts (US-6) ---------- */
243
+ // Interactive-only. ABSOLUTE rule: never prompt or persist in a non-TTY / CI / dry-run context.
244
+ // scripts/smoke-test.js runs `setup --claude --dry-run` non-interactively; a blocking read here
245
+ // would hang CI forever. The isTTY + dryRun guard below is the single gate that prevents that.
246
+ async function maybeFirstRunPrompts(a) {
247
+ if (!process.stdin.isTTY || a.dryRun) return; // non-TTY / CI / dry-run: do nothing
248
+ const tc = await loadTelemetry();
249
+ const cfg = tc.readConfig();
250
+ const askStar = cfg.promptedStarV1 !== true;
251
+ // count-default-on: 카운팅은 opt-in 질문이 아니라 투명 고지(차단 프롬프트 아님). --claude 관여 시에만 1회 출력.
252
+ const showTelemetryNotice = a.claude && cfg.promptedTelemetryV2 !== true;
253
+ if (!askStar && !showTelemetryNotice) return; // already shown before - never re-show
254
+
255
+ const readline = require('readline');
256
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
257
+ const ask = (q) => new Promise((res) => rl.question(q, res));
258
+ const next = { ...cfg };
259
+ try {
260
+ if (askStar) {
261
+ const ans = (await ask('banker가 유용했다면 GitHub 별(star)을 눌러 주세요. [Y/n] ')).trim().toLowerCase();
262
+ if (ans === '' || ans === 'y' || ans === 'yes') {
263
+ log('감사합니다. 아래 주소에서 직접 별을 눌러 주세요 (자동으로 누르지 않고 안내만 합니다):');
264
+ log(` ${REPO_URL}`);
265
+ }
266
+ next.promptedStarV1 = true;
267
+ }
268
+ if (showTelemetryNotice) {
269
+ log('banker 는 익명 사용량 카운트를 기본 수집합니다(스킬별 호출수·버전·OS·식별자 없음).');
270
+ log('끄려면 BANKER_NO_TELEMETRY=1 또는 `banker telemetry off`. 자세히: PRIVACY.md');
271
+ next.promptedTelemetryV2 = true;
272
+ }
273
+ } finally {
274
+ rl.close();
275
+ }
276
+ tc.writeConfig(next);
277
+ }
278
+
279
+ /* ---------- telemetry subcommand (US-08, count-default-on) ---------- */
280
+ const TELEMETRY_USAGE = `Usage: banker telemetry <on|off|status>
281
+ on 카운팅 opt-out 을 해제 (기본값인 켜짐으로 복귀)
282
+ off 카운팅을 끔 (config.telemetry=false 로 opt-out)
283
+ status 카운팅 / 업데이트-체크의 opt-out · 엔드포인트 · 캐시 상태를 정직하게 출력`;
284
+
285
+ async function telemetryCmd(sub) {
286
+ const tc = await loadTelemetry();
287
+ if (sub === 'on' || sub === 'off') {
288
+ const cfg = tc.readConfig();
289
+ if (sub === 'off') cfg.telemetry = false; // opt-out 저장
290
+ else delete cfg.telemetry; // opt-out 해제: 기본-on 으로 복귀
291
+ if (!tc.writeConfig(cfg)) {
292
+ warn('banker: 설정을 저장하지 못했습니다 (config 쓰기 실패).');
293
+ process.exitCode = 1;
294
+ return;
295
+ }
296
+ if (sub === 'off') {
297
+ log('카운팅을 껐습니다 (off). config.telemetry=false 로 opt-out 을 저장했습니다.');
298
+ } else {
299
+ log('카운팅 opt-out 을 해제했습니다 (on). 기본값(켜짐)으로 복귀합니다.');
300
+ if (!tc.endpoint()) log('참고: 엔드포인트가 설정되어 있지 않아 지금은 아무것도 전송하지 않습니다.');
301
+ }
302
+ return;
303
+ }
304
+ if (sub === 'status') {
305
+ const cfg = tc.readConfig();
306
+ const v = process.env.BANKER_NO_TELEMETRY;
307
+ const envOptOut = !(v === undefined || v === '' || v === '0' || v === 'false');
308
+ const cfgOptOut = cfg.telemetry === false;
309
+ log('banker 상태:');
310
+ log('[카운팅] 익명 사용량 카운팅 (기본값: 켜짐 · count-default-on)');
311
+ log(` 엔드포인트 설정: ${tc.endpoint() != null ? '설정됨' : '미설정'}`);
312
+ log(` opt-out(BANKER_NO_TELEMETRY): ${envOptOut ? '적용됨' : '미적용'}`);
313
+ log(` opt-out(config.telemetry=false): ${cfgOptOut ? '적용됨' : '미적용'}`);
314
+ log(` 실효 상태(effective): ${tc.countingActive() ? 'enabled' : 'disabled'}`);
315
+ log(' 참고: 엔드포인트가 설정되어 있고 opt-out 이 아닐 때(env·config 둘 다 아님)만 실제로 전송됩니다.');
316
+
317
+ let cache = {};
318
+ try { cache = JSON.parse(fs.readFileSync(tc.UPDATE_CHECK_PATH, 'utf8')); } catch { /* 캐시 없음/malformed */ }
319
+ const installed = tc.installedVersion();
320
+ const cmp = (cache.latest && installed) ? tc.compareVersions(cache.latest, installed) : null;
321
+ log('[업데이트 체크] 새 버전 알림 (기본값: 켜짐)');
322
+ log(` opt-out(BANKER_NO_UPDATE_CHECK/config.updateCheck=false): ${tc.noUpdateCheck() ? '적용됨' : '미적용'}`);
323
+ log(` 마지막 체크: ${cache.checkedAt ? new Date(cache.checkedAt).toISOString() : '없음'}`);
324
+ log(` 캐시된 최신 버전: ${cache.latest || '없음'} / 설치된 버전: ${installed || '알 수 없음'}`);
325
+ log(cmp === null ? ' 비교 불가 (캐시 없음 또는 malformed)'
326
+ : cmp > 0 ? ` 업데이트 있음 (${installed} → ${cache.latest})`
327
+ : ' 최신 버전 사용 중');
328
+ return;
329
+ }
330
+ log(TELEMETRY_USAGE); // 알 수 없는 하위명령: 사용법 출력
331
+ }
332
+
231
333
  const HELP = `banker v${version()} — install the banker plugin into Claude Code and/or Codex CLI
232
334
 
233
335
  Usage:
234
336
  banker setup [--claude] [--codex] [--scope user|project] [--dry-run]
235
337
  banker uninstall [--claude] [--codex] [--scope user|project] [--dry-run]
338
+ banker telemetry <on|off|status>
236
339
  banker doctor
237
340
  banker help
238
341
 
@@ -240,18 +343,22 @@ Notes:
240
343
  - setup/uninstall with no target flag applies to BOTH tools.
241
344
  - --claude registers the marketplace + installs banker@banker-plugins (skills as /banker:*).
242
345
  - --codex copies codex-eligible skills -> ~/.codex/skills/banker-* and commands -> ~/.codex/prompts/banker-*.md.
346
+ - telemetry on|off toggles anonymous usage counting opt-out (default-on; config only); status prints counting + update-check state. See PRIVACY.md.
243
347
  - Never runs as root. --dry-run prints planned actions without writing.`;
244
348
 
245
- function main() {
349
+ async function main() {
246
350
  const a = parseArgs(process.argv.slice(2));
247
351
  if (a.version) { log(version()); return; }
248
352
  if (a.help || a.cmd === 'help' || !a.cmd) { log(HELP); return; }
249
353
  if (a.cmd === 'doctor') { doctor(); return; }
354
+ if (a.cmd === 'telemetry') { await telemetryCmd(a.sub); return; }
250
355
  assertNotRoot();
251
356
  if (a.cmd === 'setup') {
252
357
  log(`banker setup${a.dryRun ? ' (dry-run)' : ''}: claude=${a.claude} codex=${a.codex}`);
253
358
  if (a.claude) setupClaude(a.dryRun);
254
359
  if (a.codex) setupCodex(a.dryRun, a.scope);
360
+ // Best-effort first-run prompts (interactive TTY only). A prompt failure must never fail setup.
361
+ try { await maybeFirstRunPrompts(a); } catch { /* non-fatal */ }
255
362
  log('done.');
256
363
  return;
257
364
  }
@@ -262,4 +369,4 @@ function main() {
262
369
  return;
263
370
  }
264
371
  }
265
- main();
372
+ main().catch((e) => { warn('banker:', e && e.message ? e.message : e); process.exitCode = 1; });
@@ -0,0 +1,271 @@
1
+ /*
2
+ * telemetry-config - banker 텔레메트리 config/consent 공용 모듈 (bin·hook 공유).
3
+ * 의존성 0 (Node 내장 모듈만: node:fs, node:path, node:os). ESM(.mjs).
4
+ * import 시 부작용 없음: 경로는 resolver 로 호출 시 계산하고, 파일시스템 접근·mkdir 은
5
+ * 각 함수 호출 시에만 일어난다. readConfig/isEnabled/endpoint 는 어떤 예외도 밖으로 던지지 않는다.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ // 단일 config-path resolver. 호출 시점의 env 를 읽어 계산한다 (테스트는 XDG_CONFIG_HOME 으로 격리).
13
+ export function configDir() {
14
+ const xdg = process.env.XDG_CONFIG_HOME;
15
+ if (xdg) return path.join(xdg, 'banker');
16
+ if (process.platform === 'win32') {
17
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'banker');
18
+ }
19
+ return path.join(os.homedir(), '.config', 'banker');
20
+ }
21
+
22
+ // 공유 경로 상수: import 시점의 resolver 결과를 캡처한다 (bin·hook 은 단명 프로세스라 env 안정적).
23
+ // 순수 경로 계산이라 파일시스템을 건드리지 않는다. 내부 함수는 configDir() 를 매 호출 재계산하므로
24
+ // 테스트가 XDG_CONFIG_HOME 을 바꿔도 올바르게 격리된다.
25
+ export const USAGE_LOG_PATH = path.join(configDir(), 'usage-log'); // 사용량 로그 절대경로
26
+ export const LAST_FLUSH_PATH = path.join(configDir(), 'last-flush');
27
+ // USAGE_LOG 계약: 한 줄 = `<skill-name>\t<hour 0-23>` (스킬 이름 + TAB + 기록 시각의 로컬 hour).
28
+ // flush 는 이 줄들을 스킬별 x 시간대별로 집계한다. LOG_MAX_LINES 는 (필드 아닌) 줄 수 상한이며,
29
+ // 초과 시 오래된 줄부터 잘라내 24h 윈도우를 근사한다.
30
+ export const LOG_MAX_LINES = 5000;
31
+
32
+ // 개인화 알림용 로컬 "써 본 스킬" 집합 경로 + 상한. 이 파일은 절대 전송하지 않는다(로컬 전용).
33
+ // update-checkin 페이로드는 usage-log 집계만 담고 이 집합은 건드리지 않는다. update-notify 만 읽는다.
34
+ export const USED_SKILLS_PATH = path.join(configDir(), 'used-skills.json');
35
+ export const USED_SKILLS_MAX = 300;
36
+
37
+ // update-check 캐시 파일({latest,checkedAt,notified})의 절대경로. USAGE_LOG_PATH 와 동일하게
38
+ // import 시점 resolver 결과를 캡처한다(단명 hook 프로세스용). writeUpdateCache 는 테스트 격리를 위해
39
+ // configDir() 를 매 호출 재계산하므로 이 상수에 의존하지 않는다.
40
+ export const UPDATE_CHECK_PATH = path.join(configDir(), 'update-check.json');
41
+ // 업데이트 체크/체크인 스로틀 창(24h). 이 간격 미경과면 재조회하지 않는다.
42
+ export const UPDATE_THROTTLE_MS = 24 * 60 * 60 * 1000;
43
+ // 최신 버전 조회용 공개 npm 레지스트리 URL. 본문 .version 을 직접 반환한다(실측 확정).
44
+ export const NPM_LATEST_URL = 'https://registry.npmjs.org/@kaydash9999/banker-plugins/latest';
45
+ // 버전별 "바뀐 스킬 목록" 매니페스트(공개 GitHub raw JSON: {"x.y.z": ["banker:skill", ...]}). update-fetch/
46
+ // update-checkin 이 best-effort GET 해 changedSkills 를 캐시에 채우고, update-notify 가 로컬 "써 본 스킬"과
47
+ // 교차해 개인화 알림을 만든다. GET 뿐이라 페이로드/식별자 전송이 없고, 실패해도 무해하다(일반 알림으로 폴백).
48
+ // 테스트는 BANKER_SKILL_CHANGES_URL 로 loopback mock 을 주입해 실제 네트워크를 때리지 않는다.
49
+ export const SKILL_CHANGES_URL = 'https://raw.githubusercontent.com/smallOpenSource/banker_plugins/main/skill-changes.json';
50
+
51
+ // config.json 을 읽어 객체로 반환. 파일 없음·malformed JSON·권한 오류 등 어떤 예외에도 던지지 않고 {} 반환.
52
+ export function readConfig() {
53
+ try {
54
+ const raw = fs.readFileSync(path.join(configDir(), 'config.json'), 'utf8');
55
+ const obj = JSON.parse(raw);
56
+ return (obj && typeof obj === 'object' && !Array.isArray(obj)) ? obj : {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ // configDir 을 mkdir -p 후 config.json 에 기록. 오류는 삼켜 false, 성공 true.
63
+ export function writeConfig(obj) {
64
+ try {
65
+ const target = configDir();
66
+ fs.mkdirSync(target, { recursive: true });
67
+ fs.writeFileSync(path.join(target, 'config.json'), JSON.stringify(obj, null, 2));
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // 배포에 내장하는 기본 카운팅 엔드포인트(유지보수자가 운영하는 Cloudflare Worker). 이 상수를 내장해서
75
+ // count-default-on 이 모든 설치에서 활성이다: endpoint() 가 (거의) 항상 값을 반환하므로 countingActive()
76
+ // 가 기본 켜짐이 되고, opt-out(BANKER_NO_TELEMETRY / banker telemetry off / config.telemetry=false)으로만
77
+ // 끈다. 자가호스팅/override 는 BANKER_TELEMETRY_ENDPOINT env 로 한다.
78
+ export const DEFAULT_ENDPOINT = 'https://banker.banker-plugins.workers.dev/';
79
+
80
+ // 카운팅 엔드포인트 문자열. 우선순위: env(BANKER_TELEMETRY_ENDPOINT) > config.endpoint > DEFAULT_ENDPOINT.
81
+ // 기본 엔드포인트를 내장하므로 env·config 미설정이어도 DEFAULT_ENDPOINT 를 반환한다(= count-default-on 이
82
+ // 모든 설치에서 활성, opt-out 으로만 끔). env 빈문자열('')은 falsy 라 여전히 config/DEFAULT 로 흐른다.
83
+ // 예외가 난 경우에만 null 을 반환한다(에러 시 보수적으로 전송 안 함).
84
+ export function endpoint() {
85
+ try {
86
+ const env = process.env.BANKER_TELEMETRY_ENDPOINT;
87
+ if (env) return env;
88
+ const ep = readConfig().endpoint;
89
+ return (typeof ep === 'string' && ep) ? ep : DEFAULT_ENDPOINT;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ // env 값이 opt-out 을 의미하는지. unset·''·'0'·'false' 는 opt-out 이 아니고, 그 외 모든 값이 opt-out.
96
+ // BANKER_NO_TELEMETRY 와 BANKER_NO_UPDATE_CHECK 가 동일 규칙을 공유한다.
97
+ function isOptOut(v) {
98
+ return !(v === undefined || v === '' || v === '0' || v === 'false');
99
+ }
100
+
101
+ // BANKER_NO_TELEMETRY 가 opt-out 을 의미하는지.
102
+ function noTelemetry() {
103
+ return isOptOut(process.env.BANKER_NO_TELEMETRY);
104
+ }
105
+
106
+ // [예약] opt-in 게이트. 현재 카운팅은 countingActive(default-on)를 쓰므로 프로덕션 호출자는 없다.
107
+ // 향후 EU 등에서 opt-in 전환이 필요할 때(PRIVACY.md 법적 상태 참조) 재도입할 수 있게 보존한다.
108
+ // 다음이 전부 참일 때만 true: (1) readConfig().telemetry===true, (2) 엔드포인트 설정됨,
109
+ // (3) BANKER_NO_TELEMETRY 가 opt-out 아님. 전체를 try/catch 로 감싸 어떤 예외에도 false.
110
+ export function isEnabled() {
111
+ try {
112
+ if (readConfig().telemetry !== true) return false;
113
+ if (!endpoint()) return false;
114
+ if (noTelemetry()) return false;
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ // 업데이트 체크를 opt-out 했는지. env BANKER_NO_UPDATE_CHECK 가 opt-out 이거나
122
+ // readConfig().updateCheck===false 이면 true. 전체를 try/catch 로 감싸 어떤 예외에도 false.
123
+ export function noUpdateCheck() {
124
+ try {
125
+ if (isOptOut(process.env.BANKER_NO_UPDATE_CHECK)) return true;
126
+ if (readConfig().updateCheck === false) return true;
127
+ return false;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ // 익명 사용량 카운팅이 활성인지 (default-on). isEnabled 의 opt-in(telemetry===true) 과 달리
134
+ // telemetry 미설정도 활성으로 본다: (1) 엔드포인트 설정됨, (2) BANKER_NO_TELEMETRY 가 opt-out 아님,
135
+ // (3) readConfig().telemetry!==false. 셋 다 참이면 true. 전체를 try/catch 로 감싸 어떤 예외에도 false.
136
+ export function countingActive() {
137
+ try {
138
+ if (!endpoint()) return false;
139
+ if (noTelemetry()) return false;
140
+ if (readConfig().telemetry === false) return false;
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ // 이 모듈 파일 기준 플러그인 루트의 plugin.json 절대경로 (bin/lib -> 루트 = 두 단계 위).
148
+ // fileURLToPath·path 는 순수 계산이라 import 부작용이 없다(파일 읽기는 installedVersion 호출 시에만).
149
+ const PLUGIN_JSON_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.claude-plugin', 'plugin.json');
150
+
151
+ // 설치된 플러그인 버전 문자열. 기본 경로는 플러그인 루트의 .claude-plugin/plugin.json 이며,
152
+ // 테스트가 임의 경로를 주입할 수 있게 파라미터로도 받는다. 파일없음·malformed·비문자열·빈문자열 → null.
153
+ export function installedVersion(pluginJsonPath = PLUGIN_JSON_PATH) {
154
+ try {
155
+ const v = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')).version;
156
+ return (typeof v === 'string' && v) ? v : null;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ // 'x.y.z' 두 버전을 major.minor.patch 정수 3튜플로 비교한다. a>b 면 양수, a<b 면 음수, 같으면 0.
163
+ // prerelease 접미사('1.2.3-rc1')·토큰 누락('1.2')·비수치('a.b.c')가 있으면 null(미확정) 반환.
164
+ // 순수 함수이며 어떤 입력에도 throw 하지 않는다.
165
+ export function compareVersions(a, b) {
166
+ const parse = (s) => {
167
+ if (typeof s !== 'string') return null;
168
+ const parts = s.split('.');
169
+ if (parts.length !== 3) return null;
170
+ const nums = [];
171
+ for (const part of parts) {
172
+ if (!/^\d+$/.test(part)) return null; // 빈·비수치·prerelease('3-rc1') 토큰 배제
173
+ nums.push(Number(part));
174
+ }
175
+ return nums;
176
+ };
177
+ const pa = parse(a);
178
+ const pb = parse(b);
179
+ if (pa === null || pb === null) return null;
180
+ for (let i = 0; i < 3; i++) {
181
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
182
+ }
183
+ return 0;
184
+ }
185
+
186
+ // update-check 캐시를 read-modify-write 로 병합 기록한다: 기존 JSON 을 읽어({} on 실패) patch 의
187
+ // 필드만 덮고 나머지 형제 필드는 보존한 뒤, configDir() mkdir -p → temp 파일 쓰기 → rename(원자적)
188
+ // 으로 기록한다. 테스트 격리를 위해 경로는 configDir() 로 매 호출 재계산한다(UPDATE_CHECK_PATH 상수
189
+ // 비의존). 모든 오류를 삼켜 best-effort: 성공 true, 실패 false.
190
+ export function writeUpdateCache(patch) {
191
+ try {
192
+ const dir = configDir();
193
+ fs.mkdirSync(dir, { recursive: true });
194
+ const cachePath = path.join(dir, 'update-check.json');
195
+ let existing = {};
196
+ try {
197
+ const obj = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
198
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) existing = obj;
199
+ } catch { /* 없음·malformed → {} */ }
200
+ const merged = { ...existing, ...(patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}) };
201
+ const tmp = path.join(dir, `update-check.json.${process.pid}.tmp`);
202
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2));
203
+ fs.renameSync(tmp, cachePath);
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ // ---- 개인화 알림용 로컬 "써 본 스킬" 집합 (전송하지 않음) ----
211
+ // 이 집합은 절대 서버로 보내지 않는다: update-checkin 페이로드는 usage-log 집계(counts)만 담는다.
212
+ // update-notify 가 로컬에서 읽어, 새 버전에서 바뀐 스킬 중 "사용자가 써 본 것"만 골라 알림에 이름을 넣는다.
213
+
214
+ // 로컬 "써 본 스킬" 배열을 반환. 없음·malformed·비배열 → []. 문자열 원소만 남긴다. 어떤 오류에도 [].
215
+ export function readUsedSkills() {
216
+ try {
217
+ const arr = JSON.parse(fs.readFileSync(path.join(configDir(), 'used-skills.json'), 'utf8'));
218
+ return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string' && x) : [];
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ // name 을 로컬 집합에 union 한다(개인화 알림용, 전송 안 함). 이미 있으면 파일을 쓰지 않는다(핫패스 절약).
225
+ // 상한(USED_SKILLS_MAX) 도달 시 새 이름은 무시한다(오래된 것 보존; 개인화는 근사여도 무방). configDir() 를
226
+ // 매 호출 재계산(테스트 격리) + temp→rename 원자적 기록. 모든 오류를 삼켜 신규 기록 시 true, 그 외 false.
227
+ export function recordUsedSkill(name) {
228
+ try {
229
+ if (typeof name !== 'string' || !name) return false;
230
+ const set = readUsedSkills();
231
+ if (set.includes(name)) return false; // 이미 기록됨 - 쓰지 않는다.
232
+ if (set.length >= USED_SKILLS_MAX) return false; // 상한 - 무시.
233
+ set.push(name);
234
+ const dir = configDir();
235
+ fs.mkdirSync(dir, { recursive: true });
236
+ const target = path.join(dir, 'used-skills.json');
237
+ const tmp = path.join(dir, `used-skills.json.${process.pid}.tmp`);
238
+ fs.writeFileSync(tmp, JSON.stringify(set));
239
+ fs.renameSync(tmp, target);
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ // 순수 함수: 버전->바뀐 스킬 배열 map 에서 installed < v <= latest 인 버전들의 스킬을 합쳐 중복 제거해 반환.
247
+ // map 비객체·installed/latest 비문자열 → []. malformed 버전 키(compareVersions null)·비배열 값은 건너뛴다.
248
+ // 어떤 입력에도 throw 하지 않는다. 개인화가 "설치~최신 사이에 바뀐 스킬"을 계산하는 데 쓴다.
249
+ export function changedSkillsBetween(map, installed, latest) {
250
+ try {
251
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return [];
252
+ if (typeof installed !== 'string' || typeof latest !== 'string') return [];
253
+ const seen = new Set();
254
+ const out = [];
255
+ for (const v of Object.keys(map)) {
256
+ const gt = compareVersions(v, installed);
257
+ const le = compareVersions(v, latest);
258
+ if (gt === null || le === null) continue; // malformed 버전 키 무시.
259
+ if (gt > 0 && le <= 0) {
260
+ const skills = map[v];
261
+ if (!Array.isArray(skills)) continue;
262
+ for (const s of skills) {
263
+ if (typeof s === 'string' && s && !seen.has(s)) { seen.add(s); out.push(s); }
264
+ }
265
+ }
266
+ }
267
+ return out;
268
+ } catch {
269
+ return [];
270
+ }
271
+ }