rootr-cli 0.7.0 → 0.7.1

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 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
- 출력: `id\tname` 목록 (계정 키면 소속된 전체 워크스페이스, 워크스페이스 키면 그 워크스페이스만).
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,7 +23,8 @@ 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), 인용 포함 출력
27
29
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
28
30
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
@@ -71,7 +73,10 @@ function readStdin() {
71
73
 
72
74
  function makeClient() {
73
75
  const cfg = loadConfig();
74
- return new RootrClient(cfg);
76
+ // Several keys can live side by side, each bound to the workspace it opens
77
+ // (config.js keys[]). Use the one that covers the active workspace instead of
78
+ // insisting on a single default key.
79
+ return new RootrClient({ ...cfg, apiKey: keyForWorkspace(cfg, cfg.workspace) });
75
80
  }
76
81
 
77
82
  function printErrorAndExit(err) {
@@ -318,11 +323,47 @@ async function cmdAttach(args) {
318
323
  }
319
324
 
320
325
  async function cmdWs() {
321
- const client = makeClient();
322
- const workspaces = await client.listWorkspaces();
326
+ const cfg = loadConfig();
327
+ const candidates = candidateClients(cfg);
328
+ if (!candidates.length) {
329
+ throw new RootrApiError('이 컴퓨터에 등록된 Rootr 키가 없습니다.', {
330
+ hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
331
+ });
332
+ }
333
+ // Probing costs an API call per workspace, so only when it diagnoses something:
334
+ // no account key means every key is scoped and most of the list is unusable.
335
+ const probe = !cfg.apiKey && (cfg.keys || []).length > 0;
336
+ const { workspaces, reachable, failures } = await surveyWorkspaces(candidates, { probe });
337
+ if (!workspaces.length) {
338
+ throw new RootrApiError(
339
+ failures ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).' : '보이는 워크스페이스가 없습니다.',
340
+ { hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.' },
341
+ );
342
+ }
343
+ let blocked = false;
323
344
  for (const w of workspaces) {
324
- console.log(`${w.id}\t${w.name || w.slug || ''}`);
345
+ const unreachable = probe && !reachable.has(w.id);
346
+ blocked = blocked || unreachable;
347
+ console.log(
348
+ `${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}${unreachable ? '\t[등록된 키로는 접근 불가]' : ''}`,
349
+ );
325
350
  }
351
+ if (blocked) {
352
+ console.error(
353
+ '\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다.\n' +
354
+ '계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`',
355
+ );
356
+ }
357
+ }
358
+
359
+ async function cmdUse(args) {
360
+ const wanted = args._.join(' ').trim();
361
+ if (!wanted) throw new Error('사용법: rootr use <워크스페이스 id | 이름 | rootr.io 주소>');
362
+ const cfg = loadConfig();
363
+ const match = await resolveWorkspaceSwitch(wanted, cfg);
364
+ if (match.error) throw new RootrApiError(match.error);
365
+ rememberWorkspace(match, cfg);
366
+ console.log(`활성 워크스페이스: "${match.workspace.name || match.workspace.slug || ''}" (${match.workspace.id})`);
326
367
  }
327
368
 
328
369
  async function cmdAsk(args) {
@@ -431,6 +472,8 @@ async function main() {
431
472
  return cmdAsk(args);
432
473
  case 'config':
433
474
  return cmdConfig(args);
475
+ case 'use':
476
+ return cmdUse(args);
434
477
  case 'mcp':
435
478
  return cmdMcp(args);
436
479
  default:
@@ -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,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, saveConfig, keyForWorkspace, upsertKey } from '../config.js';
4
+ import { loadConfig } from '../config.js';
5
5
  import { currentClientOverride } from '../context.js';
6
- import { RootrClient } from '../client.js';
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 wantedUrl = parseRootrUrl(workspace);
73
- const wanted = (wantedUrl ? wantedUrl.workspaceId : workspace).trim();
74
- const wantedLower = wanted.toLowerCase();
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
- saveConfig({ workspace: match.workspace.id });
195
- // Remember which key covers this workspace so later calls resolve
196
- // directly instead of probing every key again.
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 active = cfg.workspace;
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 probeReachability = !cfg.apiKey && (cfg.keys || []).length > 0;
250
- const seen = new Map();
251
- const reached = [];
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
- ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).\n' +
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 blocked = rows.some((r) => r.includes('접근 불가'));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {