heptabase-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.
- package/README.md +131 -0
- package/bin/heptabase.js +6 -0
- package/heptabase-cli.ts +887 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Heptabase CLI
|
|
2
|
+
|
|
3
|
+
A standalone CLI that wraps the [Heptabase MCP server](https://support.heptabase.com/en/articles/12679581-how-to-use-heptabase-mcp) into a command-line tool, built with [mcporter](https://github.com/steipete/mcporter/).
|
|
4
|
+
|
|
5
|
+
## How It Was Built
|
|
6
|
+
|
|
7
|
+
Heptabase exposes an MCP server at `https://api.heptabase.com/mcp` with OAuth authentication. The key insight is using [mcp-remote](https://github.com/geelen/mcp-remote) as a stdio adapter — it handles the OAuth browser flow and token caching, then mcporter wraps the resulting stdio MCP server into a CLI.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# 1. Authenticate with Heptabase (opens browser for OAuth)
|
|
11
|
+
npx mcp-remote@latest https://api.heptabase.com/mcp --transport http-only
|
|
12
|
+
|
|
13
|
+
# 2. Generate and compile the CLI
|
|
14
|
+
npx mcporter@latest generate-cli \
|
|
15
|
+
--command 'npx -y mcp-remote@latest https://api.heptabase.com/mcp --transport http-only' \
|
|
16
|
+
--output heptabase-cli.ts \
|
|
17
|
+
--compile heptabase \
|
|
18
|
+
--description "Heptabase knowledge base CLI"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Authentication
|
|
22
|
+
|
|
23
|
+
- First run opens a browser for Heptabase OAuth login
|
|
24
|
+
- Tokens are cached in `~/.mcp-auth/` and auto-refresh
|
|
25
|
+
- To force re-login: `rm -rf ~/.mcp-auth/`
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
### As an Agent Skill
|
|
30
|
+
|
|
31
|
+
Install as a skill for Claude Code, Cursor, Codex, and [other agents](https://skills.sh/docs):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx skills add madeyexz/heptabase-cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This makes `/heptabase-cli` available as a slash command in your agent.
|
|
38
|
+
|
|
39
|
+
### Via bunx (no install needed)
|
|
40
|
+
|
|
41
|
+
Requires [Bun](https://bun.sh/).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bunx heptabase-cli search-whiteboards --keywords "project"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Download binary from GitHub Releases
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -L https://github.com/madeyexz/heptabase-cli/releases/latest/download/heptabase -o heptabase
|
|
51
|
+
chmod +x heptabase
|
|
52
|
+
sudo mv heptabase /usr/local/bin/
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Build from source
|
|
56
|
+
|
|
57
|
+
Requires [Node.js](https://nodejs.org/) and [Bun](https://bun.sh/).
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 1. Authenticate with Heptabase (opens browser for OAuth)
|
|
61
|
+
npx mcp-remote@latest https://api.heptabase.com/mcp --transport http-only
|
|
62
|
+
|
|
63
|
+
# 2. Generate and compile
|
|
64
|
+
npx mcporter@latest generate-cli \
|
|
65
|
+
--command 'npx -y mcp-remote@latest https://api.heptabase.com/mcp --transport http-only' \
|
|
66
|
+
--output ./heptabase-cli.ts \
|
|
67
|
+
--compile ./heptabase \
|
|
68
|
+
--description "Heptabase knowledge base CLI"
|
|
69
|
+
|
|
70
|
+
# 3. Add to PATH
|
|
71
|
+
sudo ln -sf "$(pwd)/heptabase" /usr/local/bin/heptabase
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Verify
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
heptabase --help
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Search
|
|
84
|
+
heptabase search-whiteboards --keywords "topic1,topic2"
|
|
85
|
+
heptabase semantic-search-objects --queries "machine learning" --result-object-types card
|
|
86
|
+
|
|
87
|
+
# Read
|
|
88
|
+
heptabase get-object --object-id <id> --object-type card
|
|
89
|
+
heptabase get-whiteboard-with-objects --whiteboard-id <id>
|
|
90
|
+
heptabase get-journal-range --start-date 2026-01-01 --end-date 2026-02-21
|
|
91
|
+
|
|
92
|
+
# Write
|
|
93
|
+
heptabase save-to-note-card --content "# Title\n\nBody text"
|
|
94
|
+
heptabase append-to-journal --content "Some entry"
|
|
95
|
+
|
|
96
|
+
# PDF
|
|
97
|
+
heptabase search-pdf-content --pdf-card-id <id> --keywords "term1,term2"
|
|
98
|
+
heptabase get-pdf-pages --pdf-card-id <id> --start-page-number 1 --end-page-number 5
|
|
99
|
+
|
|
100
|
+
# Output formats: text (default), json, markdown, raw
|
|
101
|
+
heptabase search-whiteboards --keywords "project" --output json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For MCP setup with Claude Code, Cursor, ChatGPT, etc., see the [official Heptabase MCP docs](https://support.heptabase.com/en/articles/12679581-how-to-use-heptabase-mcp).
|
|
105
|
+
|
|
106
|
+
## Available Commands
|
|
107
|
+
|
|
108
|
+
| Command | Description |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `search-whiteboards` | Search whiteboards by keywords |
|
|
111
|
+
| `semantic-search-objects` | Hybrid full-text + semantic search across cards, journals, PDFs, highlights |
|
|
112
|
+
| `get-object` | Get full content of a card, journal, media, highlight, etc. |
|
|
113
|
+
| `get-whiteboard-with-objects` | Get all objects and connections on a whiteboard |
|
|
114
|
+
| `get-journal-range` | Fetch journal entries for a date range (max 92 days per call) |
|
|
115
|
+
| `save-to-note-card` | Create a new note card in your Inbox |
|
|
116
|
+
| `append-to-journal` | Append content to today's journal |
|
|
117
|
+
| `search-pdf-content` | BM25 keyword search within a PDF (up to 80 ranked chunks) |
|
|
118
|
+
| `get-pdf-pages` | Get specific page ranges from a PDF card |
|
|
119
|
+
|
|
120
|
+
## Project Structure
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
heptabase-cli/
|
|
124
|
+
├── heptabase # Compiled standalone binary (bun)
|
|
125
|
+
├── heptabase-cli.ts # Generated TypeScript source
|
|
126
|
+
├── package.json # npm package config (for bunx heptabase-cli)
|
|
127
|
+
├── SKILL.md # Agent skill definition (skills.sh)
|
|
128
|
+
├── config/
|
|
129
|
+
│ └── mcporter.json # mcporter server configuration
|
|
130
|
+
└── README.md
|
|
131
|
+
```
|
package/bin/heptabase.js
ADDED
package/heptabase-cli.ts
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @generated by mcporter@0.7.3 on 2026-02-21T07:28:46.447Z. DO NOT EDIT.
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { createRuntime, createServerProxy } from 'mcporter';
|
|
5
|
+
import { createCallResult } from 'mcporter';
|
|
6
|
+
|
|
7
|
+
const embeddedServer = {
|
|
8
|
+
"name": "mcp-remote",
|
|
9
|
+
"description": "Heptabase knowledge base CLI",
|
|
10
|
+
"command": {
|
|
11
|
+
"kind": "stdio",
|
|
12
|
+
"command": "npx",
|
|
13
|
+
"args": [
|
|
14
|
+
"-y",
|
|
15
|
+
"mcp-remote@latest",
|
|
16
|
+
"https://api.heptabase.com/mcp",
|
|
17
|
+
"--transport",
|
|
18
|
+
"http-only"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"source": {
|
|
22
|
+
"kind": "local",
|
|
23
|
+
"path": "<adhoc>"
|
|
24
|
+
}
|
|
25
|
+
} as const;
|
|
26
|
+
const embeddedSchemas = {
|
|
27
|
+
"save_to_note_card": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"content": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Content of the card. In markdown format. Each block should be separated by an empty line. The first line should be an h1, which will be treated as the title of the card."
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"required": [
|
|
36
|
+
"content"
|
|
37
|
+
],
|
|
38
|
+
"additionalProperties": false
|
|
39
|
+
},
|
|
40
|
+
"append_to_journal": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"content": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Content to append to the journal. In markdown format. Each block should be separated by an empty line."
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"required": [
|
|
49
|
+
"content"
|
|
50
|
+
],
|
|
51
|
+
"additionalProperties": false
|
|
52
|
+
},
|
|
53
|
+
"get_journal_range": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {
|
|
56
|
+
"startDate": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "The start date of the journal range (YYYY-MM-DD). Maximum 92 days between startDate and endDate."
|
|
59
|
+
},
|
|
60
|
+
"endDate": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "The end date of the journal range (YYYY-MM-DD). Must be >= startDate and within 92 days of startDate."
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"required": [
|
|
66
|
+
"startDate",
|
|
67
|
+
"endDate"
|
|
68
|
+
],
|
|
69
|
+
"additionalProperties": false
|
|
70
|
+
},
|
|
71
|
+
"get_whiteboard_with_objects": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"whiteboardId": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "The id of the whiteboard to retrieve with all its objects."
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"required": [
|
|
80
|
+
"whiteboardId"
|
|
81
|
+
],
|
|
82
|
+
"additionalProperties": false
|
|
83
|
+
},
|
|
84
|
+
"get_object": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"objectId": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "The id of the object to retrieve."
|
|
90
|
+
},
|
|
91
|
+
"objectType": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"enum": [
|
|
94
|
+
"card",
|
|
95
|
+
"journal",
|
|
96
|
+
"videoCard",
|
|
97
|
+
"audioCard",
|
|
98
|
+
"imageCard",
|
|
99
|
+
"highlightElement",
|
|
100
|
+
"textElement",
|
|
101
|
+
"videoElement",
|
|
102
|
+
"imageElement",
|
|
103
|
+
"chat",
|
|
104
|
+
"chatMessage",
|
|
105
|
+
"chatMessagesElement",
|
|
106
|
+
"section"
|
|
107
|
+
],
|
|
108
|
+
"description": "The type of the object. Do not use it on pdfCard."
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"required": [
|
|
112
|
+
"objectId",
|
|
113
|
+
"objectType"
|
|
114
|
+
],
|
|
115
|
+
"additionalProperties": false
|
|
116
|
+
},
|
|
117
|
+
"get_pdf_pages": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"pdfCardId": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"description": "The UUID of the PDF card to get pages from."
|
|
123
|
+
},
|
|
124
|
+
"startPageNumber": {
|
|
125
|
+
"type": "integer",
|
|
126
|
+
"description": "The page number to start from. (inclusive, starts from 1)"
|
|
127
|
+
},
|
|
128
|
+
"endPageNumber": {
|
|
129
|
+
"type": "integer",
|
|
130
|
+
"description": "The page number to end at. (inclusive)"
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"required": [
|
|
134
|
+
"pdfCardId",
|
|
135
|
+
"startPageNumber",
|
|
136
|
+
"endPageNumber"
|
|
137
|
+
],
|
|
138
|
+
"additionalProperties": false
|
|
139
|
+
},
|
|
140
|
+
"search_pdf_content": {
|
|
141
|
+
"type": "object",
|
|
142
|
+
"properties": {
|
|
143
|
+
"pdfCardId": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"description": "The UUID of the PDF card to search."
|
|
146
|
+
},
|
|
147
|
+
"keywords": {
|
|
148
|
+
"type": "array",
|
|
149
|
+
"items": {
|
|
150
|
+
"type": "string"
|
|
151
|
+
},
|
|
152
|
+
"minItems": 1,
|
|
153
|
+
"maxItems": 5,
|
|
154
|
+
"description": "No more than 5 keywords. Use varied terms, synonyms, and related concepts (e.g., [\"neural network\", \"deep learning\", \"architecture\"]). OR logic—diverse keywords = broader coverage."
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"required": [
|
|
158
|
+
"pdfCardId",
|
|
159
|
+
"keywords"
|
|
160
|
+
],
|
|
161
|
+
"additionalProperties": false
|
|
162
|
+
},
|
|
163
|
+
"semantic_search_objects": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {
|
|
166
|
+
"queries": {
|
|
167
|
+
"type": "array",
|
|
168
|
+
"items": {
|
|
169
|
+
"type": "string"
|
|
170
|
+
},
|
|
171
|
+
"minItems": 1,
|
|
172
|
+
"maxItems": 3,
|
|
173
|
+
"description": "Array of search queries in natural language (1-3 queries). Multiple queries from different perspectives improve coverage. Example: [\"climate change impacts\", \"environmental policy\"]."
|
|
174
|
+
},
|
|
175
|
+
"resultObjectTypes": {
|
|
176
|
+
"type": "array",
|
|
177
|
+
"items": {
|
|
178
|
+
"type": "string",
|
|
179
|
+
"enum": [
|
|
180
|
+
"card",
|
|
181
|
+
"pdfCard",
|
|
182
|
+
"mediaCard",
|
|
183
|
+
"highlightElement",
|
|
184
|
+
"journal"
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
"minItems": 0,
|
|
188
|
+
"description": "Filter for specific object types. Pass empty array to search all types."
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
"required": [
|
|
192
|
+
"queries",
|
|
193
|
+
"resultObjectTypes"
|
|
194
|
+
],
|
|
195
|
+
"additionalProperties": false
|
|
196
|
+
},
|
|
197
|
+
"search_whiteboards": {
|
|
198
|
+
"type": "object",
|
|
199
|
+
"properties": {
|
|
200
|
+
"keywords": {
|
|
201
|
+
"type": "array",
|
|
202
|
+
"items": {
|
|
203
|
+
"type": "string"
|
|
204
|
+
},
|
|
205
|
+
"minItems": 1,
|
|
206
|
+
"maxItems": 5,
|
|
207
|
+
"description": "1-5 keywords. Use varied terms, synonyms, and related concepts for broader coverage (OR logic). Example: [\"project management\", \"productivity\", \"workflow\"]."
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"required": [
|
|
211
|
+
"keywords"
|
|
212
|
+
],
|
|
213
|
+
"additionalProperties": false
|
|
214
|
+
}
|
|
215
|
+
} as const;
|
|
216
|
+
const embeddedName = "mcp-remote";
|
|
217
|
+
const embeddedDescription = "Heptabase knowledge base CLI";
|
|
218
|
+
const generatorInfo = "Generated by mcporter@0.7.3 — https://github.com/steipete/mcporter";
|
|
219
|
+
const generatorTools = [
|
|
220
|
+
{
|
|
221
|
+
"name": "save-to-note-card",
|
|
222
|
+
"description": "Save any information to a note card in the main space in Heptabase.",
|
|
223
|
+
"usage": "save-to-note-card --content <content> [--raw <json>]",
|
|
224
|
+
"flags": "--content <content> [--raw <json>]"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"name": "append-to-journal",
|
|
228
|
+
"description": "Append content to today's journal in Heptabase. If today's journal does not exist, it will be created.",
|
|
229
|
+
"usage": "append-to-journal --content <content> [--raw <json>]",
|
|
230
|
+
"flags": "--content <content> [--raw <json>]"
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"name": "get-journal-range",
|
|
234
|
+
"description": "Retrieve daily journal entries within a date range (inclusive) from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Complete content for each journal entry in the specified period\n- All journal entries from startDate to endDate (inclusive)\n\nUSE WHEN:\n- User asks about their journal entries during a time period\n- User wants to see what they wrote in past days/weeks/months\n- User needs to review their daily notes\n\nIMPORTANT CONSTRAINTS:\n- Each call can retrieve at most 92 days (approximately 3 months)\n- For longer periods, make multiple calls (e.g., 4 calls for one year)\n- Dates use YYYY-MM-DD format. Both startDate and endDate are inclusive.",
|
|
235
|
+
"usage": "get-journal-range --start-date <start-date> --end-date <end-date> [--raw <json>]",
|
|
236
|
+
"flags": "--start-date <start-date> --end-date <end-date> [--raw <json>]"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"name": "get-whiteboard-with-objects",
|
|
240
|
+
"description": "List all objects on a whiteboard with their content from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Complete whiteboard structure showing all objects and their relationships\n- Partial content of cards, sections, text elements, mindmaps, images on the whiteboard\n- Connections between objects\n\nUSE WHEN:\n- You've already got the whiteboard id from searchWhiteboards or semanticSearchObjects\n\nHEPTABASE STRUCTURE:\n- Whiteboards are visual canvases containing multiple objects\n- Objects on the same whiteboard are typically related to the same topic",
|
|
241
|
+
"usage": "get-whiteboard-with-objects --whiteboard-id <whiteboard-id> [--raw <json>]",
|
|
242
|
+
"flags": "--whiteboard-id <whiteboard-id> [--raw <json>]"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"name": "get-object",
|
|
246
|
+
"description": "Retrieve the complete content of an object from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Full content of cards (notes, journals, media, highlights)\n- Complete transcripts for video/audio cards\n- All content regardless of length (no chunk limits)\n\nUSE WHEN:\n- You found relevant objects via semanticSearchObjects or getWhiteboardWithObjects and need full content\n - Determine if you have all the content of an object by checking its \"hasMore\" flag\n- User asks about a specific object you've identified\n- You need complete information (e.g., for summarization, translation, or detailed questions)\n\nOBJECT TYPES:\n- card: Text notes\n- videoCard/audioCard/imageCard\n- journal: Daily journal entries \n- highlightElement\n- section/textElement: whiteboard elements\n- chat/chatMessage/chatMessagesElement: Chat conversations\n\nNOTE: Do not use this on pdfCard objects since they might be too large.",
|
|
247
|
+
"usage": "get-object --object-id <object-id> --object-type <object-type:card|journal|videoCard|audioCard|imageCard|highlightElement|textElement|videoElement|imageElement|chat|chatMessage|chatMessagesElement|section> [--raw <json>]",
|
|
248
|
+
"flags": "--object-id <object-id> --object-type <object-type:card|journal|videoCard|audioCard|imageCard|highlightElement|textElement|videoElement|imageElement|chat|chatMessage|chatMessagesElement|section> [--raw <json>]"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"name": "get-pdf-pages",
|
|
252
|
+
"description": "Retrieve specific pages from a PDF card by page numbers from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Complete content from [startPageNumber, endPageNumber] inclusive\n- All content from the specified page range\n\nUSE WHEN:\n- You know the specific page numbers to retrieve\n- User asks for content from specific pages\n- You need complete sections after finding relevant pages via search_pdf_content\n- For summarization/translation, retrieve pages in batches\n\nNOTE: Page numbers start from 1 (not 0). You can get any number of pages you want. But if you need significantly more than 100 pages, ask user for clarification first.",
|
|
253
|
+
"usage": "get-pdf-pages --pdf-card-id <pdf-card-id> --start-page-number <start-page-number:number> --end-page-number <end-page-number:number> [--raw <json>]",
|
|
254
|
+
"flags": "--pdf-card-id <pdf-card-id> --start-page-number <start-page-number:number> --end-page-number <end-page-number:number> [--raw <json>]"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"name": "search-pdf-content",
|
|
258
|
+
"description": "Search within a large PDF using BM25 keyword matching (OR logic, fuzzy) from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Up to 80 ranked chunks matching the keywords\n- Expanded contiguous ranges around matching chunks for context\n\nUSE WHEN:\n- User asks about content within a PDF\n- You need to find specific information in a PDF document\n- User wants to search for keywords or topics in a PDF\n\nIMPORTANT:\n- You must first obtain the PDF card ID using other available tools (e.g., semanticSearchObjects or getObject) before calling this function\n- Use broad keywords, synonyms, and related terms to maximize coverage\n- Follow with get_pdf_pages for complete sections if needed",
|
|
259
|
+
"usage": "search-pdf-content --pdf-card-id <pdf-card-id> --keywords <keywords:value1,value2> [--raw <json>]",
|
|
260
|
+
"flags": "--pdf-card-id <pdf-card-id> --keywords <keywords:value1,value2> [--raw <json>]"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"name": "semantic-search-objects",
|
|
264
|
+
"description": "Find WHICH objects exist on a topic in the user's Heptabase knowledge base using hybrid search (full-text + semantic).\n\nHEPTABASE STRUCTURE:\n- Cards: Knowledge units (notes, journals, PDFs, videos, images, highlights)\n- Whiteboards: Visual canvases containing cards and other objects\n\nUSE WHEN: Discovering what content exists about a topic (e.g., \"machine learning papers\", \"project notes\")\n\nSTRATEGY:\n- Use multiple queries from different perspectives (1-3 queries)\n- Results show previews with titles and partial content\n- If you find relevant objects, use getObject to retrieve complete content\n- Returned objects may reference whiteboards they're on—use searchWhiteboards if you need to explore those whiteboards",
|
|
265
|
+
"usage": "semantic-search-objects --queries <queries:value1,value2> --result-object-types <result-object-types:card|pdfCard|mediaCard|highlightElement|journal> [--raw <json>]",
|
|
266
|
+
"flags": "--queries <queries:value1,value2> --result-object-types <result-object-types:card|pdfCard|mediaCard|highlightElement|journal> [--raw <json>]"
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"name": "search-whiteboards",
|
|
270
|
+
"description": "Search for whiteboards by keywords in the user's Heptabase knowledge base.\n\nHEPTABASE STRUCTURE:\n- Whiteboards are visual canvases where users organize their knowledge\n- Each whiteboard contains cards, sections, text elements, mindmaps, images, and connections\n- Users typically group related content on the same whiteboard\n- Whiteboard names/titles indicate their topic or purpose\n\nUSE WHEN:\n- Looking for whiteboards on a specific topic (e.g., \"machine learning project\", \"research papers\")\n- User mentions exploring or understanding their workspace organization\n- You found objects via semanticSearchObjects that reference interesting whiteboards\n- Need to see how content is organized and connected\n\nSEARCH STRATEGY:\n- Use varied keywords, synonyms, and related concepts for better coverage\n- OR logic: diverse keywords = broader results\n- Example: [\"neural network\", \"deep learning\", \"architecture\"]\n\nNEXT STEPS:\n- Results show whiteboard titles and basic info\n- Use getWhiteboardWithObjects to retrieve full content of relevant whiteboards",
|
|
271
|
+
"usage": "search-whiteboards --keywords <keywords:value1,value2> [--raw <json>]",
|
|
272
|
+
"flags": "--keywords <keywords:value1,value2> [--raw <json>]"
|
|
273
|
+
}
|
|
274
|
+
] as const;
|
|
275
|
+
const embeddedMetadata = {
|
|
276
|
+
"schemaVersion": 1,
|
|
277
|
+
"generatedAt": "2026-02-21T07:28:46.447Z",
|
|
278
|
+
"generator": {
|
|
279
|
+
"name": "mcporter",
|
|
280
|
+
"version": "0.7.3"
|
|
281
|
+
},
|
|
282
|
+
"server": {
|
|
283
|
+
"name": "mcp-remote",
|
|
284
|
+
"source": {
|
|
285
|
+
"kind": "local",
|
|
286
|
+
"path": "<adhoc>"
|
|
287
|
+
},
|
|
288
|
+
"definition": {
|
|
289
|
+
"name": "mcp-remote",
|
|
290
|
+
"description": "Heptabase knowledge base CLI",
|
|
291
|
+
"command": {
|
|
292
|
+
"kind": "stdio",
|
|
293
|
+
"command": "npx",
|
|
294
|
+
"args": [
|
|
295
|
+
"-y",
|
|
296
|
+
"mcp-remote@latest",
|
|
297
|
+
"https://api.heptabase.com/mcp",
|
|
298
|
+
"--transport",
|
|
299
|
+
"http-only"
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
"artifact": {
|
|
305
|
+
"path": "",
|
|
306
|
+
"kind": "template"
|
|
307
|
+
},
|
|
308
|
+
"invocation": {
|
|
309
|
+
"serverRef": "{\"name\":\"mcp-remote\",\"description\":\"Heptabase knowledge base CLI\",\"command\":{\"kind\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"mcp-remote@latest\",\"https://api.heptabase.com/mcp\",\"--transport\",\"http-only\"]},\"source\":{\"kind\":\"local\",\"path\":\"<adhoc>\"}}",
|
|
310
|
+
"configPath": "<adhoc>",
|
|
311
|
+
"runtime": "bun",
|
|
312
|
+
"bundler": "bun",
|
|
313
|
+
"outputPath": "./heptabase-cli.ts",
|
|
314
|
+
"compile": "./heptabase",
|
|
315
|
+
"timeoutMs": 30000,
|
|
316
|
+
"minify": false
|
|
317
|
+
}
|
|
318
|
+
} as const;
|
|
319
|
+
const artifactKind = determineArtifactKind();
|
|
320
|
+
const program = new Command();
|
|
321
|
+
program.name(embeddedName);
|
|
322
|
+
program.description(embeddedDescription);
|
|
323
|
+
program.option('-t, --timeout <ms>', 'Call timeout in milliseconds', (value) => parseInt(value, 10), 30000);
|
|
324
|
+
program.option('-o, --output <format>', 'Output format: text|markdown|json|raw', 'text');
|
|
325
|
+
const commandSignatures: Record<string, string> = {
|
|
326
|
+
"save-to-note-card": "function save_to_note_card(content: string);",
|
|
327
|
+
"append-to-journal": "function append_to_journal(content: string);",
|
|
328
|
+
"get-journal-range": "function get_journal_range(startDate: string, endDate: string);",
|
|
329
|
+
"get-whiteboard-with-objects": "function get_whiteboard_with_objects(whiteboardId: string);",
|
|
330
|
+
"get-object": "function get_object(objectId: string, objectType: \"card\" | \"journal\" | \"videoCard\" | \"audioCard\" | \"imageCard\" | \"highlightElement\" | \"textElement\" | \"videoElement\" | \"imageElement\" | \"chat\" | \"chatMessage\" | \"chatMessagesElement\" | \"section\");",
|
|
331
|
+
"get-pdf-pages": "function get_pdf_pages(pdfCardId: string, startPageNumber: number, endPageNumber: number);",
|
|
332
|
+
"search-pdf-content": "function search_pdf_content(pdfCardId: string, keywords: string[]);",
|
|
333
|
+
"semantic-search-objects": "function semantic_search_objects(queries: string[], resultObjectTypes: \"card\" | \"pdfCard\" | \"mediaCard\" | \"highlightElement\" | \"journal\");",
|
|
334
|
+
"search-whiteboards": "function search_whiteboards(keywords: string[]);"
|
|
335
|
+
};
|
|
336
|
+
program.configureHelp({
|
|
337
|
+
commandTerm(cmd) {
|
|
338
|
+
const term = cmd.name();
|
|
339
|
+
return commandSignatures[term] ?? cmd.name();
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
program.showSuggestionAfterError(true);
|
|
343
|
+
|
|
344
|
+
program
|
|
345
|
+
.command("save-to-note-card")
|
|
346
|
+
.summary("save-to-note-card --content <content> [--raw <json>]")
|
|
347
|
+
.description("Save any information to a note card in the main space in Heptabase.")
|
|
348
|
+
.usage("--content <content> [--raw <json>]")
|
|
349
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
350
|
+
|
|
351
|
+
.requiredOption("--content <content>", "Content of the card. In markdown format. Each block should be separated by an empty line. The first line should be an h1, which will be treated as the title of the card.")
|
|
352
|
+
|
|
353
|
+
.alias("save_to_note_card") .action(async (cmdOpts) => {
|
|
354
|
+
const globalOptions = program.opts();
|
|
355
|
+
const runtime = await ensureRuntime();
|
|
356
|
+
const serverName = embeddedName;
|
|
357
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
358
|
+
initialSchemas: embeddedSchemas,
|
|
359
|
+
});
|
|
360
|
+
try {
|
|
361
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
362
|
+
if (cmdOpts.content !== undefined) args.content = cmdOpts.content;
|
|
363
|
+
const call = (proxy.saveToNoteCard as any)(args);
|
|
364
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
365
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
366
|
+
} finally {
|
|
367
|
+
await runtime.close(serverName).catch(() => {});
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.save_to_note_card(content: \"value\")");
|
|
371
|
+
|
|
372
|
+
program
|
|
373
|
+
.command("append-to-journal")
|
|
374
|
+
.summary("append-to-journal --content <content> [--raw <json>]")
|
|
375
|
+
.description("Append content to today's journal in Heptabase. If today's journal does not exist, it will be created.")
|
|
376
|
+
.usage("--content <content> [--raw <json>]")
|
|
377
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
378
|
+
|
|
379
|
+
.requiredOption("--content <content>", "Content to append to the journal. In markdown format. Each block should be separated by an empty line.")
|
|
380
|
+
|
|
381
|
+
.alias("append_to_journal") .action(async (cmdOpts) => {
|
|
382
|
+
const globalOptions = program.opts();
|
|
383
|
+
const runtime = await ensureRuntime();
|
|
384
|
+
const serverName = embeddedName;
|
|
385
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
386
|
+
initialSchemas: embeddedSchemas,
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
390
|
+
if (cmdOpts.content !== undefined) args.content = cmdOpts.content;
|
|
391
|
+
const call = (proxy.appendToJournal as any)(args);
|
|
392
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
393
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
394
|
+
} finally {
|
|
395
|
+
await runtime.close(serverName).catch(() => {});
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.append_to_journal(content: \"value\")");
|
|
399
|
+
|
|
400
|
+
program
|
|
401
|
+
.command("get-journal-range")
|
|
402
|
+
.summary("get-journal-range --start-date <start-date> --end-date <end-date> [--raw <json>]")
|
|
403
|
+
.description("Retrieve daily journal entries within a date range (inclusive) from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Complete content for each journal entry in the specified period\n- All journal entries from startDate to endDate (inclusive)\n\nUSE WHEN:\n- User asks about their journal entries during a time period\n- User wants to see what they wrote in past days/weeks/months\n- User needs to review their daily notes\n\nIMPORTANT CONSTRAINTS:\n- Each call can retrieve at most 92 days (approximately 3 months)\n- For longer periods, make multiple calls (e.g., 4 calls for one year)\n- Dates use YYYY-MM-DD format. Both startDate and endDate are inclusive.")
|
|
404
|
+
.usage("--start-date <start-date> --end-date <end-date> [--raw <json>]")
|
|
405
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
406
|
+
|
|
407
|
+
.requiredOption("--start-date <start-date>", "The start date of the journal range (YYYY-MM-DD). Maximum 92 days between startDate and endDate.")
|
|
408
|
+
.requiredOption("--end-date <end-date>", "The end date of the journal range (YYYY-MM-DD). Must be >= startDate and within 92 days of startDate.")
|
|
409
|
+
|
|
410
|
+
.alias("get_journal_range") .action(async (cmdOpts) => {
|
|
411
|
+
const globalOptions = program.opts();
|
|
412
|
+
const runtime = await ensureRuntime();
|
|
413
|
+
const serverName = embeddedName;
|
|
414
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
415
|
+
initialSchemas: embeddedSchemas,
|
|
416
|
+
});
|
|
417
|
+
try {
|
|
418
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
419
|
+
if (cmdOpts.startDate !== undefined) args.startDate = cmdOpts.startDate;
|
|
420
|
+
if (cmdOpts.endDate !== undefined) args.endDate = cmdOpts.endDate;
|
|
421
|
+
const call = (proxy.getJournalRange as any)(args);
|
|
422
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
423
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
424
|
+
} finally {
|
|
425
|
+
await runtime.close(serverName).catch(() => {});
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.get_journal_range(startDate: \"value\", endDate: \"value\")");
|
|
429
|
+
|
|
430
|
+
program
|
|
431
|
+
.command("get-whiteboard-with-objects")
|
|
432
|
+
.summary("get-whiteboard-with-objects --whiteboard-id <whiteboard-id> [--raw <json>]")
|
|
433
|
+
.description("List all objects on a whiteboard with their content from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Complete whiteboard structure showing all objects and their relationships\n- Partial content of cards, sections, text elements, mindmaps, images on the whiteboard\n- Connections between objects\n\nUSE WHEN:\n- You've already got the whiteboard id from searchWhiteboards or semanticSearchObjects\n\nHEPTABASE STRUCTURE:\n- Whiteboards are visual canvases containing multiple objects\n- Objects on the same whiteboard are typically related to the same topic")
|
|
434
|
+
.usage("--whiteboard-id <whiteboard-id> [--raw <json>]")
|
|
435
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
436
|
+
|
|
437
|
+
.requiredOption("--whiteboard-id <whiteboard-id>", "The id of the whiteboard to retrieve with all its objects. (example: example-id)")
|
|
438
|
+
|
|
439
|
+
.alias("get_whiteboard_with_objects") .action(async (cmdOpts) => {
|
|
440
|
+
const globalOptions = program.opts();
|
|
441
|
+
const runtime = await ensureRuntime();
|
|
442
|
+
const serverName = embeddedName;
|
|
443
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
444
|
+
initialSchemas: embeddedSchemas,
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
448
|
+
if (cmdOpts.whiteboardId !== undefined) args.whiteboardId = cmdOpts.whiteboardId;
|
|
449
|
+
const call = (proxy.getWhiteboardWithObjects as any)(args);
|
|
450
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
451
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
452
|
+
} finally {
|
|
453
|
+
await runtime.close(serverName).catch(() => {});
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.get_whiteboard_with_objects(whiteboardId: \"example-id\")");
|
|
457
|
+
|
|
458
|
+
program
|
|
459
|
+
.command("get-object")
|
|
460
|
+
.summary("get-object --object-id <object-id> --object-type <object-type:card|journal|videoCard|audioCard|imageCard|highlightElement|textElement|videoElement|imageElement|chat|chatMessage|chatMessagesElement|section> [--raw <json>]")
|
|
461
|
+
.description("Retrieve the complete content of an object from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Full content of cards (notes, journals, media, highlights)\n- Complete transcripts for video/audio cards\n- All content regardless of length (no chunk limits)\n\nUSE WHEN:\n- You found relevant objects via semanticSearchObjects or getWhiteboardWithObjects and need full content\n - Determine if you have all the content of an object by checking its \"hasMore\" flag\n- User asks about a specific object you've identified\n- You need complete information (e.g., for summarization, translation, or detailed questions)\n\nOBJECT TYPES:\n- card: Text notes\n- videoCard/audioCard/imageCard\n- journal: Daily journal entries \n- highlightElement\n- section/textElement: whiteboard elements\n- chat/chatMessage/chatMessagesElement: Chat conversations\n\nNOTE: Do not use this on pdfCard objects since they might be too large.")
|
|
462
|
+
.usage("--object-id <object-id> --object-type <object-type:card|journal|videoCard|audioCard|imageCard|highlightElement|textElement|videoElement|imageElement|chat|chatMessage|chatMessagesElement|section> [--raw <json>]")
|
|
463
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
464
|
+
|
|
465
|
+
.requiredOption("--object-id <object-id>", "The id of the object to retrieve. (example: example-id)")
|
|
466
|
+
.requiredOption("--object-type <object-type:card|journal|videoCard|audioCard|imageCard|highlightElement|textElement|videoElement|imageElement|chat|chatMessage|chatMessagesElement|section>", "The type of the object. Do not use it on pdfCard. (choices: card, journal, videoCard, audioCard, imageCard, highlightElement, textElement, videoElement, imageElement, chat, chatMessage, chatMessagesElement, section; example: card)")
|
|
467
|
+
|
|
468
|
+
.alias("get_object") .action(async (cmdOpts) => {
|
|
469
|
+
const globalOptions = program.opts();
|
|
470
|
+
const runtime = await ensureRuntime();
|
|
471
|
+
const serverName = embeddedName;
|
|
472
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
473
|
+
initialSchemas: embeddedSchemas,
|
|
474
|
+
});
|
|
475
|
+
try {
|
|
476
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
477
|
+
if (cmdOpts.objectId !== undefined) args.objectId = cmdOpts.objectId;
|
|
478
|
+
if (cmdOpts.objectType !== undefined) args.objectType = cmdOpts.objectType;
|
|
479
|
+
const call = (proxy.getObject as any)(args);
|
|
480
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
481
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
482
|
+
} finally {
|
|
483
|
+
await runtime.close(serverName).catch(() => {});
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.get_object(objectId: \"example-id\", objectType: \"card\")");
|
|
487
|
+
|
|
488
|
+
program
|
|
489
|
+
.command("get-pdf-pages")
|
|
490
|
+
.summary("get-pdf-pages --pdf-card-id <pdf-card-id> --start-page-number <start-page-number:number> --end-page-number <end-page-number:number> [--raw <json>]")
|
|
491
|
+
.description("Retrieve specific pages from a PDF card by page numbers from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Complete content from [startPageNumber, endPageNumber] inclusive\n- All content from the specified page range\n\nUSE WHEN:\n- You know the specific page numbers to retrieve\n- User asks for content from specific pages\n- You need complete sections after finding relevant pages via search_pdf_content\n- For summarization/translation, retrieve pages in batches\n\nNOTE: Page numbers start from 1 (not 0). You can get any number of pages you want. But if you need significantly more than 100 pages, ask user for clarification first.")
|
|
492
|
+
.usage("--pdf-card-id <pdf-card-id> --start-page-number <start-page-number:number> --end-page-number <end-page-number:number> [--raw <json>]")
|
|
493
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
494
|
+
|
|
495
|
+
.requiredOption("--pdf-card-id <pdf-card-id>", "The UUID of the PDF card to get pages from. (example: example-id)")
|
|
496
|
+
.requiredOption("--start-page-number <start-page-number:number>", "The page number to start from. (inclusive, starts from 1) (example: 1)", (value) => parseFloat(value))
|
|
497
|
+
.requiredOption("--end-page-number <end-page-number:number>", "The page number to end at. (inclusive) (example: 1)", (value) => parseFloat(value))
|
|
498
|
+
|
|
499
|
+
.alias("get_pdf_pages") .action(async (cmdOpts) => {
|
|
500
|
+
const globalOptions = program.opts();
|
|
501
|
+
const runtime = await ensureRuntime();
|
|
502
|
+
const serverName = embeddedName;
|
|
503
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
504
|
+
initialSchemas: embeddedSchemas,
|
|
505
|
+
});
|
|
506
|
+
try {
|
|
507
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
508
|
+
if (cmdOpts.pdfCardId !== undefined) args.pdfCardId = cmdOpts.pdfCardId;
|
|
509
|
+
if (cmdOpts.startPageNumber !== undefined) args.startPageNumber = cmdOpts.startPageNumber;
|
|
510
|
+
if (cmdOpts.endPageNumber !== undefined) args.endPageNumber = cmdOpts.endPageNumber;
|
|
511
|
+
const call = (proxy.getPdfPages as any)(args);
|
|
512
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
513
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
514
|
+
} finally {
|
|
515
|
+
await runtime.close(serverName).catch(() => {});
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.get_pdf_pages(pdfCardId: \"example-id\", startPageN, ...)");
|
|
519
|
+
|
|
520
|
+
program
|
|
521
|
+
.command("search-pdf-content")
|
|
522
|
+
.summary("search-pdf-content --pdf-card-id <pdf-card-id> --keywords <keywords:value1,value2> [--raw <json>]")
|
|
523
|
+
.description("Search within a large PDF using BM25 keyword matching (OR logic, fuzzy) from the user's Heptabase knowledge base.\n\nWHAT IT RETURNS:\n- Up to 80 ranked chunks matching the keywords\n- Expanded contiguous ranges around matching chunks for context\n\nUSE WHEN:\n- User asks about content within a PDF\n- You need to find specific information in a PDF document\n- User wants to search for keywords or topics in a PDF\n\nIMPORTANT:\n- You must first obtain the PDF card ID using other available tools (e.g., semanticSearchObjects or getObject) before calling this function\n- Use broad keywords, synonyms, and related terms to maximize coverage\n- Follow with get_pdf_pages for complete sections if needed")
|
|
524
|
+
.usage("--pdf-card-id <pdf-card-id> --keywords <keywords:value1,value2> [--raw <json>]")
|
|
525
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
526
|
+
|
|
527
|
+
.requiredOption("--pdf-card-id <pdf-card-id>", "The UUID of the PDF card to search. (example: example-id)")
|
|
528
|
+
.requiredOption("--keywords <keywords:value1,value2>", "No more than 5 keywords. Use varied terms, synonyms, and related concepts (e.g., [\"neural network\", \"deep learning\", \"architecture\"]). OR logic—diverse keywords = broader coverage. (example: value1,value2)", (value) => value.split(',').map((v) => v.trim()))
|
|
529
|
+
|
|
530
|
+
.alias("search_pdf_content") .action(async (cmdOpts) => {
|
|
531
|
+
const globalOptions = program.opts();
|
|
532
|
+
const runtime = await ensureRuntime();
|
|
533
|
+
const serverName = embeddedName;
|
|
534
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
535
|
+
initialSchemas: embeddedSchemas,
|
|
536
|
+
});
|
|
537
|
+
try {
|
|
538
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
539
|
+
if (cmdOpts.pdfCardId !== undefined) args.pdfCardId = cmdOpts.pdfCardId;
|
|
540
|
+
if (cmdOpts.keywords !== undefined) args.keywords = cmdOpts.keywords;
|
|
541
|
+
const call = (proxy.searchPdfContent as any)(args);
|
|
542
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
543
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
544
|
+
} finally {
|
|
545
|
+
await runtime.close(serverName).catch(() => {});
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.search_pdf_content(pdfCardId: \"example-id\", keywo, ...)");
|
|
549
|
+
|
|
550
|
+
program
|
|
551
|
+
.command("semantic-search-objects")
|
|
552
|
+
.summary("semantic-search-objects --queries <queries:value1,value2> --result-object-types <result-object-types:card|pdfCard|mediaCard|highlightElement|journal> [--raw <json>]")
|
|
553
|
+
.description("Find WHICH objects exist on a topic in the user's Heptabase knowledge base using hybrid search (full-text + semantic).\n\nHEPTABASE STRUCTURE:\n- Cards: Knowledge units (notes, journals, PDFs, videos, images, highlights)\n- Whiteboards: Visual canvases containing cards and other objects\n\nUSE WHEN: Discovering what content exists about a topic (e.g., \"machine learning papers\", \"project notes\")\n\nSTRATEGY:\n- Use multiple queries from different perspectives (1-3 queries)\n- Results show previews with titles and partial content\n- If you find relevant objects, use getObject to retrieve complete content\n- Returned objects may reference whiteboards they're on—use searchWhiteboards if you need to explore those whiteboards")
|
|
554
|
+
.usage("--queries <queries:value1,value2> --result-object-types <result-object-types:card|pdfCard|mediaCard|highlightElement|journal> [--raw <json>]")
|
|
555
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
556
|
+
|
|
557
|
+
.requiredOption("--queries <queries:value1,value2>", "Array of search queries in natural language (1-3 queries). Multiple queries from different perspectives improve coverage. Example: [\"climate change impacts\", \"environmental policy\"]. (example: value1,value2)", (value) => value.split(',').map((v) => v.trim()))
|
|
558
|
+
.requiredOption("--result-object-types <result-object-types:card|pdfCard|mediaCard|highlightElement|journal>", "Filter for specific object types. Pass empty array to search all types. (choices: card, pdfCard, mediaCard, highlightElement, journal; example: card)", (value) => value.split(',').map((v) => v.trim()))
|
|
559
|
+
|
|
560
|
+
.alias("semantic_search_objects") .action(async (cmdOpts) => {
|
|
561
|
+
const globalOptions = program.opts();
|
|
562
|
+
const runtime = await ensureRuntime();
|
|
563
|
+
const serverName = embeddedName;
|
|
564
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
565
|
+
initialSchemas: embeddedSchemas,
|
|
566
|
+
});
|
|
567
|
+
try {
|
|
568
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
569
|
+
if (cmdOpts.queries !== undefined) args.queries = cmdOpts.queries;
|
|
570
|
+
if (cmdOpts.resultObjectTypes !== undefined) args.resultObjectTypes = cmdOpts.resultObjectTypes;
|
|
571
|
+
const call = (proxy.semanticSearchObjects as any)(args);
|
|
572
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
573
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
574
|
+
} finally {
|
|
575
|
+
await runtime.close(serverName).catch(() => {});
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.semantic_search_objects(queries: [\"value1\", \"valu, ...)");
|
|
579
|
+
|
|
580
|
+
program
|
|
581
|
+
.command("search-whiteboards")
|
|
582
|
+
.summary("search-whiteboards --keywords <keywords:value1,value2> [--raw <json>]")
|
|
583
|
+
.description("Search for whiteboards by keywords in the user's Heptabase knowledge base.\n\nHEPTABASE STRUCTURE:\n- Whiteboards are visual canvases where users organize their knowledge\n- Each whiteboard contains cards, sections, text elements, mindmaps, images, and connections\n- Users typically group related content on the same whiteboard\n- Whiteboard names/titles indicate their topic or purpose\n\nUSE WHEN:\n- Looking for whiteboards on a specific topic (e.g., \"machine learning project\", \"research papers\")\n- User mentions exploring or understanding their workspace organization\n- You found objects via semanticSearchObjects that reference interesting whiteboards\n- Need to see how content is organized and connected\n\nSEARCH STRATEGY:\n- Use varied keywords, synonyms, and related concepts for better coverage\n- OR logic: diverse keywords = broader results\n- Example: [\"neural network\", \"deep learning\", \"architecture\"]\n\nNEXT STEPS:\n- Results show whiteboard titles and basic info\n- Use getWhiteboardWithObjects to retrieve full content of relevant whiteboards")
|
|
584
|
+
.usage("--keywords <keywords:value1,value2> [--raw <json>]")
|
|
585
|
+
.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
586
|
+
|
|
587
|
+
.requiredOption("--keywords <keywords:value1,value2>", "1-5 keywords. Use varied terms, synonyms, and related concepts for broader coverage (OR logic). Example: [\"project management\", \"productivity\", \"workflow\"]. (example: value1,value2)", (value) => value.split(',').map((v) => v.trim()))
|
|
588
|
+
|
|
589
|
+
.alias("search_whiteboards") .action(async (cmdOpts) => {
|
|
590
|
+
const globalOptions = program.opts();
|
|
591
|
+
const runtime = await ensureRuntime();
|
|
592
|
+
const serverName = embeddedName;
|
|
593
|
+
const proxy = createServerProxy(runtime, serverName, {
|
|
594
|
+
initialSchemas: embeddedSchemas,
|
|
595
|
+
});
|
|
596
|
+
try {
|
|
597
|
+
const args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
598
|
+
if (cmdOpts.keywords !== undefined) args.keywords = cmdOpts.keywords;
|
|
599
|
+
const call = (proxy.searchWhiteboards as any)(args);
|
|
600
|
+
const result = await invokeWithTimeout(call, globalOptions.timeout || 30000);
|
|
601
|
+
printResult(result, globalOptions.output ?? 'text');
|
|
602
|
+
} finally {
|
|
603
|
+
await runtime.close(serverName).catch(() => {});
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
.addHelpText('after', () => '\nExample:\n ' + "mcporter call mcp-remote.search_whiteboards(keywords: [\"value1\", \"value2\"])");
|
|
607
|
+
|
|
608
|
+
program
|
|
609
|
+
.command('__mcporter_inspect', { hidden: true })
|
|
610
|
+
.description('Internal metadata printer for mcporter inspect-cli.')
|
|
611
|
+
.action(() => {
|
|
612
|
+
const payload = buildMetadataPayload();
|
|
613
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
configureToolCommandHelps();
|
|
617
|
+
|
|
618
|
+
const FORCE_COLOR = process.env.FORCE_COLOR?.toLowerCase();
|
|
619
|
+
const forceDisableColor = FORCE_COLOR === '0' || FORCE_COLOR === 'false';
|
|
620
|
+
const forceEnableColor = FORCE_COLOR === '1' || FORCE_COLOR === 'true' || FORCE_COLOR === '2' || FORCE_COLOR === '3';
|
|
621
|
+
const hasNoColor = process.env.NO_COLOR !== undefined;
|
|
622
|
+
const stdoutStream = process.stdout as NodeJS.WriteStream | undefined;
|
|
623
|
+
const supportsAnsiColor = !hasNoColor && (forceEnableColor || (!forceDisableColor && Boolean(stdoutStream?.isTTY)));
|
|
624
|
+
|
|
625
|
+
const tint = {
|
|
626
|
+
bold(text: string): string {
|
|
627
|
+
return supportsAnsiColor ? '[1m' + text + '[0m' : text;
|
|
628
|
+
},
|
|
629
|
+
dim(text: string): string {
|
|
630
|
+
return supportsAnsiColor ? '[90m' + text + '[0m' : text;
|
|
631
|
+
},
|
|
632
|
+
extraDim(text: string): string {
|
|
633
|
+
return supportsAnsiColor ? '[38;5;244m' + text + '[0m' : text;
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
function configureGeneratedCommandHelp(command: Command): void {
|
|
638
|
+
command.configureHelp({
|
|
639
|
+
commandUsage(target) {
|
|
640
|
+
const usage = (target.name() + ' ' + target.usage()).trim() || target.name();
|
|
641
|
+
return supportsAnsiColor ? tint.bold(usage) : usage;
|
|
642
|
+
},
|
|
643
|
+
optionTerm(option) {
|
|
644
|
+
const term = option.flags ?? '';
|
|
645
|
+
return supportsAnsiColor ? tint.bold(term) : term;
|
|
646
|
+
},
|
|
647
|
+
optionDescription(option) {
|
|
648
|
+
const description = option.description ?? '';
|
|
649
|
+
return supportsAnsiColor ? tint.extraDim(description) : description;
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function configureToolCommandHelps(): void {
|
|
655
|
+
program.commands.forEach((cmd) => {
|
|
656
|
+
if (cmd.name() === '__mcporter_inspect') {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
configureGeneratedCommandHelp(cmd);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function renderStandaloneHelp(): string {
|
|
664
|
+
const colorfulTitle = tint.bold(embeddedName) + ' ' + tint.dim('— ' + embeddedDescription);
|
|
665
|
+
const plainTitle = embeddedName + ' — ' + embeddedDescription;
|
|
666
|
+
const title = supportsAnsiColor ? colorfulTitle : plainTitle;
|
|
667
|
+
const lines = [title, '', 'Usage: ' + embeddedName + ' <command> [options]', ''];
|
|
668
|
+
if (generatorTools) {
|
|
669
|
+
lines.push(formatEmbeddedTools());
|
|
670
|
+
}
|
|
671
|
+
lines.push('', formatGlobalFlags(), '', formatQuickStart());
|
|
672
|
+
if (generatorInfo) {
|
|
673
|
+
lines.push('', tint.extraDim(generatorInfo));
|
|
674
|
+
}
|
|
675
|
+
return lines.join('\n');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
program.helpInformation = () => renderStandaloneHelp();
|
|
679
|
+
|
|
680
|
+
function formatEmbeddedTools(): string {
|
|
681
|
+
const header = supportsAnsiColor ? tint.bold('Embedded tools') : 'Embedded tools';
|
|
682
|
+
if (!generatorTools.length) {
|
|
683
|
+
return header;
|
|
684
|
+
}
|
|
685
|
+
const lines = [header];
|
|
686
|
+
generatorTools.forEach((entry) => {
|
|
687
|
+
const renderedDesc = entry.description
|
|
688
|
+
? supportsAnsiColor
|
|
689
|
+
? tint.extraDim(entry.description)
|
|
690
|
+
: entry.description
|
|
691
|
+
: undefined;
|
|
692
|
+
const base = renderedDesc ? entry.name + ' - ' + renderedDesc : entry.name;
|
|
693
|
+
lines.push(' ' + base);
|
|
694
|
+
if (entry.flags) {
|
|
695
|
+
const renderedFlags = supportsAnsiColor ? tint.extraDim(entry.flags) : entry.flags;
|
|
696
|
+
lines.push(' ' + renderedFlags);
|
|
697
|
+
}
|
|
698
|
+
lines.push('');
|
|
699
|
+
});
|
|
700
|
+
if (lines[lines.length - 1] === '') {
|
|
701
|
+
lines.pop();
|
|
702
|
+
}
|
|
703
|
+
return lines.join('\n');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function formatGlobalFlags(): string {
|
|
707
|
+
const header = supportsAnsiColor ? tint.bold('Global flags') : 'Global flags';
|
|
708
|
+
const entries = [
|
|
709
|
+
['-t, --timeout <ms>', 'Call timeout in milliseconds'],
|
|
710
|
+
['-o, --output <format>', 'text | markdown | json | raw (default text)'],
|
|
711
|
+
];
|
|
712
|
+
const formatted = entries.map(([flag, summary]) => ' ' + flag.padEnd(28) + summary);
|
|
713
|
+
return [header, ...formatted].join('\n');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function formatQuickStart(): string {
|
|
717
|
+
const header = supportsAnsiColor ? tint.bold('Quick start') : 'Quick start';
|
|
718
|
+
const examples = quickStartExamples();
|
|
719
|
+
if (!examples.length) {
|
|
720
|
+
return header;
|
|
721
|
+
}
|
|
722
|
+
const formatted = examples.map(([cmd, note]) => ' ' + cmd + '\n ' + tint.dim('# ' + note));
|
|
723
|
+
return [header, ...formatted].join('\n');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function quickStartExamples(): Array<[string, string]> {
|
|
727
|
+
const examples: Array<[string, string]> = [];
|
|
728
|
+
const commandMap = new Map<string, string>();
|
|
729
|
+
program.commands.forEach((cmd) => {
|
|
730
|
+
const name = cmd.name();
|
|
731
|
+
if (name !== '__mcporter_inspect') {
|
|
732
|
+
commandMap.set(name, name);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
const embedded = Array.isArray(generatorTools) ? generatorTools : [];
|
|
736
|
+
for (const entry of embedded.slice(0, 3)) {
|
|
737
|
+
const commandName = commandMap.get(entry.name) ?? entry.name;
|
|
738
|
+
const flags = entry.flags ? ' ' + entry.flags.replace(/<[^>]+>/g, '<value>') : '';
|
|
739
|
+
examples.push([embeddedName + ' ' + commandName + flags, 'invoke ' + commandName]);
|
|
740
|
+
}
|
|
741
|
+
if (!examples.length) {
|
|
742
|
+
examples.push([embeddedName + ' <tool> --key value', 'invoke a tool with flags']);
|
|
743
|
+
}
|
|
744
|
+
return examples;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function printResult(result: unknown, format: string) {
|
|
748
|
+
const wrapped = createCallResult(result);
|
|
749
|
+
switch (format) {
|
|
750
|
+
case 'json': {
|
|
751
|
+
const json = wrapped.json();
|
|
752
|
+
if (json) {
|
|
753
|
+
console.log(JSON.stringify(json, null, 2));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
case 'markdown': {
|
|
759
|
+
const markdown = wrapped.markdown();
|
|
760
|
+
if (markdown) {
|
|
761
|
+
console.log(markdown);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
case 'raw': {
|
|
767
|
+
console.log(JSON.stringify(wrapped.raw, null, 2));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const text = wrapped.text();
|
|
772
|
+
if (text) {
|
|
773
|
+
console.log(text);
|
|
774
|
+
} else {
|
|
775
|
+
console.log(JSON.stringify(wrapped.raw, null, 2));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
|
780
|
+
const base = { ...server } as Record<string, unknown>;
|
|
781
|
+
if ((server.command as any).kind === 'http') {
|
|
782
|
+
const urlRaw = (server.command as any).url;
|
|
783
|
+
const urlValue = typeof urlRaw === 'string' ? urlRaw : String(urlRaw);
|
|
784
|
+
return {
|
|
785
|
+
...base,
|
|
786
|
+
command: {
|
|
787
|
+
...(server.command as Record<string, unknown>),
|
|
788
|
+
url: new URL(urlValue),
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if ((server.command as any).kind === 'stdio') {
|
|
793
|
+
return {
|
|
794
|
+
...base,
|
|
795
|
+
command: {
|
|
796
|
+
...(server.command as Record<string, unknown>),
|
|
797
|
+
args: [ ...((server.command as any).args ?? []) ],
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return base;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function determineArtifactKind(): 'template' | 'bundle' | 'binary' {
|
|
805
|
+
const scriptPath = typeof process !== 'undefined' && Array.isArray(process.argv) ? process.argv[1] ?? '' : '';
|
|
806
|
+
if (scriptPath.endsWith('.ts')) {
|
|
807
|
+
return 'template';
|
|
808
|
+
}
|
|
809
|
+
if (scriptPath.endsWith('.js')) {
|
|
810
|
+
return 'bundle';
|
|
811
|
+
}
|
|
812
|
+
return 'binary';
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function resolveArtifactPath(): string {
|
|
816
|
+
if (typeof process !== 'undefined' && Array.isArray(process.argv) && process.argv.length > 1) {
|
|
817
|
+
const script = process.argv[1];
|
|
818
|
+
if (script) {
|
|
819
|
+
return script;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return embeddedMetadata.artifact.path;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function buildMetadataPayload() {
|
|
826
|
+
const invocation = { ...embeddedMetadata.invocation };
|
|
827
|
+
const path = resolveArtifactPath();
|
|
828
|
+
if (artifactKind === 'template' && path) {
|
|
829
|
+
invocation.outputPath = invocation.outputPath ?? path;
|
|
830
|
+
} else if (artifactKind === 'bundle' && path) {
|
|
831
|
+
invocation.bundle = invocation.bundle ?? path;
|
|
832
|
+
} else if (artifactKind === 'binary' && path) {
|
|
833
|
+
invocation.compile = invocation.compile ?? path;
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
...embeddedMetadata,
|
|
837
|
+
artifact: {
|
|
838
|
+
path,
|
|
839
|
+
kind: artifactKind,
|
|
840
|
+
},
|
|
841
|
+
invocation,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function ensureRuntime(): Promise<Awaited<ReturnType<typeof createRuntime>>> {
|
|
846
|
+
return await createRuntime({
|
|
847
|
+
servers: [normalizeEmbeddedServer(embeddedServer)],
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function invokeWithTimeout<T>(call: Promise<T>, timeout: number): Promise<T> {
|
|
852
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
853
|
+
return await call;
|
|
854
|
+
}
|
|
855
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
856
|
+
try {
|
|
857
|
+
return await Promise.race([
|
|
858
|
+
call,
|
|
859
|
+
new Promise<never>((_, reject) => {
|
|
860
|
+
timer = setTimeout(() => {
|
|
861
|
+
reject(new Error('Call timed out after ' + timeout + 'ms.'));
|
|
862
|
+
}, timeout);
|
|
863
|
+
}),
|
|
864
|
+
]);
|
|
865
|
+
} finally {
|
|
866
|
+
if (timer) {
|
|
867
|
+
clearTimeout(timer);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function runCli(): Promise<void> {
|
|
873
|
+
const args = process.argv.slice(2);
|
|
874
|
+
if (args.length === 0) {
|
|
875
|
+
program.outputHelp();
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
await program.parseAsync(process.argv);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
|
|
882
|
+
runCli().catch((error) => {
|
|
883
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
884
|
+
console.error(message);
|
|
885
|
+
process.exit(1);
|
|
886
|
+
});
|
|
887
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "heptabase-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for your personal Heptabase knowledge base — search, read, and write notes from the terminal",
|
|
5
|
+
"bin": {
|
|
6
|
+
"heptabase": "./bin/heptabase.js"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"mcporter": "^0.7.3",
|
|
10
|
+
"commander": "^13.0.0"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"heptabase",
|
|
14
|
+
"cli",
|
|
15
|
+
"mcp",
|
|
16
|
+
"knowledge-base",
|
|
17
|
+
"notes",
|
|
18
|
+
"journal"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/madeyexz/heptabase-cli"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/madeyexz/heptabase-cli#readme",
|
|
26
|
+
"engines": {
|
|
27
|
+
"bun": ">=1.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|