z2o-cli 0.4.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.
Files changed (3) hide show
  1. package/README.md +61 -0
  2. package/index.js +525 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # z2o
2
+
3
+ 회사 온톨로지를 **내가 쓰는 AI에서 꺼내 쓴다.** Z2O 웹을 안 열어도 된다.
4
+
5
+ ```bash
6
+ npm i -g z2o-cli
7
+ z2o init z2o_live_... # 설정 → 「팀 · 연결」에서 발급한 열쇠
8
+ ```
9
+
10
+ `init` 이 세 가지를 한 번에 한다 — **로그인 · 워크스페이스 지정 · AI 규칙 파일에 사용법 심기.**
11
+
12
+ 마지막이 핵심이다: 명령어를 깔아 둬도 **AI 는 그게 있는 줄 모른다.** 규칙 파일에 한 번
13
+ 적어 두면 그다음부터 알아서 쓴다. 여러 번 돌려도 블록은 하나만 남고 기존 내용은 안 건드린다.
14
+
15
+ 이미 있는 `CLAUDE.md` · `AGENTS.md` · `GEMINI.md` 에 전부 심고, 하나도 없으면
16
+ `AGENTS.md` 와 `CLAUDE.md` 를 만든다. 다른 도구를 쓰면 **그 도구의 규칙 파일을 만들어 두고
17
+ `z2o init` 을 다시 돌리면** 된다.
18
+
19
+ **폴더마다 하기 싫으면 `z2o init -g`** — `~/.claude/CLAUDE.md` 처럼 도구의 전역 자리에
20
+ 심어 **모든 폴더에서** 통하게 한다. (깔린 도구의 자리에만 심는다.)
21
+
22
+ ⚠️ 규칙 파일은 **편의**일 뿐이다. 없어도 CLI 는 셸을 쓸 수 있는 AI 라면 어디서든 돌아간다 —
23
+ 한 번 "z2o 로 찾아봐" 라고 말해 주면 그만이다.
24
+
25
+ ```bash
26
+ z2o recent # 최근 들어온 슬랙·메일
27
+ z2o find 계약 # 원본에서 찾기
28
+ ```
29
+
30
+ Claude Code·Cursor·Codex 등 **셸을 쓸 수 있는 AI라면 무엇에서든** 된다.
31
+ MCP 와 달리 앱마다 연동 설정이 필요 없고, 토큰이 만료되지 않아 한 번 연결해 두면 계속 이어진다.
32
+
33
+ ## 명령
34
+
35
+ | | |
36
+ |---|---|
37
+ | `z2o init [토큰]` | 한 번에 붙이기 — 로그인 + 워크스페이스 + AI 규칙 파일 |
38
+ | `z2o init -g` | 규칙을 **모든 폴더**에 (`~/.claude` 등). 폴더마다 안 해도 된다 |
39
+ | `z2o login <토큰>` | 토큰만 저장 (`~/.z2o/config.json`, 0600) |
40
+ | `z2o whoami` | 지금 누구로 붙어 있나 |
41
+ | `z2o companies` | 워크스페이스 목록 |
42
+ | `z2o use <id 또는 이름>` | 기본 워크스페이스 |
43
+ | `z2o list [접두어]` | 문서 목록 (`docs/`·`ledger/` 처럼 걸러서) |
44
+ | `z2o search <낱말>` | 본문 검색 |
45
+ | `z2o read <경로...>` | 문서 본문 |
46
+ | `z2o graph` | 문서가 서로 어떻게 이어져 있는지 |
47
+ | `z2o recent [개수]` | **정리 전 원본** — 슬랙·메일이 들어온 그대로 |
48
+ | `z2o find <낱말>` | 원본에서 찾기 |
49
+ | `z2o raw <id...>` | 원본 본문 |
50
+ | `z2o push <제목> <파일\|-\>` | 자료 넣기 — **쓰기 토큰 필요** |
51
+ | `z2o organize` | 정리 돌리기 — **쓰기 토큰 필요** |
52
+
53
+ `-c <id>` 로 워크스페이스를 그때만 바꿀 수 있다.
54
+
55
+ ## 환경 변수
56
+
57
+ | | |
58
+ |---|---|
59
+ | `Z2O_API` | 서버 주소 (기본 `https://z2o.kr/_/backend`) |
60
+ | `Z2O_TOKEN` | 토큰. 설정 파일보다 우선 — CI 에서 쓴다 |
61
+ | `Z2O_COMPANY` | 워크스페이스 id |
package/index.js ADDED
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * z2o — 회사 온톨로지를 내가 쓰는 AI에서 꺼내 쓰는 명령어.
4
+ *
5
+ * 🔑 **속은 MCP 엔드포인트 하나를 부른다. REST 를 따로 안 부른다.**
6
+ *
7
+ * MCP 응답은 이미 **사람 말**이다(`- docs/a.md — 물류 회의 (3,761자)`). 읽는 쪽이
8
+ * LLM 이라 그렇게 만들어 뒀는데, 터미널에 그대로 찍어도 읽기 좋은 글이다. REST 를
9
+ * 부르면 JSON 을 여기서 다시 사람 말로 옮겨야 하고, **그 순간 MCP 와 CLI 의 말이
10
+ * 갈라지기 시작한다.** 한 곳에서 나오게 두면 영영 안 갈라진다.
11
+ *
12
+ * 🔑 **의존성이 없다.** node 18 부터 `fetch` 가 내장이라 설치가 `npm i -g z2o` 하나로 끝난다.
13
+ * MCP 의 진입장벽(앱마다 JSON 설정을 손으로 고치는 것)을 없애려고 만든 것이므로,
14
+ * 이쪽 설치가 무거우면 만든 이유가 없어진다.
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ const DEFAULT_API = 'https://z2o.kr/_/backend';
22
+ // 발급되는 토큰의 모양. `z2o_live_` + base64url.
23
+ //
24
+ // ⚠️ **보내기 전에 여기서 막는다.** `fetch` 는 헤더에 ASCII 밖 글자가 있으면
25
+ // "Cannot convert argument to a ByteString…" 같은 말로 죽는데, 사람은 그게
26
+ // 토큰 탓인 줄 모른다. 터미널에 붙여 넣다 보면 스마트 따옴표나 줄바꿈이
27
+ // 딸려 오는 일이 실제로 있다.
28
+ const TOKEN_RE = /^z2o_live_[A-Za-z0-9_-]+$/;
29
+ const CONF_DIR = join(homedir(), '.z2o');
30
+ const CONF_PATH = join(CONF_DIR, 'config.json');
31
+ // 명령어 이름이 한 번 `ztoo` 였다(2026-09-10). **그때 로그인한 사람이 안 끊기게**
32
+ // 옛 자리도 읽는다 — 저장은 언제나 새 자리로 하므로 한 번 쓰면 저절로 옮겨진다.
33
+ const OLD_CONF_PATH = join(homedir(), '.ztoo', 'config.json');
34
+
35
+ // ── 설정 ────────────────────────────────────────────────
36
+
37
+ function loadConf() {
38
+ for (const p of [CONF_PATH, OLD_CONF_PATH]) {
39
+ try {
40
+ return JSON.parse(readFileSync(p, 'utf8'));
41
+ } catch {
42
+ /* 다음 자리 */
43
+ }
44
+ }
45
+ return {};
46
+ }
47
+
48
+ function saveConf(conf) {
49
+ mkdirSync(CONF_DIR, { recursive: true });
50
+ writeFileSync(CONF_PATH, JSON.stringify(conf, null, 2) + '\n');
51
+ // ⚠️ **남이 못 읽게 한다.** 토큰이 평문으로 있는 파일이고, 홈 디렉터리는 기본이
52
+ // 0755 라 같은 기기의 다른 계정에서 읽힌다.
53
+ try {
54
+ chmodSync(CONF_PATH, 0o600);
55
+ } catch {
56
+ /* 윈도우 등 권한 모델이 다른 곳 — 저장 자체를 막지는 않는다 */
57
+ }
58
+ }
59
+
60
+ /** 환경 변수가 설정 파일을 이긴다 — CI 나 일회성 실행에서 갈아끼울 수 있게. */
61
+ function settings() {
62
+ const conf = loadConf();
63
+ return {
64
+ api: (process.env.Z2O_API || conf.api || DEFAULT_API).replace(/\/+$/, ''),
65
+ token: process.env.Z2O_TOKEN || conf.token || '',
66
+ companyId: process.env.Z2O_COMPANY || conf.companyId || '',
67
+ conf,
68
+ };
69
+ }
70
+
71
+ // ── 서버 부르기 ──────────────────────────────────────────
72
+
73
+ function die(msg, code = 1) {
74
+ process.stderr.write(msg.replace(/\n?$/, '\n'));
75
+ process.exit(code);
76
+ }
77
+
78
+ async function rpc(method, params, { token: override } = {}) {
79
+ const { api, token: saved } = settings();
80
+ // `login` 은 **아직 저장하지 않은** 토큰으로 확인해야 한다 — 그래서 넘겨받는 길을 둔다.
81
+ const token = override || saved;
82
+ if (!token) {
83
+ die('연결된 토큰이 없어요.\n z2o.kr → 오른쪽 위 프로필 → 「연결 토큰」에서 발급한 뒤 `z2o init <토큰>` 하세요.');
84
+ }
85
+
86
+ if (token && !TOKEN_RE.test(token)) {
87
+ die('토큰에 이상한 글자가 섞여 있어요. 따옴표나 줄바꿈이 딸려 오지 않았는지 확인해 주세요.');
88
+ }
89
+
90
+ let res;
91
+ try {
92
+ res = await fetch(`${api}/api/mcp`, {
93
+ method: 'POST',
94
+ headers: {
95
+ 'Content-Type': 'application/json',
96
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
97
+ },
98
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
99
+ });
100
+ } catch (e) {
101
+ die(`서버에 닿지 못했어요 (${api}).\n ${e.message}`);
102
+ }
103
+
104
+ if (res.status === 401) die('토큰이 유효하지 않거나 끊겼어요. `z2o login <토큰>` 으로 다시 연결해 주세요.');
105
+ if (res.status === 403) {
106
+ // 읽기 전용 토큰으로 쓰기를 시도한 경우. 서버 문구를 그대로 전한다.
107
+ let detail = '';
108
+ try {
109
+ detail = (await res.json())?.detail || '';
110
+ } catch { /* 본문이 JSON 이 아닐 수 있다 */ }
111
+ die(detail || '이 토큰으로는 할 수 없는 일이에요. 쓰기 토큰이 필요합니다.');
112
+ }
113
+ if (!res.ok) die(`서버가 ${res.status} 로 답했어요.`);
114
+
115
+ const body = await res.json();
116
+ if (body.error) die(`요청이 거절됐어요: ${body.error.message || body.error.code}`);
117
+ return body.result || {};
118
+ }
119
+
120
+ /** 도구 하나를 부르고 **글만** 돌려준다. MCP 응답은 이미 사람 말이라 손대지 않는다. */
121
+ async function callTool(name, args, opts) {
122
+ const result = await rpc('tools/call', { name, arguments: args }, opts);
123
+ const text = (result.content || [])
124
+ .filter((b) => b.type === 'text')
125
+ .map((b) => b.text)
126
+ .join('\n');
127
+ // 🔑 **도구가 「안 됐다」고 답한 것도 종료 코드로 옮긴다.** 셸에서 `&&` 로 이어
128
+ // 쓰는 사람이 성공/실패를 알아야 한다 — 글만 보고는 스크립트가 못 가린다.
129
+ if (result.isError) die(text || '요청을 처리하지 못했어요.');
130
+ return text;
131
+ }
132
+
133
+ /** 회사 id 를 정한다. `-c` > 환경변수 > 설정 파일 순. */
134
+ function needCompany(flags) {
135
+ const id = flags.c || settings().companyId;
136
+ if (!id) {
137
+ die('워크스페이스를 안 정했어요.\n `z2o companies` 로 목록을 보고 `z2o use <id>` 하세요.');
138
+ }
139
+ return id;
140
+ }
141
+
142
+ // ── AI 에게 알려주기 ─────────────────────────────────────
143
+ //
144
+ // 🔑 **이게 `init` 의 핵심이다.** 명령어를 깔아 둬도 **AI 는 그게 있는 줄 모른다.**
145
+ // 사람이 매번 "z2o 라는 게 있어" 라고 말해줘야 하는데, 규칙 파일에 한 번 적어 두면
146
+ // 그다음부터는 알아서 쓴다. 설치와 로그인만으로는 다리가 안 이어진다.
147
+
148
+ // 🌍 **도구마다 「모든 폴더에 적용되는」 규칙 파일 자리가 따로 있다.**
149
+ // 여기 한 번 심으면 폴더마다 `init` 을 돌릴 필요가 없다.
150
+ const GLOBAL_RULES = [
151
+ ['.claude', 'CLAUDE.md'],
152
+ ['.codex', 'AGENTS.md'],
153
+ ['.gemini', 'GEMINI.md'],
154
+ ];
155
+
156
+ const MARK_START = '<!-- z2o:start -->';
157
+ const MARK_END = '<!-- z2o:end -->';
158
+ // ⚠️ **옛 이름으로 심어 둔 블록도 알아봐야 한다.** 못 알아보면 갈아끼우지 못하고
159
+ // **같은 내용이 두 벌** 남는다.
160
+ const OLD_MARKS = [['<!-- ztoo:start -->', '<!-- ztoo:end -->']];
161
+
162
+ const BLOCK = `${MARK_START}
163
+ ## 회사 맥락 (z2o)
164
+
165
+ 슬랙·회의록·위키를 \`z2o\` 명령으로 꺼낸다. **사람에게 복사해 달라고 하지 않는다.**
166
+
167
+ | 정리 전 원본 | |
168
+ |---|---|
169
+ | \`z2o recent\` | 최근 들어온 것 (슬랙·메일) |
170
+ | \`z2o find <낱말>\` | 원본에서 찾기 |
171
+ | \`z2o raw <id>\` | 원본 본문 |
172
+
173
+ | 정리된 위키 | |
174
+ |---|---|
175
+ | \`z2o search <낱말>\` | 본문 검색 |
176
+ | \`z2o read <경로>\` | 문서 본문 |
177
+ | \`z2o list [접두어]\` | 문서 목록 |
178
+
179
+ **정리를 부탁받으면 내가 한다** — Z2O 의 AI 에게 맡기지 않는다.
180
+
181
+ | | |
182
+ |---|---|
183
+ | \`z2o push <제목> <파일>\` | 원본 넣기. 장부·표는 코드가 알아서 만든다 |
184
+ | \`z2o pending\` | 요약이 필요한 원본 |
185
+ | \`z2o write <경로> <파일>\` | 관련 문서를 읽고 고쳐 쓴 요약을 남긴다 |
186
+
187
+ 자세한 것은 \`z2o --help\`.
188
+ ${MARK_END}`;
189
+
190
+ /** 규칙 파일에 블록을 심는다. **두 번 해도 하나만 남는다.** */
191
+ function writeRules(file) {
192
+ const had = existsSync(file);
193
+ const before = had ? readFileSync(file, 'utf8') : '';
194
+ let [ms, me] = [MARK_START, MARK_END];
195
+ if (before.indexOf(ms) < 0) {
196
+ const hit = OLD_MARKS.find(([a, b]) => before.indexOf(a) >= 0 && before.indexOf(b) > before.indexOf(a));
197
+ if (hit) [ms, me] = hit;
198
+ }
199
+ const i = before.indexOf(ms);
200
+ const j = before.indexOf(me);
201
+ let after;
202
+ if (i >= 0 && j > i) {
203
+ // 이미 있으면 **갈아끼운다** — 규격이 바뀌어도 따라온다.
204
+ after = before.slice(0, i) + BLOCK + before.slice(j + me.length);
205
+ if (after === before) return { file, state: '그대로' };
206
+ writeFileSync(file, after);
207
+ return { file, state: '갱신' };
208
+ }
209
+ after = before.trimEnd() + (before.trim() ? '\n\n' : '') + BLOCK + '\n';
210
+ writeFileSync(file, after);
211
+ return { file, state: had ? '추가' : '새로 만듦' };
212
+ }
213
+
214
+ // ── 인자 파싱 ────────────────────────────────────────────
215
+ // 의존성을 안 두기로 했으므로 직접 읽는다. 필요한 건 `-c <id>` 하나뿐이다.
216
+
217
+ function parseArgs(argv) {
218
+ const flags = {};
219
+ const rest = [];
220
+ for (let i = 0; i < argv.length; i++) {
221
+ const a = argv[i];
222
+ if (a === '-c' || a === '--company') flags.c = argv[++i];
223
+ else if (a === '-g' || a === '--global') flags.global = true;
224
+ else if (a === '-h' || a === '--help') flags.help = true;
225
+ else rest.push(a);
226
+ }
227
+ return { flags, rest };
228
+ }
229
+
230
+ const HELP = `z2o — 회사 온톨로지를 내가 쓰는 AI에서 꺼내 쓴다
231
+
232
+ z2o init [토큰] 한 번에 붙이기 — 로그인 + 워크스페이스 + AI 규칙파일
233
+ z2o init -g 규칙을 **모든 폴더**에 (~/.claude 등). 폴더마다 안 해도 됨
234
+ z2o login <토큰> 토큰만 저장
235
+ z2o logout 저장한 토큰 지우기
236
+ z2o whoami 지금 어떻게 붙어 있나
237
+
238
+ z2o companies 워크스페이스 목록
239
+ z2o use <id 또는 이름> 기본 워크스페이스 정하기
240
+
241
+ ── 정리된 문서 ──
242
+ z2o list [접두어] 문서 목록 (예: z2o list ledger/)
243
+ z2o search <낱말> 본문 검색
244
+ z2o read <경로...> 문서 본문
245
+ z2o graph 문서가 서로 어떻게 이어져 있는지
246
+
247
+ ── 정리 전 원본 ── 슬랙·메일이 들어온 그대로
248
+ z2o recent [개수] 최근 들어온 원본
249
+ z2o find <낱말> 원본에서 찾기
250
+ z2o raw <id...> 원본 본문 (id 는 recent·find 가 준 것)
251
+
252
+ ── 정리를 내 AI 가 한다 ── (쓰기 토큰 필요)
253
+ z2o push <제목> <파일|-> 원본 넣기 — 장부·표는 코드가 알아서 만든다
254
+ z2o pending 요약이 필요한 원본
255
+ z2o write <경로> <파일|-> 요약을 위키에 남기기 (예: topics/물류.md)
256
+ z2o organize Z2O 의 AI 에게 정리를 맡긴다 (직접 안 쓸 때)
257
+
258
+ -c, --company <id> 이번만 다른 워크스페이스로
259
+ -g, --global init 을 모든 폴더에 적용
260
+
261
+ 환경 변수: Z2O_API · Z2O_TOKEN · Z2O_COMPANY (설정 파일보다 우선)`;
262
+
263
+ // ── 명령 ────────────────────────────────────────────────
264
+
265
+ const COMMANDS = {
266
+ async login([token]) {
267
+ if (!token) die('토큰을 함께 적어 주세요.\n z2o login z2o_live_...');
268
+ if (!TOKEN_RE.test(token)) {
269
+ die('토큰 모양이 아니에요. `z2o_live_` 로 시작하는 한 줄이어야 합니다.');
270
+ }
271
+ // 🔑 **저장하기 전에 한 번 써 본다.** 틀린 토큰을 저장해 두면 나중에 엉뚱한
272
+ // 명령에서 실패하고, 사람은 그게 토큰 탓인 줄 모른다. 확인에 실패하면
273
+ // `callTool` 이 여기서 끝내므로 **잘못된 토큰이 파일에 남지 않는다.**
274
+ const text = await callTool('list_companies', {}, { token });
275
+ saveConf({ ...loadConf(), token });
276
+ console.log('연결했어요.\n');
277
+ console.log(text);
278
+ if (!settings().companyId) console.log('\n`z2o use <id>` 로 기본 워크스페이스를 정해 두세요.');
279
+ },
280
+
281
+ /** 한 번에 다 붙인다 — 설치 뒤 이것만 하면 된다.
282
+ *
283
+ * 🔑 **로그인만으로는 부족하다.** AI 가 `z2o` 의 존재를 모르기 때문에, 사람이 매번
284
+ * "z2o 라는 게 있어" 라고 말해줘야 한다. 규칙 파일에 적어 두는 것까지가 연결이다.
285
+ */
286
+ async init(args, flags) {
287
+ const [token] = args;
288
+ const { token: saved } = settings();
289
+
290
+ // ① 로그인 — 토큰을 줬으면 그걸로, 이미 붙어 있으면 그대로.
291
+ if (token) {
292
+ await COMMANDS.login([token]);
293
+ } else if (!saved) {
294
+ die(
295
+ '먼저 열쇠가 필요해요.\n' +
296
+ ' 1. https://z2o.kr 로그인 → 오른쪽 위 프로필 → 「연결 토큰」 → 발급\n' +
297
+ ' 2. z2o init <발급받은 열쇠>',
298
+ );
299
+ } else {
300
+ console.log('이미 연결돼 있어요.\n');
301
+ }
302
+
303
+ // ② 워크스페이스 — 하나뿐이면 묻지 않고 정한다.
304
+ let needPick = false;
305
+ if (!settings().companyId) {
306
+ const rows = (await callTool('list_companies', {}))
307
+ .split('\n')
308
+ .map((l) => l.match(/^- (\S+) — (.+?)(?: \(초대받음.*\))?$/))
309
+ .filter(Boolean);
310
+ if (rows.length === 1) {
311
+ saveConf({ ...loadConf(), companyId: rows[0][1] });
312
+ console.log(`워크스페이스: ${rows[0][2]}`);
313
+ } else if (rows.length > 1) {
314
+ // ⚠️ **여럿이면 고르지 않는다.** 엉뚱한 회사에 자료를 넣게 되는 쪽이 훨씬 나쁘다.
315
+ needPick = true;
316
+ console.log('워크스페이스가 여럿이에요. 하나 고르세요:\n');
317
+ for (const r of rows) console.log(` ${r[1]} — ${r[2]}`);
318
+ console.log('');
319
+ } else {
320
+ needPick = true;
321
+ console.log('쓸 수 있는 워크스페이스가 없어요. z2o.kr 에서 만들거나 초대받으세요.\n');
322
+ }
323
+ }
324
+
325
+ // ③ AI 에게 알려주기 — 여기가 핵심이다.
326
+ if (flags.global) {
327
+ // 도구가 깔린 곳에만 심는다 — 안 쓰는 도구의 설정 폴더를 만들어 두지 않는다.
328
+ const hit = GLOBAL_RULES.filter(([d]) => existsSync(join(homedir(), d)));
329
+ if (hit.length === 0) hit.push(['.claude', 'CLAUDE.md']);
330
+ for (const [d, f] of hit) {
331
+ const dir = join(homedir(), d);
332
+ mkdirSync(dir, { recursive: true });
333
+ const r = writeRules(join(dir, f));
334
+ console.log(`~/${d}/${f} — ${r.state}`);
335
+ }
336
+ console.log(' (모든 폴더에서 통합니다)');
337
+ console.log('\n이제 AI 에게 그냥 물어보세요:');
338
+ console.log(' "최근 슬랙에서 나온 얘기 정리해줘"');
339
+ if (needPick) {
340
+ console.log('\n🔴 아직 한 걸음 남았어요:');
341
+ console.log(' z2o use <id 또는 이름>');
342
+ }
343
+ return;
344
+ }
345
+
346
+ //
347
+ // 🔑 **도구마다 읽는 파일이 다르다.** 이미 있는 것에는 전부 심고, 하나도 없으면
348
+ // `AGENTS.md` 를 만든다 — 여러 도구가 함께 읽기로 한 이름이라 한 장으로 제일
349
+ // 넓게 닿는다. Claude 를 쓰는 사람이 많으므로 `CLAUDE.md` 도 같이 만든다.
350
+ const KNOWN = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'];
351
+ const files = KNOWN.filter((f) => existsSync(f));
352
+ if (files.length === 0) files.push('AGENTS.md', 'CLAUDE.md');
353
+ for (const f of files) {
354
+ const r = writeRules(f);
355
+ console.log(`${r.file} — ${r.state}`);
356
+ }
357
+ console.log(
358
+ ' (다른 도구를 쓰면 그 도구의 규칙 파일을 만들어 두고 `z2o init` 을 다시 돌리세요)',
359
+ );
360
+
361
+ // ⚠️ **다 된 것처럼 끝내지 않는다.** 워크스페이스를 못 정했으면 아직 아무것도 안 된다 —
362
+ // 여기서 "이제 물어보세요" 라고 하면 사람은 됐다고 믿고 넘어간다.
363
+ if (needPick) {
364
+ console.log('\n🔴 아직 한 걸음 남았어요:');
365
+ console.log(' z2o use <id 또는 이름> (예: z2o use 린디)');
366
+ } else {
367
+ console.log('\n이제 AI 에게 그냥 물어보세요:');
368
+ console.log(' "최근 슬랙에서 나온 얘기 정리해줘"');
369
+ console.log(' "지난주 회의 내용 찾아서 할 일 뽑아줘"');
370
+ }
371
+ void flags;
372
+ },
373
+
374
+ async logout() {
375
+ const conf = loadConf();
376
+ delete conf.token;
377
+ saveConf(conf);
378
+ console.log('저장한 토큰을 지웠어요. (서버 쪽에서 끊으려면 프로필 → 「연결 토큰」에서 끊으세요.)');
379
+ },
380
+
381
+ async whoami() {
382
+ const { api, token, companyId } = settings();
383
+ console.log(`서버 ${api}`);
384
+ console.log(`토큰 ${token ? token.slice(0, 17) + '…' : '(없음)'}`);
385
+ console.log(`워크스페이스 ${companyId || '(안 정함)'}`);
386
+ if (!token) return;
387
+ console.log('');
388
+ console.log(await callTool('list_companies', {}));
389
+ },
390
+
391
+ async companies() {
392
+ console.log(await callTool('list_companies', {}));
393
+ },
394
+
395
+ async use([q]) {
396
+ if (!q) die('워크스페이스 id 나 이름 일부를 적어 주세요.');
397
+ const text = await callTool('list_companies', {});
398
+ // 목록 줄은 `- <id> — <이름>` 모양이다.
399
+ const rows = text
400
+ .split('\n')
401
+ .map((l) => l.match(/^- (\S+) — (.+?)(?: \(초대받음.*\))?$/))
402
+ .filter(Boolean)
403
+ .map((m) => ({ id: m[1], name: m[2] }));
404
+ const hit = rows.filter((r) => r.id === q || r.name.includes(q));
405
+ if (hit.length === 0) {
406
+ // 🔑 **매인 토큰이면 목록에 하나만 나온다.** 그걸 모르면 "왜 내 회사가 없지" 가 된다.
407
+ const why =
408
+ rows.length === 1
409
+ ? '\n\n이 열쇠는 위 워크스페이스에만 매여 있어요. 다른 곳도 쓰려면 「전부」로 새로 발급하세요.'
410
+ : '';
411
+ die(`「${q}」 에 해당하는 워크스페이스가 없어요.\n\n${text}${why}`);
412
+ }
413
+ // ⚠️ 여럿이면 고르지 않는다 — 엉뚱한 회사에 자료를 넣게 되는 쪽이 훨씬 나쁘다.
414
+ if (hit.length > 1) {
415
+ die(`「${q}」 가 여럿에 걸려요. id 로 정확히 골라 주세요.\n` + hit.map((r) => `- ${r.id} — ${r.name}`).join('\n'));
416
+ }
417
+ saveConf({ ...loadConf(), companyId: hit[0].id });
418
+ console.log(`기본 워크스페이스를 「${hit[0].name}」 로 정했어요.`);
419
+ },
420
+
421
+ async list(args, flags) {
422
+ console.log(await callTool('list_pages', { company_id: needCompany(flags), prefix: args[0] || '' }));
423
+ },
424
+
425
+ async search(args, flags) {
426
+ const q = args.join(' ').trim();
427
+ if (!q) die('찾을 낱말을 적어 주세요.');
428
+ console.log(await callTool('search_pages', { company_id: needCompany(flags), query: q }));
429
+ },
430
+
431
+ async read(args, flags) {
432
+ if (args.length === 0) die('읽을 문서 경로를 적어 주세요. (`z2o list` 로 확인)');
433
+ console.log(await callTool('read_pages', { company_id: needCompany(flags), paths: args }));
434
+ },
435
+
436
+ async graph(_args, flags) {
437
+ console.log(await callTool('get_graph', { company_id: needCompany(flags) }));
438
+ },
439
+
440
+ // ── 정리 전 원본 ──
441
+ //
442
+ // 🔑 위 넷은 **정리된 위키 문서**만 본다. 슬랙·메일이 자동으로 들어오면 정리를
443
+ // 돌리기 전까지 그 문서가 없어서 아무것도 안 잡힌다. 정리(유료)를 안 거치고도
444
+ // 맥락을 쓸 수 있어야 한다. 정리는 그다음이다.
445
+
446
+ async recent(args, flags) {
447
+ const n = Number(args[0]);
448
+ console.log(
449
+ await callTool('list_raw', {
450
+ company_id: needCompany(flags),
451
+ ...(Number.isFinite(n) && n > 0 ? { limit: n } : {}),
452
+ }),
453
+ );
454
+ },
455
+
456
+ async find(args, flags) {
457
+ const q = args.join(' ').trim();
458
+ if (!q) die('찾을 낱말을 적어 주세요.');
459
+ console.log(await callTool('search_raw', { company_id: needCompany(flags), query: q }));
460
+ },
461
+
462
+ async raw(args, flags) {
463
+ if (args.length === 0) die('읽을 원본 id 를 적어 주세요. (`z2o recent` 로 확인)');
464
+ console.log(await callTool('read_raw', { company_id: needCompany(flags), ids: args }));
465
+ },
466
+
467
+ async push(args, flags) {
468
+ const [title, path] = args;
469
+ if (!title || !path) die('제목과 파일을 적어 주세요.\n z2o push "회의록" note.md\n cat x.md | z2o push "제목" -');
470
+ // `-` 면 표준입력. 파이프로 이어 쓰는 게 이 명령의 주된 쓰임새다.
471
+ const content = path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
472
+ if (!content.trim()) die('본문이 비어 있어요.');
473
+ console.log(await callTool('ingest_text', { company_id: needCompany(flags), title, content }));
474
+ },
475
+
476
+ /** 요약이 안 된 원본. **표는 코드가 이미 만들었고 사람이 읽을 글만 비어 있다.** */
477
+ async pending(args, flags) {
478
+ const n = Number(args[0]);
479
+ console.log(
480
+ await callTool('list_pending', {
481
+ company_id: needCompany(flags),
482
+ ...(Number.isFinite(n) && n > 0 ? { limit: n } : {}),
483
+ }),
484
+ );
485
+ },
486
+
487
+ /** 요약을 위키에 남긴다.
488
+ *
489
+ * 🔑 여기서 쓴 문서는 **사람이 쓴 것으로 표시**되어 Z2O 의 자동 정리가 덮지 않는다.
490
+ * `ledger/`·`entities/`·`tables/` 는 코드가 만드는 구역이라 서버가 거절한다.
491
+ */
492
+ async write(args, flags) {
493
+ const [path, file] = args;
494
+ if (!path || !file) {
495
+ die('경로와 파일을 적어 주세요.\n z2o write topics/물류.md 요약.md\n ... | z2o write topics/물류.md -');
496
+ }
497
+ const content = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
498
+ if (!content.trim()) die('본문이 비어 있어요.');
499
+ console.log(
500
+ await callTool('write_pages', {
501
+ company_id: needCompany(flags),
502
+ pages: [{ path, content }],
503
+ }),
504
+ );
505
+ },
506
+
507
+ async organize(_args, flags) {
508
+ console.log(await callTool('organize', { company_id: needCompany(flags) }));
509
+ },
510
+ };
511
+
512
+ // ── 진입점 ──────────────────────────────────────────────
513
+
514
+ const { flags, rest } = parseArgs(process.argv.slice(2));
515
+ const [cmd, ...args] = rest;
516
+
517
+ if (!cmd || flags.help || cmd === 'help') {
518
+ console.log(HELP);
519
+ process.exit(cmd && !flags.help && cmd !== 'help' ? 1 : 0);
520
+ }
521
+
522
+ const run = COMMANDS[cmd];
523
+ if (!run) die(`모르는 명령이에요: ${cmd}\n\n${HELP}`);
524
+
525
+ run(args, flags).catch((e) => die(`문제가 생겼어요: ${e.message}`));
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "z2o-cli",
3
+ "version": "0.4.0",
4
+ "description": "제로투원 — 회사 온톨로지와 슬랙 맥락을 내가 쓰는 AI에서 꺼내 쓴다",
5
+ "keywords": [
6
+ "z2o",
7
+ "zerotoone",
8
+ "ontology",
9
+ "wiki",
10
+ "slack",
11
+ "cli",
12
+ "llm",
13
+ "claude",
14
+ "mcp"
15
+ ],
16
+ "homepage": "https://z2o.kr",
17
+ "bugs": {
18
+ "url": "https://z2o.kr"
19
+ },
20
+ "license": "UNLICENSED",
21
+ "type": "module",
22
+ "bin": {
23
+ "z2o": "index.js"
24
+ },
25
+ "files": [
26
+ "index.js",
27
+ "README.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }