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 +56 -41
- package/bin/pattyeng.js +12 -11
- package/lib/banner.js +19 -0
- package/lib/install.js +113 -78
- package/lib/skills.js +161 -119
- package/lib/upgrade.js +34 -22
- package/package.json +5 -4
- package/patty.config.toml +18 -0
- package/test/skills.test.js +34 -0
package/README.md
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
# pattyeng
|
|
2
2
|
|
|
3
|
-
Patty 엔지니어용 작업 환경
|
|
3
|
+
Patty 엔지니어용 작업 환경 도구입니다.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
23
|
+
exec zsh # zsh
|
|
24
|
+
exec bash # bash
|
|
21
25
|
```
|
|
22
26
|
|
|
23
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
67
|
-
const
|
|
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
|
|
71
|
-
headers
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
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
|
-
|
|
252
|
+
const src = candidates.find(fs.existsSync);
|
|
227
253
|
|
|
228
|
-
if (src) {
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
+
upsertRcBlock('Patty Agent API Key', `set -Ux PATTY_AGENT_API_KEY "${apiKey.replace(/"/g, '\\"')}"`);
|
|
411
445
|
} else {
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
+
throw new Error('설치를 완료할 수 없습니다. Patty SSO 로그인과 PATTY_AGENT_API_KEY 설정이 필요합니다.');
|
|
516
551
|
}
|
|
517
552
|
|
|
518
|
-
|
|
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
|
|
9
|
-
const DEST_DIR
|
|
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
|
|
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
|
-
|
|
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:
|
|
44
|
+
name: cleanYamlScalar(nameMatch ? nameMatch[1] : path.basename(skillDir)),
|
|
39
45
|
description: descMatch ? cleanYamlScalar(descMatch[1]) : '',
|
|
40
46
|
};
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
╚══════════════════════════════════════════╝
|
|
15
|
-
`);
|
|
16
|
-
|
|
17
|
-
// 1. pattyeng itself
|
|
18
|
-
step('pattyeng 최신 버전으로 업데이트 중...');
|
|
17
|
+
printBanner('upgrade');
|
|
18
|
+
|
|
19
|
+
step('pattyeng 최신 패키지 다운로드 중...');
|
|
19
20
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
39
|
+
async function applyUpgrade() {
|
|
33
40
|
step('Codex 최신 버전으로 업데이트 중...');
|
|
34
41
|
try {
|
|
35
|
-
|
|
42
|
+
npmInstallLatest('@openai/codex');
|
|
36
43
|
ok('Codex 업데이트 완료');
|
|
37
44
|
} catch (e) {
|
|
38
45
|
warn('Codex 업데이트 실패: ' + e.message);
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Patty Engineering CLI - Codex SSO setup tool",
|
|
5
5
|
"bin": {
|
|
6
|
-
"pattyeng": "
|
|
6
|
+
"pattyeng": "bin/pattyeng.js"
|
|
7
7
|
},
|
|
8
8
|
"main": "./lib/index.js",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "
|
|
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
|
-
"
|
|
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');
|