pattyeng 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,26 +1,34 @@
1
1
  # pattyeng
2
2
 
3
- Patty 엔지니어용 작업 환경 도구.
3
+ Patty 엔지니어용 작업 환경 도구입니다.
4
4
 
5
- 노트북이든, WSL이든, 오래 방치한 개발환경이든 Patty에서 일하는 필요한 기본 세팅을 번에 맞춥니다.
6
-
7
- 지금은 Codex 설정, 스킬 동기화, 업그레이드를 담당합니다. 앞으로는 MCP, hooks, 공통 환경설정까지 같은 방식으로 붙일 예정입니다.
5
+ 처음 세팅, 업데이트, Codex 스킬 설치를 같은 명령어 체계로 관리합니다. 지금은 Codex 중심입니다. 나중에 MCP, hooks, 공통 환경설정도 여기에 붙일 수 있게 만들고 있습니다.
8
6
 
9
7
  ---
10
8
 
11
- ## 설치
9
+ ## 빠른 시작
12
10
 
13
11
  ```bash
14
12
  npm install -g pattyeng
13
+ pattyeng install codex
15
14
  ```
16
15
 
17
- 설치 아래 명령어로 시작합니다.
16
+ 브라우저가 열리면 Patty 계정으로 로그인하세요.
17
+
18
+ 설치가 끝난 뒤 한 번만 터미널을 다시 불러오면 됩니다.
19
+
20
+ macOS / Linux / WSL:
18
21
 
19
22
  ```bash
20
- pattyeng install codex
23
+ exec zsh # zsh
24
+ exec bash # bash
21
25
  ```
22
26
 
23
- 브라우저가 열리면 Patty 계정으로 로그인하세요. 로그인 후 필요한 값은 자동으로 설정됩니다.
27
+ Windows:
28
+
29
+ ```text
30
+ 터미널을 닫고 새로 열기
31
+ ```
24
32
 
25
33
  ---
26
34
 
@@ -28,37 +36,23 @@ pattyeng install codex
28
36
 
29
37
  ### `pattyeng install codex`
30
38
 
31
- Codex를 Patty 환경에 맞게 세팅합니다.
39
+ Codex를 Patty 환경에 맞게 처음 세팅합니다.
32
40
 
33
- 처리하는 일:
41
+ 하는 일:
34
42
 
35
43
  - Codex 설치 확인
36
44
  - Codex 최신 버전 업데이트
37
- - Patty 설정 파일 복사
45
+ - `~/.codex/patty.config.toml` 설치
38
46
  - Patty SSO 로그인
39
- - `PATTY_AGENT_API_KEY` 등록
47
+ - `PATTY_AGENT_API_KEY` 등록 (Keycloak userinfo claim 사용)
40
48
  - `codex` 명령어가 Patty 프로필을 쓰도록 설정
49
+ - `~/.codex/skills`에 Patty 스킬 설치
41
50
 
42
- 실행:
43
-
44
- ```bash
45
- pattyeng install codex
46
- ```
47
-
48
- 마지막에 터미널만 다시 불러오면 됩니다.
49
-
50
- macOS / Linux / WSL:
51
-
52
- ```bash
53
- exec zsh # zsh 사용자
54
- exec bash # bash 사용자
55
- ```
51
+ 스킬 설치 규칙:
56
52
 
57
- Windows:
58
-
59
- ```text
60
- 터미널을 닫고 새로 열기
61
- ```
53
+ - 이미 같은 이름의 스킬 폴더가 있으면 **건너뜁니다**.
54
+ - 기존 스킬을 덮어쓰지 않습니다.
55
+ - 그래서 처음 세팅 때 안전하게 여러 번 실행해도 됩니다.
62
56
 
63
57
  ---
64
58
 
@@ -70,10 +64,13 @@ Windows:
70
64
  pattyeng upgrade
71
65
  ```
72
66
 
73
- 업데이트 대상:
67
+ 하는 일:
68
+
69
+ - npm에서 최신 `pattyeng` 패키지 다운로드
70
+ - Codex CLI 최신 버전 업데이트
71
+ - Patty 스킬 최신 버전으로 동기화
74
72
 
75
- - `pattyeng`
76
- - Codex CLI
73
+ `upgrade`는 기존 스킬을 최신 버전으로 바꿉니다. 스킬 업데이트가 필요하면 보통 이 명령어만 쓰면 됩니다.
77
74
 
78
75
  ---
79
76
 
@@ -95,13 +92,18 @@ pattyeng skills list --long
95
92
 
96
93
  ### `pattyeng skills sync`
97
94
 
98
- Patty dotfiles에 있는 최신 스킬을 `~/.codex/skills`로 동기화합니다.
95
+ 스킬만 수동으로 동기화합니다.
99
96
 
100
97
  ```bash
101
98
  pattyeng skills sync
102
99
  ```
103
100
 
104
- 스킬이 바뀌었거나 새로 추가됐을 때 실행하면 됩니다.
101
+ 보통은 직접 실행할 필요 없습니다.
102
+
103
+ - 처음 세팅: `pattyeng install codex`
104
+ - 업데이트: `pattyeng upgrade`
105
+
106
+ 위 두 명령어가 스킬까지 처리합니다.
105
107
 
106
108
  ---
107
109
 
@@ -113,11 +115,16 @@ pattyeng skills sync
113
115
  - Windows PowerShell
114
116
  - Windows CMD
115
117
 
116
- Node.js 18 이상이 필요합니다.
118
+ 필요 조건:
119
+
120
+ - Node.js 18 이상
121
+ - npm
122
+ - Patty 계정
123
+ - `pattyeng` 그룹 멤버십
117
124
 
118
125
  ---
119
126
 
120
- ## 자주 나는 문제
127
+ ## 문제 해결
121
128
 
122
129
  ### 브라우저가 자동으로 안 열림
123
130
 
@@ -127,6 +134,10 @@ Node.js 18 이상이 필요합니다.
127
134
 
128
135
  Patty 관리자에게 `pattyeng` 그룹 추가를 요청하세요.
129
136
 
137
+ ### `PATTY_AGENT_API_KEY`를 찾을 수 없다고 나옴
138
+
139
+ Keycloak `pattyeng` 그룹의 `PATTY_AGENT_API_KEY` 속성이 userinfo claim으로 매핑되어 있는지 확인하세요.
140
+
130
141
  ### 환경변수가 바로 안 잡힘
131
142
 
132
143
  새 터미널을 열거나 아래 명령어를 실행하세요.
@@ -139,9 +150,13 @@ exec bash
139
150
 
140
151
  Windows는 터미널을 닫고 새로 열면 됩니다.
141
152
 
142
- ### Codex가 이상하게 동작함
153
+ ### 스킬을 다시 받고 싶음
154
+
155
+ ```bash
156
+ pattyeng skills sync
157
+ ```
143
158
 
144
- 업데이트하세요.
159
+ ### 전체를 최신으로 맞추고 싶음
145
160
 
146
161
  ```bash
147
162
  pattyeng upgrade
@@ -151,4 +166,4 @@ pattyeng upgrade
151
166
 
152
167
  ## 한 줄 요약
153
168
 
154
- Patty에서 쓰는 개발환경 세팅을 사람 타게 만드는 도구입니다.
169
+ Patty에서 쓰는 개발환경 세팅을 반복 작업이 아니라 명령어로 관리하게 만드는 도구입니다.
package/bin/pattyeng.js CHANGED
@@ -7,8 +7,9 @@ process.stdout.on('error', (err) => {
7
7
  });
8
8
 
9
9
  const { install } = require('../lib/install');
10
- const { upgrade } = require('../lib/upgrade');
10
+ const { upgrade, applyUpgrade } = require('../lib/upgrade');
11
11
  const { skills } = require('../lib/skills');
12
+ const { printBanner } = require('../lib/banner');
12
13
 
13
14
  const args = process.argv.slice(2);
14
15
  const cmd = args[0];
@@ -28,6 +29,10 @@ async function main() {
28
29
  await upgrade();
29
30
  break;
30
31
 
32
+ case 'upgrade:apply':
33
+ await applyUpgrade();
34
+ break;
35
+
31
36
  case 'skills':
32
37
  await skills(args.slice(1));
33
38
  break;
@@ -38,16 +43,12 @@ async function main() {
38
43
  }
39
44
 
40
45
  function printHelp() {
41
- console.log(`
42
- ╔══════════════════════════════════════════╗
43
- pattyeng CLI ║
44
- ╚══════════════════════════════════════════╝
45
-
46
- 명령어:
47
- install codex Codex 설치 + SSO 로그인 + 환경설정
48
- upgrade pattyeng, Codex 최신 버전으로 업데이트
49
- skills list 설치된 스킬 목록
50
- skills sync 스킬 최신 버전으로 동기화
46
+ printBanner();
47
+ console.log(`명령어:
48
+ pattyeng install codex Codex 설치 + SSO 로그인 + 스킬 설치
49
+ pattyeng upgrade pattyeng + Codex + skills 업데이트
50
+ pattyeng skills list 설치된 스킬 목록
51
+ pattyeng skills sync 스킬 수동 동기화
51
52
  `);
52
53
  }
53
54
 
package/lib/banner.js ADDED
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ function banner(command = '') {
4
+ const suffix = command ? ` ${command}` : '';
5
+ return `
6
+ ██████╗ █████╗ ████████╗████████╗██╗ ██╗███████╗███╗ ██╗ ██████╗
7
+ ██╔══██╗██╔══██╗╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝██╔════╝████╗ ██║██╔════╝
8
+ ██████╔╝███████║ ██║ ██║ ╚████╔╝ █████╗ ██╔██╗ ██║██║ ███╗
9
+ ██╔═══╝ ██╔══██║ ██║ ██║ ╚██╔╝ ██╔══╝ ██║╚██╗██║██║ ██║
10
+ ██║ ██║ ██║ ██║ ██║ ██║ ███████╗██║ ╚████║╚██████╔╝
11
+ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ${suffix}
12
+ `;
13
+ }
14
+
15
+ function printBanner(command = '') {
16
+ process.stdout.write(banner(command));
17
+ }
18
+
19
+ module.exports = { banner, printBanner };
package/lib/install.js CHANGED
@@ -1,12 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { execSync } = require('child_process');
3
+ const { execFileSync, execSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const http = require('http');
6
6
  const https = require('https');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const crypto = require('crypto');
10
+ const { installSkills } = require('./skills');
11
+ const { printBanner } = require('./banner');
10
12
 
11
13
  // ─── Constants ───────────────────────────────────────────────────────────────
12
14
  const KEYCLOAK_URL = 'https://login.patty.io';
@@ -18,12 +20,9 @@ const CALLBACK_PATH = '/pattyeng-callback';
18
20
  const TOKEN_URL = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`;
19
21
  const AUTH_URL = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/auth`;
20
22
  const USERINFO_URL = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/userinfo`;
21
- // Admin API read-only: only fetches pattyeng group attributes
23
+ // Optional admin fallback. Do not ship admin credentials in the package.
22
24
  const ADMIN_TOKEN_URL = `${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`;
23
25
  const PATTYENG_GROUP_ID = '058502d3-38cd-4ddb-84d7-0aad50a25f75';
24
- // Service account credentials (read-only group attribute access)
25
- const SA_USER = 'admin';
26
- const SA_PASS = 'pattyeng0805@';
27
26
 
28
27
  // ─── Platform ────────────────────────────────────────────────────────────────
29
28
  const IS_WIN = process.platform === 'win32';
@@ -60,51 +59,51 @@ function generateCodeChallenge(verifier) {
60
59
  }
61
60
 
62
61
  // ─── Simple HTTPS GET/POST ────────────────────────────────────────────────────
63
- function httpsPost(url, body) {
62
+ function parseJson(raw) {
63
+ if (!raw) return {};
64
+ try { return JSON.parse(raw); } catch { return raw; }
65
+ }
66
+
67
+ function requestJson(url, { method = 'GET', token = null, form = null } = {}) {
64
68
  return new Promise((resolve, reject) => {
65
69
  const parsed = new URL(url);
66
- const data = typeof body === 'string' ? body : new URLSearchParams(body).toString();
67
- const options = {
70
+ const data = form ? new URLSearchParams(form).toString() : null;
71
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
72
+ if (data) {
73
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
74
+ headers['Content-Length'] = Buffer.byteLength(data);
75
+ }
76
+
77
+ const req = https.request({
68
78
  hostname: parsed.hostname,
69
79
  path: parsed.pathname + parsed.search,
70
- method: 'POST',
71
- headers: {
72
- 'Content-Type': 'application/x-www-form-urlencoded',
73
- 'Content-Length': Buffer.byteLength(data),
74
- },
75
- };
76
- const req = https.request(options, (res) => {
80
+ method,
81
+ headers,
82
+ }, (res) => {
77
83
  let raw = '';
78
84
  res.on('data', (c) => raw += c);
79
85
  res.on('end', () => {
80
- try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
86
+ const payload = parseJson(raw);
87
+ if (res.statusCode < 200 || res.statusCode >= 300) {
88
+ const detail = payload && (payload.error_description || payload.error || payload.message);
89
+ reject(new Error(`HTTP ${res.statusCode}${detail ? `: ${detail}` : ''}`));
90
+ return;
91
+ }
92
+ resolve(payload);
81
93
  });
82
94
  });
83
95
  req.on('error', reject);
84
- req.write(data);
96
+ if (data) req.write(data);
85
97
  req.end();
86
98
  });
87
99
  }
88
100
 
101
+ function httpsPost(url, body) {
102
+ return requestJson(url, { method: 'POST', form: body });
103
+ }
104
+
89
105
  function httpsGet(url, token) {
90
- return new Promise((resolve, reject) => {
91
- const parsed = new URL(url);
92
- const options = {
93
- hostname: parsed.hostname,
94
- path: parsed.pathname + parsed.search,
95
- method: 'GET',
96
- headers: { 'Authorization': `Bearer ${token}` },
97
- };
98
- const req = https.request(options, (res) => {
99
- let raw = '';
100
- res.on('data', (c) => raw += c);
101
- res.on('end', () => {
102
- try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
103
- });
104
- });
105
- req.on('error', reject);
106
- req.end();
107
- });
106
+ return requestJson(url, { method: 'GET', token });
108
107
  }
109
108
 
110
109
  // ─── RC file helpers ──────────────────────────────────────────────────────────
@@ -132,6 +131,7 @@ function getRcFiles() {
132
131
  function appendToRc(block) {
133
132
  const firstLine = block.trim().split('\n')[0];
134
133
  for (const rc of getRcFiles()) {
134
+ fs.mkdirSync(path.dirname(rc), { recursive: true });
135
135
  const content = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
136
136
  if (!content.includes(firstLine)) {
137
137
  fs.appendFileSync(rc, '\n' + block + '\n');
@@ -142,6 +142,32 @@ function appendToRc(block) {
142
142
  }
143
143
  }
144
144
 
145
+ function upsertRcBlock(marker, block) {
146
+ const markerLine = `# pattyeng — ${marker}`;
147
+ const markedBlock = `${markerLine}\n${block}`;
148
+ for (const rc of getRcFiles()) {
149
+ fs.mkdirSync(path.dirname(rc), { recursive: true });
150
+ const content = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
151
+ const lines = content.split('\n');
152
+ const index = lines.findIndex(line => line.trim() === markerLine);
153
+ let next;
154
+ if (index === -1) {
155
+ next = `${content.replace(/\s*$/, '')}\n\n${markedBlock}\n`;
156
+ } else {
157
+ lines.splice(index, 2, markerLine, block);
158
+ next = lines.join('\n').replace(/\s*$/, '') + '\n';
159
+ }
160
+ fs.writeFileSync(rc, next);
161
+ log(` → 설정됨: ${rc}`);
162
+ }
163
+ }
164
+
165
+ function escapeHtml(value) {
166
+ return String(value).replace(/[&<>"']/g, (ch) => ({
167
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
168
+ }[ch]));
169
+ }
170
+
145
171
  // ─── HTML pages ───────────────────────────────────────────────────────────────
146
172
  function successHtml() {
147
173
  return `<!DOCTYPE html>
@@ -185,7 +211,7 @@ function errorHtml(msg) {
185
211
  align-items:center;justify-content:center;min-height:100vh}
186
212
  .card{background:#16213e;border-radius:16px;padding:40px;max-width:480px;text-align:center}
187
213
  h1{color:#e94560}p{margin-top:16px;opacity:.8}</style></head>
188
- <body><div class="card"><h1>❌ 오류</h1><p>${msg}</p><p>터미널에서 오류를 확인하세요.</p></div></body></html>`;
214
+ <body><div class="card"><h1>❌ 오류</h1><p>${escapeHtml(msg)}</p><p>터미널에서 오류를 확인하세요.</p></div></body></html>`;
189
215
  }
190
216
 
191
217
  // ─── Step 1: Install codex ────────────────────────────────────────────────────
@@ -223,15 +249,40 @@ function copyPattyConfig() {
223
249
  path.join(__dirname, '..', 'patty.config.toml'),
224
250
  ];
225
251
  const dest = path.join(codexDir, 'patty.config.toml');
226
- let src = candidates.find(fs.existsSync);
252
+ const src = candidates.find(fs.existsSync);
227
253
 
228
- if (src) {
229
- fs.copyFileSync(src, dest);
230
- ok(`복사 완료 → ${dest}`);
231
- } else {
232
- fs.writeFileSync(dest, '[model_providers.patty]\n');
233
- warn(`원본 없음 → 기본 파일 생성: ${dest}`);
254
+ if (!src) {
255
+ throw new Error('patty.config.toml 원본을 찾을 수 없습니다. 패키지에 설정 파일이 포함되어야 합니다.');
234
256
  }
257
+
258
+ fs.copyFileSync(src, dest);
259
+ ok(`복사 완료 → ${dest}`);
260
+ }
261
+
262
+ function getPattyApiKey(userinfo) {
263
+ const value = userinfo.PATTY_AGENT_API_KEY || userinfo.patty_agent_api_key;
264
+ return Array.isArray(value) ? value[0] : value || null;
265
+ }
266
+
267
+ async function readApiKeyFromAdminApi() {
268
+ const username = process.env.PATTYENG_KEYCLOAK_ADMIN_USER;
269
+ const password = process.env.PATTYENG_KEYCLOAK_ADMIN_PASSWORD;
270
+ if (!username || !password) return null;
271
+
272
+ log(' → userinfo에 API 키 없음, 관리자 환경변수로 그룹 속성 조회 중...');
273
+ const adminTokenRes = await httpsPost(ADMIN_TOKEN_URL, {
274
+ grant_type: 'password',
275
+ client_id: 'admin-cli',
276
+ username,
277
+ password,
278
+ });
279
+ const groupData = await httpsGet(
280
+ `${KEYCLOAK_URL}/admin/realms/${REALM}/groups/${PATTYENG_GROUP_ID}`,
281
+ adminTokenRes.access_token
282
+ );
283
+ const attrs = groupData.attributes || {};
284
+ const value = attrs.PATTY_AGENT_API_KEY;
285
+ return Array.isArray(value) ? value[0] : value || null;
235
286
  }
236
287
 
237
288
  // ─── Step 4: OIDC PKCE Login ──────────────────────────────────────────────────
@@ -332,31 +383,14 @@ async function ssoLogin() {
332
383
  }
333
384
  log(' → pattyeng 그룹 확인 완료');
334
385
 
335
- // Get PATTY_AGENT_API_KEY from userinfo claim (if mapper works)
336
- // or fallback: read group attribute via admin API
337
- let apiKey = userinfo.PATTY_AGENT_API_KEY || null;
386
+ let apiKey = getPattyApiKey(userinfo);
338
387
 
339
388
  if (!apiKey) {
340
- log(' → userinfo에 API 키 없음, 그룹 속성에서 읽는 중...');
341
- // Get admin token to read group attributes
342
- const adminTokenRes = await httpsPost(ADMIN_TOKEN_URL, {
343
- grant_type: 'password',
344
- client_id: 'admin-cli',
345
- username: SA_USER,
346
- password: SA_PASS,
347
- });
348
- if (adminTokenRes.error) throw new Error('관리자 토큰 획득 실패: ' + adminTokenRes.error_description);
349
- const groupData = await httpsGet(
350
- `${KEYCLOAK_URL}/admin/realms/${REALM}/groups/${PATTYENG_GROUP_ID}`,
351
- adminTokenRes.access_token
352
- );
353
- const attrs = groupData.attributes || {};
354
- const vals = attrs.PATTY_AGENT_API_KEY || [];
355
- apiKey = vals[0] || null;
389
+ apiKey = await readApiKeyFromAdminApi();
356
390
  }
357
391
 
358
392
  if (!apiKey) {
359
- throw new Error('PATTY_AGENT_API_KEY를 그룹 속성에서 찾을 수 없습니다. 관리자에게 문의하세요.');
393
+ throw new Error('PATTY_AGENT_API_KEY를 찾을 수 없습니다. Keycloak userinfo 매퍼를 확인하세요.');
360
394
  }
361
395
 
362
396
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -398,18 +432,18 @@ function writeApiKeyToRc(apiKey) {
398
432
 
399
433
  if (IS_WIN) {
400
434
  // PowerShell profile (new PS sessions)
401
- appendToRc(`# pattyeng — Patty Agent API Key\n$env:PATTY_AGENT_API_KEY = "${apiKey}"`);
435
+ upsertRcBlock('Patty Agent API Key', `$env:PATTY_AGENT_API_KEY = "${apiKey.replace(/"/g, '`"')}"`);
402
436
  // setx: Windows user-level env var — persists across ALL new terminals (cmd, PS, git bash)
403
437
  try {
404
- execSync(`setx PATTY_AGENT_API_KEY "${apiKey}"`, { stdio: 'pipe' });
438
+ execFileSync('setx', ['PATTY_AGENT_API_KEY', apiKey], { stdio: 'pipe' });
405
439
  log(' → Windows 사용자 환경변수 등록 완료 (setx)');
406
440
  } catch (e) {
407
441
  warn('setx 실패: ' + e.message);
408
442
  }
409
443
  } else if (SHELL === 'fish') {
410
- appendToRc(`# pattyeng — Patty Agent API Key\nset -Ux PATTY_AGENT_API_KEY "${apiKey}"`);
444
+ upsertRcBlock('Patty Agent API Key', `set -Ux PATTY_AGENT_API_KEY "${apiKey.replace(/"/g, '\\"')}"`);
411
445
  } else {
412
- appendToRc(`# pattyeng — Patty Agent API Key\nexport PATTY_AGENT_API_KEY="${apiKey}"`);
446
+ upsertRcBlock('Patty Agent API Key', `export PATTY_AGENT_API_KEY="${apiKey.replace(/"/g, '\\"')}"`);
413
447
  }
414
448
 
415
449
  process.env.PATTY_AGENT_API_KEY = apiKey;
@@ -423,6 +457,7 @@ function addCodexAlias() {
423
457
  if (IS_WIN) {
424
458
  const block = `\n# pattyeng — codex alias\nfunction codex { codex.cmd -p patty $args }\n`;
425
459
  for (const rc of getRcFiles()) {
460
+ fs.mkdirSync(path.dirname(rc), { recursive: true });
426
461
  const content = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
427
462
  if (!content.includes('pattyeng — codex alias')) {
428
463
  fs.appendFileSync(rc, block);
@@ -436,16 +471,18 @@ function addCodexAlias() {
436
471
  }
437
472
 
438
473
  for (const rc of getRcFiles()) {
439
- if (!fs.existsSync(rc)) continue;
474
+ fs.mkdirSync(path.dirname(rc), { recursive: true });
475
+ if (!fs.existsSync(rc)) fs.writeFileSync(rc, '');
440
476
  let content = fs.readFileSync(rc, 'utf8');
441
477
 
442
- if (content.includes('-p patty')) {
478
+ const existingAlias = /^(alias codex=["'])(.*)(["'])\s*$/m;
479
+ const match = content.match(existingAlias);
480
+ if (match && /(?:^|\s)-p\s+patty(?:\s|$)/.test(match[2])) {
443
481
  log(` → 이미 -p patty 포함: ${rc} (건너뜀)`);
444
482
  continue;
445
483
  }
446
484
 
447
- const existingAlias = /^(alias codex=["'])(.*)(["'])\s*$/m;
448
- if (existingAlias.test(content)) {
485
+ if (match) {
449
486
  content = content.replace(existingAlias, (_, pre, cmd, post) => {
450
487
  return `${pre}${cmd} -p patty${post}`;
451
488
  });
@@ -472,6 +509,7 @@ ${username ? ` 👤 로그인: ${username}\n` : ''}
472
509
  ✅ 완료된 작업:
473
510
  • Codex 설치 및 최신 버전 업데이트
474
511
  • ~/.codex/patty.config.toml 복사
512
+ • Codex 스킬 설치
475
513
  • PATTY_AGENT_API_KEY 환경변수 등록
476
514
  • codex alias 등록 (codex = codex -p patty)
477
515
 
@@ -493,15 +531,12 @@ ${IS_WIN
493
531
 
494
532
  // ─── Main ─────────────────────────────────────────────────────────────────────
495
533
  async function install() {
496
- log(`
497
- ╔══════════════════════════════════════════╗
498
- ║ pattyeng install codex 시작 ║
499
- ╚══════════════════════════════════════════╝
500
- `);
534
+ printBanner('install codex');
501
535
 
502
536
  installCodex();
503
537
  updateCodex();
504
538
  copyPattyConfig();
539
+ installSkills({ overwrite: false });
505
540
 
506
541
  let apiKey = null;
507
542
  let username = null;
@@ -512,10 +547,10 @@ async function install() {
512
547
  ok(`SSO 로그인 성공! (${username})`);
513
548
  } catch (e) {
514
549
  warn('SSO 로그인 실패: ' + e.message);
515
- warn('PATTY_AGENT_API_KEY 등록을 건너뜁니다.');
550
+ throw new Error('설치를 완료할 수 없습니다. Patty SSO 로그인과 PATTY_AGENT_API_KEY 설정이 필요합니다.');
516
551
  }
517
552
 
518
- if (apiKey) writeApiKeyToRc(apiKey);
553
+ writeApiKeyToRc(apiKey);
519
554
  addCodexAlias();
520
555
  finalMessage(username);
521
556
  }
package/lib/skills.js CHANGED
@@ -1,33 +1,39 @@
1
1
  'use strict';
2
2
 
3
- const { execSync } = require('child_process');
4
3
  const fs = require('fs');
5
4
  const path = require('path');
6
5
  const os = require('os');
6
+ const { printBanner } = require('./banner');
7
7
 
8
- const HOME = os.homedir();
9
- const DEST_DIR = path.join(HOME, '.codex', 'skills');
8
+ const HOME = os.homedir();
9
+ const DEST_DIR = path.join(HOME, '.codex', 'skills');
10
+ const MANIFEST_PATH = path.join(DEST_DIR, '.pattyeng-skills.json');
10
11
 
11
- // Source: npm registry tarball for pattyeng — skills are bundled inside package
12
- // Fallback: look for dotfiles repo next to package install location
13
12
  function findSourceSkillsDir() {
14
13
  const candidates = [
15
- // bundled inside the npm package itself (skills/ at package root)
16
- path.join(__dirname, '..', 'skills'),
17
- // dotfiles repo sibling
18
- path.join(__dirname, '..', '..', 'skills'),
14
+ path.join(__dirname, '..', 'skills'), // bundled npm package
15
+ path.join(__dirname, '..', '..', 'skills'), // dotfiles repo
19
16
  path.join(__dirname, '..', '..', 'dotfiles', 'skills'),
20
17
  ];
21
18
  return candidates.find(d => fs.existsSync(d) && fs.statSync(d).isDirectory()) || null;
22
19
  }
23
20
 
24
21
  function log(msg) { process.stdout.write(msg + '\n'); }
25
- function step(msg) { log(`\n🔧 ${msg}`); }
26
- function ok(msg) { log(`✅ ${msg}`); }
27
22
  function warn(msg) { log(`⚠️ ${msg}`); }
28
- function info(msg) { log(` ${msg}`); }
29
23
 
30
- // Read SKILL.md name/description
24
+ function cleanYamlScalar(value) {
25
+ return String(value || '')
26
+ .trim()
27
+ .replace(/^[\'\"]|[\'\"]$/g, '')
28
+ .replace(/^>\s*/, '')
29
+ .trim();
30
+ }
31
+
32
+ function cleanDescription(value) {
33
+ const cleaned = cleanYamlScalar(value).replace(/\s+/g, ' ');
34
+ return cleaned.length > 82 ? cleaned.slice(0, 79) + '...' : cleaned;
35
+ }
36
+
31
37
  function readSkillMeta(skillDir) {
32
38
  const mdPath = path.join(skillDir, 'SKILL.md');
33
39
  if (!fs.existsSync(mdPath)) return { name: path.basename(skillDir), description: '' };
@@ -35,53 +41,156 @@ function readSkillMeta(skillDir) {
35
41
  const nameMatch = content.match(/^name:\s*(.+)$/m);
36
42
  const descMatch = content.match(/^description:\s*(.+)$/m);
37
43
  return {
38
- name: cleanYamlScalar(nameMatch ? nameMatch[1] : path.basename(skillDir)),
44
+ name: cleanYamlScalar(nameMatch ? nameMatch[1] : path.basename(skillDir)),
39
45
  description: descMatch ? cleanYamlScalar(descMatch[1]) : '',
40
46
  };
41
47
  }
42
48
 
43
- function cleanYamlScalar(value) {
44
- return String(value || '')
45
- .trim()
46
- .replace(/^['\"]|['\"]$/g, '')
47
- .replace(/^>\s*/, '')
48
- .trim();
49
+ function getSkillDirs(baseDir) {
50
+ if (!fs.existsSync(baseDir)) return [];
51
+ return fs.readdirSync(baseDir).filter(name => {
52
+ try { return fs.statSync(path.join(baseDir, name)).isDirectory(); }
53
+ catch { return false; }
54
+ }).sort();
49
55
  }
50
56
 
51
- function cleanDescription(value) {
52
- const cleaned = cleanYamlScalar(value).replace(/\s+/g, ' ');
53
- return cleaned.length > 82 ? cleaned.slice(0, 79) + '...' : cleaned;
57
+ function sameSymlink(dest, src) {
58
+ try {
59
+ return fs.lstatSync(dest).isSymbolicLink() && fs.realpathSync(dest) === fs.realpathSync(src);
60
+ } catch {
61
+ return false;
62
+ }
54
63
  }
55
64
 
56
- // Diff two SKILL.md files — returns true if different
57
- function isDifferent(srcFile, destFile) {
58
- if (!fs.existsSync(destFile)) return true;
59
- return fs.readFileSync(srcFile, 'utf8') !== fs.readFileSync(destFile, 'utf8');
65
+ function removeExisting(dest) {
66
+ let st;
67
+ try { st = fs.lstatSync(dest); } catch { return; }
68
+ if (st.isDirectory() && !st.isSymbolicLink()) fs.rmSync(dest, { recursive: true, force: true });
69
+ else fs.unlinkSync(dest);
70
+ }
71
+
72
+ function readManifest() {
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
75
+ return Array.isArray(parsed.skills) ? parsed.skills : [];
76
+ } catch {
77
+ return [];
78
+ }
60
79
  }
61
80
 
62
- // Copy a skill folder recursively
63
- function copySkill(srcDir, destDir) {
64
- if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
65
- for (const file of fs.readdirSync(srcDir)) {
66
- const src = path.join(srcDir, file);
67
- const dest = path.join(destDir, file);
68
- if (fs.statSync(src).isDirectory()) {
69
- copySkill(src, dest);
81
+ function writeManifest(skills) {
82
+ fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify({ skills: [...skills].sort() }, null, 2)}\n`);
83
+ }
84
+
85
+ function pruneDeletedSkills(srcSkills, { quiet }) {
86
+ const srcSet = new Set(srcSkills);
87
+ let removed = 0;
88
+
89
+ for (const skill of readManifest()) {
90
+ if (srcSet.has(skill)) continue;
91
+ const dest = path.join(DEST_DIR, skill);
92
+ removeExisting(dest);
93
+ removed++;
94
+ if (!quiet) log(` 삭제: ${skill}`);
95
+ }
96
+
97
+ for (const name of fs.readdirSync(DEST_DIR)) {
98
+ const dest = path.join(DEST_DIR, name);
99
+ try {
100
+ if (fs.lstatSync(dest).isSymbolicLink()) fs.realpathSync(dest);
101
+ } catch {
102
+ removeExisting(dest);
103
+ removed++;
104
+ if (!quiet) log(` 깨진 링크 삭제: ${name}`);
105
+ }
106
+ }
107
+
108
+ return removed;
109
+ }
110
+
111
+ function copyDir(src, dest) {
112
+ fs.mkdirSync(dest, { recursive: true });
113
+ for (const entry of fs.readdirSync(src)) {
114
+ const from = path.join(src, entry);
115
+ const to = path.join(dest, entry);
116
+ const st = fs.statSync(from);
117
+ if (st.isDirectory()) copyDir(from, to);
118
+ else fs.copyFileSync(from, to);
119
+ }
120
+ }
121
+
122
+ function installSkillDir(src, dest, { overwrite }) {
123
+ let exists = false;
124
+ try { fs.lstatSync(dest); exists = true; } catch { exists = false; }
125
+
126
+ if (exists) {
127
+ if (!overwrite || sameSymlink(dest, src)) return 'skipped';
128
+ removeExisting(dest);
129
+ }
130
+
131
+ try {
132
+ fs.symlinkSync(src, dest, 'dir');
133
+ } catch {
134
+ // Windows without Developer Mode/Admin can reject symlinks. Copy instead.
135
+ copyDir(src, dest);
136
+ }
137
+ return 'installed';
138
+ }
139
+
140
+ function installSkills({ overwrite = false, quiet = false } = {}) {
141
+ const srcDir = findSourceSkillsDir();
142
+ if (!srcDir) {
143
+ warn('스킬 소스 폴더를 찾을 수 없습니다.');
144
+ return { added: 0, updated: 0, skipped: 0 };
145
+ }
146
+
147
+ if (!fs.existsSync(DEST_DIR)) fs.mkdirSync(DEST_DIR, { recursive: true });
148
+
149
+ if (!quiet) {
150
+ log(`\n📚 스킬 ${overwrite ? '동기화' : '설치'} 중...`);
151
+ log(` 소스: ${srcDir}`);
152
+ log(` 대상: ${DEST_DIR}\n`);
153
+ }
154
+
155
+ let added = 0;
156
+ let updated = 0;
157
+ let skipped = 0;
158
+ const srcSkills = getSkillDirs(srcDir);
159
+ const managedSkills = new Set(overwrite ? srcSkills : readManifest());
160
+ const removed = overwrite ? pruneDeletedSkills(srcSkills, { quiet }) : 0;
161
+
162
+ for (const skill of srcSkills) {
163
+ const src = path.join(srcDir, skill);
164
+ const dest = path.join(DEST_DIR, skill);
165
+ const name = readSkillMeta(src).name;
166
+ const existed = (() => { try { fs.lstatSync(dest); return true; } catch { return false; } })();
167
+ const result = installSkillDir(src, dest, { overwrite });
168
+
169
+ if (result === 'skipped') {
170
+ skipped++;
171
+ if (!quiet) log(` 건너뜀: ${name}`);
172
+ } else if (existed) {
173
+ updated++;
174
+ managedSkills.add(skill);
175
+ if (!quiet) log(` 업데이트: ${name}`);
70
176
  } else {
71
- fs.copyFileSync(src, dest);
177
+ added++;
178
+ managedSkills.add(skill);
179
+ if (!quiet) log(` 설치: ${name}`);
72
180
  }
73
181
  }
182
+
183
+ writeManifest(managedSkills);
184
+
185
+ if (!quiet) log(`\n 설치: ${added}개 업데이트: ${updated}개 건너뜀: ${skipped}개 삭제: ${removed}개`);
186
+ return { added, updated, skipped, removed };
74
187
  }
75
188
 
76
189
  async function skills(args) {
77
190
  const sub = args[0];
78
191
 
79
- if (!sub || sub === 'list') {
80
- return listSkills(args.includes('--long'));
81
- }
82
- if (sub === 'sync') {
83
- return syncSkills();
84
- }
192
+ if (!sub || sub === 'list') return listSkills(args.includes('--long'));
193
+ if (sub === 'sync') return syncSkills();
85
194
 
86
195
  log(`\n사용법:\n pattyeng skills list — 스킬 이름만 보기\n pattyeng skills list --long — 설명까지 보기\n pattyeng skills sync — 스킬 최신 버전으로 동기화\n`);
87
196
  }
@@ -94,21 +203,15 @@ function listSkills(long = false) {
94
203
  return;
95
204
  }
96
205
 
97
- const skills = fs.readdirSync(DEST_DIR).filter(f => {
98
- try { return fs.statSync(path.join(DEST_DIR, f)).isDirectory(); }
99
- catch { return false; } // broken symlink — skip
100
- });
206
+ const names = getSkillDirs(DEST_DIR)
207
+ .map(skill => readSkillMeta(path.join(DEST_DIR, skill)).name)
208
+ .filter(name => !name.startsWith('.'));
101
209
 
102
- if (skills.length === 0) {
210
+ if (names.length === 0) {
103
211
  warn('스킬이 없습니다. pattyeng skills sync 를 실행하세요.');
104
212
  return;
105
213
  }
106
214
 
107
- const names = skills
108
- .sort()
109
- .map(skill => readSkillMeta(path.join(DEST_DIR, skill)).name)
110
- .filter(name => !name.startsWith('.'));
111
-
112
215
  if (!long) {
113
216
  const width = Math.max(18, ...names.map(n => n.length + 2));
114
217
  for (let i = 0; i < names.length; i += 3) {
@@ -118,7 +221,7 @@ function listSkills(long = false) {
118
221
  return;
119
222
  }
120
223
 
121
- for (const skill of skills.sort()) {
224
+ for (const skill of getSkillDirs(DEST_DIR)) {
122
225
  const meta = readSkillMeta(path.join(DEST_DIR, skill));
123
226
  if (meta.name.startsWith('.')) continue;
124
227
  const desc = cleanDescription(meta.description);
@@ -128,71 +231,10 @@ function listSkills(long = false) {
128
231
  }
129
232
 
130
233
  function syncSkills() {
131
- log(`
132
- ╔══════════════════════════════════════════╗
133
- ║ pattyeng skills sync 시작 ║
134
- ╚══════════════════════════════════════════╝
135
- `);
136
-
137
- const srcDir = findSourceSkillsDir();
138
- if (!srcDir) {
139
- warn('스킬 소스 폴더를 찾을 수 없습니다.');
140
- warn('dotfiles 저장소가 있어야 합니다: ~/projects/dotfiles/skills/');
141
- process.exit(1);
142
- }
143
- log(` 소스: ${srcDir}`);
144
- log(` 대상: ${DEST_DIR}\n`);
145
-
146
- if (!fs.existsSync(DEST_DIR)) fs.mkdirSync(DEST_DIR, { recursive: true });
147
-
148
- const srcSkills = fs.readdirSync(srcDir).filter(f =>
149
- fs.statSync(path.join(srcDir, f)).isDirectory()
150
- );
151
-
152
- let added = 0;
153
- let updated = 0;
154
- let skipped = 0;
155
-
156
- for (const skill of srcSkills.sort()) {
157
- const src = path.join(srcDir, skill);
158
- const dest = path.join(DEST_DIR, skill);
159
- const meta = readSkillMeta(src);
160
- const srcMd = path.join(src, 'SKILL.md');
161
- const destMd = path.join(dest, 'SKILL.md');
162
-
163
- const destExists = fs.existsSync(dest);
164
- const destLstat = destExists ? null : null, lstat = (() => { try { return fs.lstatSync(dest); } catch { return null; } })();
165
- const isSymlink = lstat && lstat.isSymbolicLink();
166
- const alreadyPointsHere = isSymlink && fs.readlinkSync(dest) === src;
167
-
168
- if (!lstat) {
169
- // doesn’t exist at all
170
- fs.symlinkSync(src, dest);
171
- log(` ✨ 새로 추가: ${meta.name}`);
172
- added++;
173
- } else if (alreadyPointsHere) {
174
- // already correct symlink
175
- skipped++;
176
- } else if (isSymlink) {
177
- // symlink pointing somewhere else — update
178
- fs.unlinkSync(dest);
179
- fs.symlinkSync(src, dest);
180
- log(` 🔄 업데이트: ${meta.name}`);
181
- updated++;
182
- } else if (isDifferent(srcMd, destMd)) {
183
- // real dir with changed content — overwrite
184
- copySkill(src, dest);
185
- log(` 🔄 업데이트: ${meta.name}`);
186
- updated++;
187
- } else {
188
- skipped++;
189
- }
190
- }
191
-
192
- log(`
193
- 추가: ${added}개 업데이트: ${updated}개 변경없음: ${skipped}개
194
-
195
- ✅ 스킬 동기화 완료`);
234
+ printBanner('skills sync');
235
+ const result = installSkills({ overwrite: true });
236
+ log('\n✅ 스킬 동기화 완료');
237
+ return result;
196
238
  }
197
239
 
198
- module.exports = { skills };
240
+ module.exports = { skills, installSkills, syncSkills };
package/lib/upgrade.js CHANGED
@@ -1,46 +1,58 @@
1
1
  'use strict';
2
2
 
3
3
  const { execSync } = require('child_process');
4
+ const { syncSkills } = require('./skills');
5
+ const { printBanner } = require('./banner');
4
6
 
5
7
  function log(msg) { process.stdout.write(msg + '\n'); }
6
8
  function step(msg) { log(`\n🔧 ${msg}`); }
7
9
  function ok(msg) { log(`✅ ${msg}`); }
8
10
  function warn(msg) { log(`⚠️ ${msg}`); }
9
11
 
12
+ function npmInstallLatest(packageName) {
13
+ execSync(`npm install -g ${packageName}@latest`, { stdio: 'inherit' });
14
+ }
15
+
10
16
  async function upgrade() {
11
- log(`
12
- ╔══════════════════════════════════════════╗
13
- pattyeng upgrade 시작 ║
14
- ╚══════════════════════════════════════════╝
15
- `);
16
-
17
- // 1. pattyeng itself
18
- step('pattyeng 최신 버전으로 업데이트 중...');
17
+ printBanner('upgrade');
18
+
19
+ step('pattyeng 최신 패키지 다운로드 중...');
19
20
  try {
20
- const before = execSync('npm list -g pattyeng --depth=0 2>/dev/null', { encoding: 'utf8' }).trim();
21
- execSync('npm install -g pattyeng@latest', { stdio: 'inherit' });
22
- const after = execSync('npm list -g pattyeng --depth=0 2>/dev/null', { encoding: 'utf8' }).trim();
23
- if (before !== after) {
24
- ok('pattyeng 업데이트 완료');
25
- } else {
26
- ok('pattyeng 이미 최신 버전');
27
- }
21
+ npmInstallLatest('pattyeng');
22
+ ok('pattyeng 최신 패키지 설치 완료');
28
23
  } catch (e) {
29
24
  warn('pattyeng 업데이트 실패: ' + e.message);
25
+ warn('현재 설치된 버전으로 나머지 작업을 계속합니다.');
26
+ return applyUpgrade();
27
+ }
28
+
29
+ step('새로 설치된 pattyeng으로 upgrade 적용 중...');
30
+ try {
31
+ execSync('pattyeng upgrade:apply', { stdio: 'inherit' });
32
+ } catch (e) {
33
+ warn('새 pattyeng 실행 실패: ' + e.message);
34
+ warn('현재 프로세스로 upgrade 적용을 계속합니다.');
35
+ await applyUpgrade();
30
36
  }
37
+ }
31
38
 
32
- // 2. codex
39
+ async function applyUpgrade() {
33
40
  step('Codex 최신 버전으로 업데이트 중...');
34
41
  try {
35
- execSync('npm install -g @openai/codex@latest', { stdio: 'inherit' });
42
+ npmInstallLatest('@openai/codex');
36
43
  ok('Codex 업데이트 완료');
37
44
  } catch (e) {
38
45
  warn('Codex 업데이트 실패: ' + e.message);
39
46
  }
40
47
 
41
- log(`
42
- 업그레이드 완료!
43
- `);
48
+ step('스킬 최신 버전으로 동기화 중...');
49
+ try {
50
+ syncSkills();
51
+ } catch (e) {
52
+ warn('스킬 동기화 실패: ' + e.message);
53
+ }
54
+
55
+ log(`\n✨ 업그레이드 완료!\n`);
44
56
  }
45
57
 
46
- module.exports = { upgrade };
58
+ module.exports = { upgrade, applyUpgrade };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "pattyeng",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Patty Engineering CLI - Codex SSO setup tool",
5
5
  "bin": {
6
- "pattyeng": "./bin/pattyeng.js"
6
+ "pattyeng": "bin/pattyeng.js"
7
7
  },
8
8
  "main": "./lib/index.js",
9
9
  "scripts": {
10
- "test": "echo \"No tests yet\""
10
+ "test": "node test/skills.test.js"
11
11
  },
12
12
  "dependencies": {
13
13
  "open": "^10.1.0"
@@ -29,6 +29,7 @@
29
29
  "bin",
30
30
  "lib",
31
31
  "skills",
32
- "README.md"
32
+ "patty.config.toml",
33
+ "test"
33
34
  ]
34
35
  }
@@ -0,0 +1,18 @@
1
+ model = "pattyeng"
2
+ model_reasoning_effort = "xhigh"
3
+ model_provider = "patty"
4
+
5
+ [features]
6
+ multi_agent = true
7
+ codex_hooks = true
8
+
9
+ [agents]
10
+ max_threads = 6
11
+ max_depth = 2
12
+
13
+ [model_providers.patty]
14
+ name = "GPT"
15
+ base_url = "https://jebo.ai/v1"
16
+ wire_api = "responses"
17
+ env_key = "PATTY_AGENT_API_KEY"
18
+ stream_idle_timeout_ms = 300000
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pattyeng-skills-'));
9
+ process.env.HOME = tmp;
10
+
11
+ const { installSkills } = require('../lib/skills');
12
+
13
+ const destDir = path.join(tmp, '.codex', 'skills');
14
+ fs.mkdirSync(path.join(destDir, 'cap'), { recursive: true });
15
+ fs.writeFileSync(path.join(destDir, 'cap', 'SKILL.md'), '---\nname: cap-user\ndescription: user-owned\n---\n');
16
+
17
+ const first = installSkills({ overwrite: false, quiet: true });
18
+ assert(first.added > 0, 'first install should add packaged skills');
19
+ assert(first.skipped >= 1, 'first install should skip pre-existing cap skill');
20
+
21
+ let manifest = JSON.parse(fs.readFileSync(path.join(destDir, '.pattyeng-skills.json'), 'utf8')).skills;
22
+ assert(!manifest.includes('cap'), 'install mode must not mark skipped user-owned cap as managed');
23
+
24
+ const second = installSkills({ overwrite: false, quiet: true });
25
+ assert.strictEqual(second.added, 0, 'second install should not add duplicate skills');
26
+ assert(second.skipped > 0, 'second install should skip existing skills');
27
+
28
+ const upgraded = installSkills({ overwrite: true, quiet: true });
29
+ assert(upgraded.updated >= 1, 'overwrite sync should update pre-existing skills');
30
+ manifest = JSON.parse(fs.readFileSync(path.join(destDir, '.pattyeng-skills.json'), 'utf8')).skills;
31
+ assert(manifest.includes('cap'), 'sync mode should mark upstream cap as managed after overwrite');
32
+
33
+ fs.rmSync(tmp, { recursive: true, force: true });
34
+ console.log('skills.test.js ok');