pinglet-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/dist/adapters/claude.js +316 -0
- package/dist/adapters/codex.js +129 -0
- package/dist/api.js +173 -0
- package/dist/cache.js +50 -0
- package/dist/cli.js +85 -0
- package/dist/commands/doctor.js +86 -0
- package/dist/commands/flush.js +32 -0
- package/dist/commands/install.js +78 -0
- package/dist/commands/login.js +90 -0
- package/dist/commands/notify.js +43 -0
- package/dist/commands/ping.js +15 -0
- package/dist/commands/post.js +80 -0
- package/dist/commands/refresh.js +39 -0
- package/dist/commands/statusline.js +39 -0
- package/dist/commands/uninstall.js +58 -0
- package/dist/config.js +92 -0
- package/dist/picker.js +29 -0
- package/dist/queue.js +91 -0
- package/dist/render.js +19 -0
- package/dist/runtime.js +92 -0
- package/dist/types.js +2 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Pinglet Client
|
|
2
|
+
|
|
3
|
+
AI가 생각하는 동안 다른 개발자의 Ping을 만나는 메시지 네트워크 — 로컬 런타임(CLI).
|
|
4
|
+
|
|
5
|
+
`pinglet-backend`(NestJS)와 연동되며, 한 번 설치하면 평소처럼 `claude` / `codex`를
|
|
6
|
+
실행하기만 해도 대기 구간에 다른 개발자의 짧은 메시지가 표시된다.
|
|
7
|
+
|
|
8
|
+
## 설치 및 사용
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -g pinglet-cli # 설치 후 명령어는 `pinglet`
|
|
12
|
+
pinglet install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
개발 시에는 로컬 백엔드에 연결한다:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
npm run build
|
|
20
|
+
|
|
21
|
+
# 개발 중 로컬 백엔드에 연결
|
|
22
|
+
node dist/cli.js install --api http://localhost:3000
|
|
23
|
+
|
|
24
|
+
# 이후 평소처럼
|
|
25
|
+
claude # statusline에 💌 Ping 표시
|
|
26
|
+
codex # turn 완료 시 macOS 알림으로 Ping 전달 (experimental)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| 명령 | 설명 |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `pinglet install [--api <url>] [--force]` | Claude Code / Codex 자동 감지 후 integration 설치 + 서버 등록 + 초기 feed 캐시 |
|
|
32
|
+
| `pinglet uninstall [--purge]` | integration 제거, 기존 설정 복원. `--purge`는 `~/.pinglet`까지 삭제 |
|
|
33
|
+
| `pinglet login [--token <jwt>]` | GitHub 로그인 후 익명 설치를 계정에 연결 |
|
|
34
|
+
| `pinglet post "메시지" [--category <c>]` | 메시지 작성 (설치만 해도 익명 작성 가능, 로그인 시 닉네임 표시) |
|
|
35
|
+
| `pinglet doctor` | 설치/캐시/큐/서버 상태 진단 |
|
|
36
|
+
| `pinglet ping` | 메시지 미리보기 (이벤트 기록 없음) |
|
|
37
|
+
|
|
38
|
+
내부 명령(integration이 호출): `statusline`, `notify`, `flush`, `refresh`
|
|
39
|
+
|
|
40
|
+
### 세션 안에서 바로 작성
|
|
41
|
+
|
|
42
|
+
Claude Code 세션에서 `/pinglet 메시지`를 입력하면 등록된다.
|
|
43
|
+
install 시 `~/.claude/commands/pinglet.md` slash command가 함께 설치되며,
|
|
44
|
+
등록 자체는 command 안의 인라인 bash 실행(`pinglet post`)이 수행하고
|
|
45
|
+
모델은 결과 한 줄만 전달하므로 토큰 사용이 매우 적다.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
> /pinglet 금요일 오후 배포는 다음 생에 합시다.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Integration 방식
|
|
52
|
+
|
|
53
|
+
- **Claude Adapter** — `~/.claude/settings.json`의 `statusLine` hook에
|
|
54
|
+
`node <cli> statusline`을 연결한다. statusline은 UI 영역이라 LLM context에
|
|
55
|
+
전혀 들어가지 않으며(Zero Token), Thinking/Working 중에 메시지가 보인다.
|
|
56
|
+
기존 statusLine 설정은 백업 후 uninstall 시 복원한다.
|
|
57
|
+
- **Spinner verbs** — `refresh` 배치가 feed를 받을 때 `spinnerVerbs`(mode:
|
|
58
|
+
"replace")에도 메시지를 등록해, "Befuddling…" 같은 기본 verb 자리에 Ping이
|
|
59
|
+
그대로 표시된다. Pinglet이 쓴 verb는 zero-width space 마커로 식별하므로
|
|
60
|
+
사용자가 직접 등록한 verb는 건드리지 않고, uninstall 시 원래대로 복원한다.
|
|
61
|
+
- **Codex Adapter (experimental)** — `~/.codex/config.toml`의 `notify` hook에 연결.
|
|
62
|
+
notify는 TUI 밖 프로세스라 터미널 안에 그릴 수 없어, MVP에서는 turn 완료 시
|
|
63
|
+
macOS 알림으로 전달한다.
|
|
64
|
+
|
|
65
|
+
## 아키텍처 원칙 구현
|
|
66
|
+
|
|
67
|
+
- **Local-first Rendering** — 서버 feed를 30~50개 미리 받아 `~/.pinglet/feed.json`에
|
|
68
|
+
캐시하고, 렌더링 순간에는 로컬 파일만 동기 I/O로 읽는다. 렌더링 path에
|
|
69
|
+
네트워크가 절대 들어가지 않는다.
|
|
70
|
+
- **오프라인 동작** — 서버 미연결 시 시드 메시지로 동작하고, 등록/feed 갱신은
|
|
71
|
+
백그라운드 `refresh`가 온라인이 되면 자동 재시도한다.
|
|
72
|
+
- **DELIVERED / QUALIFIED_IMPRESSION 분리** — 메시지 표시 시 DELIVERED를 기록하고,
|
|
73
|
+
30초 회전 주기에 3초 이상 유지된 메시지만 QUALIFIED_IMPRESSION(visibleMs 포함)으로
|
|
74
|
+
정산한다. 세션이 끊긴 구간은 마지막 tick까지만 노출로 계산한다.
|
|
75
|
+
- **Async Telemetry** — 이벤트는 `~/.pinglet/events.jsonl`(Local Event Queue)에
|
|
76
|
+
append하고, 20개 이상 또는 60초 주기로 detached 프로세스가 batch 전송한다.
|
|
77
|
+
전송 성공분만 큐에서 제거하므로 실패분은 자동 재시도되며, 재전송 중복은
|
|
78
|
+
서버가 `eventId`로 dedupe한다.
|
|
79
|
+
- **Privacy** — prompt/응답/코드/환경변수를 읽지 않는다. 수집하는 것은
|
|
80
|
+
installationId, agentType, OS, clientVersion, 메시지 delivery 이벤트뿐이다.
|
|
81
|
+
|
|
82
|
+
## 백엔드 API 사용 (pinglet-backend)
|
|
83
|
+
|
|
84
|
+
| 엔드포인트 | 용도 |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `POST /installations` | agent별 익명 설치 등록 → `{installationId, token}` |
|
|
87
|
+
| `GET /feed?limit=50` | 로컬 캐시용 feed 수신 (installation JWT) |
|
|
88
|
+
| `POST /events/batch` | 이벤트 batch 전송 (최대 200개, eventId dedupe) |
|
|
89
|
+
| `POST /messages` | 메시지 작성 (`post` 명령, 유저 JWT 또는 installation JWT) |
|
|
90
|
+
| `POST /installations/heartbeat` | lastSeenAt 갱신 |
|
|
91
|
+
| `POST /installations/link` | 로그인 후 설치를 계정에 연결 |
|
|
92
|
+
| `GET /auth/github` | GitHub OAuth 시작 (login 명령이 브라우저로 연다) |
|
|
93
|
+
|
|
94
|
+
## 로컬 파일
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
~/.pinglet/
|
|
98
|
+
config.json # apiBaseUrl, agent별 installation 토큰, adapter 설치 정보
|
|
99
|
+
feed.json # Local Feed Cache
|
|
100
|
+
state.json # 현재 표시 중 메시지, seen 카운트, flush/refresh 타이밍
|
|
101
|
+
events.jsonl # Local Event Queue (append-only)
|
|
102
|
+
```
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.detectClaude = detectClaude;
|
|
37
|
+
exports.syncSpinnerVerbs = syncSpinnerVerbs;
|
|
38
|
+
exports.spinnerVerbsStatus = spinnerVerbsStatus;
|
|
39
|
+
exports.isClaudeIntegrationInstalled = isClaudeIntegrationInstalled;
|
|
40
|
+
exports.installClaudeIntegration = installClaudeIntegration;
|
|
41
|
+
exports.uninstallClaudeIntegration = uninstallClaudeIntegration;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const child_process_1 = require("child_process");
|
|
46
|
+
const config_1 = require("../config");
|
|
47
|
+
const cache_1 = require("../cache");
|
|
48
|
+
const render_1 = require("../render");
|
|
49
|
+
const CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
|
50
|
+
const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
|
|
51
|
+
const COMMANDS_DIR = path.join(CLAUDE_DIR, "commands");
|
|
52
|
+
const COMMAND_PATH = path.join(COMMANDS_DIR, "pinglet.md");
|
|
53
|
+
const COMMAND_MARKER = "generated by pinglet";
|
|
54
|
+
/**
|
|
55
|
+
* Claude Adapter.
|
|
56
|
+
* Claude Code의 statusLine hook에 `pinglet statusline`을 연결한다.
|
|
57
|
+
* - statusline은 UI 영역이므로 LLM context에 전혀 들어가지 않는다 (Zero Token).
|
|
58
|
+
* - Claude가 Thinking/Working 중일 때 status 영역에 Ping 메시지가 보인다.
|
|
59
|
+
*/
|
|
60
|
+
function detectClaude() {
|
|
61
|
+
if (fs.existsSync(CLAUDE_DIR))
|
|
62
|
+
return true;
|
|
63
|
+
try {
|
|
64
|
+
(0, child_process_1.execFileSync)(process.platform === "win32" ? "where" : "which", ["claude"], {
|
|
65
|
+
stdio: "ignore",
|
|
66
|
+
});
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function statusLineCommand() {
|
|
74
|
+
return `node "${(0, config_1.cliPath)()}" statusline`;
|
|
75
|
+
}
|
|
76
|
+
/** 예전 버전이 등록한 hook entry인지 식별 (command에 hook-prompt 포함 여부). */
|
|
77
|
+
function isPingletHookEntry(entry) {
|
|
78
|
+
return (entry.hooks?.some((h) => typeof h.command === "string" && h.command.includes("hook-prompt")) ?? false);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `/pinglet` slash command 파일을 설치한다.
|
|
82
|
+
* 등록 자체는 command 안의 인라인 bash(`!` 실행)가 수행하고,
|
|
83
|
+
* 모델은 그 결과 한 줄만 전달하므로 토큰 사용이 매우 적다.
|
|
84
|
+
*/
|
|
85
|
+
function installSlashCommand() {
|
|
86
|
+
fs.mkdirSync(COMMANDS_DIR, { recursive: true });
|
|
87
|
+
fs.writeFileSync(COMMAND_PATH, `---
|
|
88
|
+
description: 메시지를 Pinglet에 등록해 다른 개발자들의 터미널에 표시
|
|
89
|
+
allowed-tools: Bash(node:*)
|
|
90
|
+
---
|
|
91
|
+
<!-- ${COMMAND_MARKER} -->
|
|
92
|
+
|
|
93
|
+
등록 결과: !\`node "${(0, config_1.cliPath)()}" post --quiet $ARGUMENTS\`
|
|
94
|
+
|
|
95
|
+
위 등록 결과 줄을 아무것도 덧붙이거나 바꾸지 말고 그대로 한 줄만 출력하세요.
|
|
96
|
+
다른 설명, 안내, 작업을 일절 하지 마세요.
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
/** Pinglet이 생성한 command 파일만 삭제한다 (마커로 확인). */
|
|
100
|
+
function uninstallSlashCommand() {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.readFileSync(COMMAND_PATH, "utf8").includes(COMMAND_MARKER)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
fs.unlinkSync(COMMAND_PATH);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** 예전 버전이 등록한 UserPromptSubmit hook을 정리한다 (hook 방식은 폐기됨). */
|
|
113
|
+
function uninstallPromptHook(settings) {
|
|
114
|
+
const entries = settings.hooks?.UserPromptSubmit;
|
|
115
|
+
if (!entries?.some(isPingletHookEntry))
|
|
116
|
+
return false;
|
|
117
|
+
const remaining = entries.filter((entry) => !isPingletHookEntry(entry));
|
|
118
|
+
if (remaining.length > 0) {
|
|
119
|
+
settings.hooks.UserPromptSubmit = remaining;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
delete settings.hooks.UserPromptSubmit;
|
|
123
|
+
if (Object.keys(settings.hooks).length === 0)
|
|
124
|
+
delete settings.hooks;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
/** Pinglet이 쓴 항목인지 식별하는 마커. uninstall 시 사용자 것과 구분한다. */
|
|
129
|
+
const PINGLET_TIP_MARKER = ""; // zero-width space (항목 앞에 붙임)
|
|
130
|
+
const MAX_VERBS = 30;
|
|
131
|
+
/** spinner 한 줄에 "(27s · ↓ 1.0k tokens)" 등이 뒤에 붙으므로 여유를 둔다. */
|
|
132
|
+
const MAX_VERB_LEN = 60;
|
|
133
|
+
function readSettings() {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8"));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function writeSettings(settings) {
|
|
142
|
+
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
143
|
+
// settings.json은 Claude Code가 수시로 읽는 파일이라 tmp+rename으로 원자적으로 쓴다.
|
|
144
|
+
const tmp = SETTINGS_PATH + ".pinglet-tmp";
|
|
145
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
146
|
+
fs.renameSync(tmp, SETTINGS_PATH);
|
|
147
|
+
}
|
|
148
|
+
function isPingletTip(tip) {
|
|
149
|
+
return tip.startsWith(PINGLET_TIP_MARKER);
|
|
150
|
+
}
|
|
151
|
+
function buildPingletVerbs(messages) {
|
|
152
|
+
return messages.slice(0, MAX_VERBS).map((message) => {
|
|
153
|
+
let line = (0, render_1.formatPing)(message);
|
|
154
|
+
if (line.length > MAX_VERB_LEN) {
|
|
155
|
+
line = line.slice(0, MAX_VERB_LEN - 1) + "…";
|
|
156
|
+
}
|
|
157
|
+
return PINGLET_TIP_MARKER + line;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 예전 버전이 spinnerTipsOverride에 등록해 둔 Pinglet tips를 정리한다.
|
|
162
|
+
* (tips는 "└ Tip: ..." 줄로 그려져서 verb 교체 방식으로 옮겼다.)
|
|
163
|
+
*/
|
|
164
|
+
function cleanupSpinnerTips(settings, config) {
|
|
165
|
+
const override = settings.spinnerTipsOverride;
|
|
166
|
+
if (!override?.tips?.some(isPingletTip))
|
|
167
|
+
return false;
|
|
168
|
+
const adapter = config.adapters.claude;
|
|
169
|
+
const backup = adapter?.spinnerTipsBackup;
|
|
170
|
+
const userTips = override.tips.filter((tip) => !isPingletTip(tip));
|
|
171
|
+
if (backup) {
|
|
172
|
+
settings.spinnerTipsOverride = backup;
|
|
173
|
+
}
|
|
174
|
+
else if (userTips.length > 0) {
|
|
175
|
+
settings.spinnerTipsOverride = { ...override, tips: userTips };
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
delete settings.spinnerTipsOverride;
|
|
179
|
+
}
|
|
180
|
+
if (adapter?.spinnerTipsBackup !== undefined) {
|
|
181
|
+
delete adapter.spinnerTipsBackup;
|
|
182
|
+
(0, config_1.saveConfig)(config);
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Claude Code spinner verb("Befuddling…" 자리)를 feed 메시지로 교체한다.
|
|
188
|
+
* mode: "replace"라 기본 verb 대신 Ping이 그대로 spinner 본문에 표시된다.
|
|
189
|
+
* 마커가 붙은 항목만 Pinglet 소유로 보고 교체하며, 사용자 verb는 유지한다.
|
|
190
|
+
*/
|
|
191
|
+
function syncSpinnerVerbs(config, messages) {
|
|
192
|
+
const adapter = config.adapters.claude;
|
|
193
|
+
if (!adapter)
|
|
194
|
+
return false;
|
|
195
|
+
const settings = readSettings();
|
|
196
|
+
cleanupSpinnerTips(settings, config);
|
|
197
|
+
const existing = settings.spinnerVerbs;
|
|
198
|
+
const existingVerbs = existing?.verbs ?? [];
|
|
199
|
+
// 사용자가 원래 갖고 있던 spinnerVerbs는 최초 1회 백업해 두고 uninstall 시 복원한다.
|
|
200
|
+
const isOurs = existingVerbs.some(isPingletTip);
|
|
201
|
+
if (existing && !isOurs && adapter.spinnerVerbsBackup === undefined) {
|
|
202
|
+
adapter.spinnerVerbsBackup = existing;
|
|
203
|
+
(0, config_1.saveConfig)(config);
|
|
204
|
+
}
|
|
205
|
+
const userVerbs = existingVerbs.filter((verb) => !isPingletTip(verb));
|
|
206
|
+
const pingletVerbs = buildPingletVerbs(messages);
|
|
207
|
+
// 실제 feed 메시지가 없으면 spinner를 교체하지 않는다 —
|
|
208
|
+
// 우리가 넣어둔 verb가 있으면 걷어내고 Claude Code 기본 spinner로 되돌린다.
|
|
209
|
+
if (pingletVerbs.length === 0) {
|
|
210
|
+
if (!isOurs)
|
|
211
|
+
return false;
|
|
212
|
+
const backup = adapter.spinnerVerbsBackup;
|
|
213
|
+
if (backup) {
|
|
214
|
+
settings.spinnerVerbs = backup;
|
|
215
|
+
delete adapter.spinnerVerbsBackup;
|
|
216
|
+
(0, config_1.saveConfig)(config);
|
|
217
|
+
}
|
|
218
|
+
else if (userVerbs.length > 0) {
|
|
219
|
+
settings.spinnerVerbs = { ...existing, verbs: userVerbs };
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
delete settings.spinnerVerbs;
|
|
223
|
+
}
|
|
224
|
+
writeSettings(settings);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
settings.spinnerVerbs = {
|
|
228
|
+
mode: "replace",
|
|
229
|
+
verbs: [...userVerbs, ...pingletVerbs],
|
|
230
|
+
};
|
|
231
|
+
writeSettings(settings);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
/** doctor용: spinner verb로 등록된 Pinglet 메시지 개수. */
|
|
235
|
+
function spinnerVerbsStatus() {
|
|
236
|
+
const settings = readSettings();
|
|
237
|
+
const verbs = settings.spinnerVerbs?.verbs ?? [];
|
|
238
|
+
return { count: verbs.filter(isPingletTip).length };
|
|
239
|
+
}
|
|
240
|
+
function isClaudeIntegrationInstalled() {
|
|
241
|
+
const settings = readSettings();
|
|
242
|
+
return settings.statusLine?.command?.includes("pinglet") ?? false;
|
|
243
|
+
}
|
|
244
|
+
function installClaudeIntegration(config, options = {}) {
|
|
245
|
+
const settings = readSettings();
|
|
246
|
+
const existing = settings.statusLine;
|
|
247
|
+
const isOurs = existing?.command?.includes("pinglet");
|
|
248
|
+
if (existing && !isOurs && !options.force) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
reason: "기존 statusLine 설정이 있습니다. 덮어쓰려면 --force로 다시 실행하세요.",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
config.adapters.claude = {
|
|
255
|
+
// 재설치 시 기존 백업(spinnerVerbsBackup 등)이 날아가지 않게 유지한다.
|
|
256
|
+
...config.adapters.claude,
|
|
257
|
+
installedAt: new Date().toISOString(),
|
|
258
|
+
settingsPath: SETTINGS_PATH,
|
|
259
|
+
// 사용자의 기존 설정은 백업해 두고 uninstall 시 복원한다.
|
|
260
|
+
...(existing && !isOurs && { statusLineBackup: existing }),
|
|
261
|
+
};
|
|
262
|
+
(0, config_1.saveConfig)(config);
|
|
263
|
+
settings.statusLine = { type: "command", command: statusLineCommand() };
|
|
264
|
+
uninstallPromptHook(settings); // 구버전 hook 방식 흔적 정리
|
|
265
|
+
writeSettings(settings);
|
|
266
|
+
installSlashCommand();
|
|
267
|
+
// spinner verb는 로컬 캐시에 실제 feed가 있을 때만 채운다. 없으면 기본 spinner 유지.
|
|
268
|
+
syncSpinnerVerbs(config, (0, cache_1.loadFeedMessages)());
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
function uninstallClaudeIntegration(config) {
|
|
272
|
+
const settings = readSettings();
|
|
273
|
+
let changed = false;
|
|
274
|
+
if (settings.statusLine?.command?.includes("pinglet")) {
|
|
275
|
+
const backup = config.adapters.claude?.statusLineBackup;
|
|
276
|
+
if (backup) {
|
|
277
|
+
settings.statusLine = backup;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
delete settings.statusLine;
|
|
281
|
+
}
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
if (cleanupSpinnerTips(settings, config)) {
|
|
285
|
+
changed = true;
|
|
286
|
+
}
|
|
287
|
+
if (uninstallPromptHook(settings)) {
|
|
288
|
+
changed = true;
|
|
289
|
+
}
|
|
290
|
+
if (uninstallSlashCommand()) {
|
|
291
|
+
changed = true;
|
|
292
|
+
}
|
|
293
|
+
const verbs = settings.spinnerVerbs;
|
|
294
|
+
if (verbs?.verbs?.some(isPingletTip)) {
|
|
295
|
+
const verbsBackup = config.adapters.claude?.spinnerVerbsBackup;
|
|
296
|
+
const userVerbs = verbs.verbs.filter((verb) => !isPingletTip(verb));
|
|
297
|
+
if (verbsBackup) {
|
|
298
|
+
settings.spinnerVerbs = verbsBackup;
|
|
299
|
+
}
|
|
300
|
+
else if (userVerbs.length > 0) {
|
|
301
|
+
settings.spinnerVerbs = { ...verbs, verbs: userVerbs };
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
delete settings.spinnerVerbs;
|
|
305
|
+
}
|
|
306
|
+
changed = true;
|
|
307
|
+
}
|
|
308
|
+
if (changed) {
|
|
309
|
+
writeSettings(settings);
|
|
310
|
+
}
|
|
311
|
+
if (config.adapters.claude) {
|
|
312
|
+
delete config.adapters.claude;
|
|
313
|
+
(0, config_1.saveConfig)(config);
|
|
314
|
+
}
|
|
315
|
+
return changed;
|
|
316
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.detectCodex = detectCodex;
|
|
37
|
+
exports.isCodexIntegrationInstalled = isCodexIntegrationInstalled;
|
|
38
|
+
exports.installCodexIntegration = installCodexIntegration;
|
|
39
|
+
exports.uninstallCodexIntegration = uninstallCodexIntegration;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
44
|
+
const config_1 = require("../config");
|
|
45
|
+
const CODEX_DIR = path.join(os.homedir(), ".codex");
|
|
46
|
+
const CONFIG_TOML_PATH = path.join(CODEX_DIR, "config.toml");
|
|
47
|
+
/**
|
|
48
|
+
* Codex Adapter (experimental).
|
|
49
|
+
* Codex CLI의 notify hook에 `pinglet notify`를 연결한다.
|
|
50
|
+
* notify는 터미널 UI 밖에서 실행되는 프로세스라 TUI 안에 직접 그릴 수 없어,
|
|
51
|
+
* MVP에서는 turn 완료 시 OS 알림(macOS notification)으로 Ping을 전달한다.
|
|
52
|
+
*/
|
|
53
|
+
function detectCodex() {
|
|
54
|
+
if (fs.existsSync(CODEX_DIR))
|
|
55
|
+
return true;
|
|
56
|
+
try {
|
|
57
|
+
(0, child_process_1.execFileSync)(process.platform === "win32" ? "where" : "which", ["codex"], {
|
|
58
|
+
stdio: "ignore",
|
|
59
|
+
});
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function notifyLine() {
|
|
67
|
+
return `notify = ["node", ${JSON.stringify((0, config_1.cliPath)())}, "notify"]`;
|
|
68
|
+
}
|
|
69
|
+
function readToml() {
|
|
70
|
+
try {
|
|
71
|
+
return fs.readFileSync(CONFIG_TOML_PATH, "utf8");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function findNotifyLine(toml) {
|
|
78
|
+
const match = toml.match(/^\s*notify\s*=.*$/m);
|
|
79
|
+
return match ? match[0] : null;
|
|
80
|
+
}
|
|
81
|
+
function isCodexIntegrationInstalled() {
|
|
82
|
+
const line = findNotifyLine(readToml());
|
|
83
|
+
return (line !== null && (line.includes("pinglet") || line.includes((0, config_1.cliPath)())));
|
|
84
|
+
}
|
|
85
|
+
function installCodexIntegration(config, options = {}) {
|
|
86
|
+
const toml = readToml();
|
|
87
|
+
const existing = findNotifyLine(toml);
|
|
88
|
+
const isOurs = existing !== null &&
|
|
89
|
+
(existing.includes("pinglet") || existing.includes((0, config_1.cliPath)()));
|
|
90
|
+
if (existing && !isOurs && !options.force) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: "기존 notify 설정이 있습니다. 덮어쓰려면 --force로 다시 실행하세요.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
config.adapters.codex = {
|
|
97
|
+
installedAt: new Date().toISOString(),
|
|
98
|
+
configPath: CONFIG_TOML_PATH,
|
|
99
|
+
...(existing && !isOurs && { notifyBackup: existing }),
|
|
100
|
+
};
|
|
101
|
+
(0, config_1.saveConfig)(config);
|
|
102
|
+
let next;
|
|
103
|
+
if (existing) {
|
|
104
|
+
next = toml.replace(existing, notifyLine());
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// TOML top-level 키는 첫 [table] 이전에 있어야 하므로 파일 맨 앞에 넣는다.
|
|
108
|
+
next = notifyLine() + "\n" + toml;
|
|
109
|
+
}
|
|
110
|
+
fs.mkdirSync(CODEX_DIR, { recursive: true });
|
|
111
|
+
fs.writeFileSync(CONFIG_TOML_PATH, next);
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
function uninstallCodexIntegration(config) {
|
|
115
|
+
const toml = readToml();
|
|
116
|
+
const existing = findNotifyLine(toml);
|
|
117
|
+
const isOurs = existing !== null &&
|
|
118
|
+
(existing.includes("pinglet") || existing.includes((0, config_1.cliPath)()));
|
|
119
|
+
if (!existing || !isOurs)
|
|
120
|
+
return false;
|
|
121
|
+
const backup = config.adapters.codex?.notifyBackup;
|
|
122
|
+
const next = backup
|
|
123
|
+
? toml.replace(existing, backup)
|
|
124
|
+
: toml.replace(existing + "\n", "").replace(existing, "");
|
|
125
|
+
fs.writeFileSync(CONFIG_TOML_PATH, next);
|
|
126
|
+
delete config.adapters.codex;
|
|
127
|
+
(0, config_1.saveConfig)(config);
|
|
128
|
+
return true;
|
|
129
|
+
}
|