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/dist/api.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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.MAX_BATCH_SIZE = void 0;
|
|
37
|
+
exports.checkHealth = checkHealth;
|
|
38
|
+
exports.registerInstallation = registerInstallation;
|
|
39
|
+
exports.fetchFeed = fetchFeed;
|
|
40
|
+
exports.uploadEventBatch = uploadEventBatch;
|
|
41
|
+
exports.sendHeartbeat = sendHeartbeat;
|
|
42
|
+
exports.createMessage = createMessage;
|
|
43
|
+
exports.linkInstallation = linkInstallation;
|
|
44
|
+
const os = __importStar(require("os"));
|
|
45
|
+
const crypto_1 = require("crypto");
|
|
46
|
+
const config_1 = require("./config");
|
|
47
|
+
const TIMEOUT_MS = 5000;
|
|
48
|
+
/** 서버 EventBatchDto의 ArrayMaxSize(200)와 맞춘다. */
|
|
49
|
+
exports.MAX_BATCH_SIZE = 200;
|
|
50
|
+
async function request(config, method, apiPath, options = {}) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(config.apiBaseUrl + apiPath, {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
...(options.token && { authorization: `Bearer ${options.token}` }),
|
|
57
|
+
},
|
|
58
|
+
...(options.body !== undefined && { body: JSON.stringify(options.body) }),
|
|
59
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
return null;
|
|
63
|
+
return (await res.json());
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function checkHealth(config) {
|
|
70
|
+
const res = await request(config, "GET", "/health");
|
|
71
|
+
return res?.status === "ok";
|
|
72
|
+
}
|
|
73
|
+
/** POST /installations — 익명 등록. 로그인 상태면 userToken으로 귀속된다. */
|
|
74
|
+
async function registerInstallation(config, agentType) {
|
|
75
|
+
const res = await request(config, "POST", "/installations", {
|
|
76
|
+
token: config.userToken,
|
|
77
|
+
body: {
|
|
78
|
+
agentType,
|
|
79
|
+
os: `${process.platform}-${os.release()}`.slice(0, 50),
|
|
80
|
+
clientVersion: config_1.VERSION,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
if (!res?.installationId || !res.token)
|
|
84
|
+
return null;
|
|
85
|
+
return {
|
|
86
|
+
installationId: res.installationId,
|
|
87
|
+
token: res.token,
|
|
88
|
+
registeredAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** GET /feed — 로컬 캐시용 메시지 30~50개를 미리 받는다. */
|
|
92
|
+
async function fetchFeed(config, record, limit = 50) {
|
|
93
|
+
const res = await request(config, "GET", `/feed?limit=${limit}`, { token: record.token });
|
|
94
|
+
if (!res?.items)
|
|
95
|
+
return null;
|
|
96
|
+
return res.items
|
|
97
|
+
.filter((item) => item.messageId && item.text)
|
|
98
|
+
.map((item) => ({
|
|
99
|
+
id: item.messageId,
|
|
100
|
+
text: item.text,
|
|
101
|
+
author: item.authorNickname || "익명의 개발자",
|
|
102
|
+
category: item.category,
|
|
103
|
+
contentType: item.contentType === "SYSTEM" || item.contentType === "SPONSORED"
|
|
104
|
+
? item.contentType
|
|
105
|
+
: "USER",
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
/** POST /events/batch — eventId 기반으로 서버가 dedupe하므로 재전송에 안전하다. */
|
|
109
|
+
async function uploadEventBatch(config, record, events) {
|
|
110
|
+
const res = await request(config, "POST", "/events/batch", {
|
|
111
|
+
token: record.token,
|
|
112
|
+
body: {
|
|
113
|
+
batchId: "batch_" + (0, crypto_1.randomUUID)(),
|
|
114
|
+
events: events.map(({ eventId, type, messageId, visibleMs, occurredAt }) => ({
|
|
115
|
+
eventId,
|
|
116
|
+
type,
|
|
117
|
+
messageId,
|
|
118
|
+
...(visibleMs !== undefined && { visibleMs }),
|
|
119
|
+
occurredAt,
|
|
120
|
+
})),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return res !== null;
|
|
124
|
+
}
|
|
125
|
+
async function sendHeartbeat(config, record) {
|
|
126
|
+
await request(config, "POST", "/installations/heartbeat", {
|
|
127
|
+
token: record.token,
|
|
128
|
+
body: { clientVersion: config_1.VERSION },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* POST /messages — 메시지 작성 (분당 5개 제한).
|
|
133
|
+
* 로그인했으면 유저 토큰으로(닉네임 표시), 아니면 설치 토큰으로 익명 작성한다.
|
|
134
|
+
*/
|
|
135
|
+
async function createMessage(config, text, category) {
|
|
136
|
+
const token = config.userToken ?? Object.values(config.installations)[0]?.token;
|
|
137
|
+
if (!token)
|
|
138
|
+
return { ok: false, error: "UNAUTHORIZED" };
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(config.apiBaseUrl + "/messages", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"content-type": "application/json",
|
|
144
|
+
authorization: `Bearer ${token}`,
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({ text, ...(category && { category }) }),
|
|
147
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
148
|
+
});
|
|
149
|
+
if (res.status === 401)
|
|
150
|
+
return { ok: false, error: "UNAUTHORIZED" };
|
|
151
|
+
if (res.status === 429)
|
|
152
|
+
return { ok: false, error: "RATE_LIMITED" };
|
|
153
|
+
if (!res.ok)
|
|
154
|
+
return { ok: false, error: "SERVER" };
|
|
155
|
+
const body = (await res.json());
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
id: body.id,
|
|
159
|
+
status: body.status,
|
|
160
|
+
moderationReason: body.moderationReason,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { ok: false, error: "NETWORK" };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** POST /installations/link — 로그인 후 기존 익명 설치를 계정에 연결한다. */
|
|
168
|
+
async function linkInstallation(config, installationId) {
|
|
169
|
+
if (!config.userToken)
|
|
170
|
+
return false;
|
|
171
|
+
const res = await request(config, "POST", "/installations/link", { token: config.userToken, body: { installationId } });
|
|
172
|
+
return res?.linked === true;
|
|
173
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadFeedMessages = loadFeedMessages;
|
|
4
|
+
exports.saveFeedMessages = saveFeedMessages;
|
|
5
|
+
exports.feedAgeMs = feedAgeMs;
|
|
6
|
+
exports.loadState = loadState;
|
|
7
|
+
exports.saveState = saveState;
|
|
8
|
+
const config_1 = require("./config");
|
|
9
|
+
/**
|
|
10
|
+
* 렌더링 path에서 호출되므로 전부 동기 I/O — 네트워크는 절대 타지 않는다.
|
|
11
|
+
* 서버 feed를 아직 못 받았으면 빈 배열 — Pinglet은 아무것도 표시하지 않고
|
|
12
|
+
* Claude Code 기본 spinner가 그대로 유지된다.
|
|
13
|
+
*/
|
|
14
|
+
function loadFeedMessages() {
|
|
15
|
+
const cache = (0, config_1.readJsonFile)(config_1.FEED_PATH);
|
|
16
|
+
return cache?.messages ?? [];
|
|
17
|
+
}
|
|
18
|
+
function saveFeedMessages(messages) {
|
|
19
|
+
const file = {
|
|
20
|
+
fetchedAt: new Date().toISOString(),
|
|
21
|
+
messages,
|
|
22
|
+
};
|
|
23
|
+
(0, config_1.writeJsonFile)(config_1.FEED_PATH, file);
|
|
24
|
+
}
|
|
25
|
+
/** 서버 feed를 마지막으로 받은 시점부터의 경과(ms). 캐시가 없으면 null. */
|
|
26
|
+
function feedAgeMs() {
|
|
27
|
+
const cache = (0, config_1.readJsonFile)(config_1.FEED_PATH);
|
|
28
|
+
if (!cache?.fetchedAt)
|
|
29
|
+
return null;
|
|
30
|
+
const t = Date.parse(cache.fetchedAt);
|
|
31
|
+
return Number.isNaN(t) ? null : Date.now() - t;
|
|
32
|
+
}
|
|
33
|
+
function loadState() {
|
|
34
|
+
const state = (0, config_1.readJsonFile)(config_1.STATE_PATH);
|
|
35
|
+
if (state) {
|
|
36
|
+
state.seen ??= {};
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
39
|
+
return { seen: {} };
|
|
40
|
+
}
|
|
41
|
+
function saveState(state) {
|
|
42
|
+
// seen map이 무한히 자라지 않게 상한을 둔다.
|
|
43
|
+
const keys = Object.keys(state.seen);
|
|
44
|
+
if (keys.length > 500) {
|
|
45
|
+
for (const key of keys.slice(0, keys.length - 500)) {
|
|
46
|
+
delete state.seen[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
(0, config_1.writeJsonFile)(config_1.STATE_PATH, state);
|
|
50
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const config_1 = require("./config");
|
|
5
|
+
const install_1 = require("./commands/install");
|
|
6
|
+
const uninstall_1 = require("./commands/uninstall");
|
|
7
|
+
const doctor_1 = require("./commands/doctor");
|
|
8
|
+
const login_1 = require("./commands/login");
|
|
9
|
+
const statusline_1 = require("./commands/statusline");
|
|
10
|
+
const notify_1 = require("./commands/notify");
|
|
11
|
+
const flush_1 = require("./commands/flush");
|
|
12
|
+
const refresh_1 = require("./commands/refresh");
|
|
13
|
+
const ping_1 = require("./commands/ping");
|
|
14
|
+
const post_1 = require("./commands/post");
|
|
15
|
+
const HELP = `pinglet v${config_1.VERSION} — AI가 생각하는 동안 만나는 개발자 메시지 네트워크
|
|
16
|
+
|
|
17
|
+
사용법: pinglet <command>
|
|
18
|
+
|
|
19
|
+
install [--api <url>] [--force] Claude Code / Codex 자동 감지 후 integration 설치
|
|
20
|
+
[--claude | --codex] 특정 agent만 설치
|
|
21
|
+
uninstall [--purge] integration 제거 (--purge: ~/.pinglet까지 삭제)
|
|
22
|
+
login [--token <jwt>] GitHub 계정 연결 (선택)
|
|
23
|
+
post "메시지" [--category <c>] 메시지 작성 (설치만 해도 익명 작성 가능)
|
|
24
|
+
doctor 설치/캐시/서버 상태 진단
|
|
25
|
+
ping 메시지 미리보기
|
|
26
|
+
|
|
27
|
+
내부 명령 (integration이 호출):
|
|
28
|
+
statusline Claude Code statusLine hook
|
|
29
|
+
notify Codex notify hook
|
|
30
|
+
flush 이벤트 큐 batch 전송
|
|
31
|
+
refresh feed 캐시 갱신 + heartbeat
|
|
32
|
+
`;
|
|
33
|
+
async function main() {
|
|
34
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
35
|
+
switch (command) {
|
|
36
|
+
case "install":
|
|
37
|
+
await (0, install_1.runInstall)(rest);
|
|
38
|
+
break;
|
|
39
|
+
case "uninstall":
|
|
40
|
+
(0, uninstall_1.runUninstall)(rest);
|
|
41
|
+
break;
|
|
42
|
+
case "login":
|
|
43
|
+
await (0, login_1.runLogin)(rest);
|
|
44
|
+
break;
|
|
45
|
+
case "doctor":
|
|
46
|
+
await (0, doctor_1.runDoctor)();
|
|
47
|
+
break;
|
|
48
|
+
case "post":
|
|
49
|
+
await (0, post_1.runPost)(rest);
|
|
50
|
+
break;
|
|
51
|
+
case "ping":
|
|
52
|
+
(0, ping_1.runPing)();
|
|
53
|
+
break;
|
|
54
|
+
case "statusline":
|
|
55
|
+
(0, statusline_1.runStatusline)();
|
|
56
|
+
break;
|
|
57
|
+
case "notify":
|
|
58
|
+
(0, notify_1.runNotify)(rest);
|
|
59
|
+
break;
|
|
60
|
+
case "flush":
|
|
61
|
+
await (0, flush_1.runFlush)();
|
|
62
|
+
break;
|
|
63
|
+
case "refresh":
|
|
64
|
+
await (0, refresh_1.runRefresh)();
|
|
65
|
+
break;
|
|
66
|
+
case "--version":
|
|
67
|
+
case "-v":
|
|
68
|
+
console.log(config_1.VERSION);
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
console.log(HELP);
|
|
72
|
+
if (command && command !== "help" && command !== "--help") {
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
main().catch((error) => {
|
|
78
|
+
// statusline/notify는 조용히 실패해야 Claude/Codex 경험을 해치지 않는다.
|
|
79
|
+
const command = process.argv[2];
|
|
80
|
+
if (command === "statusline" || command === "notify") {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
console.error("pinglet:", error instanceof Error ? error.message : error);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runDoctor = runDoctor;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const api_1 = require("../api");
|
|
6
|
+
const cache_1 = require("../cache");
|
|
7
|
+
const queue_1 = require("../queue");
|
|
8
|
+
const claude_1 = require("../adapters/claude");
|
|
9
|
+
const codex_1 = require("../adapters/codex");
|
|
10
|
+
const ok = (label) => console.log(`✓ ${label}`);
|
|
11
|
+
const warn = (label) => console.log(`○ ${label}`);
|
|
12
|
+
const bad = (label) => console.log(`✗ ${label}`);
|
|
13
|
+
async function runDoctor() {
|
|
14
|
+
const config = (0, config_1.loadConfig)();
|
|
15
|
+
console.log("");
|
|
16
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
17
|
+
if (nodeMajor >= 18)
|
|
18
|
+
ok(`Node.js ${process.versions.node}`);
|
|
19
|
+
else
|
|
20
|
+
bad(`Node.js ${process.versions.node} — 18 이상이 필요합니다`);
|
|
21
|
+
// Claude
|
|
22
|
+
if ((0, claude_1.detectClaude)()) {
|
|
23
|
+
ok("Claude Code detected");
|
|
24
|
+
if ((0, claude_1.isClaudeIntegrationInstalled)()) {
|
|
25
|
+
ok("Claude integration installed");
|
|
26
|
+
const spinner = (0, claude_1.spinnerVerbsStatus)();
|
|
27
|
+
if (spinner.count > 0) {
|
|
28
|
+
ok(`Spinner 메시지: ${spinner.count}개 등록됨`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
warn("Spinner 메시지 미등록 — `pinglet refresh` 실행");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
warn("Claude integration not installed — `pinglet install` 실행");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
warn("Claude Code not found");
|
|
39
|
+
}
|
|
40
|
+
// Codex
|
|
41
|
+
if ((0, codex_1.detectCodex)()) {
|
|
42
|
+
ok("Codex detected");
|
|
43
|
+
if ((0, codex_1.isCodexIntegrationInstalled)())
|
|
44
|
+
ok("Codex integration installed");
|
|
45
|
+
else
|
|
46
|
+
warn("Codex integration not installed — `pinglet install` 실행");
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
warn("Codex not found");
|
|
50
|
+
}
|
|
51
|
+
// 서버 등록 상태
|
|
52
|
+
for (const agentType of ["CLAUDE", "CODEX"]) {
|
|
53
|
+
const record = config.installations[agentType];
|
|
54
|
+
if (record)
|
|
55
|
+
ok(`${agentType} installation registered (${record.installationId})`);
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(config.installations).length === 0) {
|
|
58
|
+
warn("서버에 등록된 installation이 없습니다 (오프라인 설치 상태)");
|
|
59
|
+
}
|
|
60
|
+
// 로컬 캐시 / 큐
|
|
61
|
+
const messages = (0, cache_1.loadFeedMessages)();
|
|
62
|
+
const age = (0, cache_1.feedAgeMs)();
|
|
63
|
+
if (messages.length === 0) {
|
|
64
|
+
warn("Feed cache 비어 있음 — 기본 spinner로 표시 중 (서버 feed 수신 후 Ping 표시)");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const ageMin = age === null ? "?" : Math.round(age / 60_000);
|
|
68
|
+
ok(`Feed cache: ${messages.length}개 (${ageMin}분 전 갱신)`);
|
|
69
|
+
}
|
|
70
|
+
const queued = (0, queue_1.countEvents)();
|
|
71
|
+
if (queued < 200)
|
|
72
|
+
ok(`Event queue: ${queued}개 대기`);
|
|
73
|
+
else
|
|
74
|
+
warn(`Event queue: ${queued}개 대기 — 서버 전송이 밀려 있습니다`);
|
|
75
|
+
// API
|
|
76
|
+
const online = await (0, api_1.checkHealth)(config);
|
|
77
|
+
if (online)
|
|
78
|
+
ok(`API reachable (${config.apiBaseUrl})`);
|
|
79
|
+
else
|
|
80
|
+
warn(`API unreachable (${config.apiBaseUrl}) — 오프라인 모드로 동작 중`);
|
|
81
|
+
if (config.userToken)
|
|
82
|
+
ok("로그인됨");
|
|
83
|
+
else
|
|
84
|
+
warn("로그인 안 됨 — 익명 작성은 가능, 닉네임 표시/반응은 `pinglet login` 후");
|
|
85
|
+
console.log("");
|
|
86
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runFlush = runFlush;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const api_1 = require("../api");
|
|
6
|
+
const queue_1 = require("../queue");
|
|
7
|
+
/**
|
|
8
|
+
* Local Event Queue를 batch로 서버에 전송한다 (아키텍처 §8).
|
|
9
|
+
* 전송 성공한 이벤트만 큐에서 제거하므로 실패분은 다음 주기에 재시도되고,
|
|
10
|
+
* 재전송 중복은 서버가 eventId로 dedupe한다.
|
|
11
|
+
*/
|
|
12
|
+
async function runFlush() {
|
|
13
|
+
const config = (0, config_1.loadConfig)();
|
|
14
|
+
const events = (0, queue_1.readEvents)();
|
|
15
|
+
if (events.length === 0)
|
|
16
|
+
return;
|
|
17
|
+
for (const agentType of ["CLAUDE", "CODEX"]) {
|
|
18
|
+
const record = config.installations[agentType];
|
|
19
|
+
const group = events.filter((e) => e.agentType === agentType);
|
|
20
|
+
if (group.length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
if (!record)
|
|
23
|
+
continue; // 미등록 상태 — refresh가 등록을 재시도한다.
|
|
24
|
+
for (let i = 0; i < group.length; i += api_1.MAX_BATCH_SIZE) {
|
|
25
|
+
const chunk = group.slice(i, i + api_1.MAX_BATCH_SIZE);
|
|
26
|
+
const ok = await (0, api_1.uploadEventBatch)(config, record, chunk);
|
|
27
|
+
if (!ok)
|
|
28
|
+
break;
|
|
29
|
+
(0, queue_1.removeEvents)(new Set(chunk.map((e) => e.eventId)));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runInstall = runInstall;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const api_1 = require("../api");
|
|
6
|
+
const cache_1 = require("../cache");
|
|
7
|
+
const claude_1 = require("../adapters/claude");
|
|
8
|
+
const codex_1 = require("../adapters/codex");
|
|
9
|
+
async function runInstall(args) {
|
|
10
|
+
const force = args.includes("--force");
|
|
11
|
+
// --claude / --codex로 대상을 고를 수 있다. 둘 다 없으면 감지된 전부.
|
|
12
|
+
const onlyClaude = args.includes("--claude");
|
|
13
|
+
const onlyCodex = args.includes("--codex");
|
|
14
|
+
const wantClaude = onlyClaude || !onlyCodex;
|
|
15
|
+
const wantCodex = onlyCodex || !onlyClaude;
|
|
16
|
+
const config = (0, config_1.loadConfig)();
|
|
17
|
+
const apiIndex = args.indexOf("--api");
|
|
18
|
+
if (apiIndex !== -1 && args[apiIndex + 1]) {
|
|
19
|
+
config.apiBaseUrl = args[apiIndex + 1].replace(/\/$/, "");
|
|
20
|
+
(0, config_1.saveConfig)(config);
|
|
21
|
+
}
|
|
22
|
+
console.log("");
|
|
23
|
+
const hasClaude = wantClaude && (0, claude_1.detectClaude)();
|
|
24
|
+
const hasCodex = wantCodex && (0, codex_1.detectCodex)();
|
|
25
|
+
console.log(hasClaude ? "✓ Claude Code detected" : "○ Claude Code not found");
|
|
26
|
+
console.log(hasCodex ? "✓ Codex detected" : "○ Codex not found");
|
|
27
|
+
if (!hasClaude && !hasCodex) {
|
|
28
|
+
console.log("\n설치할 대상이 없습니다. Claude Code 또는 Codex를 먼저 설치해 주세요.");
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const online = await (0, api_1.checkHealth)(config);
|
|
33
|
+
if (!online) {
|
|
34
|
+
console.log(`○ Pinglet 서버(${config.apiBaseUrl})에 연결할 수 없어 오프라인 모드로 설치합니다.`);
|
|
35
|
+
console.log(" 서버 등록과 feed 수신은 온라인 상태가 되면 자동으로 재시도됩니다.");
|
|
36
|
+
}
|
|
37
|
+
const register = async (agentType) => {
|
|
38
|
+
if (config.installations[agentType] || !online)
|
|
39
|
+
return;
|
|
40
|
+
const record = await (0, api_1.registerInstallation)(config, agentType);
|
|
41
|
+
if (record) {
|
|
42
|
+
config.installations[agentType] = record;
|
|
43
|
+
(0, config_1.saveConfig)(config);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
if (hasClaude) {
|
|
47
|
+
await register("CLAUDE");
|
|
48
|
+
const result = (0, claude_1.installClaudeIntegration)(config, { force });
|
|
49
|
+
console.log(result.ok
|
|
50
|
+
? "✓ Claude integration installed"
|
|
51
|
+
: `✗ Claude integration skipped — ${result.reason}`);
|
|
52
|
+
}
|
|
53
|
+
if (hasCodex) {
|
|
54
|
+
await register("CODEX");
|
|
55
|
+
const result = (0, codex_1.installCodexIntegration)(config, { force });
|
|
56
|
+
console.log(result.ok
|
|
57
|
+
? "✓ Codex integration installed (experimental)"
|
|
58
|
+
: `✗ Codex integration skipped — ${result.reason}`);
|
|
59
|
+
}
|
|
60
|
+
// 첫 feed를 미리 받아 로컬 캐시를 채워 둔다.
|
|
61
|
+
const record = Object.values(config.installations)[0];
|
|
62
|
+
if (online && record) {
|
|
63
|
+
const messages = await (0, api_1.fetchFeed)(config, record);
|
|
64
|
+
if (messages) {
|
|
65
|
+
(0, cache_1.saveFeedMessages)(messages);
|
|
66
|
+
if (messages.length > 0) {
|
|
67
|
+
console.log(`✓ Feed cached (${messages.length} messages)`);
|
|
68
|
+
if ((0, claude_1.syncSpinnerVerbs)(config, messages)) {
|
|
69
|
+
console.log("✓ Claude spinner synced");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
console.log("\n설치 완료! 평소처럼 claude 또는 codex를 실행하면 Ping이 표시됩니다.");
|
|
75
|
+
console.log(" 상태 확인: pinglet doctor");
|
|
76
|
+
console.log(' 메시지 작성: pinglet post "메시지" (익명 — 바로 가능)');
|
|
77
|
+
console.log(" 계정 연결: pinglet login (선택 — 닉네임 표시/반응에 필요)");
|
|
78
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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.runLogin = runLogin;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const readline = __importStar(require("readline"));
|
|
39
|
+
const config_1 = require("../config");
|
|
40
|
+
const api_1 = require("../api");
|
|
41
|
+
function prompt(question) {
|
|
42
|
+
const rl = readline.createInterface({
|
|
43
|
+
input: process.stdin,
|
|
44
|
+
output: process.stdout,
|
|
45
|
+
});
|
|
46
|
+
return new Promise((resolve) => rl.question(question, (answer) => {
|
|
47
|
+
rl.close();
|
|
48
|
+
resolve(answer);
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* GitHub OAuth 로그인.
|
|
53
|
+
* 브라우저에서 /auth/github → callback JSON의 token을 CLI에 붙여넣는 방식 (MVP).
|
|
54
|
+
*/
|
|
55
|
+
async function runLogin(args) {
|
|
56
|
+
const config = (0, config_1.loadConfig)();
|
|
57
|
+
let token;
|
|
58
|
+
const tokenIndex = args.indexOf("--token");
|
|
59
|
+
if (tokenIndex !== -1 && args[tokenIndex + 1]) {
|
|
60
|
+
token = args[tokenIndex + 1];
|
|
61
|
+
}
|
|
62
|
+
if (!token) {
|
|
63
|
+
const url = `${config.apiBaseUrl}/auth/github`;
|
|
64
|
+
console.log("\n브라우저에서 GitHub 로그인 후, 응답 JSON의 token 값을 붙여넣어 주세요.");
|
|
65
|
+
console.log(` ${url}\n`);
|
|
66
|
+
if (process.platform === "darwin") {
|
|
67
|
+
try {
|
|
68
|
+
(0, child_process_1.spawn)("open", [url], { stdio: "ignore" }).unref();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// 브라우저를 못 열어도 URL은 출력했으므로 진행
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
token = (await prompt("token: ")).trim();
|
|
75
|
+
}
|
|
76
|
+
if (!token) {
|
|
77
|
+
console.log("✗ token이 비어 있습니다.");
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
config.userToken = token;
|
|
82
|
+
(0, config_1.saveConfig)(config);
|
|
83
|
+
console.log("✓ 로그인 정보 저장 완료");
|
|
84
|
+
for (const [agentType, record] of Object.entries(config.installations)) {
|
|
85
|
+
const linked = await (0, api_1.linkInstallation)(config, record.installationId);
|
|
86
|
+
console.log(linked
|
|
87
|
+
? `✓ ${agentType} installation을 계정에 연결했습니다`
|
|
88
|
+
: `○ ${agentType} installation 연결 실패 — 서버 연결 후 다시 시도하세요`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runNotify = runNotify;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const cache_1 = require("../cache");
|
|
6
|
+
const picker_1 = require("../picker");
|
|
7
|
+
const queue_1 = require("../queue");
|
|
8
|
+
const runtime_1 = require("../runtime");
|
|
9
|
+
/**
|
|
10
|
+
* Codex CLI notify hook 진입점 (experimental).
|
|
11
|
+
* Codex는 turn 완료 시 마지막 인자로 notification JSON을 넘긴다.
|
|
12
|
+
* notify 프로세스는 TUI 밖에서 돌기 때문에 MVP에서는 macOS 알림으로 Ping을 전달한다.
|
|
13
|
+
*/
|
|
14
|
+
function runNotify(args) {
|
|
15
|
+
let notificationType;
|
|
16
|
+
try {
|
|
17
|
+
const payload = JSON.parse(args[args.length - 1] ?? "");
|
|
18
|
+
notificationType = payload?.type;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// payload가 없으면 그냥 진행 (수동 테스트 허용)
|
|
22
|
+
}
|
|
23
|
+
if (notificationType && notificationType !== "agent-turn-complete") {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const state = (0, cache_1.loadState)();
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const message = (0, picker_1.pickMessage)((0, cache_1.loadFeedMessages)(), state);
|
|
29
|
+
if (!message) {
|
|
30
|
+
(0, runtime_1.runMaintenance)(state, now);
|
|
31
|
+
(0, cache_1.saveState)(state);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
(0, queue_1.appendEvent)({ agentType: "CODEX", type: "DELIVERED", messageId: message.id });
|
|
35
|
+
state.seen[message.id] = (state.seen[message.id] ?? 0) + 1;
|
|
36
|
+
(0, runtime_1.runMaintenance)(state, now);
|
|
37
|
+
(0, cache_1.saveState)(state);
|
|
38
|
+
if (process.platform === "darwin") {
|
|
39
|
+
const body = message.text.replace(/["\\]/g, "");
|
|
40
|
+
const title = `Pinglet — ${message.author.replace(/["\\]/g, "")}`;
|
|
41
|
+
(0, child_process_1.execFile)("osascript", ["-e", `display notification "${body}" with title "${title}"`], () => { });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runPing = runPing;
|
|
4
|
+
const cache_1 = require("../cache");
|
|
5
|
+
const picker_1 = require("../picker");
|
|
6
|
+
const render_1 = require("../render");
|
|
7
|
+
/** 미리보기: 이벤트 기록 없이 메시지 하나를 골라 출력한다. */
|
|
8
|
+
function runPing() {
|
|
9
|
+
const message = (0, picker_1.pickMessage)((0, cache_1.loadFeedMessages)(), (0, cache_1.loadState)());
|
|
10
|
+
if (!message) {
|
|
11
|
+
console.log("표시할 Ping이 없습니다 — 서버 feed를 받으면 표시됩니다.");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log((0, render_1.formatPing)(message));
|
|
15
|
+
}
|