haunter 0.0.0-stage → 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -2
  3. package/dist/index.js +971 -0
  4. package/package.json +57 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haunter contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,218 @@
1
- # Temporary Holding Version
1
+ # Haunter CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Capture notes, find pages, and manage tasks in [Haunter](https://www.haunter.app)
4
+ from your terminal. Requires Node.js 22 or later and a Haunter account.
5
+
6
+ ## Get started
7
+
8
+ Install the CLI and sign in:
9
+
10
+ ```bash
11
+ npm install -g haunter
12
+ haunter login
13
+ haunter workspaces list
14
+ haunter workspaces use WORKSPACE_ID
15
+ ```
16
+
17
+ Your browser opens Haunter's sign-in and consent screen. Choose the workspaces
18
+ and permissions the CLI can use. Replace `WORKSPACE_ID` with an ID from the list.
19
+ If you approve exactly one workspace, commands use it automatically.
20
+
21
+ You can also run commands without a global install using `npx haunter --help`.
22
+
23
+ ## Capture notes
24
+
25
+ Create a page from quoted text, a Markdown file, or piped input:
26
+
27
+ ```bash
28
+ haunter capture "An idea for later"
29
+ haunter capture --file meeting.md --title "Meeting notes"
30
+ cat meeting.md | haunter capture --title "Meeting notes"
31
+ ```
32
+
33
+ Without `--title`, the first nonempty line becomes the title, up to 120
34
+ characters. Each command prints the page title and ID. Input is limited to
35
+ 100,000 characters.
36
+
37
+ To add content to an existing page, use `--page`. To create a child page, use
38
+ `--parent`. Replace `PAGE_ID` with the page's ID:
39
+
40
+ ```bash
41
+ haunter capture "Another thought" --page PAGE_ID
42
+ haunter capture --file notes.md --parent PAGE_ID --title "Follow-up"
43
+ ```
44
+
45
+ Appending preserves existing content. `--page` cannot be combined with `--title`
46
+ or `--parent`.
47
+
48
+ ## Find and read pages
49
+
50
+ ```bash
51
+ haunter pages list
52
+ haunter pages search "meeting notes"
53
+ haunter pages read PAGE_ID
54
+ ```
55
+
56
+ Lists show page IDs and titles. Reading a page prints its Markdown. Quote search
57
+ phrases and task titles containing spaces.
58
+
59
+ Rename a page without changing its content or location:
60
+
61
+ ```bash
62
+ haunter pages rename PAGE_ID "Updated meeting notes"
63
+ ```
64
+
65
+ Page titles can contain up to 300 characters.
66
+
67
+ ## Search your workspace and list canvases
68
+
69
+ Search across pages, tasks, and canvases in your selected workspace:
70
+
71
+ ```bash
72
+ haunter search "meeting notes"
73
+ haunter search "meeting" --kind task
74
+ haunter canvases list
75
+ ```
76
+
77
+ Search queries must contain 2–200 characters. Search returns up to 20 matches
78
+ per request; use `--limit 1..50` to change that or `--kind all|page|task|canvas`
79
+ to filter the type. Each result shows its type, ID, title, and matching excerpt.
80
+
81
+ When more results are available, the CLI prints a next cursor on stderr.
82
+ Repeat the same query and `--kind` with `--cursor NEXT_CURSOR` to continue.
83
+ With `--json`, the cursor is in the `nextCursor` field of the result.
84
+
85
+ `canvases list` shows IDs and titles of standalone canvases. Canvases embedded
86
+ inside pages are not included.
87
+
88
+ ## Manage tasks
89
+
90
+ ```bash
91
+ haunter tasks list
92
+ haunter tasks list --due today
93
+ haunter tasks add "Review proposal" --due 2026-12-12
94
+ haunter tasks done TASK_ID
95
+ haunter tasks reopen TASK_ID
96
+ haunter tasks update TASK_ID --title "Review revised proposal" --due 2026-12-13
97
+ haunter tasks update TASK_ID --clear-due
98
+ ```
99
+
100
+ Task lists default to your open tasks, with a limit of 50. Use
101
+ `--filter open|completed|all`, `--scope mine|everyone`,
102
+ `--due today|overdue|upcoming`, and `--limit 1..200` to change the selection.
103
+ Creating and completing tasks prints the task ID.
104
+
105
+ `tasks reopen` marks a completed task as open. `tasks update` changes only the
106
+ fields you supply. Use `--title` to rename a standalone task, `--due YYYY-MM-DD`
107
+ to set its due date, or `--clear-due` to remove its date, time, and reminder.
108
+ `--due` and `--clear-due` cannot be combined. To rename a task inside a page,
109
+ edit its title in that page. Task titles can contain up to 1,000 characters.
110
+
111
+ ## Discover and call more tools
112
+
113
+ Discover tools available to your signed-in connection, including new
114
+ tools that don't have a dedicated CLI command:
115
+
116
+ ```bash
117
+ haunter tools list
118
+ haunter tools describe search_workspace
119
+ haunter tools call search_workspace --input search.json --json
120
+ ```
121
+
122
+ Create `search.json` with the arguments shown by `tools describe`:
123
+
124
+ ```json
125
+ {"query":"meeting notes"}
126
+ ```
127
+
128
+ The CLI checks the current tool list and argument schema each time you call a
129
+ tool. New tools become available as the server adds them, within your approved
130
+ permissions. Run `haunter login` again if you need different access. Tools
131
+ reserved for embedded app interfaces are omitted. Tools requiring background
132
+ MCP tasks are not supported.
133
+
134
+ `--input` accepts a JSON file, or `-` for piped input. It must contain one JSON
135
+ object, up to 1,000,000 bytes. Without `--input`, arguments start as `{}`:
136
+
137
+ ```bash
138
+ printf '%s' '{"query":"meeting notes"}' | haunter tools call search_workspace --input - --json
139
+ haunter tools call list_workspaces --json
140
+ ```
141
+
142
+ For tools with a `workspaceId` argument, the CLI fills it from `--workspace`,
143
+ `HAUNTER_WORKSPACE`, the saved selection, or the only approved workspace.
144
+ A `workspaceId` supplied in JSON takes precedence over saved or environment
145
+ defaults; a conflicting `--workspace` is an error. Tools without that argument
146
+ don't use a default workspace and reject `--workspace`.
147
+
148
+ `tools describe` and `tools call` print JSON, formatted for reading unless
149
+ `--json` is set. Calls preserve the complete MCP result: `content`,
150
+ `structuredContent`, and any metadata. Image data and resource links stay in
151
+ the JSON; the CLI does not render or download them. A tool error also preserves
152
+ the result on stdout, reports an error on stderr, and exits with code 1. Check
153
+ the exit code before treating a result as successful.
154
+
155
+ ## Use the CLI in scripts
156
+
157
+ Add `--json` to get one JSON value on stdout:
158
+
159
+ ```bash
160
+ haunter pages search "meeting" --json
161
+ haunter tasks list --filter all --json
162
+ haunter capture "An idea for later" --json
163
+ ```
164
+
165
+ Errors go to stderr as `{"error":"…"}` in JSON mode. Successful commands exit
166
+ with code 0, errors with 1, and cancellation with 130. Sign-in instructions go to
167
+ stderr, including when `login --json` is used.
168
+
169
+ Commands use saved credentials and refresh them when needed. Only `login` opens
170
+ a browser. If a write times out, check Haunter before repeating it: the change
171
+ may have been saved.
172
+
173
+ ## Choose a workspace or server
174
+
175
+ Pass `--workspace WORKSPACE_ID` (or `-w WORKSPACE_ID`) to a search, canvas, page,
176
+ capture, task, or workspace-scoped `tools call` command to override the saved
177
+ workspace.
178
+
179
+ | Setting | Flag | Environment variable | Default |
180
+ | --- | --- | --- | --- |
181
+ | Workspace | `--workspace` | `HAUNTER_WORKSPACE` | Saved selection, or the only approved workspace |
182
+ | Server | `--server` | `HAUNTER_SERVER` | `https://www.haunter.app/mcp` |
183
+ | Configuration directory | — | `HAUNTER_CONFIG_DIR` | `$XDG_CONFIG_HOME/haunter` or `~/.config/haunter`; `%APPDATA%\haunter` on Windows |
184
+
185
+ Flags take precedence over environment variables. Credentials and workspace
186
+ selections are separate for each server. A bare server origin gets `/mcp`
187
+ appended. HTTPS is required except for local development:
188
+
189
+ ```bash
190
+ haunter login --server http://localhost:3000
191
+ haunter pages list --server http://localhost:3000
192
+ ```
193
+
194
+ ## Sign-in and troubleshooting
195
+
196
+ - Run `haunter status` to see whether credentials are saved and which workspace
197
+ is selected. This checks local settings, not whether the session is still valid.
198
+ - Run `haunter login` again if a session is revoked or you need different access.
199
+ Choose permissions that allow creating pages or managing tasks to use those commands.
200
+ - Use `haunter login --no-browser` to open the printed URL yourself. Complete
201
+ sign-in in a browser on the same computer as the CLI; the callback uses
202
+ `127.0.0.1`. Sign-in times out after five minutes.
203
+ - If the callback port is busy, use `haunter login --port 43822`. The default is
204
+ 43821; a chosen port is remembered for that server.
205
+ - Commands for the same server run one at a time to protect token refresh.
206
+ If a process is forcibly terminated, verify that it has stopped before removing
207
+ the `.lock` file named in the error.
208
+
209
+ Credentials are stored locally in a file with owner-only permissions on Unix.
210
+ Treat the configuration directory as sensitive. `haunter logout` removes local
211
+ tokens. To revoke access on the server, disconnect **Haunter CLI** in
212
+ **Haunter → Settings → Agents**.
213
+
214
+ Run `haunter --help` for the complete command list.
215
+
216
+ ## License
217
+
218
+ MIT. See [LICENSE](LICENSE).
package/dist/index.js ADDED
@@ -0,0 +1,971 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createReadStream as createReadStream2 } from "node:fs";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import { z as z2 } from "zod";
7
+ // package.json
8
+ var version = "0.2.0";
9
+
10
+ // src/args.ts
11
+ import { parseArgs } from "node:util";
12
+ var options = {
13
+ help: { type: "boolean", short: "h" },
14
+ version: { type: "boolean", short: "v" },
15
+ json: { type: "boolean" },
16
+ server: { type: "string" },
17
+ workspace: { type: "string", short: "w" },
18
+ title: { type: "string" },
19
+ page: { type: "string" },
20
+ parent: { type: "string" },
21
+ file: { type: "string" },
22
+ input: { type: "string" },
23
+ due: { type: "string" },
24
+ "clear-due": { type: "boolean" },
25
+ kind: { type: "string" },
26
+ cursor: { type: "string" },
27
+ filter: { type: "string" },
28
+ scope: { type: "string" },
29
+ limit: { type: "string" },
30
+ port: { type: "string" },
31
+ "no-browser": { type: "boolean" }
32
+ };
33
+ var commands = {
34
+ login: { args: 0, flags: ["port", "no-browser"] },
35
+ logout: { args: 0, flags: [] },
36
+ status: { args: 0, flags: [] },
37
+ search: { args: 1, flags: ["workspace", "kind", "limit", "cursor"] },
38
+ "canvases list": { args: 0, flags: ["workspace"] },
39
+ "tools list": { args: 0, flags: [] },
40
+ "tools describe": { args: 1, flags: [] },
41
+ "tools call": { args: 1, flags: ["input", "workspace"] },
42
+ "workspaces list": { args: 0, flags: [] },
43
+ "workspaces use": { args: 1, flags: [] },
44
+ "pages list": { args: 0, flags: ["workspace"] },
45
+ "pages search": { args: 1, flags: ["workspace"] },
46
+ "pages read": { args: 1, flags: ["workspace"] },
47
+ "pages rename": { args: 2, flags: ["workspace"] },
48
+ capture: { args: 1, flags: ["workspace", "title", "page", "parent", "file"] },
49
+ "tasks list": {
50
+ args: 0,
51
+ flags: ["workspace", "filter", "scope", "limit", "due"]
52
+ },
53
+ "tasks add": { args: 1, flags: ["workspace", "due"] },
54
+ "tasks done": { args: 1, flags: ["workspace"] },
55
+ "tasks reopen": { args: 1, flags: ["workspace"] },
56
+ "tasks update": {
57
+ args: 1,
58
+ flags: ["workspace", "title", "due", "clear-due"]
59
+ }
60
+ };
61
+ function parseArguments(args) {
62
+ const parsed = parseArgs({
63
+ args,
64
+ options,
65
+ allowPositionals: true,
66
+ strict: true,
67
+ tokens: true
68
+ });
69
+ const { values, positionals, tokens } = parsed;
70
+ const group = positionals[0];
71
+ const grouped = [
72
+ "pages",
73
+ "tasks",
74
+ "workspaces",
75
+ "tools",
76
+ "canvases"
77
+ ].includes(group);
78
+ const command = grouped ? `${group} ${positionals[1] ?? ""}`.trim() : group;
79
+ const operands = positionals.slice(grouped ? 2 : 1);
80
+ if (values.help || values.version || !group)
81
+ return { command, operands, values, help: !values.version };
82
+ const spec = commands[command];
83
+ if (!spec)
84
+ throw new Error(`Unknown command: ${command}. Run haunter --help.`);
85
+ if (operands.length > spec.args || command !== "capture" && operands.length !== spec.args) {
86
+ throw new Error(`${command} expects ${spec.args === 0 ? "no arguments" : `${spec.args === 1 ? "one argument" : `${spec.args} arguments`} (quote text containing spaces)`}. Run haunter --help.`);
87
+ }
88
+ const seen = new Set;
89
+ for (const token of tokens) {
90
+ if (token.kind !== "option")
91
+ continue;
92
+ if (seen.has(token.name))
93
+ throw new Error(`Specify --${token.name} only once.`);
94
+ seen.add(token.name);
95
+ if (!["json", "server", ...spec.flags].includes(token.name))
96
+ throw new Error(`--${token.name} is not supported by ${command}.`);
97
+ if (typeof token.value === "string" && !token.value.trim())
98
+ throw new Error(`--${token.name} cannot be empty.`);
99
+ }
100
+ if (operands.some((value) => !value.trim()))
101
+ throw new Error("Arguments cannot be empty.");
102
+ if (command === "capture") {
103
+ if (values.page && (values.title || values.parent))
104
+ throw new Error("--page appends to an existing page; do not combine it with --title or --parent.");
105
+ if (operands.length && values.file)
106
+ throw new Error("Pass capture text or --file, not both.");
107
+ }
108
+ if (values.port)
109
+ integer(values.port, "port", 1, 65535);
110
+ if (values.limit)
111
+ integer(values.limit, "limit", 1, command === "search" ? 50 : 200);
112
+ if (command === "search") {
113
+ if (operands[0].trim().length < 2 || operands[0].trim().length > 200)
114
+ throw new Error("Search queries must be between 2 and 200 characters.");
115
+ if (values.kind && !["all", "page", "task", "canvas"].includes(values.kind))
116
+ throw new Error("--kind must be all, page, task, or canvas.");
117
+ if (values.cursor && values.cursor.length > 4096)
118
+ throw new Error("--cursor must be a cursor returned by search (at most 4,096 characters).");
119
+ }
120
+ if (command === "pages rename" && operands[1].trim().length > 300)
121
+ throw new Error("Page titles must be 300 characters or fewer.");
122
+ if (command === "tasks update") {
123
+ if (!values.title && !values.due && !values["clear-due"])
124
+ throw new Error("Provide --title, --due, or --clear-due to update a task.");
125
+ if (values.due && values["clear-due"])
126
+ throw new Error("Use --due or --clear-due, not both.");
127
+ if (values.title && values.title.trim().length > 1000)
128
+ throw new Error("Task titles must be 1,000 characters or fewer.");
129
+ }
130
+ if (values.filter && !["open", "completed", "all"].includes(values.filter))
131
+ throw new Error("--filter must be open, completed, or all.");
132
+ if (values.scope && !["mine", "everyone"].includes(values.scope))
133
+ throw new Error("--scope must be mine or everyone.");
134
+ if (values.due) {
135
+ if (command === "tasks list") {
136
+ if (!["today", "overdue", "upcoming"].includes(values.due))
137
+ throw new Error("--due must be today, overdue, or upcoming for tasks list.");
138
+ } else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.due) || Number.isNaN(Date.parse(values.due)) || new Date(values.due).toISOString().slice(0, 10) !== values.due) {
139
+ throw new Error("--due must be a valid date in YYYY-MM-DD format for tasks add or update.");
140
+ }
141
+ }
142
+ return { command, operands, values, help: false };
143
+ }
144
+ function integer(value, flag, min, max) {
145
+ if (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max)
146
+ throw new Error(`--${flag} must be an integer from ${min} to ${max}.`);
147
+ }
148
+ var HELP = `Haunter — pages, notes, and tasks from your terminal
149
+
150
+ Usage: haunter <command> [options]
151
+
152
+ login Sign in through your browser
153
+ --no-browser Print the sign-in URL without opening it
154
+ --port <port> Loopback callback port (default: 43821)
155
+ logout Remove locally saved sign-in credentials
156
+ status Show local sign-in and workspace settings
157
+ workspaces list List workspaces you approved
158
+ workspaces use <id> Save the default workspace for this server
159
+ search <query> Search pages, tasks, and canvases
160
+ --kind <all|page|task|canvas> Filter the result type (default: all)
161
+ --limit <1-50> Results per request (default: 20)
162
+ --cursor <cursor> Continue the same search from its next cursor
163
+ canvases list List standalone canvases
164
+ pages list List pages
165
+ pages search <query> Search pages (quote phrases)
166
+ pages read <id> Print a page as Markdown
167
+ pages rename <id> <title> Rename a page (quote titles containing spaces)
168
+ capture [text] Create a page from text, a file, or stdin
169
+ --title <title> New page title (default: first nonempty line)
170
+ --file <path> Read Markdown from a file; use - for stdin
171
+ --parent <id> Create under an existing page
172
+ --page <id> Append to an existing page instead
173
+ tasks list List your open tasks
174
+ --filter <open|completed|all>
175
+ --scope <mine|everyone>
176
+ --due <today|overdue|upcoming>
177
+ --limit <1-200> Maximum tasks (default: 50)
178
+ tasks add <title> Create a task; optional --due YYYY-MM-DD
179
+ tasks done <id> Complete a task
180
+ tasks reopen <id> Reopen a completed task
181
+ tasks update <id> Update a task's title or due date
182
+ --title <title> New title for a standalone task
183
+ --due <YYYY-MM-DD> Set the due date
184
+ --clear-due Clear the date, time, and reminder
185
+ tools list Discover tools available to your connection
186
+ tools describe <name> Show a tool's description and JSON schemas
187
+ tools call <name> Call a discovered tool with JSON arguments
188
+ --input <path|-> Read a JSON object from a file or stdin
189
+
190
+ Options:
191
+ -w, --workspace <id> Override the saved workspace for a command
192
+ --server <url> Haunter URL (default: https://www.haunter.app/mcp)
193
+ --json Write JSON to stdout; errors go to stderr
194
+ -h, --help Show this help
195
+ -v, --version Show the CLI version
196
+
197
+ Examples:
198
+ haunter login
199
+ haunter workspaces use <id>
200
+ haunter capture "An idea for later"
201
+ cat meeting.md | haunter capture --title "Meeting notes"
202
+ haunter capture --file notes.md --page <id>
203
+ haunter tasks list --due today --json
204
+
205
+ Environment: HAUNTER_SERVER, HAUNTER_WORKSPACE, HAUNTER_CONFIG_DIR
206
+ `;
207
+
208
+ // src/auth.ts
209
+ import { spawn } from "node:child_process";
210
+ import { randomBytes, timingSafeEqual } from "node:crypto";
211
+ import { createServer } from "node:http";
212
+ import {
213
+ auth
214
+ } from "@modelcontextprotocol/sdk/client/auth.js";
215
+ var LOGIN_REQUIRED = "Sign in with haunter login, then try again.";
216
+
217
+ class AuthProvider {
218
+ store;
219
+ profile;
220
+ authorize;
221
+ oauthState;
222
+ verifier;
223
+ constructor(store, profile, authorize, oauthState = randomBytes(32).toString("base64url")) {
224
+ this.store = store;
225
+ this.profile = profile;
226
+ this.authorize = authorize;
227
+ this.oauthState = oauthState;
228
+ }
229
+ get redirectUrl() {
230
+ return this.profile.redirectUrl ?? "http://127.0.0.1:43821/callback";
231
+ }
232
+ get clientMetadata() {
233
+ return {
234
+ client_name: "Haunter CLI",
235
+ client_uri: "https://www.haunter.app",
236
+ redirect_uris: [this.redirectUrl],
237
+ grant_types: ["authorization_code", "refresh_token"],
238
+ response_types: ["code"],
239
+ token_endpoint_auth_method: "none",
240
+ scope: "haunter:mcp offline_access"
241
+ };
242
+ }
243
+ state() {
244
+ return this.oauthState;
245
+ }
246
+ clientInformation() {
247
+ return this.profile.client;
248
+ }
249
+ async saveClientInformation(client) {
250
+ this.profile.client = { ...this.clientMetadata, ...client };
251
+ await this.store.save(this.profile);
252
+ }
253
+ tokens() {
254
+ return this.profile.tokens;
255
+ }
256
+ async saveTokens(tokens) {
257
+ this.profile.tokens = tokens;
258
+ await this.store.save(this.profile);
259
+ }
260
+ async redirectToAuthorization(url) {
261
+ if (!this.authorize)
262
+ throw new Error(LOGIN_REQUIRED);
263
+ await this.authorize(url);
264
+ }
265
+ saveCodeVerifier(verifier) {
266
+ if (!this.authorize)
267
+ throw new Error(LOGIN_REQUIRED);
268
+ this.verifier = verifier;
269
+ }
270
+ codeVerifier() {
271
+ if (!this.verifier)
272
+ throw new Error("The sign-in attempt expired. Run haunter login again.");
273
+ return this.verifier;
274
+ }
275
+ async invalidateCredentials(scope) {
276
+ if (scope === "all" || scope === "client")
277
+ delete this.profile.client;
278
+ if (scope === "all" || scope === "tokens")
279
+ delete this.profile.tokens;
280
+ if (scope === "all" || scope === "verifier")
281
+ this.verifier = undefined;
282
+ await this.store.save(this.profile);
283
+ }
284
+ }
285
+ async function oauthCallback(state, port, signal) {
286
+ let succeed;
287
+ let fail;
288
+ const code = new Promise((resolve, reject) => {
289
+ succeed = resolve;
290
+ fail = reject;
291
+ });
292
+ code.catch(() => {});
293
+ let accepted = false;
294
+ const server = createServer((request, response) => {
295
+ response.setHeader("Cache-Control", "no-store");
296
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
297
+ let url;
298
+ try {
299
+ url = new URL(request.url ?? "/", "http://127.0.0.1");
300
+ } catch {
301
+ response.writeHead(400).end("Invalid callback URL");
302
+ return;
303
+ }
304
+ const address = server.address();
305
+ if (request.method !== "GET" || url.pathname !== "/callback" || !address || typeof address === "string" || request.headers.host !== `127.0.0.1:${address.port}`) {
306
+ response.writeHead(404).end("Not found");
307
+ return;
308
+ }
309
+ const received = Buffer.from(url.searchParams.get("state") ?? "");
310
+ const expected = Buffer.from(state);
311
+ if (accepted || received.length !== expected.length || !timingSafeEqual(received, expected)) {
312
+ response.writeHead(400).end("Invalid sign-in state. Return to the terminal and try again.");
313
+ return;
314
+ }
315
+ if (url.searchParams.has("error")) {
316
+ accepted = true;
317
+ response.writeHead(400).end("Sign-in was not approved. Return to your terminal.");
318
+ fail(new Error("Sign-in was not approved."));
319
+ return;
320
+ }
321
+ const value = url.searchParams.get("code");
322
+ if (!value || value.length > 8192) {
323
+ response.writeHead(400).end("Missing authorization code");
324
+ return;
325
+ }
326
+ accepted = true;
327
+ response.end("Authorization received. You can return to your terminal.");
328
+ succeed(value);
329
+ });
330
+ await new Promise((resolve, reject) => {
331
+ server.once("error", reject);
332
+ server.listen(port, "127.0.0.1", () => {
333
+ server.removeListener("error", reject);
334
+ resolve();
335
+ });
336
+ });
337
+ const address = server.address();
338
+ if (!address || typeof address === "string")
339
+ throw new Error("Could not start the sign-in callback.");
340
+ const timer = setTimeout(() => fail(new Error("Sign-in timed out. Run haunter login again.")), 5 * 60000);
341
+ const abort = () => fail(new Error("Sign-in cancelled."));
342
+ signal?.addEventListener("abort", abort, { once: true });
343
+ if (signal?.aborted)
344
+ abort();
345
+ return {
346
+ url: `http://127.0.0.1:${address.port}/callback`,
347
+ code,
348
+ close: async () => {
349
+ clearTimeout(timer);
350
+ signal?.removeEventListener("abort", abort);
351
+ server.closeAllConnections();
352
+ await new Promise((resolve) => server.close(() => resolve()));
353
+ }
354
+ };
355
+ }
356
+ function openBrowser(url) {
357
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
358
+ throw new Error("The sign-in server returned an unsafe authorization URL.");
359
+ }
360
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
361
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url.href] : [url.href];
362
+ return new Promise((resolve, reject) => {
363
+ const child = spawn(command, args, { stdio: "ignore", shell: false });
364
+ child.once("error", reject);
365
+ child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open a browser.")));
366
+ });
367
+ }
368
+ function boundedFetch(signal) {
369
+ return (input, init) => fetch(input, {
370
+ ...init,
371
+ signal: AbortSignal.any([
372
+ AbortSignal.timeout(30000),
373
+ ...signal ? [signal] : [],
374
+ ...init?.signal ? [init.signal] : []
375
+ ])
376
+ });
377
+ }
378
+ async function login(store, profile, options) {
379
+ const state = randomBytes(32).toString("base64url");
380
+ const port = options.port ?? (profile.redirectUrl ? Number(new URL(profile.redirectUrl).port) : 43821);
381
+ const callback = await oauthCallback(state, port, options.signal).catch((error) => {
382
+ throw new Error("Could not listen for sign-in. Try haunter login --port 43822.", { cause: error });
383
+ });
384
+ try {
385
+ if (profile.redirectUrl !== callback.url)
386
+ delete profile.client;
387
+ profile.redirectUrl = callback.url;
388
+ delete profile.tokens;
389
+ delete profile.workspaceId;
390
+ await store.save(profile);
391
+ const provider = new AuthProvider(store, profile, async (url) => {
392
+ options.notice(`Open this URL to sign in and choose workspace access:
393
+ ${url.href}`);
394
+ if (!options.noBrowser)
395
+ await (options.open ?? openBrowser)(url).catch(() => options.notice("Open the URL above in your browser to continue."));
396
+ }, state);
397
+ const fetchFn = boundedFetch(options.signal);
398
+ const result = await auth(provider, {
399
+ serverUrl: store.server,
400
+ scope: "haunter:mcp offline_access",
401
+ fetchFn
402
+ });
403
+ if (result === "REDIRECT") {
404
+ const authorizationCode = await callback.code;
405
+ const finished = await auth(provider, {
406
+ serverUrl: store.server,
407
+ authorizationCode,
408
+ fetchFn
409
+ });
410
+ if (finished !== "AUTHORIZED")
411
+ throw new Error("Sign-in did not finish. Run haunter login again.");
412
+ }
413
+ if (!profile.tokens)
414
+ throw new Error("Sign-in returned no credentials.");
415
+ } finally {
416
+ await callback.close();
417
+ }
418
+ }
419
+
420
+ // src/client.ts
421
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
422
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
423
+ import {
424
+ CallToolResultSchema,
425
+ ListToolsResultSchema
426
+ } from "@modelcontextprotocol/sdk/types.js";
427
+ async function connect(store, profile, signal) {
428
+ if (!profile.tokens)
429
+ throw new Error(LOGIN_REQUIRED);
430
+ const client = new Client({ name: "haunter-cli", version }, { capabilities: {} });
431
+ const transport = new StreamableHTTPClientTransport(new URL(store.server), {
432
+ authProvider: new AuthProvider(store, profile),
433
+ fetch: boundedFetch(signal),
434
+ reconnectionOptions: {
435
+ maxRetries: 0,
436
+ initialReconnectionDelay: 1000,
437
+ maxReconnectionDelay: 1000,
438
+ reconnectionDelayGrowFactor: 1
439
+ }
440
+ });
441
+ try {
442
+ await client.connect(transport, { timeout: 30000, signal });
443
+ } catch (error) {
444
+ await client.close();
445
+ throw error;
446
+ }
447
+ return {
448
+ async listTools() {
449
+ const tools = [];
450
+ const names = new Set;
451
+ const cursors = new Set;
452
+ let cursor;
453
+ for (let page = 0;page < 100; page++) {
454
+ const result = await client.request({
455
+ method: "tools/list",
456
+ params: cursor === undefined ? {} : { cursor }
457
+ }, ListToolsResultSchema, { timeout: 30000, signal });
458
+ for (const tool of result.tools) {
459
+ if (names.has(tool.name))
460
+ throw new Error(`Haunter listed duplicate tool ${tool.name}.`);
461
+ names.add(tool.name);
462
+ tools.push(tool);
463
+ }
464
+ cursor = result.nextCursor;
465
+ if (cursor === undefined)
466
+ return tools;
467
+ if (cursors.has(cursor))
468
+ throw new Error("Haunter repeated a tool-list cursor.");
469
+ cursors.add(cursor);
470
+ }
471
+ throw new Error("Haunter's tool list exceeded 100 pages.");
472
+ },
473
+ callTool: (name, args) => client.request({ method: "tools/call", params: { name, arguments: args } }, CallToolResultSchema, { timeout: 30000, signal }),
474
+ async call(name, args) {
475
+ const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 30000, signal });
476
+ const text = Array.isArray(result.content) ? result.content.filter((item) => item.type === "text").map((item) => item.text).join(`
477
+ `) : "";
478
+ if (result.isError)
479
+ throw new Error(text || `Haunter could not complete ${name}.`);
480
+ if (result.structuredContent)
481
+ return result.structuredContent;
482
+ try {
483
+ return JSON.parse(text);
484
+ } catch {
485
+ throw new Error(`Haunter returned an unexpected response for ${name}.`);
486
+ }
487
+ },
488
+ close: () => client.close()
489
+ };
490
+ }
491
+
492
+ // src/tools.ts
493
+ import { createReadStream } from "node:fs";
494
+ import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
495
+ async function readToolInput(file, io) {
496
+ if (!file)
497
+ return {};
498
+ if (file === "-" && io.isTTY)
499
+ throw new Error("Pipe a JSON object into tools call --input -.");
500
+ const stream = file === "-" ? io.stdin : createReadStream(file);
501
+ const chunks = [];
502
+ let bytes = 0;
503
+ for await (const chunk of stream) {
504
+ io.signal?.throwIfAborted();
505
+ const buffer = Buffer.from(chunk);
506
+ bytes += buffer.length;
507
+ if (bytes > 1e6)
508
+ throw new Error("Tool input exceeds 1,000,000 bytes.");
509
+ chunks.push(buffer);
510
+ }
511
+ let input;
512
+ try {
513
+ input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
514
+ } catch {
515
+ throw new Error("Tool input must be valid JSON.");
516
+ }
517
+ if (!input || typeof input !== "object" || Array.isArray(input))
518
+ throw new Error("Tool input must be a JSON object.");
519
+ return input;
520
+ }
521
+ function isCliTool(tool) {
522
+ const ui = tool._meta?.ui;
523
+ if (!ui || typeof ui !== "object")
524
+ return true;
525
+ const visibility = ui.visibility;
526
+ return !Array.isArray(visibility) || visibility.includes("model");
527
+ }
528
+ function toolValidator(schema, name) {
529
+ try {
530
+ return new CfWorkerJsonSchemaValidator().getValidator(schema);
531
+ } catch {
532
+ throw new Error(`Cannot validate the schema for ${name}. Update the CLI and try again.`);
533
+ }
534
+ }
535
+
536
+ // src/config.ts
537
+ import { createHash, randomUUID } from "node:crypto";
538
+ import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
539
+ import { homedir } from "node:os";
540
+ import { join, resolve } from "node:path";
541
+ import {
542
+ OAuthClientInformationFullSchema,
543
+ OAuthTokensSchema
544
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
545
+ import { z } from "zod";
546
+ var DEFAULT_SERVER = "https://www.haunter.app/mcp";
547
+ var profileSchema = z.object({
548
+ version: z.literal(1),
549
+ server: z.string(),
550
+ workspaceId: z.string().optional(),
551
+ redirectUrl: z.string().optional(),
552
+ client: OAuthClientInformationFullSchema.optional(),
553
+ tokens: OAuthTokensSchema.optional()
554
+ });
555
+ function serverUrl(value) {
556
+ const url = new URL(value);
557
+ const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
558
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.search || url.hash) {
559
+ throw new Error("Use an HTTPS server URL (HTTP is allowed only on localhost), without credentials, a query, or a fragment.");
560
+ }
561
+ if (url.pathname === "/")
562
+ url.pathname = "/mcp";
563
+ return url.toString().replace(/\/$/, "");
564
+ }
565
+ function configDirectory(env = process.env) {
566
+ if (env.HAUNTER_CONFIG_DIR)
567
+ return resolve(env.HAUNTER_CONFIG_DIR);
568
+ const base = process.platform === "win32" ? env.APPDATA ?? join(homedir(), "AppData", "Roaming") : env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
569
+ return join(base, "haunter");
570
+ }
571
+
572
+ class ProfileStore {
573
+ directory;
574
+ server;
575
+ path;
576
+ constructor(directory, server) {
577
+ this.directory = directory;
578
+ this.server = server;
579
+ this.path = join(directory, `${createHash("sha256").update(server).digest("hex")}.json`);
580
+ }
581
+ async load() {
582
+ try {
583
+ const profile = profileSchema.parse(JSON.parse(await readFile(this.path, "utf8")));
584
+ if (profile.server !== this.server)
585
+ throw new Error("Server mismatch");
586
+ return profile;
587
+ } catch (error) {
588
+ if (isFileError(error, "ENOENT"))
589
+ return { version: 1, server: this.server };
590
+ throw new Error(`Could not read CLI configuration at ${this.path}. Move it aside and run haunter login again.`, { cause: error });
591
+ }
592
+ }
593
+ async save(profile) {
594
+ profileSchema.parse(profile);
595
+ await mkdir(this.directory, { recursive: true, mode: 448 });
596
+ await chmod(this.directory, 448);
597
+ const temporary = `${this.path}.${randomUUID()}.tmp`;
598
+ const file = await open(temporary, "wx", 384);
599
+ try {
600
+ await file.writeFile(`${JSON.stringify(profile, null, 2)}
601
+ `);
602
+ await file.sync();
603
+ await file.close();
604
+ await rename(temporary, this.path);
605
+ } finally {
606
+ await file.close();
607
+ await unlink(temporary).catch((error) => {
608
+ if (!isFileError(error, "ENOENT"))
609
+ throw error;
610
+ });
611
+ }
612
+ }
613
+ async lock() {
614
+ await mkdir(this.directory, { recursive: true, mode: 448 });
615
+ const lockPath = `${this.path}.lock`;
616
+ try {
617
+ const file = await open(lockPath, "wx", 384);
618
+ await file.writeFile(String(process.pid));
619
+ await file.close();
620
+ } catch (error) {
621
+ if (!isFileError(error, "EEXIST"))
622
+ throw error;
623
+ throw new Error(`Another Haunter command is using this server. Wait for it to finish. If it was interrupted, remove ${lockPath} before retrying.`);
624
+ }
625
+ return () => unlink(lockPath).catch((error) => {
626
+ if (!isFileError(error, "ENOENT"))
627
+ throw error;
628
+ });
629
+ }
630
+ }
631
+ function isFileError(error, code) {
632
+ return error instanceof Error && "code" in error && error.code === code;
633
+ }
634
+
635
+ // src/cli.ts
636
+ var workspaceResult = z2.object({
637
+ workspaces: z2.array(z2.object({ id: z2.string(), name: z2.string(), role: z2.string() }).passthrough())
638
+ });
639
+ var pagesResult = z2.object({
640
+ pages: z2.array(z2.object({ pageId: z2.string(), title: z2.string() }).passthrough())
641
+ });
642
+ var pageResult = z2.object({ title: z2.string(), markdown: z2.string() });
643
+ var taskRow = z2.object({
644
+ taskId: z2.string(),
645
+ title: z2.string(),
646
+ completed: z2.boolean(),
647
+ dueDate: z2.string().nullable().optional()
648
+ }).passthrough();
649
+ var tasksResult = z2.object({ tasks: z2.array(taskRow), hasMore: z2.boolean() });
650
+ var savedPage = z2.object({ pageId: z2.string(), title: z2.string() });
651
+ var canvasesResult = z2.object({
652
+ canvases: z2.array(z2.object({ canvasId: z2.string(), title: z2.string() }).passthrough())
653
+ }).passthrough();
654
+ var searchResult = z2.object({
655
+ items: z2.array(z2.object({
656
+ kind: z2.enum(["page", "task", "canvas"]),
657
+ id: z2.string(),
658
+ title: z2.string(),
659
+ snippet: z2.string()
660
+ }).passthrough()),
661
+ nextCursor: z2.string().nullable()
662
+ }).passthrough();
663
+ var taskActions = {
664
+ "tasks add": { tool: "create_task", label: "Created" },
665
+ "tasks done": { tool: "complete_task", label: "Completed" },
666
+ "tasks reopen": { tool: "reopen_task", label: "Reopened" },
667
+ "tasks update": { tool: "update_task", label: "Updated" }
668
+ };
669
+ async function runCli(args, io, dependencies = { connect, login }) {
670
+ let secrets = [];
671
+ let release;
672
+ let client;
673
+ let json = args.includes("--json");
674
+ const write = (data, text) => io.stdout(json ? `${JSON.stringify(data)}
675
+ ` : `${safeText(text)}
676
+ `);
677
+ try {
678
+ const { command, operands, values, help } = parseArguments(args);
679
+ json = Boolean(values.json);
680
+ if (values.version) {
681
+ write({ version }, version);
682
+ return 0;
683
+ }
684
+ if (help) {
685
+ write({ help: HELP }, HELP.trimEnd());
686
+ return 0;
687
+ }
688
+ if (["pages rename", "tasks update", "tasks reopen"].includes(command))
689
+ uuid(operands[0]);
690
+ const server = serverUrl(values.server ?? io.env.HAUNTER_SERVER ?? DEFAULT_SERVER);
691
+ const store = new ProfileStore(configDirectory(io.env), server);
692
+ release = await store.lock();
693
+ const profile = await store.load();
694
+ secrets = [
695
+ profile.tokens?.access_token,
696
+ profile.tokens?.refresh_token,
697
+ profile.client?.client_secret
698
+ ].filter((value) => Boolean(value));
699
+ if (command === "status") {
700
+ const status = {
701
+ server,
702
+ signedIn: Boolean(profile.tokens),
703
+ workspaceId: profile.workspaceId ?? null
704
+ };
705
+ write(status, `${status.signedIn ? "Credentials saved" : "Signed out"}
706
+ Server: ${server}
707
+ Workspace: ${status.workspaceId ?? "not selected"}`);
708
+ return 0;
709
+ }
710
+ if (command === "logout") {
711
+ delete profile.tokens;
712
+ delete profile.workspaceId;
713
+ await store.save(profile);
714
+ write({ signedIn: false, server }, "Local credentials removed. To revoke the connection, disconnect Haunter CLI in Haunter → Settings → Agents.");
715
+ return 0;
716
+ }
717
+ if (command === "login") {
718
+ await dependencies.login(store, profile, {
719
+ port: values.port ? Number(values.port) : undefined,
720
+ noBrowser: values["no-browser"],
721
+ signal: io.signal,
722
+ notice: (text) => io.stderr(`${safeText(text)}
723
+ `)
724
+ });
725
+ write({ signedIn: true, server }, "Signed in. Run haunter workspaces list, then haunter workspaces use <id>.");
726
+ return 0;
727
+ }
728
+ const markdown = command === "capture" ? await captureText(operands[0], values.file, io) : undefined;
729
+ const toolInput = command === "tools call" ? await readToolInput(values.input, io) : {};
730
+ client = await dependencies.connect(store, profile, io.signal);
731
+ if (command.startsWith("tools ")) {
732
+ const tools = (await client.listTools()).filter(isCliTool);
733
+ if (command === "tools list") {
734
+ write({ tools }, tools.map((tool) => `${tool.name} ${oneLine(tool.description ?? tool.title ?? "")}`).join(`
735
+ `) || "No tools available. Run haunter login to review access.");
736
+ return 0;
737
+ }
738
+ const tool = tools.find((item) => item.name === operands[0]);
739
+ if (!tool)
740
+ throw new Error(`Tool ${operands[0]} is not available to this connection. Run haunter tools list, or haunter login to review access.`);
741
+ if (command === "tools describe") {
742
+ write(tool, JSON.stringify(tool, null, 2));
743
+ return 0;
744
+ }
745
+ if (tool.execution?.taskSupport === "required")
746
+ throw new Error(`${tool.name} requires MCP task support, which this CLI does not support.`);
747
+ if (Object.hasOwn(tool.inputSchema.properties ?? {}, "workspaceId")) {
748
+ if (Object.hasOwn(toolInput, "workspaceId")) {
749
+ if (values.workspace && values.workspace !== toolInput.workspaceId)
750
+ throw new Error("--workspace conflicts with workspaceId in the JSON input.");
751
+ } else {
752
+ toolInput.workspaceId = await resolveWorkspace(client, values.workspace ?? io.env.HAUNTER_WORKSPACE ?? profile.workspaceId);
753
+ }
754
+ } else if (values.workspace) {
755
+ throw new Error(`${tool.name} does not accept --workspace.`);
756
+ }
757
+ const validation = toolValidator(tool.inputSchema, tool.name)(toolInput);
758
+ if (!validation.valid)
759
+ throw new Error(`Invalid arguments for ${tool.name}: ${validation.errorMessage}`);
760
+ const validateOutput = tool.outputSchema ? toolValidator(tool.outputSchema, tool.name) : undefined;
761
+ const result = await client.callTool(tool.name, toolInput);
762
+ write(result, JSON.stringify(result, null, 2));
763
+ if (result.isError)
764
+ throw new Error(`${tool.name} failed. See the tool result on stdout.`);
765
+ if (validateOutput && !validateOutput(result.structuredContent).valid)
766
+ throw new Error(`${tool.name} returned a result that does not match its output schema. The call was sent; check Haunter before repeating it.`);
767
+ return 0;
768
+ }
769
+ if (command === "workspaces list" || command === "workspaces use") {
770
+ const result = workspaceResult.parse(await client.call("list_workspaces", {}));
771
+ if (command === "workspaces list")
772
+ write(result, result.workspaces.map((w) => `${w.id} ${oneLine(w.name)} ${w.role}`).join(`
773
+ `) || "No workspaces available. Run haunter login to review access.");
774
+ else {
775
+ const selected = result.workspaces.find((w) => w.id === operands[0]);
776
+ if (!selected)
777
+ throw new Error("Workspace not available. Choose an ID from haunter workspaces list.");
778
+ profile.workspaceId = selected.id;
779
+ await store.save(profile);
780
+ write(selected, `Using ${oneLine(selected.name)} (${selected.id}).`);
781
+ }
782
+ return 0;
783
+ }
784
+ const workspaceId = await resolveWorkspace(client, values.workspace ?? io.env.HAUNTER_WORKSPACE ?? profile.workspaceId);
785
+ const workspace = { workspaceId };
786
+ if (command === "search") {
787
+ const result = searchResult.parse(await client.call("search_workspace", {
788
+ ...workspace,
789
+ query: operands[0].trim(),
790
+ kind: values.kind ?? "all",
791
+ limit: values.limit ? Number(values.limit) : 20,
792
+ ...values.cursor ? { cursor: values.cursor } : {}
793
+ }));
794
+ write(result, result.items.map((item) => `${item.kind} ${item.id} ${oneLine(item.title || "Untitled")}${item.snippet ? `
795
+ ${oneLine(item.snippet)}` : ""}`).join(`
796
+ `) || "No results found.");
797
+ if (result.nextCursor && !json)
798
+ io.stderr(`More results available. Repeat the same query and --kind with --cursor.
799
+ Next cursor: ${oneLine(result.nextCursor)}
800
+ `);
801
+ } else if (command === "canvases list") {
802
+ const result = canvasesResult.parse(await client.call("list_canvases", workspace));
803
+ write(result, result.canvases.map((canvas) => `${canvas.canvasId} ${oneLine(canvas.title || "Untitled")}`).join(`
804
+ `) || "No standalone canvases found.");
805
+ } else if (command === "pages rename") {
806
+ const result = await client.call("update_page", {
807
+ ...workspace,
808
+ pageId: operands[0],
809
+ title: operands[1].trim()
810
+ });
811
+ const page = savedPage.parse(result);
812
+ write(result, `Renamed to ${oneLine(page.title)}
813
+ ${page.pageId}`);
814
+ } else if (command === "pages list" || command === "pages search") {
815
+ const result = pagesResult.parse(await client.call(command === "pages list" ? "list_pages" : "search_pages", {
816
+ ...workspace,
817
+ ...command === "pages search" ? { query: operands[0] } : {}
818
+ }));
819
+ write(result, result.pages.map((p) => `${p.pageId} ${oneLine(p.title || "Untitled")}`).join(`
820
+ `) || "No pages found.");
821
+ } else if (command === "pages read") {
822
+ const result = await client.call("read_page", {
823
+ ...workspace,
824
+ pageId: uuid(operands[0]),
825
+ format: "markdown"
826
+ });
827
+ write(result, pageResult.parse(result).markdown);
828
+ } else if (command === "capture") {
829
+ const title = (values.title ?? markdown?.split(/\r?\n/).find((line) => line.trim())?.replace(/^#+\s*/, "").trim().slice(0, 120)) || "Untitled";
830
+ const result = await client.call(values.page ? "append_to_page" : "create_page", {
831
+ ...workspace,
832
+ markdown,
833
+ ...values.page ? { pageId: uuid(values.page) } : {
834
+ title,
835
+ ...values.parent ? { parentPageId: uuid(values.parent) } : {}
836
+ }
837
+ });
838
+ const page = savedPage.parse(result);
839
+ write(result, `${values.page ? "Appended to" : "Created"} ${oneLine(page.title)}
840
+ ${page.pageId}`);
841
+ } else if (command === "tasks list") {
842
+ const result = tasksResult.parse(await client.call("list_tasks", {
843
+ ...workspace,
844
+ filter: values.filter ?? "open",
845
+ scope: values.scope ?? "mine",
846
+ limit: values.limit ? Number(values.limit) : 50,
847
+ ...values.due ? { due: values.due } : {}
848
+ }));
849
+ write(result, result.tasks.map((t) => `${t.completed ? "[x]" : "[ ]"} ${t.taskId} ${oneLine(t.title)}${t.dueDate ? ` ${t.dueDate}` : ""}`).join(`
850
+ `) || "No tasks found.");
851
+ if (result.hasMore && !json)
852
+ io.stderr(`More tasks are available. Narrow the filters or increase --limit (maximum 200).
853
+ `);
854
+ } else {
855
+ const action = taskActions[command];
856
+ if (!action)
857
+ throw new Error(`Unsupported command: ${command}. Run haunter --help.`);
858
+ const result = await client.call(action.tool, {
859
+ ...workspace,
860
+ ...command === "tasks add" ? {
861
+ title: operands[0],
862
+ ...values.due ? { dueDate: values.due } : {}
863
+ } : { taskId: uuid(operands[0]) },
864
+ ...command === "tasks update" ? {
865
+ ...values.title !== undefined ? { title: values.title.trim() } : {},
866
+ ...values.due !== undefined ? { dueDate: values.due } : {},
867
+ ...values["clear-due"] ? { dueDate: null } : {}
868
+ } : {}
869
+ });
870
+ const task = taskRow.parse(result);
871
+ write(result, `${action.label} ${oneLine(task.title)}
872
+ ${task.taskId}`);
873
+ }
874
+ return 0;
875
+ } catch (error) {
876
+ let message = io.signal?.aborted ? "Cancelled." : error instanceof z2.ZodError ? "Haunter returned an unexpected response. Update the CLI and try again." : error instanceof Error ? error.message : "Haunter could not complete the command.";
877
+ for (const secret of secrets)
878
+ message = message.replaceAll(secret, "[redacted]");
879
+ io.stderr(json ? `${JSON.stringify({ error: safeText(message) })}
880
+ ` : `haunter: ${safeText(message)}
881
+ `);
882
+ return io.signal?.aborted ? 130 : 1;
883
+ } finally {
884
+ await client?.close().catch(() => {});
885
+ await release?.().catch(() => {
886
+ const error = "Could not remove the command lock. Check the configuration directory before retrying.";
887
+ io.stderr(json ? `${JSON.stringify({ error })}
888
+ ` : `haunter: ${error}
889
+ `);
890
+ });
891
+ }
892
+ }
893
+ async function resolveWorkspace(client, selected) {
894
+ if (selected)
895
+ return selected;
896
+ const result = workspaceResult.parse(await client.call("list_workspaces", {}));
897
+ if (result.workspaces.length !== 1)
898
+ throw new Error("Select a workspace with haunter workspaces use <id>, or pass --workspace <id>.");
899
+ return result.workspaces[0].id;
900
+ }
901
+ function uuid(value) {
902
+ if (!z2.uuid().safeParse(value).success)
903
+ throw new Error("Expected a page or task ID in UUID format.");
904
+ return value;
905
+ }
906
+ async function captureText(text, file, io) {
907
+ if (text !== undefined) {
908
+ if (text.length > 1e5)
909
+ throw new Error("Capture text exceeds 100,000 characters.");
910
+ return text;
911
+ }
912
+ if (!file && io.isTTY)
913
+ throw new Error("Provide capture text, --file <path>, or pipe Markdown into haunter capture.");
914
+ const input = file && file !== "-" ? createReadStream2(file) : io.stdin;
915
+ const chunks = [];
916
+ let bytes = 0;
917
+ for await (const chunk of input) {
918
+ io.signal?.throwIfAborted();
919
+ const buffer = Buffer.from(chunk);
920
+ bytes += buffer.length;
921
+ if (bytes > 400000)
922
+ throw new Error("Capture text exceeds 100,000 characters.");
923
+ chunks.push(buffer);
924
+ }
925
+ const markdown = Buffer.concat(chunks).toString("utf8");
926
+ if (!markdown.trim())
927
+ throw new Error("There is no text to capture.");
928
+ if (markdown.length > 1e5)
929
+ throw new Error("Capture text exceeds 100,000 characters.");
930
+ return markdown;
931
+ }
932
+ function safeText(value) {
933
+ return stripVTControlCharacters(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
934
+ }
935
+ function oneLine(value) {
936
+ return safeText(value).replace(/\s+/g, " ");
937
+ }
938
+
939
+ // src/index.ts
940
+ var controller = new AbortController;
941
+ var brokenPipe = false;
942
+ var cancel = () => {
943
+ controller.abort();
944
+ process.stdin.destroy();
945
+ };
946
+ process.once("SIGINT", cancel);
947
+ process.once("SIGTERM", cancel);
948
+ process.stdout.on("error", (error) => {
949
+ if (error.code === "EPIPE") {
950
+ brokenPipe = true;
951
+ controller.abort();
952
+ process.exitCode = 0;
953
+ } else
954
+ throw error;
955
+ });
956
+ process.exitCode = await runCli(process.argv.slice(2), {
957
+ stdout: (text) => {
958
+ process.stdout.write(text);
959
+ },
960
+ stderr: (text) => {
961
+ process.stderr.write(text);
962
+ },
963
+ stdin: process.stdin,
964
+ isTTY: Boolean(process.stdin.isTTY),
965
+ env: process.env,
966
+ signal: controller.signal
967
+ });
968
+ process.removeListener("SIGINT", cancel);
969
+ process.removeListener("SIGTERM", cancel);
970
+ if (brokenPipe)
971
+ process.exitCode = 0;
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
- "name": "haunter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "haunter",
3
+ "version": "0.2.0",
4
+ "description": "Pages, notes, and tasks in Haunter from your terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "haunter": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "packageManager": "bun@1.4.2",
18
+ "license": "MIT",
19
+ "author": "Haunter contributors",
20
+ "homepage": "https://www.haunter.app",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/taylorbryant/haunter.git",
24
+ "directory": "packages/cli"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/taylorbryant/haunter/issues"
28
+ },
29
+ "keywords": [
30
+ "haunter",
31
+ "cli",
32
+ "notes",
33
+ "tasks",
34
+ "markdown"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org/"
39
+ },
40
+ "scripts": {
41
+ "build": "bun scripts/build.ts",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "bun test tests",
44
+ "check": "bun run typecheck && bun run test && bun run build",
45
+ "verify:package": "bun scripts/verify-package.ts",
46
+ "prepack": "bun run check"
47
+ },
48
+ "dependencies": {
49
+ "@cfworker/json-schema": "4.1.1",
50
+ "@modelcontextprotocol/sdk": "1.32.1",
51
+ "zod": "4.6.7"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "1.4.3",
55
+ "@types/node": "26.6.5",
56
+ "typescript": "7.0.2"
57
+ }
58
+ }