rootr-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ import { z } from 'zod';
2
+
3
+ import { RootrApiError } from '../client.js';
4
+ import { resolveTarget, requireResolved } from '../resolve.js';
5
+ import { textResult, errorResult, makeClient } from './shared.js';
6
+
7
+ /** Registers the original 6 document tools: list/read/append/edit/write/search. */
8
+ export function registerDocumentTools(server) {
9
+ server.registerTool(
10
+ 'rootr_list',
11
+ {
12
+ title: 'List Rootr documents',
13
+ description:
14
+ 'List documents and folders in the connected Rootr (루터) workspace, optionally filtered to a path prefix. ' +
15
+ 'Use this first to discover what exists before reading or writing.',
16
+ inputSchema: {
17
+ path: z
18
+ .string()
19
+ .optional()
20
+ .describe('Optional path prefix to filter results, e.g. "/notes"'),
21
+ },
22
+ },
23
+ async ({ path }) => {
24
+ try {
25
+ const client = makeClient();
26
+ const nodes = await client.getTree();
27
+ const rows = nodes
28
+ .filter((n) => {
29
+ if (!path) return true;
30
+ const p = n.path || '';
31
+ return p === path || p.startsWith(path.endsWith('/') ? path : path + '/');
32
+ })
33
+ .map((n) => `${n.type || '?'} ${n.path || n.name || n.id}`)
34
+ .sort();
35
+ return textResult(rows.length ? rows.join('\n') : '(no matching nodes)');
36
+ } catch (err) {
37
+ return errorResult(err);
38
+ }
39
+ },
40
+ );
41
+
42
+ server.registerTool(
43
+ 'rootr_read',
44
+ {
45
+ title: 'Read a Rootr document',
46
+ description:
47
+ 'Read the full markdown content of a Rootr (루터) document by path (starting with "/") or by node id. ' +
48
+ 'Read a document before editing or appending to it so you know its current structure.',
49
+ inputSchema: {
50
+ target: z.string().describe('Document path (e.g. "/notes/todo.md") or node id'),
51
+ },
52
+ },
53
+ async ({ target }) => {
54
+ try {
55
+ const client = makeClient();
56
+ const resolved = await resolveTarget(client, target);
57
+ const id = requireResolved(resolved);
58
+ const { content } = await client.getDocumentMarkdown(id);
59
+ return textResult(content);
60
+ } catch (err) {
61
+ return errorResult(err);
62
+ }
63
+ },
64
+ );
65
+
66
+ server.registerTool(
67
+ 'rootr_append',
68
+ {
69
+ title: 'Append to a Rootr document (preferred write)',
70
+ description:
71
+ 'Append content to a Rootr document, optionally under a given heading. This is a conflict-free, ' +
72
+ 'additive operation — it never overwrites existing content, so PREFER this over rootr_write whenever ' +
73
+ 'you just need to add new information (log entries, notes, findings) to an existing document.',
74
+ inputSchema: {
75
+ target: z.string().describe('Document path (starting with "/") or node id'),
76
+ content: z.string().describe('Markdown content to append'),
77
+ underHeading: z
78
+ .string()
79
+ .optional()
80
+ .describe(
81
+ 'Heading to append under, e.g. "## Notes" or "Notes". If omitted, appends to the end of the document. ' +
82
+ 'A missing heading section is created at the document end.',
83
+ ),
84
+ },
85
+ },
86
+ async ({ target, content, underHeading }) => {
87
+ try {
88
+ const client = makeClient();
89
+ const resolved = await resolveTarget(client, target);
90
+ const id = requireResolved(resolved);
91
+ const result = await client.appendDocument(id, { content, underHeading });
92
+ return textResult(`appended (etag ${result.etag || 'n/a'})`);
93
+ } catch (err) {
94
+ return errorResult(err);
95
+ }
96
+ },
97
+ );
98
+
99
+ server.registerTool(
100
+ 'rootr_edit',
101
+ {
102
+ title: 'Edit a Rootr document by anchor replace',
103
+ description:
104
+ 'Replace an exact snippet of text ("find") with new text ("replace") inside a Rootr document. ' +
105
+ 'Fails with a clear error if the snippet is missing or ambiguous (not unique), so read the document ' +
106
+ 'first to copy the exact text to find. Good for precise, targeted edits; use rootr_append instead when ' +
107
+ 'you are only adding new content.',
108
+ inputSchema: {
109
+ target: z.string().describe('Document path (starting with "/") or node id'),
110
+ find: z.string().describe('Exact existing text to find (must be unique unless replaceAll is set)'),
111
+ replace: z.string().describe('Text to replace it with'),
112
+ replaceAll: z.boolean().optional().describe('Replace all occurrences instead of requiring uniqueness'),
113
+ },
114
+ },
115
+ async ({ target, find, replace, replaceAll }) => {
116
+ try {
117
+ const client = makeClient();
118
+ const resolved = await resolveTarget(client, target);
119
+ const id = requireResolved(resolved);
120
+ await client.patchDocumentAnchor(id, { find, replace, replaceAll });
121
+ return textResult('edited');
122
+ } catch (err) {
123
+ return errorResult(err);
124
+ }
125
+ },
126
+ );
127
+
128
+ server.registerTool(
129
+ 'rootr_write',
130
+ {
131
+ title: 'Overwrite a Rootr document (destructive)',
132
+ description:
133
+ 'Replace the ENTIRE content of a Rootr document, or create a new document at a path if it does not exist. ' +
134
+ 'This is DESTRUCTIVE — it discards whatever is currently there. Read the document first and pass its etag ' +
135
+ 'as ifMatch to avoid clobbering concurrent changes. Prefer rootr_append or rootr_edit for incremental changes.',
136
+ inputSchema: {
137
+ target: z.string().describe('Document path (starting with "/", created if missing) or node id'),
138
+ content: z.string().describe('Full new markdown content for the document'),
139
+ ifMatch: z
140
+ .string()
141
+ .optional()
142
+ .describe('ETag obtained from a previous rootr_read/rootr_write, to guard against overwriting concurrent edits'),
143
+ },
144
+ },
145
+ async ({ target, content, ifMatch }) => {
146
+ try {
147
+ const client = makeClient();
148
+ const resolved = await resolveTarget(client, target);
149
+ if (!resolved.existed) {
150
+ if (!resolved.path) throw new RootrApiError(`대상을 찾을 수 없습니다: ${target}`);
151
+ // creating with content IS the write — a follow-up PUT would just
152
+ // double the version history and extraction work
153
+ const created = await client.createNode({
154
+ type: 'DOCUMENT',
155
+ path: resolved.path,
156
+ createParents: true,
157
+ autoRename: false,
158
+ content,
159
+ });
160
+ const id = created.id || (created.node && created.node.id);
161
+ return textResult(`created ${resolved.path} (id ${id})`);
162
+ }
163
+ const { etag } = await client.putDocument(resolved.id, content, { ifMatch });
164
+ return textResult(`wrote (etag ${etag || 'n/a'})`);
165
+ } catch (err) {
166
+ return errorResult(err);
167
+ }
168
+ },
169
+ );
170
+
171
+ server.registerTool(
172
+ 'rootr_search',
173
+ {
174
+ title: 'Search the Rootr workspace',
175
+ description:
176
+ 'Full-text search across the connected Rootr (루터) workspace. Use this to find relevant documents before ' +
177
+ 'reading or editing them.',
178
+ inputSchema: {
179
+ query: z.string().describe('Search query text'),
180
+ },
181
+ },
182
+ async ({ query }) => {
183
+ try {
184
+ const client = makeClient();
185
+ const results = await client.search(query);
186
+ const rows = results.map((r) => `${r.path || r.name || r.nodeId} — ${r.snippet || ''}`);
187
+ return textResult(rows.length ? rows.join('\n') : '(no results)');
188
+ } catch (err) {
189
+ return errorResult(err);
190
+ }
191
+ },
192
+ );
193
+ }
@@ -0,0 +1,162 @@
1
+ import { z } from 'zod';
2
+
3
+ import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
4
+
5
+ /** Issue tracker tools: create tracker, list/create/get/update issues, comment. */
6
+ export function registerIssueTools(server) {
7
+ server.registerTool(
8
+ 'rootr_create_issue_tracker',
9
+ {
10
+ title: 'Create an issue tracker in a Rootr workspace',
11
+ description:
12
+ 'Create a new GitHub-style issue tracker document in a Rootr (루터) workspace. Returns the tracker with ' +
13
+ 'its id, plus its default labels/types — use rootr_create_issue afterward to add issues to it.',
14
+ inputSchema: {
15
+ name: z.string().describe('Name of the issue tracker'),
16
+ workspace: z
17
+ .string()
18
+ .optional()
19
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
20
+ },
21
+ },
22
+ async ({ name, workspace }) => {
23
+ try {
24
+ const ws = requireWorkspace(workspace);
25
+ const client = makeClient(ws);
26
+ const result = await client.createIssueTracker(ws, name);
27
+ return jsonResult(result);
28
+ } catch (err) {
29
+ return errorResult(err);
30
+ }
31
+ },
32
+ );
33
+
34
+ server.registerTool(
35
+ 'rootr_list_issues',
36
+ {
37
+ title: 'List issues in a tracker',
38
+ description:
39
+ 'List issues in a Rootr (루터) issue tracker, optionally filtered by state (OPEN/CLOSED/ALL), label id, ' +
40
+ 'type id, or a text query. Use rootr_get_issue for full details of one issue.',
41
+ inputSchema: {
42
+ trackerId: z.string().describe('Issue tracker node id'),
43
+ state: z.string().optional().describe('OPEN | CLOSED | ALL'),
44
+ q: z.string().optional().describe('Free-text search within issues'),
45
+ label: z.string().optional().describe('Label id to filter by'),
46
+ type: z.string().optional().describe('Issue type id to filter by'),
47
+ },
48
+ },
49
+ async ({ trackerId, state, q, label, type }) => {
50
+ try {
51
+ const client = makeClient();
52
+ const result = await client.listIssues(trackerId, { state, q, label, type });
53
+ return jsonResult(result);
54
+ } catch (err) {
55
+ return errorResult(err);
56
+ }
57
+ },
58
+ );
59
+
60
+ server.registerTool(
61
+ 'rootr_create_issue',
62
+ {
63
+ title: 'Create an issue',
64
+ description:
65
+ 'Create a new issue in a Rootr (루터) issue tracker. labels is an array of label ids, type is a type id ' +
66
+ '(both from the tracker\'s custom type/label definitions — see rootr_read on the tracker or its metadata). ' +
67
+ 'parentIssueId nests this issue under another issue (sub-issue).',
68
+ inputSchema: {
69
+ trackerId: z.string().describe('Issue tracker node id'),
70
+ title: z.string().describe('Issue title'),
71
+ body: z.string().optional().describe('Issue body (markdown)'),
72
+ labels: z.array(z.string()).optional().describe('Label ids to attach'),
73
+ type: z.string().optional().describe('Issue type id'),
74
+ parentIssueId: z.string().optional().describe('Parent issue id, to create this as a sub-issue'),
75
+ },
76
+ },
77
+ async ({ trackerId, title, body, labels, type, parentIssueId }) => {
78
+ try {
79
+ const client = makeClient();
80
+ const result = await client.createIssue(trackerId, { title, body, labels, type, parentIssueId });
81
+ return jsonResult(result);
82
+ } catch (err) {
83
+ return errorResult(err);
84
+ }
85
+ },
86
+ );
87
+
88
+ server.registerTool(
89
+ 'rootr_get_issue',
90
+ {
91
+ title: 'Get an issue',
92
+ description: 'Fetch full details of a single Rootr (루터) issue by id.',
93
+ inputSchema: {
94
+ issueId: z.string().describe('Issue id'),
95
+ },
96
+ },
97
+ async ({ issueId }) => {
98
+ try {
99
+ const client = makeClient();
100
+ const result = await client.getIssue(issueId);
101
+ return jsonResult(result);
102
+ } catch (err) {
103
+ return errorResult(err);
104
+ }
105
+ },
106
+ );
107
+
108
+ server.registerTool(
109
+ 'rootr_update_issue',
110
+ {
111
+ title: 'Update an issue',
112
+ description:
113
+ 'Update arbitrary fields of a Rootr (루터) issue. To close an issue, set state to "CLOSED" and stateReason ' +
114
+ 'to one of completed | not_planned | duplicate.',
115
+ inputSchema: {
116
+ issueId: z.string().describe('Issue id'),
117
+ title: z.string().optional(),
118
+ body: z.string().optional(),
119
+ state: z.string().optional().describe('e.g. "OPEN" or "CLOSED"'),
120
+ stateReason: z.string().optional().describe('completed | not_planned | duplicate (when closing)'),
121
+ labels: z.array(z.string()).optional().describe('Label ids (replaces current labels)'),
122
+ type: z.string().optional().describe('Issue type id'),
123
+ assigneeId: z.string().optional().describe('User id to assign'),
124
+ },
125
+ },
126
+ async ({ issueId, ...patch }) => {
127
+ try {
128
+ const client = makeClient();
129
+ // Drop undefined keys so we don't send explicit nulls for untouched fields.
130
+ const body = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
131
+ const result = await client.updateIssue(issueId, body);
132
+ return jsonResult(result);
133
+ } catch (err) {
134
+ return errorResult(err);
135
+ }
136
+ },
137
+ );
138
+
139
+ server.registerTool(
140
+ 'rootr_comment_issue',
141
+ {
142
+ title: 'Comment on an issue',
143
+ description:
144
+ 'Add a comment to a Rootr (루터) issue within its tracker. Comments carry a user identity, ' +
145
+ 'so this requires an ACCOUNT key (PAT) as ROOTR_API_KEY — a workspace key gets a 403.',
146
+ inputSchema: {
147
+ trackerId: z.string().describe('Issue tracker node id'),
148
+ issueId: z.string().describe('Issue id to comment on'),
149
+ body: z.string().describe('Comment body (markdown)'),
150
+ },
151
+ },
152
+ async ({ trackerId, issueId, body }) => {
153
+ try {
154
+ const client = makeClient();
155
+ const result = await client.commentIssue(trackerId, issueId, body);
156
+ return textResult(typeof result === 'string' ? result : JSON.stringify(result, null, 2));
157
+ } catch (err) {
158
+ return errorResult(err);
159
+ }
160
+ },
161
+ );
162
+ }
@@ -0,0 +1,158 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
4
+
5
+ /** LOG datastore tools: create/update-fields/add-entries/query-entries/stats. */
6
+ export function registerLogTools(server) {
7
+ server.registerTool(
8
+ 'rootr_create_log_store',
9
+ {
10
+ title: 'Create a typed LOG datastore',
11
+ description:
12
+ 'Create a new typed LOG store (a structured, queryable table-like node) in a Rootr (루터) workspace. ' +
13
+ 'Each field has a name/type; type is one of string|int|float|bool|datetime|json|enum|level|relation. ' +
14
+ 'Relation fields additionally need target (a node id to relate to) and optional many/relation flags. ' +
15
+ 'Use rootr_add_log_entries afterward to insert rows, and rootr_query_log_entries / rootr_log_stats to read them back.',
16
+ inputSchema: {
17
+ name: z.string().describe('Name of the LOG store'),
18
+ fields: z
19
+ .array(z.record(z.any()))
20
+ .describe(
21
+ 'Field definitions: [{name, type, ...}]. type=string|int|float|bool|datetime|json|enum|level|relation. ' +
22
+ 'relation fields add target (node id), many?, relation?.',
23
+ ),
24
+ parentId: z.string().optional().describe('Parent node id to nest the LOG store under'),
25
+ workspace: z
26
+ .string()
27
+ .optional()
28
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
29
+ },
30
+ },
31
+ async ({ name, fields, parentId, workspace }) => {
32
+ try {
33
+ const ws = requireWorkspace(workspace);
34
+ const client = makeClient(ws);
35
+ const result = await client.createLogStore(ws, { name, fields, parentId });
36
+ return jsonResult(result);
37
+ } catch (err) {
38
+ return errorResult(err);
39
+ }
40
+ },
41
+ );
42
+
43
+ server.registerTool(
44
+ 'rootr_update_log_fields',
45
+ {
46
+ title: 'Replace a LOG store\'s field definitions (full replace)',
47
+ description:
48
+ 'Update the field schema of an existing LOG store. WARNING: this REPLACES THE ENTIRE FIELD SET — ' +
49
+ 'you must resend every field you want to keep, not just the ones you are changing/adding. ' +
50
+ 'Read the LOG store first (e.g. via rootr_read or a get-log call) to see its current fields before calling this.',
51
+ inputSchema: {
52
+ logId: z.string().describe('LOG store node id'),
53
+ fields: z
54
+ .array(z.record(z.any()))
55
+ .describe('COMPLETE field list to set — omitted existing fields will be dropped'),
56
+ },
57
+ },
58
+ async ({ logId, fields }) => {
59
+ try {
60
+ const client = makeClient();
61
+ const result = await client.updateLogFields(logId, fields);
62
+ return jsonResult(result);
63
+ } catch (err) {
64
+ return errorResult(err);
65
+ }
66
+ },
67
+ );
68
+
69
+ server.registerTool(
70
+ 'rootr_add_log_entries',
71
+ {
72
+ title: 'Add entries to a LOG store',
73
+ description:
74
+ 'Append one or more entries (rows) to a LOG store. Each entry may carry ts, source, level, message, data ' +
75
+ '(shaped per the store\'s field schema). This is additive — existing entries are untouched.',
76
+ inputSchema: {
77
+ logId: z.string().describe('LOG store node id'),
78
+ entries: z
79
+ .array(z.record(z.any()))
80
+ .describe('Entries to add: [{ts?, source?, level?, message?, data?}, ...]'),
81
+ },
82
+ },
83
+ async ({ logId, entries }) => {
84
+ try {
85
+ const client = makeClient();
86
+ const result = await client.addLogEntries(logId, entries);
87
+ return jsonResult(result);
88
+ } catch (err) {
89
+ return errorResult(err);
90
+ }
91
+ },
92
+ );
93
+
94
+ server.registerTool(
95
+ 'rootr_query_log_entries',
96
+ {
97
+ title: 'Query entries from a LOG store',
98
+ description:
99
+ 'Query/filter entries from a LOG store by time range, source, level, and/or anomaly flag. ' +
100
+ 'Use this to inspect recent activity or find anomalous entries before asking rootr_ask for root-cause analysis.',
101
+ inputSchema: {
102
+ logId: z.string().describe('LOG store node id'),
103
+ from: z.string().optional().describe('ISO timestamp lower bound'),
104
+ to: z.string().optional().describe('ISO timestamp upper bound'),
105
+ source: z.string().optional().describe('Filter by source'),
106
+ level: z.string().optional().describe('Filter by level'),
107
+ anomalyOnly: z.boolean().optional().describe('Only return entries flagged as anomalous'),
108
+ limit: z.number().optional().describe('Max entries to return'),
109
+ order: z.string().optional().describe('Sort order, e.g. "asc" or "desc"'),
110
+ },
111
+ },
112
+ async ({ logId, from, to, source, level, anomalyOnly, limit, order }) => {
113
+ try {
114
+ const client = makeClient();
115
+ const result = await client.queryLogEntries(logId, {
116
+ from,
117
+ to,
118
+ source,
119
+ level,
120
+ anomalyOnly,
121
+ limit,
122
+ order,
123
+ });
124
+ return jsonResult(result);
125
+ } catch (err) {
126
+ return errorResult(err);
127
+ }
128
+ },
129
+ );
130
+
131
+ server.registerTool(
132
+ 'rootr_log_stats',
133
+ {
134
+ title: 'Aggregate stats over a LOG store',
135
+ description:
136
+ 'Compute aggregate statistics over a LOG store\'s entries, grouped by hour/day/source/level, with a ' +
137
+ 'metric of count/avg/max/min/sum (avg/max/min/sum require a numeric `field`). Useful for trend/volume ' +
138
+ 'questions without pulling raw entries.',
139
+ inputSchema: {
140
+ logId: z.string().describe('LOG store node id'),
141
+ groupBy: z.string().optional().describe('hour | day | source | level'),
142
+ metric: z.string().optional().describe('count | avg | max | min | sum'),
143
+ field: z.string().optional().describe('Numeric field name, required for avg/max/min/sum'),
144
+ from: z.string().optional().describe('ISO timestamp lower bound'),
145
+ to: z.string().optional().describe('ISO timestamp upper bound'),
146
+ },
147
+ },
148
+ async ({ logId, groupBy, metric, field, from, to }) => {
149
+ try {
150
+ const client = makeClient();
151
+ const result = await client.logStats(logId, { groupBy, metric, field, from, to });
152
+ return jsonResult(result);
153
+ } catch (err) {
154
+ return errorResult(err);
155
+ }
156
+ },
157
+ );
158
+ }
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient } from './shared.js';
4
+ import { resolveTarget, requireResolved } from '../resolve.js';
5
+
6
+ /** Miscellaneous document/node tools: version history, trash. */
7
+ export function registerMiscTools(server) {
8
+ server.registerTool(
9
+ 'rootr_document_versions',
10
+ {
11
+ title: 'List a document\'s version history',
12
+ description:
13
+ 'List saved versions of a Rootr (루터) document, most recent first. Useful for seeing what changed and ' +
14
+ 'when, or to recover an older revision.',
15
+ inputSchema: {
16
+ target: z.string().describe('Document path (starting with "/") or node id'),
17
+ limit: z.number().optional().describe('Max number of versions to return'),
18
+ },
19
+ },
20
+ async ({ target, limit }) => {
21
+ try {
22
+ const client = makeClient();
23
+ const resolved = await resolveTarget(client, target);
24
+ const id = requireResolved(resolved);
25
+ const result = await client.getDocumentVersions(id, { limit });
26
+ return jsonResult(result);
27
+ } catch (err) {
28
+ return errorResult(err);
29
+ }
30
+ },
31
+ );
32
+
33
+ server.registerTool(
34
+ 'rootr_delete_node',
35
+ {
36
+ title: 'Delete a node (move to trash)',
37
+ description:
38
+ 'Delete a Rootr (루터) document or folder by moving it to the trash — this is recoverable, not a ' +
39
+ 'permanent delete. Use rootr_read/rootr_list first to confirm you have the right target.',
40
+ inputSchema: {
41
+ target: z.string().describe('Document/folder path (starting with "/") or node id'),
42
+ },
43
+ },
44
+ async ({ target }) => {
45
+ try {
46
+ const client = makeClient();
47
+ const resolved = await resolveTarget(client, target);
48
+ const id = requireResolved(resolved);
49
+ await client.deleteNode(id);
50
+ return jsonResult({ deleted: id, note: 'moved to trash, recoverable' });
51
+ } catch (err) {
52
+ return errorResult(err);
53
+ }
54
+ },
55
+ );
56
+ }
@@ -0,0 +1,48 @@
1
+ import { loadConfig } from '../config.js';
2
+ import { RootrClient, RootrApiError } from '../client.js';
3
+
4
+ export function textResult(text) {
5
+ return { content: [{ type: 'text', text }] };
6
+ }
7
+
8
+ export function errorResult(err) {
9
+ const message =
10
+ err instanceof RootrApiError
11
+ ? `${err.message}${err.hint ? `\n힌트: ${err.hint}` : ''}`
12
+ : err.message || String(err);
13
+ return { content: [{ type: 'text', text: message }], isError: true };
14
+ }
15
+
16
+ export function jsonResult(value) {
17
+ return textResult(JSON.stringify(value, null, 2));
18
+ }
19
+
20
+ /**
21
+ * Build a RootrClient. If `workspaceOverride` is given, it wins over the
22
+ * configured/env workspace — this backs the optional `workspace` argument
23
+ * that every workspace-scoped MCP tool accepts.
24
+ */
25
+ export function makeClient(workspaceOverride) {
26
+ const cfg = loadConfig();
27
+ const workspace = workspaceOverride || cfg.workspace;
28
+ return new RootrClient({ ...cfg, workspace });
29
+ }
30
+
31
+ /**
32
+ * Resolve the effective workspace id for a tool call: explicit arg wins,
33
+ * then ROOTR_WORKSPACE/config. Throws a clear RootrApiError otherwise.
34
+ */
35
+ export function requireWorkspace(workspaceOverride) {
36
+ const cfg = loadConfig();
37
+ const workspace = workspaceOverride || cfg.workspace;
38
+ if (!workspace) {
39
+ throw new RootrApiError('워크스페이스가 설정되지 않았습니다.', {
40
+ hint:
41
+ '이 도구의 workspace 인자로 워크스페이스 id를 넘기거나, ' +
42
+ 'rootr config --workspace <id> 로 설정하거나 ROOTR_WORKSPACE 환경변수를 지정하세요.',
43
+ });
44
+ }
45
+ return workspace;
46
+ }
47
+
48
+ export { RootrApiError };