pattyeng 1.0.6 → 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
- ```
56
-
57
- Windows:
51
+ 스킬 설치 규칙:
58
52
 
59
- ```text
60
- 터미널을 닫고 새로 열기
61
- ```
53
+ - 이미 같은 이름의 스킬 폴더가 있으면 **건너뜁니다**.
54
+ - 기존 스킬을 덮어쓰지 않습니다.
55
+ - 그래서 처음 세팅 때 안전하게 여러 번 실행해도 됩니다.
62
56
 
63
57
  ---
64
58
 
@@ -70,32 +64,46 @@ 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
 
80
77
  ### `pattyeng skills list`
81
78
 
82
- 현재 설치된 Codex 스킬을 봅니다.
79
+ 설치된 스킬 이름을 짧게 봅니다.
83
80
 
84
81
  ```bash
85
82
  pattyeng skills list
86
83
  ```
87
84
 
85
+ 설명까지 보고 싶으면:
86
+
87
+ ```bash
88
+ pattyeng skills list --long
89
+ ```
90
+
88
91
  ---
89
92
 
90
93
  ### `pattyeng skills sync`
91
94
 
92
- Patty dotfiles에 있는 최신 스킬을 `~/.codex/skills`로 동기화합니다.
95
+ 스킬만 수동으로 동기화합니다.
93
96
 
94
97
  ```bash
95
98
  pattyeng skills sync
96
99
  ```
97
100
 
98
- 스킬이 바뀌었거나 새로 추가됐을 때 실행하면 됩니다.
101
+ 보통은 직접 실행할 필요 없습니다.
102
+
103
+ - 처음 세팅: `pattyeng install codex`
104
+ - 업데이트: `pattyeng upgrade`
105
+
106
+ 위 두 명령어가 스킬까지 처리합니다.
99
107
 
100
108
  ---
101
109
 
@@ -107,11 +115,16 @@ pattyeng skills sync
107
115
  - Windows PowerShell
108
116
  - Windows CMD
109
117
 
110
- Node.js 18 이상이 필요합니다.
118
+ 필요 조건:
119
+
120
+ - Node.js 18 이상
121
+ - npm
122
+ - Patty 계정
123
+ - `pattyeng` 그룹 멤버십
111
124
 
112
125
  ---
113
126
 
114
- ## 자주 나는 문제
127
+ ## 문제 해결
115
128
 
116
129
  ### 브라우저가 자동으로 안 열림
117
130
 
@@ -121,6 +134,10 @@ Node.js 18 이상이 필요합니다.
121
134
 
122
135
  Patty 관리자에게 `pattyeng` 그룹 추가를 요청하세요.
123
136
 
137
+ ### `PATTY_AGENT_API_KEY`를 찾을 수 없다고 나옴
138
+
139
+ Keycloak `pattyeng` 그룹의 `PATTY_AGENT_API_KEY` 속성이 userinfo claim으로 매핑되어 있는지 확인하세요.
140
+
124
141
  ### 환경변수가 바로 안 잡힘
125
142
 
126
143
  새 터미널을 열거나 아래 명령어를 실행하세요.
@@ -133,9 +150,13 @@ exec bash
133
150
 
134
151
  Windows는 터미널을 닫고 새로 열면 됩니다.
135
152
 
136
- ### Codex가 이상하게 동작함
153
+ ### 스킬을 다시 받고 싶음
154
+
155
+ ```bash
156
+ pattyeng skills sync
157
+ ```
137
158
 
138
- 업데이트하세요.
159
+ ### 전체를 최신으로 맞추고 싶음
139
160
 
140
161
  ```bash
141
162
  pattyeng upgrade
@@ -145,4 +166,4 @@ pattyeng upgrade
145
166
 
146
167
  ## 한 줄 요약
147
168
 
148
- Patty에서 쓰는 개발환경 세팅을 사람 타게 만드는 도구입니다.
169
+ Patty에서 쓰는 개발환경 세팅을 반복 작업이 아니라 명령어로 관리하게 만드는 도구입니다.
package/bin/pattyeng.js CHANGED
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ process.stdout.on('error', (err) => {
5
+ if (err.code === 'EPIPE') process.exit(0);
6
+ throw err;
7
+ });
8
+
4
9
  const { install } = require('../lib/install');
5
- const { upgrade } = require('../lib/upgrade');
10
+ const { upgrade, applyUpgrade } = require('../lib/upgrade');
6
11
  const { skills } = require('../lib/skills');
12
+ const { printBanner } = require('../lib/banner');
7
13
 
8
14
  const args = process.argv.slice(2);
9
15
  const cmd = args[0];
@@ -23,6 +29,10 @@ async function main() {
23
29
  await upgrade();
24
30
  break;
25
31
 
32
+ case 'upgrade:apply':
33
+ await applyUpgrade();
34
+ break;
35
+
26
36
  case 'skills':
27
37
  await skills(args.slice(1));
28
38
  break;
@@ -33,16 +43,12 @@ async function main() {
33
43
  }
34
44
 
35
45
  function printHelp() {
36
- console.log(`
37
- ╔══════════════════════════════════════════╗
38
- pattyeng CLI ║
39
- ╚══════════════════════════════════════════╝
40
-
41
- 명령어:
42
- install codex Codex 설치 + SSO 로그인 + 환경설정
43
- upgrade pattyeng, Codex 최신 버전으로 업데이트
44
- skills list 설치된 스킬 목록
45
- 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 스킬 수동 동기화
46
52
  `);
47
53
  }
48
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,136 +41,200 @@ 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: nameMatch ? nameMatch[1].trim() : path.basename(skillDir),
39
- description: descMatch ? descMatch[1].trim().slice(0, 60) : '',
44
+ name: cleanYamlScalar(nameMatch ? nameMatch[1] : path.basename(skillDir)),
45
+ description: descMatch ? cleanYamlScalar(descMatch[1]) : '',
40
46
  };
41
47
  }
42
48
 
43
- // Diff two SKILL.md files — returns true if different
44
- function isDifferent(srcFile, destFile) {
45
- if (!fs.existsSync(destFile)) return true;
46
- return fs.readFileSync(srcFile, 'utf8') !== fs.readFileSync(destFile, 'utf8');
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();
47
55
  }
48
56
 
49
- // Copy a skill folder recursively
50
- function copySkill(srcDir, destDir) {
51
- if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
52
- for (const file of fs.readdirSync(srcDir)) {
53
- const src = path.join(srcDir, file);
54
- const dest = path.join(destDir, file);
55
- if (fs.statSync(src).isDirectory()) {
56
- copySkill(src, dest);
57
- } else {
58
- fs.copyFileSync(src, dest);
59
- }
57
+ function sameSymlink(dest, src) {
58
+ try {
59
+ return fs.lstatSync(dest).isSymbolicLink() && fs.realpathSync(dest) === fs.realpathSync(src);
60
+ } catch {
61
+ return false;
60
62
  }
61
63
  }
62
64
 
63
- async function skills(args) {
64
- const sub = args[0];
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
+ }
65
71
 
66
- if (!sub || sub === 'list') {
67
- return listSkills();
68
- }
69
- if (sub === 'sync') {
70
- return syncSkills();
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 [];
71
78
  }
79
+ }
72
80
 
73
- log(`\n사용법:\n pattyeng skills list — 설치된 스킬 목록\n pattyeng skills sync — 스킬 최신 버전으로 동기화\n`);
81
+ function writeManifest(skills) {
82
+ fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify({ skills: [...skills].sort() }, null, 2)}\n`);
74
83
  }
75
84
 
76
- function listSkills() {
77
- log(`\n📚 설치된 스킬 목록 (${DEST_DIR})\n`);
85
+ function pruneDeletedSkills(srcSkills, { quiet }) {
86
+ const srcSet = new Set(srcSkills);
87
+ let removed = 0;
78
88
 
79
- if (!fs.existsSync(DEST_DIR)) {
80
- warn('~/.codex/skills 폴더가 없습니다. pattyeng skills sync 를 먼저 실행하세요.');
81
- return;
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}`);
82
95
  }
83
96
 
84
- const skills = fs.readdirSync(DEST_DIR).filter(f => {
85
- try { return fs.statSync(path.join(DEST_DIR, f)).isDirectory(); }
86
- catch { return false; } // broken symlink — skip
87
- });
88
-
89
- if (skills.length === 0) {
90
- warn('스킬이 없습니다. pattyeng skills sync 를 실행하세요.');
91
- return;
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
+ }
92
106
  }
93
107
 
94
- for (const skill of skills.sort()) {
95
- const meta = readSkillMeta(path.join(DEST_DIR, skill));
96
- const desc = meta.description ? `— ${meta.description}` : '';
97
- log(` • ${meta.name.padEnd(20)} ${desc}`);
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);
98
119
  }
99
- log('');
100
120
  }
101
121
 
102
- function syncSkills() {
103
- log(`
104
- ╔══════════════════════════════════════════╗
105
- ║ pattyeng skills sync 시작 ║
106
- ╚══════════════════════════════════════════╝
107
- `);
122
+ function installSkillDir(src, dest, { overwrite }) {
123
+ let exists = false;
124
+ try { fs.lstatSync(dest); exists = true; } catch { exists = false; }
108
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 } = {}) {
109
141
  const srcDir = findSourceSkillsDir();
110
142
  if (!srcDir) {
111
143
  warn('스킬 소스 폴더를 찾을 수 없습니다.');
112
- warn('dotfiles 저장소가 있어야 합니다: ~/projects/dotfiles/skills/');
113
- process.exit(1);
144
+ return { added: 0, updated: 0, skipped: 0 };
114
145
  }
115
- log(` 소스: ${srcDir}`);
116
- log(` 대상: ${DEST_DIR}\n`);
117
146
 
118
147
  if (!fs.existsSync(DEST_DIR)) fs.mkdirSync(DEST_DIR, { recursive: true });
119
148
 
120
- const srcSkills = fs.readdirSync(srcDir).filter(f =>
121
- fs.statSync(path.join(srcDir, f)).isDirectory()
122
- );
149
+ if (!quiet) {
150
+ log(`\n📚 스킬 ${overwrite ? '동기화' : '설치'} 중...`);
151
+ log(` 소스: ${srcDir}`);
152
+ log(` 대상: ${DEST_DIR}\n`);
153
+ }
123
154
 
124
- let added = 0;
155
+ let added = 0;
125
156
  let updated = 0;
126
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;
127
161
 
128
- for (const skill of srcSkills.sort()) {
129
- const src = path.join(srcDir, skill);
162
+ for (const skill of srcSkills) {
163
+ const src = path.join(srcDir, skill);
130
164
  const dest = path.join(DEST_DIR, skill);
131
- const meta = readSkillMeta(src);
132
- const srcMd = path.join(src, 'SKILL.md');
133
- const destMd = path.join(dest, 'SKILL.md');
134
-
135
- const destExists = fs.existsSync(dest);
136
- const destLstat = destExists ? null : null, lstat = (() => { try { return fs.lstatSync(dest); } catch { return null; } })();
137
- const isSymlink = lstat && lstat.isSymbolicLink();
138
- const alreadyPointsHere = isSymlink && fs.readlinkSync(dest) === src;
139
-
140
- if (!lstat) {
141
- // doesn’t exist at all
142
- fs.symlinkSync(src, dest);
143
- log(` ✨ 새로 추가: ${meta.name}`);
144
- added++;
145
- } else if (alreadyPointsHere) {
146
- // already correct symlink
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') {
147
170
  skipped++;
148
- } else if (isSymlink) {
149
- // symlink pointing somewhere else — update
150
- fs.unlinkSync(dest);
151
- fs.symlinkSync(src, dest);
152
- log(` 🔄 업데이트: ${meta.name}`);
153
- updated++;
154
- } else if (isDifferent(srcMd, destMd)) {
155
- // real dir with changed content — overwrite
156
- copySkill(src, dest);
157
- log(` 🔄 업데이트: ${meta.name}`);
171
+ if (!quiet) log(` 건너뜀: ${name}`);
172
+ } else if (existed) {
158
173
  updated++;
174
+ managedSkills.add(skill);
175
+ if (!quiet) log(` 업데이트: ${name}`);
159
176
  } else {
160
- skipped++;
177
+ added++;
178
+ managedSkills.add(skill);
179
+ if (!quiet) log(` 설치: ${name}`);
180
+ }
181
+ }
182
+
183
+ writeManifest(managedSkills);
184
+
185
+ if (!quiet) log(`\n 설치: ${added}개 업데이트: ${updated}개 건너뜀: ${skipped}개 삭제: ${removed}개`);
186
+ return { added, updated, skipped, removed };
187
+ }
188
+
189
+ async function skills(args) {
190
+ const sub = args[0];
191
+
192
+ if (!sub || sub === 'list') return listSkills(args.includes('--long'));
193
+ if (sub === 'sync') return syncSkills();
194
+
195
+ log(`\n사용법:\n pattyeng skills list — 스킬 이름만 보기\n pattyeng skills list --long — 설명까지 보기\n pattyeng skills sync — 스킬 최신 버전으로 동기화\n`);
196
+ }
197
+
198
+ function listSkills(long = false) {
199
+ log(`\n📚 스킬 목록 (${DEST_DIR})\n`);
200
+
201
+ if (!fs.existsSync(DEST_DIR)) {
202
+ warn('~/.codex/skills 폴더가 없습니다. pattyeng skills sync 를 먼저 실행하세요.');
203
+ return;
204
+ }
205
+
206
+ const names = getSkillDirs(DEST_DIR)
207
+ .map(skill => readSkillMeta(path.join(DEST_DIR, skill)).name)
208
+ .filter(name => !name.startsWith('.'));
209
+
210
+ if (names.length === 0) {
211
+ warn('스킬이 없습니다. pattyeng skills sync 를 실행하세요.');
212
+ return;
213
+ }
214
+
215
+ if (!long) {
216
+ const width = Math.max(18, ...names.map(n => n.length + 2));
217
+ for (let i = 0; i < names.length; i += 3) {
218
+ log(' ' + names.slice(i, i + 3).map(n => n.padEnd(width)).join(''));
161
219
  }
220
+ log(`\n 총 ${names.length}개. 설명 보기: pattyeng skills list --long\n`);
221
+ return;
162
222
  }
163
223
 
164
- log(`
165
- 추가: ${added}개 업데이트: ${updated}개 변경없음: ${skipped}개
224
+ for (const skill of getSkillDirs(DEST_DIR)) {
225
+ const meta = readSkillMeta(path.join(DEST_DIR, skill));
226
+ if (meta.name.startsWith('.')) continue;
227
+ const desc = cleanDescription(meta.description);
228
+ log(` • ${meta.name.padEnd(20)} ${desc ? `— ${desc}` : ''}`);
229
+ }
230
+ log('');
231
+ }
166
232
 
167
- 스킬 동기화 완료`);
233
+ function syncSkills() {
234
+ printBanner('skills sync');
235
+ const result = installSkills({ overwrite: true });
236
+ log('\n✅ 스킬 동기화 완료');
237
+ return result;
168
238
  }
169
239
 
170
- 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.6",
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');