rootr-cli 0.7.0 → 0.8.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 +6 -3
- package/bin/rootr.js +90 -6
- package/lib/client.js +62 -1
- package/lib/credentials.js +169 -0
- package/lib/mcp-tools/databases.js +121 -1
- package/lib/mcp-tools/templates.js +156 -0
- package/lib/mcp-tools/workspaces.js +21 -179
- package/lib/mcp.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -152,13 +152,16 @@ rootr rm /notes/todo.md --yes # 확인 없이 (스크립트용)
|
|
|
152
152
|
(파이프/CI처럼 입력이 없는 환경에서는 "아니오"로 처리되어 안전합니다).
|
|
153
153
|
`docs:write` 스코프가 필요합니다.
|
|
154
154
|
|
|
155
|
-
### 워크스페이스 목록
|
|
155
|
+
### 워크스페이스 목록 · 전환
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
|
-
rootr ws
|
|
158
|
+
rootr ws # 목록 (현재 워크스페이스에 *, 못 여는 곳은 표시)
|
|
159
|
+
rootr use "영업팀" # 전환 — id · 이름 · rootr.io 주소 아무거나
|
|
159
160
|
```
|
|
160
161
|
|
|
161
|
-
|
|
162
|
+
`rootr ws` 출력은 `id\tname` 목록입니다. 등록된 키가 워크스페이스 전용 키뿐이면 목록에는
|
|
163
|
+
보이지만 열 수 없는 워크스페이스에 `[등록된 키로는 접근 불가]`가 붙습니다 — 목록에 보이는
|
|
164
|
+
것과 접근되는 것은 다릅니다. `rootr use`는 그 워크스페이스를 여는 키를 골라 함께 기억합니다.
|
|
162
165
|
|
|
163
166
|
### 워크스페이스 여러 개 쓰기 (키 등록 + 전환)
|
|
164
167
|
|
package/bin/rootr.js
CHANGED
|
@@ -3,7 +3,8 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { dirname, join, basename, extname } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import { loadConfig, saveConfig, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
|
|
6
|
+
import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
|
|
7
|
+
import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
|
|
7
8
|
import { RootrClient, RootrApiError } from '../lib/client.js';
|
|
8
9
|
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
9
10
|
|
|
@@ -22,8 +23,10 @@ const USAGE = `Rootr CLI v${pkg.version}
|
|
|
22
23
|
rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
|
|
23
24
|
rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
|
|
24
25
|
rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] 파일 업로드(+문서에 삽입)
|
|
25
|
-
rootr ws 내 워크스페이스 목록 (id\tname)
|
|
26
|
+
rootr ws 내 워크스페이스 목록 (id\tname, 열 수 없는 곳은 표시)
|
|
27
|
+
rootr use <id|이름|주소> 활성 워크스페이스 전환 (그 워크스페이스용 키도 기억)
|
|
26
28
|
rootr ask <question> [--workspace <w>] GraphRAG 질의응답 (RCA), 인용 포함 출력
|
|
29
|
+
rootr db create [--file <spec.json>] [--parent <path|id>] 표(DATABASE) 생성 — 컬럼 순서 그대로, 행까지 한 번에
|
|
27
30
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
|
|
28
31
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
|
|
29
32
|
rootr mcp MCP stdio 서버 구동 (도구 90종+ — README 참고)
|
|
@@ -71,7 +74,10 @@ function readStdin() {
|
|
|
71
74
|
|
|
72
75
|
function makeClient() {
|
|
73
76
|
const cfg = loadConfig();
|
|
74
|
-
|
|
77
|
+
// Several keys can live side by side, each bound to the workspace it opens
|
|
78
|
+
// (config.js keys[]). Use the one that covers the active workspace instead of
|
|
79
|
+
// insisting on a single default key.
|
|
80
|
+
return new RootrClient({ ...cfg, apiKey: keyForWorkspace(cfg, cfg.workspace) });
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
function printErrorAndExit(err) {
|
|
@@ -318,13 +324,49 @@ async function cmdAttach(args) {
|
|
|
318
324
|
}
|
|
319
325
|
|
|
320
326
|
async function cmdWs() {
|
|
321
|
-
const
|
|
322
|
-
const
|
|
327
|
+
const cfg = loadConfig();
|
|
328
|
+
const candidates = candidateClients(cfg);
|
|
329
|
+
if (!candidates.length) {
|
|
330
|
+
throw new RootrApiError('이 컴퓨터에 등록된 Rootr 키가 없습니다.', {
|
|
331
|
+
hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// Probing costs an API call per workspace, so only when it diagnoses something:
|
|
335
|
+
// no account key means every key is scoped and most of the list is unusable.
|
|
336
|
+
const probe = !cfg.apiKey && (cfg.keys || []).length > 0;
|
|
337
|
+
const { workspaces, reachable, failures } = await surveyWorkspaces(candidates, { probe });
|
|
338
|
+
if (!workspaces.length) {
|
|
339
|
+
throw new RootrApiError(
|
|
340
|
+
failures ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).' : '보이는 워크스페이스가 없습니다.',
|
|
341
|
+
{ hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.' },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
let blocked = false;
|
|
323
345
|
for (const w of workspaces) {
|
|
324
|
-
|
|
346
|
+
const unreachable = probe && !reachable.has(w.id);
|
|
347
|
+
blocked = blocked || unreachable;
|
|
348
|
+
console.log(
|
|
349
|
+
`${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}${unreachable ? '\t[등록된 키로는 접근 불가]' : ''}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (blocked) {
|
|
353
|
+
console.error(
|
|
354
|
+
'\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다.\n' +
|
|
355
|
+
'계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`',
|
|
356
|
+
);
|
|
325
357
|
}
|
|
326
358
|
}
|
|
327
359
|
|
|
360
|
+
async function cmdUse(args) {
|
|
361
|
+
const wanted = args._.join(' ').trim();
|
|
362
|
+
if (!wanted) throw new Error('사용법: rootr use <워크스페이스 id | 이름 | rootr.io 주소>');
|
|
363
|
+
const cfg = loadConfig();
|
|
364
|
+
const match = await resolveWorkspaceSwitch(wanted, cfg);
|
|
365
|
+
if (match.error) throw new RootrApiError(match.error);
|
|
366
|
+
rememberWorkspace(match, cfg);
|
|
367
|
+
console.log(`활성 워크스페이스: "${match.workspace.name || match.workspace.slug || ''}" (${match.workspace.id})`);
|
|
368
|
+
}
|
|
369
|
+
|
|
328
370
|
async function cmdAsk(args) {
|
|
329
371
|
const question = args._.join(' ');
|
|
330
372
|
if (!question) throw new Error('사용법: rootr ask <question> [--workspace <w>]');
|
|
@@ -392,6 +434,44 @@ async function cmdMcp() {
|
|
|
392
434
|
|
|
393
435
|
// ---- entry ------------------------------------------------------------
|
|
394
436
|
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 표(DATABASE) 만들기 — 컬럼 순서를 그대로 지키려면 한 번에 만들어야 한다.
|
|
440
|
+
*
|
|
441
|
+
* 스펙은 JSON으로 받는다(파일 또는 표준입력): `{ name, properties[], views?, rows? }`.
|
|
442
|
+
* 컬럼을 하나씩 붙이는 방식은 호출 순서가 곧 컬럼 순서가 되어 옮겨온 표가 뒤죽박죽이
|
|
443
|
+
* 됐다 — 그래서 CLI도 "한 번에" 쪽만 제공한다.
|
|
444
|
+
*/
|
|
445
|
+
async function cmdDb(args) {
|
|
446
|
+
const sub = args._[0];
|
|
447
|
+
if (sub !== 'create') {
|
|
448
|
+
throw new Error('사용법: rootr db create [--file <spec.json>] [--parent <path|id>] [--ws <id>]');
|
|
449
|
+
}
|
|
450
|
+
const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
|
|
451
|
+
let spec;
|
|
452
|
+
try {
|
|
453
|
+
spec = JSON.parse(raw);
|
|
454
|
+
} catch {
|
|
455
|
+
throw new Error('스펙을 JSON으로 읽을 수 없습니다. { "name": "...", "properties": [...] } 형태여야 합니다.');
|
|
456
|
+
}
|
|
457
|
+
if (!spec.name && !spec.path) throw new Error('스펙에 name 또는 path가 있어야 합니다.');
|
|
458
|
+
|
|
459
|
+
const client = makeClient();
|
|
460
|
+
let parentId = spec.parentId;
|
|
461
|
+
if (typeof args.parent === 'string') {
|
|
462
|
+
const resolved = await resolveTarget(client, args.parent);
|
|
463
|
+
if (!resolved.existed) throw new Error(`부모를 찾을 수 없습니다: ${args.parent}`);
|
|
464
|
+
parentId = resolved.id;
|
|
465
|
+
}
|
|
466
|
+
const ws = args.ws || client.workspace;
|
|
467
|
+
if (!ws) throw new Error('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.');
|
|
468
|
+
|
|
469
|
+
const db = await client.createDatabase(ws, { ...spec, parentId });
|
|
470
|
+
const cols = (db.properties || []).map((p) => p.name).join(', ');
|
|
471
|
+
console.log(`표 생성: ${db.path ?? db.name} (${db.id})`);
|
|
472
|
+
if (cols) console.log(`컬럼 순서: ${cols}`);
|
|
473
|
+
if (typeof db.rowCount === 'number') console.log(`행: ${db.rowCount}개`);
|
|
474
|
+
}
|
|
395
475
|
async function main() {
|
|
396
476
|
const argv = process.argv.slice(2);
|
|
397
477
|
const args = parseArgs(argv);
|
|
@@ -431,6 +511,10 @@ async function main() {
|
|
|
431
511
|
return cmdAsk(args);
|
|
432
512
|
case 'config':
|
|
433
513
|
return cmdConfig(args);
|
|
514
|
+
case 'use':
|
|
515
|
+
return cmdUse(args);
|
|
516
|
+
case 'db':
|
|
517
|
+
return cmdDb(args);
|
|
434
518
|
case 'mcp':
|
|
435
519
|
return cmdMcp(args);
|
|
436
520
|
default:
|
package/lib/client.js
CHANGED
|
@@ -550,7 +550,68 @@ export class RootrClient {
|
|
|
550
550
|
});
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
// ----
|
|
553
|
+
// ---- Templates ---------------------------------------------------------
|
|
554
|
+
// 문서 양식(템플릿). 인앱 어시스턴트는 예전부터 쓸 수 있었는데 MCP·CLI에는
|
|
555
|
+
// 없어서, 밖에서 붙은 에이전트는 양식이 있는 줄도 모르고 매번 맨바닥부터
|
|
556
|
+
// 만들었다(2026-08-18 점검에서 드러난 구멍).
|
|
557
|
+
|
|
558
|
+
async listTemplates(workspace) {
|
|
559
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/templates`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async applyTemplate(workspace, templateId, { parentId } = {}) {
|
|
563
|
+
return this.request(
|
|
564
|
+
`/workspaces/${encodeURIComponent(workspace)}/templates/${encodeURIComponent(templateId)}/apply`,
|
|
565
|
+
{ method: 'POST', body: { parentId } },
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async createTemplate(workspace, { name, emoji, description, kind, spec } = {}) {
|
|
570
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/templates`, {
|
|
571
|
+
method: 'POST',
|
|
572
|
+
body: { name, emoji, description, kind, spec },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async saveTemplateFromNode(workspace, nodeId, { name, emoji, description } = {}) {
|
|
577
|
+
return this.request(
|
|
578
|
+
`/workspaces/${encodeURIComponent(workspace)}/templates/from-node/${encodeURIComponent(nodeId)}`,
|
|
579
|
+
{ method: 'POST', body: { name, emoji, description } },
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async updateTemplate(workspace, templateId, patch = {}) {
|
|
584
|
+
return this.request(
|
|
585
|
+
`/workspaces/${encodeURIComponent(workspace)}/templates/${encodeURIComponent(templateId)}`,
|
|
586
|
+
{ method: 'PATCH', body: patch },
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---- Databases --------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Create a DATABASE node with its columns (and optionally its first rows) in
|
|
594
|
+
* ONE call. `properties` order IS the column order — that is the whole point:
|
|
595
|
+
* building a table by adding columns one at a time made the order depend on
|
|
596
|
+
* the order of the calls, which is how migrated tables came out scrambled.
|
|
597
|
+
*/
|
|
598
|
+
async createDatabase(workspace, { name, parentId, path, createParents, autoRename, icon, properties, views, rows } = {}) {
|
|
599
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/databases`, {
|
|
600
|
+
method: 'POST',
|
|
601
|
+
body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows },
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 뷰 하나만 고친다(이름·감출 컬럼·그룹 기준). PATCH /databases/:id 는 views 배열을
|
|
607
|
+
* 통째로 갈아끼워서 빠뜨린 뷰가 사라지는데, 여기서는 서버가 그 뷰만 병합한다.
|
|
608
|
+
*/
|
|
609
|
+
async updateDatabaseView(databaseId, viewId, patch = {}) {
|
|
610
|
+
return this.request(
|
|
611
|
+
`/databases/${encodeURIComponent(databaseId)}/views/${encodeURIComponent(viewId)}`,
|
|
612
|
+
{ method: 'PATCH', body: patch },
|
|
613
|
+
);
|
|
614
|
+
}
|
|
554
615
|
|
|
555
616
|
/** Database definition: { id, name, icon, path, properties[], views[], rowCount }. */
|
|
556
617
|
async getDatabase(databaseId) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { loadConfig, saveConfig, upsertKey } from './config.js';
|
|
2
|
+
import { RootrClient } from './client.js';
|
|
3
|
+
import { parseRootrUrl } from './resolve.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every credential this machine can use, default key first. An account key
|
|
7
|
+
* (PAT) reaches every workspace its owner belongs to; a workspace key reaches
|
|
8
|
+
* one. Which one applies to a given workspace is discovered by asking the API
|
|
9
|
+
* (listWorkspaces / a workspace-scoped call), never guessed from the key.
|
|
10
|
+
*
|
|
11
|
+
* Shared by the CLI (`rootr ws`, `rootr use`) and the MCP tools so both answer
|
|
12
|
+
* "which workspaces can I actually open?" the same way.
|
|
13
|
+
*/
|
|
14
|
+
export function candidateClients(cfg = loadConfig()) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
if (cfg.apiKey) {
|
|
18
|
+
seen.add(cfg.apiKey);
|
|
19
|
+
out.push({ apiKey: cfg.apiKey, label: 'default', client: new RootrClient({ ...cfg, apiKey: cfg.apiKey }) });
|
|
20
|
+
}
|
|
21
|
+
for (const k of cfg.keys || []) {
|
|
22
|
+
if (!k.apiKey || seen.has(k.apiKey)) continue;
|
|
23
|
+
seen.add(k.apiKey);
|
|
24
|
+
out.push({ apiKey: k.apiKey, label: k.label || k.workspace || 'key', client: new RootrClient({ ...cfg, apiKey: k.apiKey }) });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect every workspace the registered keys can see, and (when asked) which
|
|
31
|
+
* of them a key can actually open — listing is not a capability probe: a
|
|
32
|
+
* workspace key lists all of its owner's workspaces while being rejected by
|
|
33
|
+
* every one but its own.
|
|
34
|
+
*/
|
|
35
|
+
export async function surveyWorkspaces(candidates, { probe = false, probeLimit = 12 } = {}) {
|
|
36
|
+
const seen = new Map();
|
|
37
|
+
const reachable = new Set();
|
|
38
|
+
let listed = 0;
|
|
39
|
+
let failures = 0;
|
|
40
|
+
for (const { client } of candidates) {
|
|
41
|
+
let list = [];
|
|
42
|
+
try {
|
|
43
|
+
list = await client.listWorkspaces();
|
|
44
|
+
listed += 1;
|
|
45
|
+
} catch {
|
|
46
|
+
failures += 1;
|
|
47
|
+
continue; // a revoked/foreign key shouldn't hide the others
|
|
48
|
+
}
|
|
49
|
+
for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
|
|
50
|
+
if (!probe) continue;
|
|
51
|
+
for (const w of list.slice(0, probeLimit)) {
|
|
52
|
+
if (reachable.has(w.id)) continue;
|
|
53
|
+
try {
|
|
54
|
+
await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
|
|
55
|
+
reachable.add(w.id);
|
|
56
|
+
} catch {
|
|
57
|
+
// scoped key rejected here; another key may still cover it
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { workspaces: [...seen.values()], reachable, listed, failures };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve "which workspace did the user mean, and which key opens it?" from an
|
|
66
|
+
* id, a name, or a rootr.io URL. Returns either { workspace, candidate } or a
|
|
67
|
+
* { error } describing the cause AND the next step — a bare "not found" is what
|
|
68
|
+
* used to send agents guessing at invitations for workspaces the user owns.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
|
|
71
|
+
const url = parseRootrUrl(wantedRaw);
|
|
72
|
+
const wanted = (url ? url.workspaceId : wantedRaw).trim();
|
|
73
|
+
const wantedLower = wanted.toLowerCase();
|
|
74
|
+
const candidates = candidateClients(cfg);
|
|
75
|
+
if (!candidates.length) {
|
|
76
|
+
return {
|
|
77
|
+
error:
|
|
78
|
+
'이 컴퓨터에 등록된 Rootr 키가 없습니다.\n' +
|
|
79
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급받아 `rootr config --api-key <키>` 로 등록하세요.',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 1 — resolve the name/id to a real workspace. /workspaces lists the
|
|
84
|
+
// KEY OWNER's workspaces, so any key can answer this.
|
|
85
|
+
let target = null;
|
|
86
|
+
const visible = [];
|
|
87
|
+
let listed = 0;
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
let list = [];
|
|
90
|
+
try {
|
|
91
|
+
list = await candidate.client.listWorkspaces();
|
|
92
|
+
listed += 1;
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const w of list) if (!visible.some((v) => v.id === w.id)) visible.push(w);
|
|
97
|
+
const hit = list.find(
|
|
98
|
+
(w) => w.id === wanted || (w.name || '').toLowerCase() === wantedLower || (w.slug || '').toLowerCase() === wantedLower,
|
|
99
|
+
);
|
|
100
|
+
if (hit) {
|
|
101
|
+
target = hit;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Step 2 — find a key that can actually WORK in it.
|
|
107
|
+
let usable = null;
|
|
108
|
+
let lastRejection = null;
|
|
109
|
+
if (target) {
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
try {
|
|
112
|
+
await candidate.client.request(`/workspaces/${encodeURIComponent(target.id)}`);
|
|
113
|
+
usable = candidate;
|
|
114
|
+
break;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
lastRejection = err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Listing can be unavailable (a legacy key with no owner attached gets 403 on
|
|
122
|
+
// /workspaces) while that key still opens the very workspace being asked for.
|
|
123
|
+
if (!target) {
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
try {
|
|
126
|
+
const ws = await candidate.client.request(`/workspaces/${encodeURIComponent(wanted)}`);
|
|
127
|
+
if (ws && ws.id) return { workspace: ws, candidate };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
lastRejection = err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (target && usable) return { workspace: target, candidate: usable };
|
|
134
|
+
|
|
135
|
+
if (target) {
|
|
136
|
+
const scoped = lastRejection && lastRejection.status === 403;
|
|
137
|
+
return {
|
|
138
|
+
error:
|
|
139
|
+
`"${target.name || target.id}" (${target.id}) 는 접근 권한 목록에는 있지만, 등록된 키로는 열 수 없습니다` +
|
|
140
|
+
`${target.role ? ` (내 역할: ${target.role})` : ''}.\n` +
|
|
141
|
+
(scoped
|
|
142
|
+
? '원인: 지금 등록된 키가 다른 워크스페이스 전용 키입니다 (워크스페이스 키는 자기 워크스페이스 밖에서 403).\n'
|
|
143
|
+
: `원인: ${lastRejection ? lastRejection.message : '알 수 없음'}\n`) +
|
|
144
|
+
'조치 (둘 중 하나, 사용자가 직접):\n' +
|
|
145
|
+
'1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
|
|
146
|
+
`2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const seenList = visible.length
|
|
150
|
+
? `\n지금 보이는 워크스페이스: ${visible.map((w) => `${w.name || w.slug || ''}(${w.id})`).join(', ')}`
|
|
151
|
+
: '';
|
|
152
|
+
return {
|
|
153
|
+
error:
|
|
154
|
+
listed === 0
|
|
155
|
+
? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키가 만료/취소되었거나 목록 권한이 없음).\n' +
|
|
156
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 새로 발급해 `rootr config --api-key <키>` 로 등록하세요.'
|
|
157
|
+
: `"${wanted}" 워크스페이스가 내 소속 목록에 없습니다.${seenList}\n` +
|
|
158
|
+
'조치: 주소가 맞는지 확인하고, 다른 계정의 워크스페이스라면 그 계정으로 발급한 키를 ' +
|
|
159
|
+
'`rootr config --add-key <키>` 로 등록하세요.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Persist the switch: remember the workspace and which key covers it. */
|
|
164
|
+
export function rememberWorkspace({ workspace, candidate }, cfg = loadConfig()) {
|
|
165
|
+
saveConfig({ workspace: workspace.id });
|
|
166
|
+
if (candidate && candidate.apiKey !== cfg.apiKey) {
|
|
167
|
+
upsertKey({ apiKey: candidate.apiKey, workspace: workspace.id, label: candidate.label });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
jsonResult,
|
|
5
|
+
errorResult,
|
|
6
|
+
makeClient,
|
|
7
|
+
requireWorkspace,
|
|
8
|
+
resolveNodeId,
|
|
9
|
+
resolveOptionalNodeId,
|
|
10
|
+
NODE_TARGET_HINT,
|
|
11
|
+
} from './shared.js';
|
|
4
12
|
|
|
5
13
|
/** Human text for the `databaseId` param — accepts a path just like the document tools. */
|
|
6
14
|
const DATABASE_TARGET_DESC = `DATABASE node id${NODE_TARGET_HINT} (e.g. "/제작 공정/칸반")`;
|
|
@@ -33,6 +41,118 @@ function resolveValueKeys(properties, values) {
|
|
|
33
41
|
* "status" column = move a kanban card between columns).
|
|
34
42
|
*/
|
|
35
43
|
export function registerDatabaseTools(server) {
|
|
44
|
+
server.registerTool(
|
|
45
|
+
'rootr_create_database',
|
|
46
|
+
{
|
|
47
|
+
title: 'Create a Rootr database (table / board / kanban)',
|
|
48
|
+
description:
|
|
49
|
+
'Create a DATABASE node — a typed table with columns, views and rows — in ONE call, optionally seeded with ' +
|
|
50
|
+
'its first rows. **Use this whenever you need a table**: moving a Notion database over, building a kanban ' +
|
|
51
|
+
'board, a task/requirement/risk tracker, anything with trackable rows. Do NOT fake a table with a markdown ' +
|
|
52
|
+
'document, and do NOT create an empty database and then add columns one by one — `properties` order IS the ' +
|
|
53
|
+
'column order, so one call keeps the columns in exactly the order you list them. ' +
|
|
54
|
+
'The FIRST property must be type "title" (the row label). Other types: text | number | select | ' +
|
|
55
|
+
'multi_select | date | checkbox | url | person. select/multi_select take options:[{name,color}]. ' +
|
|
56
|
+
'Views default to a single table view; a "board" view needs config.groupBy = a select property id, a ' +
|
|
57
|
+
'"timeline" view needs config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
|
|
58
|
+
'{ start: "YYYY-MM-DD", end? }, multi_select is string[].',
|
|
59
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
60
|
+
inputSchema: {
|
|
61
|
+
name: z.string().describe('Table name, e.g. "업무 목록"'),
|
|
62
|
+
parentId: z.string().optional().describe(`Parent folder id${NODE_TARGET_HINT}; workspace root if omitted`),
|
|
63
|
+
path: z.string().optional().describe('Full path incl. name — wins over parentId+name, e.g. "/팀/업무 목록"'),
|
|
64
|
+
createParents: z.boolean().optional().describe('Create missing folders in `path` (default false)'),
|
|
65
|
+
icon: z.string().optional().describe('Emoji icon for the node'),
|
|
66
|
+
properties: z
|
|
67
|
+
.array(
|
|
68
|
+
z.object({
|
|
69
|
+
name: z.string().describe('Column header, e.g. "담당자"'),
|
|
70
|
+
type: z
|
|
71
|
+
.enum(['title', 'text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'person'])
|
|
72
|
+
.describe('Column type; the FIRST property must be "title"'),
|
|
73
|
+
options: z
|
|
74
|
+
.array(z.object({ name: z.string(), color: z.string() }))
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('select/multi_select only: the choices'),
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
.optional()
|
|
80
|
+
.describe('Columns IN DISPLAY ORDER. Defaults to a single title column named "Name".'),
|
|
81
|
+
views: z
|
|
82
|
+
.array(z.record(z.unknown()))
|
|
83
|
+
.optional()
|
|
84
|
+
.describe('Views, e.g. [{ name: "보드", type: "board", config: { groupBy: "<select property id>" } }]'),
|
|
85
|
+
rows: z
|
|
86
|
+
.array(z.object({ values: z.record(z.unknown()) }))
|
|
87
|
+
.optional()
|
|
88
|
+
.describe('Initial rows, e.g. [{ values: { "이름": "킥오프", "기한": { "start": "2026-08-01" } } }]'),
|
|
89
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
async ({ name, parentId, path, createParents, icon, properties, views, rows, workspace }) => {
|
|
93
|
+
try {
|
|
94
|
+
const ws = requireWorkspace(workspace);
|
|
95
|
+
const client = makeClient(ws);
|
|
96
|
+
parentId = await resolveOptionalNodeId(client, parentId);
|
|
97
|
+
const database = await client.createDatabase(ws, {
|
|
98
|
+
name,
|
|
99
|
+
parentId,
|
|
100
|
+
path,
|
|
101
|
+
createParents,
|
|
102
|
+
icon,
|
|
103
|
+
properties,
|
|
104
|
+
views,
|
|
105
|
+
rows,
|
|
106
|
+
});
|
|
107
|
+
return jsonResult(database);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return errorResult(err);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
server.registerTool(
|
|
115
|
+
'rootr_update_view',
|
|
116
|
+
{
|
|
117
|
+
title: 'Edit a database view (hide columns, rename, regroup)',
|
|
118
|
+
description:
|
|
119
|
+
'Change ONE view of a Rootr (루터) DATABASE. Use `hiddenProps` to choose which columns that view shows — ' +
|
|
120
|
+
'the same table can look different per view (a "담당자" view hiding the amounts, etc). Hiding a column ' +
|
|
121
|
+
'does NOT erase its values: it disappears from that view only and comes back when you unhide it. ' +
|
|
122
|
+
'Columns may be given by NAME or id; the title column cannot be hidden and is ignored if listed. ' +
|
|
123
|
+
'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched.',
|
|
124
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
125
|
+
inputSchema: {
|
|
126
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
127
|
+
viewId: z.string().describe('View id or view NAME (from rootr_read_database)'),
|
|
128
|
+
hiddenProps: z
|
|
129
|
+
.array(z.string())
|
|
130
|
+
.optional()
|
|
131
|
+
.describe('Columns to hide in this view, by name or id. [] shows every column again.'),
|
|
132
|
+
name: z.string().optional().describe('New view name'),
|
|
133
|
+
groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
|
|
134
|
+
dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
|
|
135
|
+
endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
async ({ databaseId, viewId, hiddenProps, name, groupBy, dateProp, endDateProp }) => {
|
|
139
|
+
try {
|
|
140
|
+
const client = makeClient();
|
|
141
|
+
const id = await resolveNodeId(client, databaseId);
|
|
142
|
+
const view = await client.updateDatabaseView(id, viewId, {
|
|
143
|
+
hiddenProps,
|
|
144
|
+
name,
|
|
145
|
+
groupBy,
|
|
146
|
+
dateProp,
|
|
147
|
+
endDateProp,
|
|
148
|
+
});
|
|
149
|
+
return jsonResult({ view });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return errorResult(err);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
36
156
|
server.registerTool(
|
|
37
157
|
'rootr_read_database',
|
|
38
158
|
{
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jsonResult,
|
|
5
|
+
errorResult,
|
|
6
|
+
makeClient,
|
|
7
|
+
requireWorkspace,
|
|
8
|
+
resolveNodeId,
|
|
9
|
+
resolveOptionalNodeId,
|
|
10
|
+
NODE_TARGET_HINT,
|
|
11
|
+
} from './shared.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 문서 양식(템플릿) 도구.
|
|
15
|
+
*
|
|
16
|
+
* REST와 인앱 어시스턴트에는 예전부터 있었는데 MCP·CLI에만 없었다. 그래서 밖에서
|
|
17
|
+
* 붙은 에이전트(Claude 등)는 워크스페이스에 쓸 만한 양식이 이미 있는 줄도 모르고
|
|
18
|
+
* 매번 맨바닥부터 문서를 지어냈다 — 2026-08-18 점검에서 드러난 구멍이다.
|
|
19
|
+
*
|
|
20
|
+
* 양식은 세 종류다: `document`(마크다운 한 장), `database`(컬럼·뷰가 짜인 표),
|
|
21
|
+
* `tree`(폴더+문서+표가 얽힌 묶음). 빌트인 양식과 워크스페이스가 직접 만든 양식이
|
|
22
|
+
* 같은 목록에 함께 나온다.
|
|
23
|
+
*/
|
|
24
|
+
export function registerTemplateTools(server) {
|
|
25
|
+
server.registerTool(
|
|
26
|
+
'rootr_list_templates',
|
|
27
|
+
{
|
|
28
|
+
title: 'List document/database templates',
|
|
29
|
+
description:
|
|
30
|
+
'List the templates available in a Rootr (루터) workspace — builtins plus anything this workspace saved. ' +
|
|
31
|
+
'Each has an id, name, emoji, description and `kind` (document | database | tree). ' +
|
|
32
|
+
'**Call this before building a document or table from scratch**: if a fitting template exists, ' +
|
|
33
|
+
'rootr_apply_template gives the team their own agreed format instead of a shape you invented.',
|
|
34
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
35
|
+
inputSchema: {
|
|
36
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
async ({ workspace }) => {
|
|
40
|
+
try {
|
|
41
|
+
const ws = requireWorkspace(workspace);
|
|
42
|
+
return jsonResult(await makeClient(ws).listTemplates(ws));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return errorResult(err);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
server.registerTool(
|
|
50
|
+
'rootr_apply_template',
|
|
51
|
+
{
|
|
52
|
+
title: 'Create a node from a template',
|
|
53
|
+
description:
|
|
54
|
+
'Instantiate a Rootr (루터) template — creates the real node(s) it describes and returns the created node. ' +
|
|
55
|
+
'A `document` template makes one document, `database` makes a table with its columns and views already set, ' +
|
|
56
|
+
'`tree` makes a whole folder/document/table structure. Get ids from rootr_list_templates.',
|
|
57
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
58
|
+
inputSchema: {
|
|
59
|
+
templateId: z.string().describe('Template id from rootr_list_templates'),
|
|
60
|
+
parentId: z
|
|
61
|
+
.string()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe(`Parent folder id${NODE_TARGET_HINT}; workspace root if omitted`),
|
|
64
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
async ({ templateId, parentId, workspace }) => {
|
|
68
|
+
try {
|
|
69
|
+
const ws = requireWorkspace(workspace);
|
|
70
|
+
const client = makeClient(ws);
|
|
71
|
+
parentId = await resolveOptionalNodeId(client, parentId);
|
|
72
|
+
return jsonResult(await client.applyTemplate(ws, templateId, { parentId }));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return errorResult(err);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
server.registerTool(
|
|
80
|
+
'rootr_save_as_template',
|
|
81
|
+
{
|
|
82
|
+
title: 'Save a node (or an inline spec) as a template',
|
|
83
|
+
description:
|
|
84
|
+
'Turn something into a reusable Rootr (루터) template. Either pass `nodeId` to capture an existing ' +
|
|
85
|
+
'document/table/folder as-is, or pass `kind` + `spec` to define one inline. Use this when the user says ' +
|
|
86
|
+
'"make this the standard format" or asks for a new form/양식. ' +
|
|
87
|
+
'NOTE: a template is workspace-wide and readable by every member — do not capture a restricted node ' +
|
|
88
|
+
'unless the user means to share its content with the whole workspace.',
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
90
|
+
inputSchema: {
|
|
91
|
+
name: z.string().describe('Template name, e.g. "주간 보고서 양식"'),
|
|
92
|
+
nodeId: z
|
|
93
|
+
.string()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe(`Capture this existing node${NODE_TARGET_HINT}. Omit when passing kind+spec instead.`),
|
|
96
|
+
kind: z
|
|
97
|
+
.enum(['document', 'database', 'tree'])
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('Inline definition: document (markdown) | database (columns+views) | tree (structure)'),
|
|
100
|
+
spec: z
|
|
101
|
+
.record(z.unknown())
|
|
102
|
+
.optional()
|
|
103
|
+
.describe('Inline definition body, matching `kind`. Ignored when nodeId is given.'),
|
|
104
|
+
emoji: z.string().optional().describe('Emoji shown in the template gallery'),
|
|
105
|
+
description: z.string().optional().describe('One line on when to use it'),
|
|
106
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
async ({ name, nodeId, kind, spec, emoji, description, workspace }) => {
|
|
110
|
+
try {
|
|
111
|
+
const ws = requireWorkspace(workspace);
|
|
112
|
+
const client = makeClient(ws);
|
|
113
|
+
if (nodeId) {
|
|
114
|
+
const id = await resolveNodeId(client, nodeId);
|
|
115
|
+
return jsonResult(await client.saveTemplateFromNode(ws, id, { name, emoji, description }));
|
|
116
|
+
}
|
|
117
|
+
if (!kind || !spec) {
|
|
118
|
+
throw new Error('Pass either nodeId, or both kind and spec.');
|
|
119
|
+
}
|
|
120
|
+
return jsonResult(await client.createTemplate(ws, { name, emoji, description, kind, spec }));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return errorResult(err);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
server.registerTool(
|
|
128
|
+
'rootr_update_template',
|
|
129
|
+
{
|
|
130
|
+
title: 'Edit a template',
|
|
131
|
+
description:
|
|
132
|
+
'Change a Rootr (루터) template — its name, emoji, description or `spec`. Editing a builtin transparently ' +
|
|
133
|
+
'creates a workspace copy, so the builtin itself is never damaged. Use this for "change the standard format" ' +
|
|
134
|
+
'requests instead of saving a second near-identical template.',
|
|
135
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
136
|
+
inputSchema: {
|
|
137
|
+
templateId: z.string().describe('Template id from rootr_list_templates'),
|
|
138
|
+
name: z.string().optional().describe('New name'),
|
|
139
|
+
emoji: z.string().optional().describe('New emoji'),
|
|
140
|
+
description: z.string().optional().describe('New one-line description'),
|
|
141
|
+
spec: z.record(z.unknown()).optional().describe('Replacement body, matching the template kind'),
|
|
142
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
async ({ templateId, name, emoji, description, spec, workspace }) => {
|
|
146
|
+
try {
|
|
147
|
+
const ws = requireWorkspace(workspace);
|
|
148
|
+
return jsonResult(
|
|
149
|
+
await makeClient(ws).updateTemplate(ws, templateId, { name, emoji, description, spec }),
|
|
150
|
+
);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return errorResult(err);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
|
|
4
|
-
import { loadConfig
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
5
|
import { currentClientOverride } from '../context.js';
|
|
6
|
-
import {
|
|
6
|
+
import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../credentials.js';
|
|
7
7
|
import { parseRootrUrl } from '../resolve.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -26,28 +26,6 @@ function parseTree(tree) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* Every credential this machine can use, default key first. An account key
|
|
31
|
-
* (PAT) reaches every workspace its owner belongs to; workspace keys reach one.
|
|
32
|
-
* Which one applies to a given workspace is discovered by asking the API
|
|
33
|
-
* (listWorkspaces), not guessed from the key string.
|
|
34
|
-
*/
|
|
35
|
-
function candidateClients() {
|
|
36
|
-
const cfg = loadConfig();
|
|
37
|
-
const out = [];
|
|
38
|
-
const seen = new Set();
|
|
39
|
-
if (cfg.apiKey) {
|
|
40
|
-
seen.add(cfg.apiKey);
|
|
41
|
-
out.push({ apiKey: cfg.apiKey, label: 'default', client: new RootrClient({ ...cfg, apiKey: cfg.apiKey }) });
|
|
42
|
-
}
|
|
43
|
-
for (const k of cfg.keys || []) {
|
|
44
|
-
if (!k.apiKey || seen.has(k.apiKey)) continue;
|
|
45
|
-
seen.add(k.apiKey);
|
|
46
|
-
out.push({ apiKey: k.apiKey, label: k.label || k.workspace || 'key', client: new RootrClient({ ...cfg, apiKey: k.apiKey }) });
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
29
|
/** Workspace listing and scaffolding tools: rootr_workspaces, rootr_scaffold_plan, rootr_create_workspace, rootr_scaffold_apply. */
|
|
52
30
|
export function registerWorkspaceTools(server) {
|
|
53
31
|
server.registerTool(
|
|
@@ -69,12 +47,10 @@ export function registerWorkspaceTools(server) {
|
|
|
69
47
|
},
|
|
70
48
|
async ({ workspace }) => {
|
|
71
49
|
try {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (currentClientOverride()) {
|
|
77
|
-
const hosted = currentClientOverride();
|
|
50
|
+
const hosted = currentClientOverride();
|
|
51
|
+
if (hosted) {
|
|
52
|
+
const url = parseRootrUrl(workspace);
|
|
53
|
+
const wanted = (url ? url.workspaceId : workspace).trim();
|
|
78
54
|
if (wanted === hosted.workspace) {
|
|
79
55
|
return textResult(`이미 이 워크스페이스(${hosted.workspace})에 연결되어 있습니다. 그대로 진행하세요.`);
|
|
80
56
|
}
|
|
@@ -86,117 +62,10 @@ export function registerWorkspaceTools(server) {
|
|
|
86
62
|
),
|
|
87
63
|
);
|
|
88
64
|
}
|
|
89
|
-
const candidates = candidateClients();
|
|
90
|
-
if (!candidates.length) {
|
|
91
|
-
return errorResult(
|
|
92
|
-
new Error(
|
|
93
|
-
'이 컴퓨터에 등록된 Rootr 키가 없습니다.\n' +
|
|
94
|
-
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급받아 `rootr config --api-key <키>` 로 등록하세요.',
|
|
95
|
-
),
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Step 1 — resolve the name/id to a real workspace. /workspaces lists
|
|
100
|
-
// the KEY OWNER's workspaces, so any key can answer this.
|
|
101
|
-
let target = null;
|
|
102
|
-
let listed = 0;
|
|
103
|
-
const visible = [];
|
|
104
|
-
for (const candidate of candidates) {
|
|
105
|
-
let list = [];
|
|
106
|
-
try {
|
|
107
|
-
list = await candidate.client.listWorkspaces();
|
|
108
|
-
listed += 1;
|
|
109
|
-
} catch {
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
for (const w of list) if (!visible.some((v) => v.id === w.id)) visible.push(w);
|
|
113
|
-
const hit = list.find(
|
|
114
|
-
(w) => w.id === wanted || (w.name || '').toLowerCase() === wantedLower || (w.slug || '').toLowerCase() === wantedLower,
|
|
115
|
-
);
|
|
116
|
-
if (hit) {
|
|
117
|
-
target = hit;
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Step 2 — find a key that can actually WORK in it. Listing is not a
|
|
123
|
-
// capability probe: a workspace-scoped key still lists all of its
|
|
124
|
-
// owner's workspaces while being rejected by every one but its own.
|
|
125
|
-
// Only a workspace-scoped call proves the binding.
|
|
126
|
-
let usable = null;
|
|
127
|
-
let lastRejection = null;
|
|
128
|
-
if (target) {
|
|
129
|
-
for (const candidate of candidates) {
|
|
130
|
-
try {
|
|
131
|
-
await candidate.client.request(`/workspaces/${encodeURIComponent(target.id)}`);
|
|
132
|
-
usable = candidate;
|
|
133
|
-
break;
|
|
134
|
-
} catch (err) {
|
|
135
|
-
lastRejection = err;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// Listing can be unavailable (a legacy key with no owner attached gets
|
|
141
|
-
// 403 on /workspaces) — but that key may still be able to open the very
|
|
142
|
-
// workspace being asked for. Ask the workspace directly before giving up.
|
|
143
|
-
let match = target && usable ? { workspace: target, candidate: usable } : null;
|
|
144
|
-
if (!match && !target) {
|
|
145
|
-
for (const candidate of candidates) {
|
|
146
|
-
try {
|
|
147
|
-
const ws = await candidate.client.request(`/workspaces/${encodeURIComponent(wanted)}`);
|
|
148
|
-
if (ws && ws.id) {
|
|
149
|
-
match = { workspace: ws, candidate };
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
} catch (err) {
|
|
153
|
-
lastRejection = err;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Listed-but-unreachable is the common real case: the workspace IS the
|
|
159
|
-
// user's, but every registered key is workspace-scoped to another one.
|
|
160
|
-
// Name that cause explicitly — a vague "no key" message makes the
|
|
161
|
-
// assistant invent permission problems (asking for an invite to the
|
|
162
|
-
// user's OWN workspace).
|
|
163
|
-
if (target && !usable) {
|
|
164
|
-
const scoped = lastRejection && lastRejection.status === 403;
|
|
165
|
-
return errorResult(
|
|
166
|
-
new Error(
|
|
167
|
-
`"${target.name || target.id}" (${target.id}) 는 접근 권한 목록에는 있지만, 등록된 키로는 열 수 없습니다` +
|
|
168
|
-
`${target.role ? ` (내 역할: ${target.role})` : ''}.\n` +
|
|
169
|
-
(scoped
|
|
170
|
-
? '원인: 지금 등록된 키가 다른 워크스페이스 전용 키입니다 (워크스페이스 키는 자기 워크스페이스 밖에서 403).\n'
|
|
171
|
-
: `원인: ${lastRejection ? lastRejection.message : '알 수 없음'}\n`) +
|
|
172
|
-
'조치 (둘 중 하나, 사용자가 직접):\n' +
|
|
173
|
-
'1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
|
|
174
|
-
`2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``,
|
|
175
|
-
),
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
if (!match) {
|
|
179
|
-
const seenList = visible.length
|
|
180
|
-
? `\n지금 보이는 워크스페이스: ${visible.map((w) => `${w.name || w.slug || ''}(${w.id})`).join(', ')}`
|
|
181
|
-
: '';
|
|
182
|
-
return errorResult(
|
|
183
|
-
new Error(
|
|
184
|
-
listed === 0
|
|
185
|
-
? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키가 만료/취소되었거나 목록 권한이 없음).\n' +
|
|
186
|
-
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 새로 발급해 `rootr config --api-key <키>` 로 등록하세요.'
|
|
187
|
-
: `"${wanted}" 워크스페이스가 내 소속 목록에 없습니다.${seenList}\n` +
|
|
188
|
-
'조치: 주소가 맞는지 확인하고, 다른 계정의 워크스페이스라면 그 계정으로 발급한 키를 ' +
|
|
189
|
-
'`rootr config --add-key <키>` 로 등록하세요.',
|
|
190
|
-
),
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
65
|
const cfg = loadConfig();
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (match.candidate.apiKey !== cfg.apiKey) {
|
|
198
|
-
upsertKey({ apiKey: match.candidate.apiKey, workspace: match.workspace.id, label: match.candidate.label });
|
|
199
|
-
}
|
|
66
|
+
const match = await resolveWorkspaceSwitch(workspace, cfg);
|
|
67
|
+
if (match.error) return errorResult(new Error(match.error));
|
|
68
|
+
rememberWorkspace(match, cfg);
|
|
200
69
|
const name = match.workspace.name || match.workspace.slug || '';
|
|
201
70
|
return textResult(`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.`);
|
|
202
71
|
} catch (err) {
|
|
@@ -241,52 +110,25 @@ export function registerWorkspaceTools(server) {
|
|
|
241
110
|
);
|
|
242
111
|
}
|
|
243
112
|
const cfg = loadConfig();
|
|
244
|
-
const
|
|
113
|
+
const candidates = candidateClients(cfg);
|
|
245
114
|
// Probing costs one API call per workspace (they count against the
|
|
246
115
|
// daily read quota), so only do it in the case it actually diagnoses:
|
|
247
116
|
// no account key registered, i.e. every credential here is scoped to
|
|
248
117
|
// one workspace and most of the list is unusable.
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
let failures = 0;
|
|
253
|
-
for (const { client, label } of candidateClients()) {
|
|
254
|
-
let list = [];
|
|
255
|
-
try {
|
|
256
|
-
list = await client.listWorkspaces();
|
|
257
|
-
} catch {
|
|
258
|
-
failures += 1;
|
|
259
|
-
continue; // a revoked/foreign key shouldn't hide the others
|
|
260
|
-
}
|
|
261
|
-
for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
|
|
262
|
-
// Which workspace each key can actually WRITE in is what decides
|
|
263
|
-
// whether rootr_use_workspace will succeed — listing does not.
|
|
264
|
-
for (const w of probeReachability ? list.slice(0, 12) : []) {
|
|
265
|
-
if (reached.includes(w.id)) continue;
|
|
266
|
-
try {
|
|
267
|
-
await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
|
|
268
|
-
reached.push(w.id);
|
|
269
|
-
} catch {
|
|
270
|
-
// scoped key rejected here; another key may still cover it
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
void label;
|
|
274
|
-
}
|
|
275
|
-
const rows = [...seen.values()].map(
|
|
276
|
-
(w) =>
|
|
277
|
-
`${w.id === active ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}` +
|
|
278
|
-
(!probeReachability || reached.includes(w.id) ? '' : '\t[등록된 키로는 접근 불가]'),
|
|
279
|
-
);
|
|
280
|
-
if (!rows.length) {
|
|
118
|
+
const probe = !cfg.apiKey && (cfg.keys || []).length > 0;
|
|
119
|
+
const { workspaces, reachable, failures } = await surveyWorkspaces(candidates, { probe });
|
|
120
|
+
if (!workspaces.length) {
|
|
281
121
|
return textResult(
|
|
282
|
-
failures
|
|
283
|
-
|
|
284
|
-
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.'
|
|
285
|
-
: '이 컴퓨터에 등록된 Rootr 키가 없습니다.\n' +
|
|
286
|
-
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
|
|
122
|
+
(failures ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).\n' : '이 컴퓨터에 등록된 Rootr 키가 없습니다.\n') +
|
|
123
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
|
|
287
124
|
);
|
|
288
125
|
}
|
|
289
|
-
const
|
|
126
|
+
const rows = workspaces.map(
|
|
127
|
+
(w) =>
|
|
128
|
+
`${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}` +
|
|
129
|
+
(!probe || reachable.has(w.id) ? '' : '\t[등록된 키로는 접근 불가]'),
|
|
130
|
+
);
|
|
131
|
+
const blocked = probe && workspaces.some((w) => !reachable.has(w.id));
|
|
290
132
|
return textResult(
|
|
291
133
|
`${rows.join('\n')}\n\n(* = 현재 워크스페이스, rootr_use_workspace 로 전환)` +
|
|
292
134
|
(blocked
|
package/lib/mcp.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerLogTools } from './mcp-tools/logs.js';
|
|
|
7
7
|
import { registerAskTools } from './mcp-tools/ask.js';
|
|
8
8
|
import { registerIssueTools } from './mcp-tools/issues.js';
|
|
9
9
|
import { registerDatabaseTools } from './mcp-tools/databases.js';
|
|
10
|
+
import { registerTemplateTools } from './mcp-tools/templates.js';
|
|
10
11
|
import { registerWebhookTools } from './mcp-tools/webhooks.js';
|
|
11
12
|
import { registerMiscTools } from './mcp-tools/misc.js';
|
|
12
13
|
import { registerCrmTools } from './mcp-tools/crm.js';
|
|
@@ -122,6 +123,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
122
123
|
withQuotaWarnings(server);
|
|
123
124
|
|
|
124
125
|
registerDocumentTools(server);
|
|
126
|
+
registerTemplateTools(server);
|
|
125
127
|
registerWorkspaceTools(server);
|
|
126
128
|
registerLogTools(server);
|
|
127
129
|
registerAskTools(server);
|