z2o-cli 0.7.0 → 0.9.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/index.js CHANGED
@@ -15,9 +15,13 @@
15
15
  */
16
16
 
17
17
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, cpSync, rmSync } from 'node:fs';
18
- import { homedir } from 'node:os';
18
+ import { homedir, hostname } from 'node:os';
19
19
  import { join, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { createServer } from 'node:http';
22
+ import { createHash, randomBytes } from 'node:crypto';
23
+ import { spawn } from 'node:child_process';
24
+ import { createInterface } from 'node:readline/promises';
21
25
 
22
26
  const DEFAULT_API = 'https://z2o.kr/_/backend';
23
27
  // 발급되는 토큰의 모양. `z2o_live_` + base64url.
@@ -81,7 +85,7 @@ async function rpc(method, params, { token: override } = {}) {
81
85
  // `login` 은 **아직 저장하지 않은** 토큰으로 확인해야 한다 — 그래서 넘겨받는 길을 둔다.
82
86
  const token = override || saved;
83
87
  if (!token) {
84
- die('연결된 토큰이 없어요.\n z2o.kr 오른쪽 프로필 「연결 토큰」에서 발급한 뒤 `z2o init <토큰>` 하세요.');
88
+ die('아직 연결 안 됐어요.\n `z2o login` 하면 브라우저에서 「허용」 번으로 연결됩니다.');
85
89
  }
86
90
 
87
91
  if (token && !TOKEN_RE.test(token)) {
@@ -102,7 +106,7 @@ async function rpc(method, params, { token: override } = {}) {
102
106
  die(`서버에 닿지 못했어요 (${api}).\n ${e.message}`);
103
107
  }
104
108
 
105
- if (res.status === 401) die('토큰이 유효하지 않거나 끊겼어요. `z2o login <토큰>` 으로 다시 연결해 주세요.');
109
+ if (res.status === 401) die('연결이 끊겼어요(토큰이 끊겼거나 틀림). `z2o login` 으로 다시 연결해 주세요.');
106
110
  if (res.status === 403) {
107
111
  // 읽기 전용 토큰으로 쓰기를 시도한 경우. 서버 문구를 그대로 전한다.
108
112
  let detail = '';
@@ -131,6 +135,119 @@ async function callTool(name, args, opts) {
131
135
  return text;
132
136
  }
133
137
 
138
+ // ── 브라우저로 붙이기 ────────────────────────────────────
139
+ //
140
+ // 🔑 **사람이 토큰 문자열을 볼 일이 없게 한다.** 터미널에 붙여 넣는 것은 스푸핑·탈취
141
+ // 위험이 있고(2026-09-10 데일리), 비개발자에게는 그 자체가 벽이다. `gh auth login` 과
142
+ // 같은 모양 — 브라우저에서 「허용」을 누르면 여기로 돌아온다.
143
+ //
144
+ // 🔑 **PKCE.** 비밀값은 이 프로세스 밖으로 안 나간다. 브라우저를 오가는 것은 그 해시와
145
+ // 5분짜리 코드뿐이고, 코드는 비밀값 없이는 토큰으로 못 바꾼다.
146
+
147
+ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
148
+
149
+ /** 백엔드 주소에서 웹 주소를 얻는다. 운영은 같은 도메인 뒤에 `/_/backend` 가 붙는다. */
150
+ function webBase(api) {
151
+ if (process.env.Z2O_WEB) return process.env.Z2O_WEB.replace(/\/+$/, '');
152
+ if (/^https?:\/\/(localhost|127\.0\.0\.1):8000$/.test(api)) return api.replace(':8000', ':3000');
153
+ return api.replace(/\/_\/backend$/, '');
154
+ }
155
+
156
+ function openBrowser(url) {
157
+ const [cmd, args] =
158
+ process.platform === 'darwin' ? ['open', [url]]
159
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
160
+ : ['xdg-open', [url]];
161
+ try {
162
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
163
+ p.on('error', () => { /* 못 열어도 주소를 찍어 두었으니 사람이 연다 */ });
164
+ p.unref();
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ const DONE_HTML = (msg) => `<!doctype html><meta charset="utf-8"><title>제로투원</title>
172
+ <body style="font-family:-apple-system,system-ui,sans-serif;background:#f6f6f2;display:grid;place-items:center;height:100vh;margin:0">
173
+ <div style="text-align:center;color:#34322e"><div style="font-size:18px;font-weight:600">${msg}</div>
174
+ <div style="margin-top:8px;font-size:13px;opacity:.55">이 창은 닫아도 돼요.</div></div></body>`;
175
+
176
+ /** 브라우저에서 허용받아 토큰을 저장한다. 워크스페이스를 매었으면 그것도 정해 둔다. */
177
+ async function browserLogin({ paste = false } = {}) {
178
+ const { api } = settings();
179
+ const web = webBase(api);
180
+ const verifier = b64url(randomBytes(32));
181
+ const challenge = b64url(createHash('sha256').update(verifier).digest());
182
+ const state = b64url(randomBytes(16));
183
+ const q = new URLSearchParams({ cc: challenge, state, name: `${hostname().replace(/\.local$/, '')} · z2o` });
184
+
185
+ let code;
186
+ if (paste) {
187
+ // 브라우저가 다른 기기에 있다(원격 서버·SSH). 여기로 돌아올 길이 없으니 코드를 옮겨 적는다.
188
+ q.set('mode', 'paste');
189
+ console.log(`이 주소를 브라우저에서 열고 「허용」을 누르세요:\n\n ${web}/cli-login?${q}\n`);
190
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
191
+ code = (await rl.question('화면에 뜬 연결 코드를 붙여 넣으세요: ')).trim();
192
+ rl.close();
193
+ } else {
194
+ code = await new Promise((resolve, reject) => {
195
+ // 🔑 **내 컴퓨터에서만 받는다**(127.0.0.1). 포트는 비어 있는 아무 곳 — 고정하면 겹친다.
196
+ const server = createServer((req, res) => {
197
+ const u = new URL(req.url || '/', 'http://127.0.0.1');
198
+ if (u.pathname !== '/callback') {
199
+ res.writeHead(404).end();
200
+ return;
201
+ }
202
+ // ⚠️ `state` 가 다르면 이 창에서 시작한 요청이 아니다 — 받지 않는다.
203
+ const got = u.searchParams.get('state') === state ? u.searchParams.get('code') : null;
204
+ res.writeHead(got ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
205
+ res.end(DONE_HTML(got ? '연결됐어요. 터미널로 돌아가세요.' : '요청이 맞지 않아요. 터미널에서 z2o login 을 다시 해 주세요.'));
206
+ if (!got) return;
207
+ clearTimeout(timer);
208
+ server.close();
209
+ resolve(got);
210
+ });
211
+ const timer = setTimeout(() => {
212
+ server.close();
213
+ reject(new Error('5분 안에 허용하지 않아 멈췄어요. `z2o login` 을 다시 해 주세요.'));
214
+ }, 5 * 60 * 1000);
215
+ server.on('error', reject);
216
+ server.listen(0, '127.0.0.1', () => {
217
+ q.set('port', String(server.address().port));
218
+ const url = `${web}/cli-login?${q}`;
219
+ console.log(openBrowser(url)
220
+ ? '브라우저에서 제로투원을 열었어요. 「허용」을 누르면 여기로 돌아옵니다.'
221
+ : '브라우저를 열지 못했어요.');
222
+ console.log(`\n안 열리면 이 주소를 직접 여세요:\n ${url}\n`);
223
+ console.log('(브라우저가 다른 기기에 있으면 Ctrl+C 로 멈추고 `z2o login --paste`)\n');
224
+ });
225
+ });
226
+ }
227
+ if (!code) die('연결 코드가 비었어요.');
228
+
229
+ let res;
230
+ try {
231
+ res = await fetch(`${api}/api/tokens/cli/exchange`, {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({ code, code_verifier: verifier }),
235
+ });
236
+ } catch (e) {
237
+ die(`서버에 닿지 못했어요 (${api}).\n ${e.message}`);
238
+ }
239
+ const body = await res.json().catch(() => ({}));
240
+ if (!res.ok || !body.token) die(body.detail || `서버가 ${res.status} 로 답했어요.`);
241
+
242
+ // 워크스페이스에 매인 토큰이면 그걸 기본으로 — `z2o use` 를 따로 안 해도 된다.
243
+ saveConf({ ...loadConf(), token: body.token, ...(body.company_id ? { companyId: body.company_id } : {}) });
244
+ console.log(
245
+ `연결했어요 — ${body.company_name || '내가 볼 수 있는 워크스페이스 전부'} · ` +
246
+ `${body.scope === 'write' ? '자료·정리까지' : '읽기만'}`,
247
+ );
248
+ return body;
249
+ }
250
+
134
251
  /** 회사 id 를 정한다. `-c` > 환경변수 > 설정 파일 순. */
135
252
  function needCompany(flags) {
136
253
  const id = flags.c || settings().companyId;
@@ -245,6 +362,7 @@ function parseArgs(argv) {
245
362
  const a = argv[i];
246
363
  if (a === '-c' || a === '--company') flags.c = argv[++i];
247
364
  else if (a === '-g' || a === '--global') flags.global = true;
365
+ else if (a === '--paste') flags.paste = true;
248
366
  else if (a === '-h' || a === '--help') flags.help = true;
249
367
  else rest.push(a);
250
368
  }
@@ -253,9 +371,11 @@ function parseArgs(argv) {
253
371
 
254
372
  const HELP = `z2o — 회사 온톨로지를 내가 쓰는 AI에서 꺼내 쓴다
255
373
 
256
- z2o init [토큰] 한 번에 붙이기 — 로그인 + 워크스페이스 + AI 규칙파일
374
+ z2o init 한 번에 붙이기 — 브라우저 로그인 + 워크스페이스 + AI 규칙파일
257
375
  z2o init -g 규칙을 **모든 폴더**에 (~/.claude 등). 폴더마다 안 해도 됨
258
- z2o login <토큰> 토큰만 저장
376
+ z2o login 브라우저에서 「허용」 한 번으로 연결 (토큰을 볼 일이 없다)
377
+ z2o login --paste 브라우저가 다른 기기에 있을 때 — 화면의 코드를 붙여 넣는다
378
+ z2o login <토큰> 발급받은 토큰을 직접 넣기
259
379
  z2o logout 저장한 토큰 지우기
260
380
  z2o whoami 지금 어떻게 붙어 있나
261
381
 
@@ -272,6 +392,7 @@ const HELP = `z2o — 회사 온톨로지를 내가 쓰는 AI에서 꺼내 쓴
272
392
  z2o recent [개수] 최근 들어온 원본
273
393
  z2o find <낱말> 원본에서 찾기
274
394
  z2o raw <id...> 원본 본문 (id 는 recent·find 가 준 것)
395
+ z2o sync [slack|tiro|...] 지금 당겨오기 — 매시 자동 수집을 기다리지 않고 (쓰기 토큰)
275
396
 
276
397
  ── 정리를 내 AI 가 한다 ── (쓰기 토큰 필요)
277
398
  z2o rules 이 워크스페이스의 위키 규칙 — **쓰기 전에 먼저**
@@ -284,14 +405,23 @@ const HELP = `z2o — 회사 온톨로지를 내가 쓰는 AI에서 꺼내 쓴
284
405
 
285
406
  -c, --company <id> 이번만 다른 워크스페이스로
286
407
  -g, --global init 을 모든 폴더에 적용
408
+ --paste login·init 을 코드 붙여넣기로
287
409
 
288
410
  환경 변수: Z2O_API · Z2O_TOKEN · Z2O_COMPANY (설정 파일보다 우선)`;
289
411
 
290
412
  // ── 명령 ────────────────────────────────────────────────
291
413
 
292
414
  const COMMANDS = {
293
- async login([token]) {
294
- if (!token) die('토큰을 함께 적어 주세요.\n z2o login z2o_live_...');
415
+ async login([token], flags = {}) {
416
+ // 토큰을 안 주면 브라우저로 — 이게 기본 길이다. 토큰을 직접 넣는 길은 남겨 둔다(CI·스크립트).
417
+ if (!token) {
418
+ await browserLogin({ paste: flags.paste });
419
+ if (!settings().companyId) {
420
+ console.log('\n' + (await callTool('list_companies', {})));
421
+ console.log('\n`z2o use <id 또는 이름>` 으로 기본 워크스페이스를 정해 두세요.');
422
+ }
423
+ return;
424
+ }
295
425
  if (!TOKEN_RE.test(token)) {
296
426
  die('토큰 모양이 아니에요. `z2o_live_` 로 시작하는 한 줄이어야 합니다.');
297
427
  }
@@ -314,15 +444,12 @@ const COMMANDS = {
314
444
  const [token] = args;
315
445
  const { token: saved } = settings();
316
446
 
317
- // ① 로그인 — 토큰을 줬으면 그걸로, 이미 붙어 있으면 그대로.
447
+ // ① 로그인 — 토큰을 줬으면 그걸로, 없으면 브라우저로, 이미 붙어 있으면 그대로.
318
448
  if (token) {
319
449
  await COMMANDS.login([token]);
320
450
  } else if (!saved) {
321
- die(
322
- '먼저 열쇠가 필요해요.\n' +
323
- ' 1. https://z2o.kr 로그인 → 오른쪽 위 프로필 → 「연결 토큰」 → 발급\n' +
324
- ' 2. z2o init <발급받은 열쇠>',
325
- );
451
+ await browserLogin({ paste: flags.paste });
452
+ console.log('');
326
453
  } else {
327
454
  console.log('이미 연결돼 있어요.\n');
328
455
  }
@@ -561,6 +688,13 @@ const COMMANDS = {
561
688
  async organize(_args, flags) {
562
689
  console.log(await callTool('organize', { company_id: needCompany(flags) }));
563
690
  },
691
+
692
+ /** 지금 당겨오기. 자동 수집(매시 정각)을 기다리지 않고 방금 것을 가져온다. */
693
+ async sync([source], flags) {
694
+ const ok = ['slack', 'tiro', 'notion', 'calendar'];
695
+ if (source && !ok.includes(source)) die(`고를 수 있는 것: ${ok.join(' · ')} (비우면 전부)`);
696
+ console.log(await callTool('sync_sources', { company_id: needCompany(flags), ...(source ? { source } : {}) }));
697
+ },
564
698
  };
565
699
 
566
700
  // ── 진입점 ──────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "z2o-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "제로투원 — 회사 온톨로지와 슬랙 맥락을 내가 쓰는 AI에서 꺼내 쓴다",
5
5
  "keywords": [
6
6
  "z2o",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "z2o",
4
- "version": "0.7.0",
4
+ "version": "0.9.0",
5
5
  "description": "제로투원 — 회사 위키·슬랙·회의록을 읽고(/z2o:read) 위키에 정리해 넣는다(/z2o:ingest). z2o CLI 를 감싼다.",
6
6
  "author": {
7
7
  "name": "Lindy Operation"
@@ -31,7 +31,8 @@ z2o push "<제목>" <파일> 없을 때만
31
31
  ```
32
32
 
33
33
  - ⚠️ **슬랙·티로·캘린더는 매시 자동 수집된다.** 회의록을 손으로 또 넣으면 **같은 회의가 두 벌**이 된다.
34
- `find` 로 찾아지면 넣지 않는다. 방금 끝난 회의면 시간 다시 본다.
34
+ `find` 로 찾아지면 넣지 않는다. 방금 끝난 회의면 **`z2o sync` 지금 당겨온 뒤** 다시 `find` 한다
35
+ (티로는 요약이 만들어진 뒤에야 들어온다 — 그래도 없으면 그때 `push`).
35
36
  - 원본은 **한 글자도 고치지 않고** 넣는다. 요약은 넣지 않는다 — 요약은 5단계에서 위키에 쓴다.
36
37
  - 비밀번호·토큰·계좌번호가 보이면 **넣기 전에 사람에게 묻는다.**
37
38
 
@@ -22,6 +22,8 @@ allowed-tools:
22
22
  - **오늘·이번 주·"슬랙에서"** 같은 요청 → 원본부터. `recent` 로 훑고 `raw` 로 읽는다.
23
23
  - **"~가 뭐였지", "~ 현황"** 같은 요청 → 위키부터.
24
24
  - 둘 다 본 뒤 **어긋나면 둘 다 말한다.** 원본이 더 최신이다 — 위키는 정리된 시점에 멈춰 있다.
25
+ - **방금 것이 안 보이면** — 자동 수집은 매시 정각이다. 사람에게 *"지금 당겨올까요?"* 묻고,
26
+ 좋다면 `z2o sync` (하나만이면 `z2o sync tiro`) 뒤 다시 `recent`. 읽기 토큰이면 안 되니 정각을 기다리라고 알린다.
25
27
 
26
28
  ## 위키 찾는 법
27
29