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 +63 -42
- package/bin/pattyeng.js +17 -11
- package/lib/banner.js +19 -0
- package/lib/install.js +113 -78
- package/lib/skills.js +173 -103
- 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
|
-
```
|
|
56
|
-
|
|
57
|
-
Windows:
|
|
51
|
+
스킬 설치 규칙:
|
|
58
52
|
|
|
59
|
-
|
|
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
|
-
|
|
76
|
-
- Codex CLI
|
|
73
|
+
`upgrade`는 기존 스킬을 최신 버전으로 바꿉니다. 스킬 업데이트가 필요하면 보통 이 명령어만 쓰면 됩니다.
|
|
77
74
|
|
|
78
75
|
---
|
|
79
76
|
|
|
80
77
|
### `pattyeng skills list`
|
|
81
78
|
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
//
|
|
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,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:
|
|
39
|
-
description: descMatch
|
|
44
|
+
name: cleanYamlScalar(nameMatch ? nameMatch[1] : path.basename(skillDir)),
|
|
45
|
+
description: descMatch ? cleanYamlScalar(descMatch[1]) : '',
|
|
40
46
|
};
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
81
|
+
function writeManifest(skills) {
|
|
82
|
+
fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify({ skills: [...skills].sort() }, null, 2)}\n`);
|
|
74
83
|
}
|
|
75
84
|
|
|
76
|
-
function
|
|
77
|
-
|
|
85
|
+
function pruneDeletedSkills(srcSkills, { quiet }) {
|
|
86
|
+
const srcSet = new Set(srcSkills);
|
|
87
|
+
let removed = 0;
|
|
78
88
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
);
|
|
149
|
+
if (!quiet) {
|
|
150
|
+
log(`\n📚 스킬 ${overwrite ? '동기화' : '설치'} 중...`);
|
|
151
|
+
log(` 소스: ${srcDir}`);
|
|
152
|
+
log(` 대상: ${DEST_DIR}\n`);
|
|
153
|
+
}
|
|
123
154
|
|
|
124
|
-
let added
|
|
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
|
|
129
|
-
const src
|
|
162
|
+
for (const skill of srcSkills) {
|
|
163
|
+
const src = path.join(srcDir, skill);
|
|
130
164
|
const dest = path.join(DEST_DIR, skill);
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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');
|