tana-mcp-codemode 0.1.0 → 0.2.2
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/LICENSE.MD +9 -0
- package/README.md +123 -151
- package/package.json +7 -3
- package/src/api/client.ts +3 -3
- package/src/api/format.ts +125 -0
- package/src/api/tana.ts +125 -20
- package/src/api/types.ts +10 -20
- package/src/index.ts +92 -13
- package/src/prompts.ts +32 -16
- package/src/sandbox/executor.ts +21 -16
- package/src/sandbox/stdin.ts +0 -69
package/LICENSE.MD
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fábio galiano (fabiogaliano.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A codemode MCP server for [Tana](https://tana.inc) knowledge management. AI writ
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Codemode Pattern** — AI writes executable TypeScript, not structured API calls
|
|
8
|
-
- **
|
|
8
|
+
- **Tana Local API** — Workspaces, nodes, tags, fields, calendar, import
|
|
9
9
|
- **Top-level Await** — `await tana.workspaces.list()` works directly
|
|
10
10
|
- **Timeout Protection** — 10s max execution prevents infinite loops
|
|
11
11
|
- **Script History** — SQLite persistence for debugging and replay
|
|
@@ -37,18 +37,18 @@ bun install
|
|
|
37
37
|
|
|
38
38
|
## Configuration
|
|
39
39
|
|
|
40
|
-
| Variable
|
|
41
|
-
|
|
|
42
|
-
| `TANA_API_TOKEN`
|
|
43
|
-
| `TANA_API_URL`
|
|
44
|
-
| `TANA_TIMEOUT`
|
|
45
|
-
| `TANA_HISTORY_PATH`
|
|
40
|
+
| Variable | Default | Description |
|
|
41
|
+
| ----------------------- | ----------------------- | -------------------------------------------------------------- |
|
|
42
|
+
| `TANA_API_TOKEN` | (required) | Bearer token from Tana Desktop |
|
|
43
|
+
| `TANA_API_URL` | `http://127.0.0.1:8262` | Tana Local API URL |
|
|
44
|
+
| `TANA_TIMEOUT` | `10000` | Request timeout in ms |
|
|
45
|
+
| `TANA_HISTORY_PATH` | (platform default) | Custom path for SQLite history database |
|
|
46
|
+
| `MAIN_TANA_WORKSPACE` | (none) | Default workspace name or ID, resolved at startup |
|
|
47
|
+
| `TANA_SEARCH_WORKSPACES`| (none) | Comma-separated workspace names or IDs for default search scoping |
|
|
46
48
|
|
|
47
49
|
## MCP Integration
|
|
48
50
|
|
|
49
|
-
Add to your
|
|
50
|
-
|
|
51
|
-
### If installed globally via bun:
|
|
51
|
+
Add to your MCP client's configuration:
|
|
52
52
|
|
|
53
53
|
```json
|
|
54
54
|
{
|
|
@@ -56,14 +56,25 @@ Add to your Claude Desktop config (`claude_desktop_config.json`):
|
|
|
56
56
|
"tana": {
|
|
57
57
|
"command": "tana-mcp-codemode",
|
|
58
58
|
"env": {
|
|
59
|
-
"TANA_API_TOKEN": "your_token_here"
|
|
59
|
+
"TANA_API_TOKEN": "your_token_here",
|
|
60
|
+
// Optional
|
|
61
|
+
"MAIN_TANA_WORKSPACE": "My Workspace",
|
|
62
|
+
"TANA_SEARCH_WORKSPACES": "My Workspace",
|
|
63
|
+
// Optional: customize where the SQLite history database is stored
|
|
64
|
+
"TANA_HISTORY_PATH": "/path/to/history.db"
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
```
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
| Client | Config Location |
|
|
72
|
+
| ----------------- | ----------------------------------------------------------- |
|
|
73
|
+
| Claude Desktop | `claude_desktop_config.json` |
|
|
74
|
+
| Claude Code | `.mcp.json` (project) or `~/.claude/settings.json` (global) |
|
|
75
|
+
| Cursor / Windsurf | IDE MCP settings |
|
|
76
|
+
|
|
77
|
+
**If installed from source**, use `bun` as the command:
|
|
67
78
|
|
|
68
79
|
```json
|
|
69
80
|
{
|
|
@@ -72,108 +83,18 @@ Add to your Claude Desktop config (`claude_desktop_config.json`):
|
|
|
72
83
|
"command": "bun",
|
|
73
84
|
"args": ["run", "/path/to/tana-mcp-codemode/src/index.ts"],
|
|
74
85
|
"env": {
|
|
75
|
-
"TANA_API_TOKEN": "your_token_here"
|
|
86
|
+
"TANA_API_TOKEN": "your_token_here",
|
|
87
|
+
// Optional
|
|
88
|
+
"MAIN_TANA_WORKSPACE": "My Workspace",
|
|
89
|
+
"TANA_SEARCH_WORKSPACES": "My Workspace",
|
|
90
|
+
// Optional: customize where the SQLite history database is stored
|
|
91
|
+
"TANA_HISTORY_PATH": "/path/to/history.db"
|
|
76
92
|
}
|
|
77
93
|
}
|
|
78
94
|
}
|
|
79
95
|
}
|
|
80
96
|
```
|
|
81
97
|
|
|
82
|
-
## API Reference
|
|
83
|
-
|
|
84
|
-
The server exposes a single `execute` tool. AI writes TypeScript code with access to the `tana` object:
|
|
85
|
-
|
|
86
|
-
### Workspaces
|
|
87
|
-
|
|
88
|
-
```typescript
|
|
89
|
-
const workspaces = await tana.workspaces.list();
|
|
90
|
-
// → Workspace[] (id, name, homeNodeId)
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
### Nodes
|
|
94
|
-
|
|
95
|
-
```typescript
|
|
96
|
-
await tana.nodes.search(query, options?) // → SearchResult[]
|
|
97
|
-
await tana.nodes.read(nodeId, maxDepth?) // → string (markdown)
|
|
98
|
-
await tana.nodes.getChildren(nodeId, opts?) // → { children, total, hasMore }
|
|
99
|
-
await tana.nodes.edit({ nodeId, name?, description? })
|
|
100
|
-
await tana.nodes.trash(nodeId)
|
|
101
|
-
await tana.nodes.check(nodeId) // mark done
|
|
102
|
-
await tana.nodes.uncheck(nodeId) // mark undone
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
### Tags (Supertags)
|
|
106
|
-
|
|
107
|
-
```typescript
|
|
108
|
-
await tana.tags.list(workspaceId, limit?)
|
|
109
|
-
await tana.tags.getSchema(tagId, includeEditInstructions?)
|
|
110
|
-
await tana.tags.modify(nodeId, action, tagIds) // action: "add" | "remove"
|
|
111
|
-
await tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
|
|
112
|
-
await tana.tags.addField({ tagId, name, dataType, ... })
|
|
113
|
-
await tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
### Fields
|
|
117
|
-
|
|
118
|
-
```typescript
|
|
119
|
-
await tana.fields.setOption(nodeId, attributeId, optionId) // dropdown fields
|
|
120
|
-
await tana.fields.setContent(nodeId, attributeId, content) // text/date/url fields
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
### Calendar
|
|
124
|
-
|
|
125
|
-
```typescript
|
|
126
|
-
await tana.calendar.getOrCreate(workspaceId, granularity, date?)
|
|
127
|
-
// granularity: "day" | "week" | "month" | "year"
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
### Import (Tana Paste)
|
|
131
|
-
|
|
132
|
-
```typescript
|
|
133
|
-
await tana.import(parentNodeId, tanaPasteContent)
|
|
134
|
-
// → { success, nodeIds?, error? }
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
### Utility
|
|
138
|
-
|
|
139
|
-
```typescript
|
|
140
|
-
await tana.health() // → { status: "ok" }
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
## Script Helpers
|
|
144
|
-
|
|
145
|
-
### stdin() — Input Data
|
|
146
|
-
|
|
147
|
-
Pass data to scripts via the `input` parameter:
|
|
148
|
-
|
|
149
|
-
```typescript
|
|
150
|
-
// Parse JSON input
|
|
151
|
-
const data = stdin().json<{ ids: string[] }>();
|
|
152
|
-
|
|
153
|
-
// Split into lines
|
|
154
|
-
const lines = stdin().lines();
|
|
155
|
-
|
|
156
|
-
// Raw text
|
|
157
|
-
const text = stdin().text();
|
|
158
|
-
|
|
159
|
-
// Check if input exists
|
|
160
|
-
if (stdin().hasInput()) {
|
|
161
|
-
// process input
|
|
162
|
-
}
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### workflow — Progress Tracking
|
|
166
|
-
|
|
167
|
-
Track multi-step operations:
|
|
168
|
-
|
|
169
|
-
```typescript
|
|
170
|
-
workflow.start("Processing nodes");
|
|
171
|
-
workflow.step("Fetching workspaces");
|
|
172
|
-
workflow.progress(5, 100, "Processing");
|
|
173
|
-
workflow.complete("Done!");
|
|
174
|
-
// Or: workflow.abort("Error message");
|
|
175
|
-
```
|
|
176
|
-
|
|
177
98
|
## Examples
|
|
178
99
|
|
|
179
100
|
### Search for nodes
|
|
@@ -208,16 +129,6 @@ await tana.import(parentNodeId, `
|
|
|
208
129
|
`);
|
|
209
130
|
```
|
|
210
131
|
|
|
211
|
-
### Process input data
|
|
212
|
-
|
|
213
|
-
```typescript
|
|
214
|
-
const { nodeIds } = stdin().json<{ nodeIds: string[] }>();
|
|
215
|
-
for (const id of nodeIds) {
|
|
216
|
-
const content = await tana.nodes.read(id);
|
|
217
|
-
console.log(content);
|
|
218
|
-
}
|
|
219
|
-
```
|
|
220
|
-
|
|
221
132
|
## Debug UI
|
|
222
133
|
|
|
223
134
|
A WebSocket-based dashboard for testing scripts:
|
|
@@ -229,11 +140,29 @@ bun run src/debug-server.ts
|
|
|
229
140
|
# Open http://localhost:3333
|
|
230
141
|
```
|
|
231
142
|
|
|
232
|
-
|
|
143
|
+
**Routes:**
|
|
144
|
+
- `http://localhost:3333/#debug` — Script execution console
|
|
145
|
+
- `http://localhost:3333/#benchmark` — Codemode vs tana-local performance comparison
|
|
146
|
+
|
|
147
|
+
**Features:**
|
|
233
148
|
- Real-time script execution
|
|
234
149
|
- Workflow event timeline
|
|
235
|
-
- Input data testing
|
|
236
150
|
- Error display with suggestions
|
|
151
|
+
- Benchmark results visualization (cost, speed, token usage)
|
|
152
|
+
|
|
153
|
+
### workflow Helper (Debug UI only)
|
|
154
|
+
|
|
155
|
+
Track multi-step operations with a timeline view:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
workflow.start("Processing nodes");
|
|
159
|
+
workflow.step("Fetching workspaces");
|
|
160
|
+
workflow.progress(5, 100, "Processing");
|
|
161
|
+
workflow.complete("Done!");
|
|
162
|
+
// Or: workflow.abort("Error message");
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
> **Note**: `workflow` is only available in the Debug UI. It's not exposed in the production MCP prompt.
|
|
237
166
|
|
|
238
167
|
### Building the React UI (optional)
|
|
239
168
|
|
|
@@ -259,7 +188,6 @@ src/
|
|
|
259
188
|
│ └── types.ts # API type definitions
|
|
260
189
|
├── sandbox/
|
|
261
190
|
│ ├── executor.ts # Code execution engine
|
|
262
|
-
│ ├── stdin.ts # Input data helper
|
|
263
191
|
│ └── workflow.ts # Progress tracking
|
|
264
192
|
├── storage/
|
|
265
193
|
│ └── history.ts # SQLite script history
|
|
@@ -272,8 +200,8 @@ ui/ # React debug dashboard
|
|
|
272
200
|
### How It Works
|
|
273
201
|
|
|
274
202
|
1. AI sends TypeScript code to the `execute` tool
|
|
275
|
-
2. `
|
|
276
|
-
3. Code runs with injected `tana
|
|
203
|
+
2. `executor.ts` creates an AsyncFunction for top-level await
|
|
204
|
+
3. Code runs with injected `tana` and `console` objects
|
|
277
205
|
4. 10s timeout via Promise.race prevents hangs
|
|
278
206
|
5. `console.log()` output is captured and returned
|
|
279
207
|
6. Script run is saved to SQLite history
|
|
@@ -290,38 +218,19 @@ Runs are persisted to SQLite:
|
|
|
290
218
|
|
|
291
219
|
Old runs (>30 days) are automatically cleaned up on startup.
|
|
292
220
|
|
|
293
|
-
### Custom History Location
|
|
294
|
-
|
|
295
|
-
Set `TANA_HISTORY_PATH` to use a custom database path:
|
|
296
|
-
|
|
297
|
-
```json
|
|
298
|
-
{
|
|
299
|
-
"mcpServers": {
|
|
300
|
-
"tana": {
|
|
301
|
-
"command": "tana-mcp-codemode",
|
|
302
|
-
"env": {
|
|
303
|
-
"TANA_API_TOKEN": "your_token_here",
|
|
304
|
-
"TANA_HISTORY_PATH": "/path/to/custom/tana-history.db"
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
```
|
|
310
|
-
|
|
311
221
|
### What Gets Saved
|
|
312
222
|
|
|
313
223
|
Each script run records:
|
|
314
224
|
|
|
315
|
-
| Field
|
|
316
|
-
|
|
317
|
-
| `script`
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
322
|
-
| `
|
|
323
|
-
| `
|
|
324
|
-
| `duration_ms` | Execution time |
|
|
225
|
+
| Field | Description |
|
|
226
|
+
| ------------------- | ------------------------------------- |
|
|
227
|
+
| `script` | The TypeScript code that was executed |
|
|
228
|
+
| `output` | Captured `console.log()` output |
|
|
229
|
+
| `error` | Error message if execution failed |
|
|
230
|
+
| `api_calls` | Which Tana API methods were called |
|
|
231
|
+
| `node_ids_affected` | Node IDs that were read/modified |
|
|
232
|
+
| `workspace_id` | Workspace used (if detected) |
|
|
233
|
+
| `duration_ms` | Execution time |
|
|
325
234
|
|
|
326
235
|
## Development
|
|
327
236
|
|
|
@@ -339,6 +248,69 @@ bun run generate
|
|
|
339
248
|
bun run debug
|
|
340
249
|
```
|
|
341
250
|
|
|
251
|
+
## API Reference
|
|
252
|
+
|
|
253
|
+
The server exposes a single `execute` tool. AI writes TypeScript code with access to the `tana` object:
|
|
254
|
+
|
|
255
|
+
### Workspaces
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
const workspaces = await tana.workspaces.list();
|
|
259
|
+
// → Workspace[] (id, name, homeNodeId)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Nodes
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
await tana.nodes.search(query, options?) // → SearchResult[]
|
|
266
|
+
await tana.nodes.read(nodeId, maxDepth?) // → string (markdown)
|
|
267
|
+
await tana.nodes.getChildren(nodeId, opts?) // → { children, total, hasMore }
|
|
268
|
+
await tana.nodes.edit({ nodeId, name?, description? })
|
|
269
|
+
await tana.nodes.trash(nodeId)
|
|
270
|
+
await tana.nodes.check(nodeId) // mark done
|
|
271
|
+
await tana.nodes.uncheck(nodeId) // mark undone
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Tags (Supertags)
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
await tana.tags.listAll(workspaceId) // All workspace supertags (paginated)
|
|
278
|
+
await tana.tags.getSchema(tagId, opts?) // opts: { includeInheritedFields? }
|
|
279
|
+
await tana.tags.modify(nodeId, action, tagIds) // action: "add" | "remove"
|
|
280
|
+
await tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
|
|
281
|
+
await tana.tags.addField({ tagId, name, dataType, ... })
|
|
282
|
+
await tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Fields
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
await tana.fields.setOption(nodeId, attributeId, optionId) // dropdown fields
|
|
289
|
+
await tana.fields.setContent(nodeId, attributeId, content) // text/date/url fields
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Calendar
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
await tana.calendar.getOrCreate(workspaceId, granularity, date?)
|
|
296
|
+
// granularity: "day" | "week" | "month" | "year"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Import (Tana Paste)
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
await tana.import(parentNodeId, tanaPasteContent)
|
|
303
|
+
// → { success, nodeIds?, error? }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Utility
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
tana.workspace // Pre-resolved default workspace (from MAIN_TANA_WORKSPACE) or null
|
|
310
|
+
tana.format(data) // Compact display of any API response
|
|
311
|
+
await tana.health() // → { status: "ok" }
|
|
312
|
+
```
|
|
313
|
+
|
|
342
314
|
## License
|
|
343
315
|
|
|
344
316
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tana-mcp-codemode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Codemode MCP server for Tana — AI writes TypeScript that executes against Tana Local API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
"start": "bun run --env-file=.env src/index.ts",
|
|
14
14
|
"dev": "bun run --env-file=.env --watch src/index.ts",
|
|
15
15
|
"debug": "bun run --env-file=.env src/debug-server.ts",
|
|
16
|
+
"test": "bun test",
|
|
16
17
|
"typecheck": "bunx tsc --noEmit",
|
|
17
|
-
"prepublishOnly": "bun run typecheck"
|
|
18
|
+
"prepublishOnly": "bun run typecheck",
|
|
19
|
+
"release:patch": "npm version patch && npm publish",
|
|
20
|
+
"release:minor": "npm version minor && npm publish",
|
|
21
|
+
"release:major": "npm version major && npm publish"
|
|
18
22
|
},
|
|
19
23
|
"files": [
|
|
20
24
|
"src/index.ts",
|
|
@@ -36,7 +40,7 @@
|
|
|
36
40
|
],
|
|
37
41
|
"repository": {
|
|
38
42
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/fabiogaliano/tana-mcp-codemode"
|
|
43
|
+
"url": "git+https://github.com/fabiogaliano/tana-mcp-codemode.git"
|
|
40
44
|
},
|
|
41
45
|
"engines": {
|
|
42
46
|
"bun": ">=1.0.0"
|
package/src/api/client.ts
CHANGED
|
@@ -18,8 +18,8 @@ export class TanaAPIError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const RETRY_CONFIG = {
|
|
21
|
-
maxRetries:
|
|
22
|
-
baseDelayMs:
|
|
21
|
+
maxRetries: 2,
|
|
22
|
+
baseDelayMs: 500,
|
|
23
23
|
retryableCodes: new Set([408, 429, 500, 502, 503, 504]),
|
|
24
24
|
};
|
|
25
25
|
|
|
@@ -230,7 +230,7 @@ export class TanaClient {
|
|
|
230
230
|
export function createClient(): TanaClient {
|
|
231
231
|
const baseUrl = process.env.TANA_API_URL || "http://127.0.0.1:8262";
|
|
232
232
|
const token = process.env.TANA_API_TOKEN;
|
|
233
|
-
const timeout = parseInt(process.env.TANA_TIMEOUT || "
|
|
233
|
+
const timeout = parseInt(process.env.TANA_TIMEOUT || "3000", 10);
|
|
234
234
|
|
|
235
235
|
if (!token) {
|
|
236
236
|
throw new Error(
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact Formatter for Tana API Responses
|
|
3
|
+
*
|
|
4
|
+
* Pure function — detects response shape and returns a compact string representation.
|
|
5
|
+
* Designed to reduce token waste when AI logs API results via console.log(tana.format(data)).
|
|
6
|
+
*
|
|
7
|
+
* Shape detection uses discriminating fields from the OpenAPI spec:
|
|
8
|
+
* - SearchResult[]: has `breadcrumb`
|
|
9
|
+
* - Children: has `children` + `total` + `hasMore`
|
|
10
|
+
* - ChildNode[]: has `childCount`
|
|
11
|
+
* - Tag[]: has `id` + `name`, no `breadcrumb`/`childCount`/`homeNodeId`
|
|
12
|
+
* - Workspace[]: has `homeNodeId`
|
|
13
|
+
* - ImportResult: has `success` + (`nodeIds` or `error`)
|
|
14
|
+
* - string: passthrough (read/getSchema already return markdown)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function hasKey(obj: unknown, key: string): boolean {
|
|
18
|
+
return typeof obj === "object" && obj !== null && key in obj;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatTags(tags: { id: string; name: string }[]): string {
|
|
22
|
+
if (!tags || tags.length === 0) return "";
|
|
23
|
+
return " " + tags.map((t) => `#${t.name}`).join(" ");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatSearchResults(data: unknown[]): string {
|
|
27
|
+
const lines = data.map((r: any) => {
|
|
28
|
+
const tags = formatTags(r.tags);
|
|
29
|
+
const breadcrumb = r.breadcrumb?.length ? ` — ${r.breadcrumb.join(" > ")}` : "";
|
|
30
|
+
return ` [${r.id}] ${r.name}${tags}${breadcrumb}`;
|
|
31
|
+
});
|
|
32
|
+
return `${data.length} results:\n${lines.join("\n")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatChildren(data: any): string {
|
|
36
|
+
const showing = data.children.length;
|
|
37
|
+
const header = `${data.total} children (showing ${showing}${data.hasMore ? ", has more" : ""}):`;
|
|
38
|
+
const lines = data.children.map((c: any) => {
|
|
39
|
+
const tags = formatTags(c.tags);
|
|
40
|
+
const kids = c.childCount > 0 ? ` (${c.childCount} children)` : "";
|
|
41
|
+
return ` [${c.id}] ${c.name}${tags}${kids}`;
|
|
42
|
+
});
|
|
43
|
+
return `${header}\n${lines.join("\n")}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatChildNodes(data: unknown[]): string {
|
|
47
|
+
const lines = data.map((c: any) => {
|
|
48
|
+
const tags = formatTags(c.tags);
|
|
49
|
+
const kids = c.childCount > 0 ? ` (${c.childCount} children)` : "";
|
|
50
|
+
return ` [${c.id}] ${c.name}${tags}${kids}`;
|
|
51
|
+
});
|
|
52
|
+
return `${data.length} nodes:\n${lines.join("\n")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatTags_(data: unknown[]): string {
|
|
56
|
+
const lines = data.map((t: any) => ` [${t.id}] ${t.name}`);
|
|
57
|
+
return `${data.length} tags:\n${lines.join("\n")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatWorkspaces(data: unknown[]): string {
|
|
61
|
+
const lines = data.map((w: any) => ` [${w.id}] ${w.name} (home: ${w.homeNodeId})`);
|
|
62
|
+
return `${data.length} workspaces:\n${lines.join("\n")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatImportResult(data: any): string {
|
|
66
|
+
if (data.success) {
|
|
67
|
+
const ids = data.nodeIds?.length ? ` (${data.nodeIds.join(", ")})` : "";
|
|
68
|
+
return `Import success: ${data.nodeIds?.length ?? 0} nodes${ids}`;
|
|
69
|
+
}
|
|
70
|
+
return `Import failed: ${data.error ?? "unknown error"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compact formatter for any Tana API response.
|
|
75
|
+
*
|
|
76
|
+
* Detects the shape of the data and returns a human-readable, token-efficient string.
|
|
77
|
+
* Falls back to JSON.stringify for unrecognized shapes.
|
|
78
|
+
*/
|
|
79
|
+
export function format(data: unknown): string {
|
|
80
|
+
if (typeof data === "string") return data;
|
|
81
|
+
if (data === null || data === undefined) return String(data);
|
|
82
|
+
|
|
83
|
+
// ImportResult: { success, nodeIds?, error? }
|
|
84
|
+
if (hasKey(data, "success") && (hasKey(data, "nodeIds") || hasKey(data, "error"))) {
|
|
85
|
+
return formatImportResult(data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Children: { children, total, hasMore }
|
|
89
|
+
if (hasKey(data, "children") && hasKey(data, "total") && hasKey(data, "hasMore")) {
|
|
90
|
+
return formatChildren(data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(data)) {
|
|
94
|
+
if (data.length === 0) return "0 results";
|
|
95
|
+
|
|
96
|
+
const first = data[0];
|
|
97
|
+
|
|
98
|
+
// SearchResult[]: has `breadcrumb`
|
|
99
|
+
if (hasKey(first, "breadcrumb")) {
|
|
100
|
+
return formatSearchResults(data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ChildNode[]: has `childCount`
|
|
104
|
+
if (hasKey(first, "childCount")) {
|
|
105
|
+
return formatChildNodes(data);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Workspace[]: has `homeNodeId`
|
|
109
|
+
if (hasKey(first, "homeNodeId")) {
|
|
110
|
+
return formatWorkspaces(data);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Tag[]: has `id` + `name`, no discriminators for other types
|
|
114
|
+
if (hasKey(first, "id") && hasKey(first, "name") && !hasKey(first, "breadcrumb") && !hasKey(first, "childCount") && !hasKey(first, "homeNodeId")) {
|
|
115
|
+
return formatTags_(data);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fallback: pretty JSON
|
|
120
|
+
try {
|
|
121
|
+
return JSON.stringify(data, null, 2);
|
|
122
|
+
} catch {
|
|
123
|
+
return String(data);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/api/tana.ts
CHANGED
|
@@ -16,12 +16,14 @@ import type {
|
|
|
16
16
|
SearchResult,
|
|
17
17
|
Children,
|
|
18
18
|
EditNodeOptions,
|
|
19
|
+
MoveNodeOptions,
|
|
19
20
|
Tag,
|
|
20
21
|
CreateTagOptions,
|
|
21
22
|
AddFieldOptions,
|
|
22
23
|
SetCheckboxOptions,
|
|
23
24
|
ImportResult,
|
|
24
25
|
} from "./types";
|
|
26
|
+
import { format } from "./format";
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* TanaAPI Interface
|
|
@@ -30,6 +32,9 @@ import type {
|
|
|
30
32
|
* Provides high-level methods organized by domain (workspaces, nodes, tags, etc.)
|
|
31
33
|
*/
|
|
32
34
|
export interface TanaAPI {
|
|
35
|
+
/** Pre-resolved default workspace (from MAIN_TANA_WORKSPACE env var), or null */
|
|
36
|
+
workspace: Workspace | null;
|
|
37
|
+
|
|
33
38
|
/** Check API health */
|
|
34
39
|
health(): Promise<{ status: string; timestamp: string; nodeSpaceReady: boolean }>;
|
|
35
40
|
|
|
@@ -50,6 +55,10 @@ export interface TanaAPI {
|
|
|
50
55
|
): Promise<Children>;
|
|
51
56
|
/** Edit a node's name/description */
|
|
52
57
|
edit(options: EditNodeOptions): Promise<{ success: boolean }>;
|
|
58
|
+
/** Move node to a new parent */
|
|
59
|
+
move(options: MoveNodeOptions): Promise<{ success: boolean }>;
|
|
60
|
+
/** Open a node in the Tana UI */
|
|
61
|
+
open(nodeId: string, openType?: "current" | "panel" | "tab"): Promise<{ success: boolean }>;
|
|
53
62
|
/** Move node to trash */
|
|
54
63
|
trash(nodeId: string): Promise<{ success: boolean }>;
|
|
55
64
|
/** Check a node's checkbox */
|
|
@@ -59,10 +68,10 @@ export interface TanaAPI {
|
|
|
59
68
|
};
|
|
60
69
|
|
|
61
70
|
tags: {
|
|
62
|
-
/** List tags in a workspace */
|
|
63
|
-
|
|
71
|
+
/** List all tags in a workspace */
|
|
72
|
+
listAll(workspaceId: string): Promise<Tag[]>;
|
|
64
73
|
/** Get tag schema */
|
|
65
|
-
getSchema(tagId: string, includeEditInstructions?: boolean): Promise<string>;
|
|
74
|
+
getSchema(tagId: string, includeEditInstructions?: boolean, includeInheritedFields?: boolean): Promise<string>;
|
|
66
75
|
/** Add/remove tags from a node */
|
|
67
76
|
modify(
|
|
68
77
|
nodeId: string,
|
|
@@ -82,13 +91,15 @@ export interface TanaAPI {
|
|
|
82
91
|
setOption(
|
|
83
92
|
nodeId: string,
|
|
84
93
|
attributeId: string,
|
|
85
|
-
optionId: string
|
|
94
|
+
optionId: string,
|
|
95
|
+
mode?: "replace" | "append"
|
|
86
96
|
): Promise<{ success: boolean }>;
|
|
87
|
-
/** Set a field to a string value */
|
|
97
|
+
/** Set a field to a string value, or null to clear it */
|
|
88
98
|
setContent(
|
|
89
99
|
nodeId: string,
|
|
90
100
|
attributeId: string,
|
|
91
|
-
content: string
|
|
101
|
+
content: string | null,
|
|
102
|
+
mode?: "replace" | "append"
|
|
92
103
|
): Promise<{ success: boolean }>;
|
|
93
104
|
};
|
|
94
105
|
|
|
@@ -103,10 +114,19 @@ export interface TanaAPI {
|
|
|
103
114
|
|
|
104
115
|
/** Import Tana Paste formatted content */
|
|
105
116
|
import(parentNodeId: string, content: string): Promise<ImportResult>;
|
|
117
|
+
|
|
118
|
+
/** Compact formatter for any API response */
|
|
119
|
+
format(data: unknown): string;
|
|
106
120
|
}
|
|
107
121
|
|
|
108
|
-
export function createTanaAPI(
|
|
122
|
+
export function createTanaAPI(
|
|
123
|
+
client: TanaClient,
|
|
124
|
+
workspace?: Workspace | null,
|
|
125
|
+
defaultSearchWorkspaceIds?: string[]
|
|
126
|
+
): TanaAPI {
|
|
109
127
|
return {
|
|
128
|
+
workspace: workspace ?? null,
|
|
129
|
+
|
|
110
130
|
async health() {
|
|
111
131
|
return client.get<{ status: string; timestamp: string; nodeSpaceReady: boolean }>("/health");
|
|
112
132
|
},
|
|
@@ -147,8 +167,10 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
147
167
|
|
|
148
168
|
addQueryParams(query as unknown as Record<string, unknown>, "query");
|
|
149
169
|
if (options?.limit) params.push(`limit=${options.limit}`);
|
|
150
|
-
|
|
151
|
-
|
|
170
|
+
const effectiveWorkspaceIds = options?.workspaceIds
|
|
171
|
+
?? (defaultSearchWorkspaceIds?.length ? defaultSearchWorkspaceIds : undefined);
|
|
172
|
+
if (effectiveWorkspaceIds) {
|
|
173
|
+
effectiveWorkspaceIds.forEach((id, i) => {
|
|
152
174
|
params.push(`workspaceIds[${i}]=${encodeURIComponent(id)}`);
|
|
153
175
|
});
|
|
154
176
|
}
|
|
@@ -187,6 +209,31 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
187
209
|
return { success: !!result.nodeId };
|
|
188
210
|
},
|
|
189
211
|
|
|
212
|
+
async move(options: MoveNodeOptions): Promise<{ success: boolean }> {
|
|
213
|
+
const result = await client.post<{ nodeId: string; message: string }>(
|
|
214
|
+
`/nodes/${options.nodeId}/move`,
|
|
215
|
+
{
|
|
216
|
+
targetNodeId: options.targetNodeId,
|
|
217
|
+
keepSourceReference: options.keepSourceReference,
|
|
218
|
+
position: options.position,
|
|
219
|
+
referenceNodeId: options.referenceNodeId,
|
|
220
|
+
sourceParentId: options.sourceParentId,
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
return { success: !!result.nodeId };
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async open(
|
|
227
|
+
nodeId: string,
|
|
228
|
+
openType: "current" | "panel" | "tab" = "current"
|
|
229
|
+
): Promise<{ success: boolean }> {
|
|
230
|
+
const result = await client.post<{ nodeId: string; message: string }>(
|
|
231
|
+
`/nodes/${nodeId}/open`,
|
|
232
|
+
{ openType }
|
|
233
|
+
);
|
|
234
|
+
return { success: !!result.nodeId };
|
|
235
|
+
},
|
|
236
|
+
|
|
190
237
|
async trash(nodeId: string): Promise<{ success: boolean }> {
|
|
191
238
|
const result = await client.post<{ nodeId: string; message: string }>(
|
|
192
239
|
`/nodes/${nodeId}/trash`,
|
|
@@ -213,20 +260,36 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
213
260
|
},
|
|
214
261
|
|
|
215
262
|
tags: {
|
|
216
|
-
async
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
263
|
+
async listAll(workspaceId: string): Promise<Tag[]> {
|
|
264
|
+
const schemaNodeId = `${workspaceId}_SCHEMA`;
|
|
265
|
+
const tags: Tag[] = [];
|
|
266
|
+
let offset = 0;
|
|
267
|
+
|
|
268
|
+
while (true) {
|
|
269
|
+
const page = await client.get<Children>(
|
|
270
|
+
`/nodes/${schemaNodeId}/children?limit=200&offset=${offset}`
|
|
271
|
+
);
|
|
272
|
+
for (const child of page.children) {
|
|
273
|
+
if (child.docType === "tagDef") {
|
|
274
|
+
tags.push({ id: child.id, name: child.name });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!page.hasMore) break;
|
|
278
|
+
offset += 200;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return tags;
|
|
220
282
|
},
|
|
221
283
|
|
|
222
284
|
async getSchema(
|
|
223
285
|
tagId: string,
|
|
224
|
-
includeEditInstructions = false
|
|
286
|
+
includeEditInstructions = false,
|
|
287
|
+
includeInheritedFields = true
|
|
225
288
|
): Promise<string> {
|
|
226
289
|
const result = await client.get<{ markdown: string }>(
|
|
227
|
-
`/tags/${tagId}/schema?includeEditInstructions=${includeEditInstructions}`
|
|
290
|
+
`/tags/${tagId}/schema?includeEditInstructions=${includeEditInstructions}&includeInheritedFields=${includeInheritedFields}`
|
|
228
291
|
);
|
|
229
|
-
return result.markdown;
|
|
292
|
+
return truncateOptionLists(result.markdown);
|
|
230
293
|
},
|
|
231
294
|
|
|
232
295
|
async modify(
|
|
@@ -286,11 +349,12 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
286
349
|
async setOption(
|
|
287
350
|
nodeId: string,
|
|
288
351
|
attributeId: string,
|
|
289
|
-
optionId: string
|
|
352
|
+
optionId: string,
|
|
353
|
+
mode?: "replace" | "append"
|
|
290
354
|
): Promise<{ success: boolean }> {
|
|
291
355
|
const result = await client.post<{ nodeId: string; message: string }>(
|
|
292
356
|
`/nodes/${nodeId}/fields/${attributeId}/option`,
|
|
293
|
-
{ optionId }
|
|
357
|
+
{ optionId, mode }
|
|
294
358
|
);
|
|
295
359
|
return { success: !!result.nodeId };
|
|
296
360
|
},
|
|
@@ -298,11 +362,12 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
298
362
|
async setContent(
|
|
299
363
|
nodeId: string,
|
|
300
364
|
attributeId: string,
|
|
301
|
-
content: string
|
|
365
|
+
content: string | null,
|
|
366
|
+
mode?: "replace" | "append"
|
|
302
367
|
): Promise<{ success: boolean }> {
|
|
303
368
|
const result = await client.post<{ nodeId: string; message: string }>(
|
|
304
369
|
`/nodes/${nodeId}/fields/${attributeId}/content`,
|
|
305
|
-
{ content }
|
|
370
|
+
{ content, mode }
|
|
306
371
|
);
|
|
307
372
|
return { success: !!result.nodeId };
|
|
308
373
|
},
|
|
@@ -328,5 +393,45 @@ export function createTanaAPI(client: TanaClient): TanaAPI {
|
|
|
328
393
|
content,
|
|
329
394
|
});
|
|
330
395
|
},
|
|
396
|
+
|
|
397
|
+
format(data: unknown): string {
|
|
398
|
+
return format(data);
|
|
399
|
+
},
|
|
331
400
|
};
|
|
332
401
|
}
|
|
402
|
+
|
|
403
|
+
const MAX_OPTIONS_SHOWN = 5;
|
|
404
|
+
const OPTION_LINE_RE = /^ - .+ \(id:/;
|
|
405
|
+
|
|
406
|
+
function truncateOptionLists(markdown: string): string {
|
|
407
|
+
const lines = markdown.split("\n");
|
|
408
|
+
const result: string[] = [];
|
|
409
|
+
let optionCount = 0;
|
|
410
|
+
let inOptions = false;
|
|
411
|
+
|
|
412
|
+
for (const line of lines) {
|
|
413
|
+
if (OPTION_LINE_RE.test(line)) {
|
|
414
|
+
if (!inOptions) {
|
|
415
|
+
inOptions = true;
|
|
416
|
+
optionCount = 0;
|
|
417
|
+
}
|
|
418
|
+
optionCount++;
|
|
419
|
+
if (optionCount <= MAX_OPTIONS_SHOWN) {
|
|
420
|
+
result.push(line);
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
if (inOptions && optionCount > MAX_OPTIONS_SHOWN) {
|
|
424
|
+
result.push(` - ... (${optionCount - MAX_OPTIONS_SHOWN} more, ${optionCount} total)`);
|
|
425
|
+
}
|
|
426
|
+
inOptions = false;
|
|
427
|
+
optionCount = 0;
|
|
428
|
+
result.push(line);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (inOptions && optionCount > MAX_OPTIONS_SHOWN) {
|
|
433
|
+
result.push(` - ... (${optionCount - MAX_OPTIONS_SHOWN} more, ${optionCount} total)`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return result.join("\n");
|
|
437
|
+
}
|
package/src/api/types.ts
CHANGED
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { operations } from "../generated/api";
|
|
10
10
|
|
|
11
|
-
// ============================================================================
|
|
12
|
-
// Response Types (extracted from operation responses)
|
|
13
|
-
// ============================================================================
|
|
14
|
-
|
|
15
11
|
/** Health check response */
|
|
16
12
|
export type HealthResponse =
|
|
17
13
|
operations["health.ping"]["responses"]["200"]["content"]["application/json"];
|
|
@@ -87,10 +83,6 @@ export type SetFieldOptionResponse =
|
|
|
87
83
|
export type SetFieldContentResponse =
|
|
88
84
|
operations["nodes.setFieldContent"]["responses"]["200"]["content"]["application/json"];
|
|
89
85
|
|
|
90
|
-
// ============================================================================
|
|
91
|
-
// Request/Query Types (extracted from operation parameters)
|
|
92
|
-
// ============================================================================
|
|
93
|
-
|
|
94
86
|
/** Search query structure */
|
|
95
87
|
export type SearchQuery = NonNullable<
|
|
96
88
|
operations["nodes.search"]["parameters"]["query"]
|
|
@@ -106,10 +98,6 @@ export type FieldDataType = NonNullable<
|
|
|
106
98
|
operations["tags.addField"]["requestBody"]
|
|
107
99
|
>["content"]["application/json"]["dataType"];
|
|
108
100
|
|
|
109
|
-
// ============================================================================
|
|
110
|
-
// Request Body Types
|
|
111
|
-
// ============================================================================
|
|
112
|
-
|
|
113
101
|
/** Create tag request body */
|
|
114
102
|
export type CreateTagBody = NonNullable<
|
|
115
103
|
operations["tags.create"]["requestBody"]
|
|
@@ -135,10 +123,6 @@ export type NodeUpdateBody = NonNullable<
|
|
|
135
123
|
operations["nodes.update"]["requestBody"]
|
|
136
124
|
>["content"]["application/json"];
|
|
137
125
|
|
|
138
|
-
// ============================================================================
|
|
139
|
-
// Derived Helper Types (for ergonomic API usage)
|
|
140
|
-
// ============================================================================
|
|
141
|
-
|
|
142
126
|
/** Options for search */
|
|
143
127
|
export interface SearchOptions {
|
|
144
128
|
limit?: number;
|
|
@@ -177,6 +161,16 @@ export interface SetCheckboxOptions {
|
|
|
177
161
|
};
|
|
178
162
|
}
|
|
179
163
|
|
|
164
|
+
/** Options for moving a node to a new parent */
|
|
165
|
+
export interface MoveNodeOptions {
|
|
166
|
+
nodeId: string;
|
|
167
|
+
targetNodeId: string;
|
|
168
|
+
keepSourceReference?: boolean;
|
|
169
|
+
position?: "start" | "end" | "after" | "before";
|
|
170
|
+
referenceNodeId?: string;
|
|
171
|
+
sourceParentId?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
180
174
|
/** Options for editing a node */
|
|
181
175
|
export interface EditNodeOptions {
|
|
182
176
|
nodeId: string;
|
|
@@ -199,9 +193,5 @@ export interface ImportResult {
|
|
|
199
193
|
error?: string;
|
|
200
194
|
}
|
|
201
195
|
|
|
202
|
-
// ============================================================================
|
|
203
|
-
// Re-export full generated types for escape hatch
|
|
204
|
-
// ============================================================================
|
|
205
|
-
|
|
206
196
|
export type { operations } from "../generated/api";
|
|
207
197
|
export type { paths } from "../generated/api";
|
package/src/index.ts
CHANGED
|
@@ -12,29 +12,114 @@ import { z } from "zod";
|
|
|
12
12
|
|
|
13
13
|
import { createClient } from "./api/client";
|
|
14
14
|
import { createTanaAPI } from "./api/tana";
|
|
15
|
+
import type { TanaClient } from "./api/client";
|
|
16
|
+
import type { Workspace } from "./api/types";
|
|
15
17
|
import { executeSandbox } from "./sandbox/executor";
|
|
16
18
|
import { cleanupOldRuns, initDb } from "./storage/history";
|
|
17
19
|
import { TOOL_DESCRIPTION } from "./prompts";
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the default workspace from MAIN_TANA_WORKSPACE env var.
|
|
23
|
+
* Matches by ID first, then case-insensitive name.
|
|
24
|
+
* Returns null if unset, unmatched, or API unreachable.
|
|
25
|
+
*/
|
|
26
|
+
async function resolveWorkspace(client: TanaClient): Promise<Workspace | null> {
|
|
27
|
+
const envValue = process.env.MAIN_TANA_WORKSPACE?.trim();
|
|
28
|
+
if (!envValue) return null;
|
|
29
|
+
|
|
30
|
+
let workspaces: Workspace[];
|
|
31
|
+
try {
|
|
32
|
+
workspaces = await client.get<Workspace[]>("/workspaces");
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(
|
|
35
|
+
`[workspace] Failed to fetch workspaces: ${err instanceof Error ? err.message : err}`
|
|
36
|
+
);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const byId = workspaces.find((w) => w.id === envValue);
|
|
41
|
+
if (byId) {
|
|
42
|
+
console.error(`[workspace] Resolved "${envValue}" → ${byId.name} (${byId.id})`);
|
|
43
|
+
return byId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lowerEnv = envValue.toLowerCase();
|
|
47
|
+
const byName = workspaces.find((w) => w.name.toLowerCase() === lowerEnv);
|
|
48
|
+
if (byName) {
|
|
49
|
+
console.error(`[workspace] Resolved "${envValue}" → ${byName.name} (${byName.id})`);
|
|
50
|
+
return byName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const available = workspaces.map((w) => `${w.name} (${w.id})`).join(", ");
|
|
54
|
+
console.error(
|
|
55
|
+
`[workspace] No match for "${envValue}". Available: ${available || "none"}`
|
|
56
|
+
);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves TANA_SEARCH_WORKSPACES env var to an array of workspace IDs.
|
|
62
|
+
* Each value can be an ID or case-insensitive name.
|
|
63
|
+
* Unresolved values are logged and skipped.
|
|
64
|
+
*/
|
|
65
|
+
async function resolveSearchWorkspaces(client: TanaClient): Promise<string[]> {
|
|
66
|
+
const envValue = process.env.TANA_SEARCH_WORKSPACES?.trim();
|
|
67
|
+
if (!envValue) return [];
|
|
68
|
+
|
|
69
|
+
const values = envValue.split(",").map((v) => v.trim()).filter(Boolean);
|
|
70
|
+
if (values.length === 0) return [];
|
|
71
|
+
|
|
72
|
+
let workspaces: Workspace[];
|
|
73
|
+
try {
|
|
74
|
+
workspaces = await client.get<Workspace[]>("/workspaces");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(
|
|
77
|
+
`[search-workspaces] Failed to fetch workspaces: ${err instanceof Error ? err.message : err}`
|
|
78
|
+
);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const resolvedIds: string[] = [];
|
|
83
|
+
for (const value of values) {
|
|
84
|
+
const byId = workspaces.find((w) => w.id === value);
|
|
85
|
+
if (byId) {
|
|
86
|
+
resolvedIds.push(byId.id);
|
|
87
|
+
console.error(`[search-workspaces] Resolved "${value}" → ${byId.name} (${byId.id})`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const lowerValue = value.toLowerCase();
|
|
91
|
+
const byName = workspaces.find((w) => w.name.toLowerCase() === lowerValue);
|
|
92
|
+
if (byName) {
|
|
93
|
+
resolvedIds.push(byName.id);
|
|
94
|
+
console.error(`[search-workspaces] Resolved "${value}" → ${byName.name} (${byName.id})`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const available = workspaces.map((w) => `${w.name} (${w.id})`).join(", ");
|
|
98
|
+
console.error(
|
|
99
|
+
`[search-workspaces] No match for "${value}". Available: ${available || "none"}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return resolvedIds;
|
|
104
|
+
}
|
|
105
|
+
|
|
19
106
|
async function main() {
|
|
20
|
-
// Initialize database and cleanup old runs
|
|
21
107
|
initDb();
|
|
22
108
|
const cleaned = cleanupOldRuns(30);
|
|
23
109
|
if (cleaned > 0) {
|
|
24
110
|
console.error(`Cleaned up ${cleaned} old script runs`);
|
|
25
111
|
}
|
|
26
112
|
|
|
27
|
-
// Create Tana client and API
|
|
28
113
|
const client = createClient();
|
|
29
|
-
const
|
|
114
|
+
const workspace = await resolveWorkspace(client);
|
|
115
|
+
const searchWorkspaceIds = await resolveSearchWorkspaces(client);
|
|
116
|
+
const tana = createTanaAPI(client, workspace, searchWorkspaceIds);
|
|
30
117
|
|
|
31
|
-
// Create MCP server using the modern McpServer API
|
|
32
118
|
const server = new McpServer({
|
|
33
119
|
name: "tana-mcp-codemode",
|
|
34
120
|
version: "0.1.0",
|
|
35
121
|
});
|
|
36
122
|
|
|
37
|
-
// Register the execute tool using registerTool (non-deprecated API)
|
|
38
123
|
server.registerTool(
|
|
39
124
|
"execute",
|
|
40
125
|
{
|
|
@@ -45,16 +130,11 @@ async function main() {
|
|
|
45
130
|
.string()
|
|
46
131
|
.optional()
|
|
47
132
|
.describe("Optional session ID for grouping script runs"),
|
|
48
|
-
input: z
|
|
49
|
-
.string()
|
|
50
|
-
.optional()
|
|
51
|
-
.describe("Data to pass to script via stdin() helper"),
|
|
52
133
|
},
|
|
53
134
|
},
|
|
54
|
-
async ({ code, sessionId
|
|
55
|
-
const result = await executeSandbox(code, tana, sessionId
|
|
135
|
+
async ({ code, sessionId }) => {
|
|
136
|
+
const result = await executeSandbox(code, tana, sessionId);
|
|
56
137
|
|
|
57
|
-
// Format response
|
|
58
138
|
let responseText = "";
|
|
59
139
|
if (result.output) {
|
|
60
140
|
responseText += result.output;
|
|
@@ -77,7 +157,6 @@ async function main() {
|
|
|
77
157
|
}
|
|
78
158
|
);
|
|
79
159
|
|
|
80
|
-
// Start server with stdio transport
|
|
81
160
|
const transport = new StdioServerTransport();
|
|
82
161
|
await server.connect(transport);
|
|
83
162
|
|
package/src/prompts.ts
CHANGED
|
@@ -6,7 +6,7 @@ export const TOOL_DESCRIPTION = `Execute TypeScript code to interact with Tana.
|
|
|
6
6
|
|
|
7
7
|
## APIs
|
|
8
8
|
|
|
9
|
-
tana.
|
|
9
|
+
tana.workspace → Workspace | null (pre-resolved default workspace)
|
|
10
10
|
tana.workspaces.list() → Workspace[] { id, name, homeNodeId }
|
|
11
11
|
|
|
12
12
|
### Nodes
|
|
@@ -14,20 +14,22 @@ tana.nodes.search(query, options?) → SearchResult[]
|
|
|
14
14
|
tana.nodes.read(nodeId, maxDepth?) → string (markdown)
|
|
15
15
|
tana.nodes.getChildren(nodeId, { limit?, offset? }) → { children, total, hasMore }
|
|
16
16
|
tana.nodes.edit({ nodeId, name?, description? }) → { success }
|
|
17
|
+
tana.nodes.move({ nodeId, targetNodeId, keepSourceReference?, position?, referenceNodeId?, sourceParentId? }) → { success }
|
|
18
|
+
tana.nodes.open(nodeId, openType?) → { success } // openType: "current" | "panel" | "tab" (default: "current")
|
|
17
19
|
tana.nodes.trash(nodeId) → { success }
|
|
18
20
|
tana.nodes.check(nodeId) / uncheck(nodeId) → { success }
|
|
19
21
|
|
|
20
22
|
### Tags
|
|
21
|
-
tana.tags.
|
|
22
|
-
tana.tags.getSchema(tagId
|
|
23
|
+
tana.tags.listAll(workspaceId) → Tag[] (workspace supertags only — for schema analysis)
|
|
24
|
+
tana.tags.getSchema(tagId) → string
|
|
23
25
|
tana.tags.modify(nodeId, "add"|"remove", tagIds[])
|
|
24
26
|
tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
|
|
25
27
|
tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email"|"checkbox"|"user"|"instance"|"options", ... })
|
|
26
28
|
tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
|
|
27
29
|
|
|
28
30
|
### Fields
|
|
29
|
-
tana.fields.setOption(nodeId, attributeId, optionId)
|
|
30
|
-
tana.fields.setContent(nodeId, attributeId, content)
|
|
31
|
+
tana.fields.setOption(nodeId, attributeId, optionId, mode?) // mode: "replace" | "append" (default: "replace")
|
|
32
|
+
tana.fields.setContent(nodeId, attributeId, content, mode?) // content: string | null (null clears field), mode: "replace" | "append"
|
|
31
33
|
|
|
32
34
|
### Calendar
|
|
33
35
|
tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
|
|
@@ -35,13 +37,10 @@ tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
|
|
|
35
37
|
### Import
|
|
36
38
|
tana.import(parentNodeId, tanaPasteContent) → { success, nodeIds? }
|
|
37
39
|
|
|
38
|
-
###
|
|
39
|
-
|
|
40
|
-
library: \`\${workspaceId}_STASH\`
|
|
40
|
+
### Formatting
|
|
41
|
+
tana.format(data) → string (compact display of any API response)
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
stdin().json<T>() | .lines() | .text() | .hasInput()
|
|
44
|
-
workflow.start(msg) | .step(msg) | .progress(n, total, msg) | .complete(msg) | .abort(err)
|
|
43
|
+
Entry points: \`\${workspaceId}_CAPTURE_INBOX\` (inbox), \`\${workspaceId}_STASH\` (library)
|
|
45
44
|
|
|
46
45
|
## Edit (search-and-replace)
|
|
47
46
|
|
|
@@ -53,15 +52,35 @@ Empty old_string matches absent field.
|
|
|
53
52
|
{ textContains: string } | { textMatches: "/regex/i" }
|
|
54
53
|
{ hasType: tagId } | { field: { fieldId, stringValue?, numberValue?, nodeId?, state? } }
|
|
55
54
|
{ compare: { fieldId, operator: "gt"|"lt"|"eq", value, type } }
|
|
56
|
-
{ childOf: { nodeIds[], recursive?, includeRefs? } } | { ownedBy: { nodeId, recursive?, includeSelf? } }
|
|
57
55
|
{ linksTo: nodeIds[] }
|
|
58
|
-
{ is: "done"|"todo"|"template"|"entity"|"calendarNode"|"search"|"inLibrary" }
|
|
56
|
+
{ is: "done"|"todo"|"template"|"field"|"published"|"entity"|"calendarNode"|"onDayNode"|"chat"|"search"|"command"|"inLibrary" }
|
|
59
57
|
{ has: "tag"|"field"|"media"|"audio"|"video"|"image" }
|
|
60
58
|
{ created: { last: N } } | { edited: { last?, by?, since? } } | { done: { last: N } }
|
|
61
59
|
{ onDate: "YYYY-MM-DD" | { date, fieldId?, overlaps? } }
|
|
62
60
|
{ overdue: true } | { inLibrary: true }
|
|
63
61
|
{ and: [...] } | { or: [...] } | { not: {...} }
|
|
64
62
|
|
|
63
|
+
## Default Workspace
|
|
64
|
+
|
|
65
|
+
tana.workspace is pre-resolved if MAIN_TANA_WORKSPACE is set. Prefer over workspaces.list(): \`const ws = tana.workspace ?? (await tana.workspaces.list())[0];\`
|
|
66
|
+
|
|
67
|
+
## Output
|
|
68
|
+
|
|
69
|
+
console.log() output becomes LLM context. Keep it compact:
|
|
70
|
+
- Use tana.format(data) for any API response, or .map() for task-specific fields
|
|
71
|
+
- search returns metadata only (name, id, tags); use .read() for content and field values
|
|
72
|
+
- Never JSON.stringify API responses
|
|
73
|
+
|
|
74
|
+
## API Notes
|
|
75
|
+
|
|
76
|
+
- search: no offset/pagination — use narrower queries, not repeated calls
|
|
77
|
+
- getChildren: only endpoint with pagination (limit + offset)
|
|
78
|
+
- Timeout is 10s. On timeout, try a different approach, not the same call.
|
|
79
|
+
- childOf/ownedBy/inWorkspace operators broken. Scope by workspace: search(query, { workspaceIds: ["id"] })
|
|
80
|
+
- Tag names are not unique. Find a tag by name: search({ and: [{ hasType: "SYS_T01" }, { textContains: "name" }] })
|
|
81
|
+
- search results: { id, name, breadcrumb[], tags[{id,name}], tagIds[], workspaceId, docType, description, created, inTrash }
|
|
82
|
+
- getSchema output: line 1 is \`# Tag definition: name (id:xxx)\`. Line 2 is \`Extends #parent (id:xxx)\` when tag has inheritance, or \`Extends #parent (base type) (id:xxx)\` for Tana built-in types. Parse "Extends" to find relationships.
|
|
83
|
+
|
|
65
84
|
## Examples
|
|
66
85
|
|
|
67
86
|
Search: await tana.nodes.search({ and: [{ hasType: "tagId" }, { is: "todo" }] })
|
|
@@ -75,7 +94,4 @@ await tana.import(parentNodeId, \`
|
|
|
75
94
|
\`)
|
|
76
95
|
\`\`\`
|
|
77
96
|
|
|
78
|
-
## Output
|
|
79
|
-
|
|
80
|
-
console.log() to return data.
|
|
81
97
|
`;
|
package/src/sandbox/executor.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import type { SandboxResult } from "../types";
|
|
13
13
|
import type { TanaAPI } from "../api/tana";
|
|
14
14
|
import { saveScriptRun } from "../storage/history";
|
|
15
|
-
import { createStdinHelper } from "./stdin";
|
|
16
15
|
import { createWorkflowHelper } from "./workflow";
|
|
17
16
|
|
|
18
17
|
const EXECUTION_TIMEOUT = 10_000; // 10 seconds
|
|
@@ -32,6 +31,8 @@ function createTrackedTanaAPI(
|
|
|
32
31
|
const trackNodeId = (id: string) => tracker.nodeIds.add(id);
|
|
33
32
|
|
|
34
33
|
return {
|
|
34
|
+
workspace: tana.workspace,
|
|
35
|
+
|
|
35
36
|
health: () => track("health", () => tana.health()),
|
|
36
37
|
|
|
37
38
|
workspaces: {
|
|
@@ -57,6 +58,14 @@ function createTrackedTanaAPI(
|
|
|
57
58
|
trackNodeId(options.nodeId);
|
|
58
59
|
return track("nodes.edit", () => tana.nodes.edit(options));
|
|
59
60
|
},
|
|
61
|
+
move: (options) => {
|
|
62
|
+
trackNodeId(options.nodeId);
|
|
63
|
+
return track("nodes.move", () => tana.nodes.move(options));
|
|
64
|
+
},
|
|
65
|
+
open: (nodeId, openType) => {
|
|
66
|
+
trackNodeId(nodeId);
|
|
67
|
+
return track("nodes.open", () => tana.nodes.open(nodeId, openType));
|
|
68
|
+
},
|
|
60
69
|
trash: (nodeId) => {
|
|
61
70
|
trackNodeId(nodeId);
|
|
62
71
|
return track("nodes.trash", () => tana.nodes.trash(nodeId));
|
|
@@ -72,9 +81,9 @@ function createTrackedTanaAPI(
|
|
|
72
81
|
},
|
|
73
82
|
|
|
74
83
|
tags: {
|
|
75
|
-
|
|
84
|
+
listAll: (workspaceId: string) => {
|
|
76
85
|
tracker.workspaceId = workspaceId;
|
|
77
|
-
return track("tags.
|
|
86
|
+
return track("tags.listAll", () => tana.tags.listAll(workspaceId));
|
|
78
87
|
},
|
|
79
88
|
getSchema: (tagId, includeEditInstructions) =>
|
|
80
89
|
track("tags.getSchema", () => tana.tags.getSchema(tagId, includeEditInstructions)),
|
|
@@ -116,14 +125,15 @@ function createTrackedTanaAPI(
|
|
|
116
125
|
trackNodeId(parentNodeId);
|
|
117
126
|
return track("import", () => tana.import(parentNodeId, content));
|
|
118
127
|
},
|
|
128
|
+
|
|
129
|
+
format: (data) => tana.format(data),
|
|
119
130
|
};
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
export async function executeSandbox(
|
|
123
134
|
code: string,
|
|
124
135
|
tana: TanaAPI,
|
|
125
|
-
sessionId?: string
|
|
126
|
-
input?: string
|
|
136
|
+
sessionId?: string
|
|
127
137
|
): Promise<SandboxResult> {
|
|
128
138
|
const startTime = performance.now();
|
|
129
139
|
const logs: string[] = [];
|
|
@@ -132,7 +142,7 @@ export async function executeSandbox(
|
|
|
132
142
|
const tracker = {
|
|
133
143
|
calls: [] as string[],
|
|
134
144
|
nodeIds: new Set<string>(),
|
|
135
|
-
workspaceId:
|
|
145
|
+
workspaceId: tana.workspace?.id ?? null,
|
|
136
146
|
};
|
|
137
147
|
|
|
138
148
|
// Custom console that captures output
|
|
@@ -151,8 +161,6 @@ export async function executeSandbox(
|
|
|
151
161
|
},
|
|
152
162
|
};
|
|
153
163
|
|
|
154
|
-
// Create helpers for script execution
|
|
155
|
-
const stdin = createStdinHelper(input);
|
|
156
164
|
const effectiveSessionId = sessionId ?? crypto.randomUUID();
|
|
157
165
|
const workflow = createWorkflowHelper(effectiveSessionId);
|
|
158
166
|
|
|
@@ -160,7 +168,6 @@ export async function executeSandbox(
|
|
|
160
168
|
const trackedTana = createTrackedTanaAPI(tana, tracker);
|
|
161
169
|
|
|
162
170
|
try {
|
|
163
|
-
// Create async function to support top-level await
|
|
164
171
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
165
172
|
const AsyncFunction = Object.getPrototypeOf(
|
|
166
173
|
async function () {}
|
|
@@ -183,26 +190,26 @@ export async function executeSandbox(
|
|
|
183
190
|
"exports",
|
|
184
191
|
];
|
|
185
192
|
|
|
193
|
+
const transpiler = new Bun.Transpiler({ loader: "ts" });
|
|
194
|
+
const jsCode = transpiler.transformSync(code);
|
|
195
|
+
|
|
186
196
|
const fn = new AsyncFunction(
|
|
187
197
|
"tana",
|
|
188
198
|
"console",
|
|
189
|
-
"stdin",
|
|
190
199
|
"workflow",
|
|
191
200
|
...shadowedGlobals,
|
|
192
|
-
|
|
201
|
+
jsCode
|
|
193
202
|
);
|
|
194
203
|
|
|
195
|
-
// Race between execution and timeout
|
|
196
204
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
197
205
|
setTimeout(() => {
|
|
198
206
|
reject(new Error(`Execution timed out after ${EXECUTION_TIMEOUT}ms`));
|
|
199
207
|
}, EXECUTION_TIMEOUT);
|
|
200
208
|
});
|
|
201
209
|
|
|
202
|
-
// Pass undefined for all shadowed globals
|
|
203
210
|
const undefinedArgs = shadowedGlobals.map(() => undefined);
|
|
204
211
|
await Promise.race([
|
|
205
|
-
fn(trackedTana, sandboxConsole,
|
|
212
|
+
fn(trackedTana, sandboxConsole, workflow, ...undefinedArgs),
|
|
206
213
|
timeoutPromise,
|
|
207
214
|
]);
|
|
208
215
|
|
|
@@ -217,7 +224,6 @@ export async function executeSandbox(
|
|
|
217
224
|
error: null,
|
|
218
225
|
durationMs,
|
|
219
226
|
sessionId: effectiveSessionId,
|
|
220
|
-
input: input ?? null,
|
|
221
227
|
apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
|
|
222
228
|
nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
|
|
223
229
|
workspaceId: tracker.workspaceId,
|
|
@@ -242,7 +248,6 @@ export async function executeSandbox(
|
|
|
242
248
|
error: errorMessage,
|
|
243
249
|
durationMs,
|
|
244
250
|
sessionId: effectiveSessionId,
|
|
245
|
-
input: input ?? null,
|
|
246
251
|
apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
|
|
247
252
|
nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
|
|
248
253
|
workspaceId: tracker.workspaceId,
|
package/src/sandbox/stdin.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* stdin() Helper
|
|
3
|
-
*
|
|
4
|
-
* Provides convenient methods for handling input data passed to scripts.
|
|
5
|
-
* In MCP context, this handles data passed via the `input` parameter.
|
|
6
|
-
*
|
|
7
|
-
* Usage in scripts:
|
|
8
|
-
* const data = stdin().json(); // Parse as JSON
|
|
9
|
-
* const lines = stdin().lines(); // Split into lines
|
|
10
|
-
* const text = stdin().text(); // Raw trimmed text
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export interface StdinHelper {
|
|
14
|
-
/** Get raw input as trimmed string */
|
|
15
|
-
text(): string;
|
|
16
|
-
/** Split input into array of lines (empty lines filtered) */
|
|
17
|
-
lines(): string[];
|
|
18
|
-
/** Parse input as JSON (throws if invalid) */
|
|
19
|
-
json<T = unknown>(): T;
|
|
20
|
-
/** Parse input as JSON, return null if invalid */
|
|
21
|
-
jsonSafe<T = unknown>(): T | null;
|
|
22
|
-
/** Check if any input was provided */
|
|
23
|
-
hasInput(): boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createStdinHelper(input: string | undefined): () => StdinHelper {
|
|
27
|
-
const rawInput = input ?? "";
|
|
28
|
-
|
|
29
|
-
return () => ({
|
|
30
|
-
text(): string {
|
|
31
|
-
return rawInput.trim();
|
|
32
|
-
},
|
|
33
|
-
|
|
34
|
-
lines(): string[] {
|
|
35
|
-
return rawInput
|
|
36
|
-
.split("\n")
|
|
37
|
-
.map((line) => line.trim())
|
|
38
|
-
.filter((line) => line.length > 0);
|
|
39
|
-
},
|
|
40
|
-
|
|
41
|
-
json<T = unknown>(): T {
|
|
42
|
-
const text = rawInput.trim();
|
|
43
|
-
if (!text) {
|
|
44
|
-
throw new Error("stdin is empty - no JSON to parse");
|
|
45
|
-
}
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(text) as T;
|
|
48
|
-
} catch (e) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
`Failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
|
|
55
|
-
jsonSafe<T = unknown>(): T | null {
|
|
56
|
-
const text = rawInput.trim();
|
|
57
|
-
if (!text) return null;
|
|
58
|
-
try {
|
|
59
|
-
return JSON.parse(text) as T;
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
hasInput(): boolean {
|
|
66
|
-
return rawInput.trim().length > 0;
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
}
|