haunter 0.0.0-stage → 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/dist/index.js +746 -0
  4. package/package.json +56 -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,135 @@
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
+ ## Manage tasks
60
+
61
+ ```bash
62
+ haunter tasks list
63
+ haunter tasks list --due today
64
+ haunter tasks add "Review proposal" --due 2026-12-12
65
+ haunter tasks done TASK_ID
66
+ ```
67
+
68
+ Task lists default to your open tasks, with a limit of 50. Use
69
+ `--filter open|completed|all`, `--scope mine|everyone`,
70
+ `--due today|overdue|upcoming`, and `--limit 1..200` to change the selection.
71
+ Creating and completing tasks prints the task ID.
72
+
73
+ ## Use the CLI in scripts
74
+
75
+ Add `--json` to get one JSON value on stdout:
76
+
77
+ ```bash
78
+ haunter pages search "meeting" --json
79
+ haunter tasks list --filter all --json
80
+ haunter capture "An idea for later" --json
81
+ ```
82
+
83
+ Errors go to stderr as `{"error":"…"}` in JSON mode. Successful commands exit
84
+ with code 0, errors with 1, and cancellation with 130. Sign-in instructions go to
85
+ stderr, including when `login --json` is used.
86
+
87
+ Commands use saved credentials and refresh them when needed. Only `login` opens
88
+ a browser. If a write times out, check Haunter before repeating it: the change
89
+ may have been saved.
90
+
91
+ ## Choose a workspace or server
92
+
93
+ Pass `--workspace WORKSPACE_ID` (or `-w WORKSPACE_ID`) to a page, capture, or task
94
+ command to override the saved workspace.
95
+
96
+ | Setting | Flag | Environment variable | Default |
97
+ | --- | --- | --- | --- |
98
+ | Workspace | `--workspace` | `HAUNTER_WORKSPACE` | Saved selection, or the only approved workspace |
99
+ | Server | `--server` | `HAUNTER_SERVER` | `https://www.haunter.app/mcp` |
100
+ | Configuration directory | — | `HAUNTER_CONFIG_DIR` | `$XDG_CONFIG_HOME/haunter` or `~/.config/haunter`; `%APPDATA%\haunter` on Windows |
101
+
102
+ Flags take precedence over environment variables. Credentials and workspace
103
+ selections are separate for each server. A bare server origin gets `/mcp`
104
+ appended. HTTPS is required except for local development:
105
+
106
+ ```bash
107
+ haunter login --server http://localhost:3000
108
+ haunter pages list --server http://localhost:3000
109
+ ```
110
+
111
+ ## Sign-in and troubleshooting
112
+
113
+ - Run `haunter status` to see whether credentials are saved and which workspace
114
+ is selected. This checks local settings, not whether the session is still valid.
115
+ - Run `haunter login` again if a session is revoked or you need different access.
116
+ Choose permissions that allow creating pages or managing tasks to use those commands.
117
+ - Use `haunter login --no-browser` to open the printed URL yourself. Complete
118
+ sign-in in a browser on the same computer as the CLI; the callback uses
119
+ `127.0.0.1`. Sign-in times out after five minutes.
120
+ - If the callback port is busy, use `haunter login --port 43822`. The default is
121
+ 43821; a chosen port is remembered for that server.
122
+ - Commands for the same server run one at a time to protect token refresh.
123
+ If a process is forcibly terminated, verify that it has stopped before removing
124
+ the `.lock` file named in the error.
125
+
126
+ Credentials are stored locally in a file with owner-only permissions on Unix.
127
+ Treat the configuration directory as sensitive. `haunter logout` removes local
128
+ tokens. To revoke access on the server, disconnect **Haunter CLI** in
129
+ **Haunter → Settings → Agents**.
130
+
131
+ Run `haunter --help` for the complete command list.
132
+
133
+ ## License
134
+
135
+ MIT. See [LICENSE](LICENSE).
package/dist/index.js ADDED
@@ -0,0 +1,746 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createReadStream } from "node:fs";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import { z as z2 } from "zod";
7
+ // package.json
8
+ var version = "0.1.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
+ due: { type: "string" },
23
+ filter: { type: "string" },
24
+ scope: { type: "string" },
25
+ limit: { type: "string" },
26
+ port: { type: "string" },
27
+ "no-browser": { type: "boolean" }
28
+ };
29
+ var commands = {
30
+ login: { args: 0, flags: ["port", "no-browser"] },
31
+ logout: { args: 0, flags: [] },
32
+ status: { args: 0, flags: [] },
33
+ "workspaces list": { args: 0, flags: [] },
34
+ "workspaces use": { args: 1, flags: [] },
35
+ "pages list": { args: 0, flags: ["workspace"] },
36
+ "pages search": { args: 1, flags: ["workspace"] },
37
+ "pages read": { args: 1, flags: ["workspace"] },
38
+ capture: { args: 1, flags: ["workspace", "title", "page", "parent", "file"] },
39
+ "tasks list": {
40
+ args: 0,
41
+ flags: ["workspace", "filter", "scope", "limit", "due"]
42
+ },
43
+ "tasks add": { args: 1, flags: ["workspace", "due"] },
44
+ "tasks done": { args: 1, flags: ["workspace"] }
45
+ };
46
+ function parseArguments(args) {
47
+ const parsed = parseArgs({
48
+ args,
49
+ options,
50
+ allowPositionals: true,
51
+ strict: true,
52
+ tokens: true
53
+ });
54
+ const { values, positionals, tokens } = parsed;
55
+ const group = positionals[0];
56
+ const grouped = ["pages", "tasks", "workspaces"].includes(group);
57
+ const command = grouped ? `${group} ${positionals[1] ?? ""}`.trim() : group;
58
+ const operands = positionals.slice(grouped ? 2 : 1);
59
+ if (values.help || values.version || !group)
60
+ return { command, operands, values, help: !values.version };
61
+ const spec = commands[command];
62
+ if (!spec)
63
+ throw new Error(`Unknown command: ${command}. Run haunter --help.`);
64
+ if (operands.length > spec.args || command !== "capture" && operands.length !== spec.args) {
65
+ throw new Error(`${command} expects ${spec.args ? "one argument (quote text containing spaces)" : "no arguments"}. Run haunter --help.`);
66
+ }
67
+ const seen = new Set;
68
+ for (const token of tokens) {
69
+ if (token.kind !== "option")
70
+ continue;
71
+ if (seen.has(token.name))
72
+ throw new Error(`Specify --${token.name} only once.`);
73
+ seen.add(token.name);
74
+ if (!["json", "server", ...spec.flags].includes(token.name))
75
+ throw new Error(`--${token.name} is not supported by ${command}.`);
76
+ if (typeof token.value === "string" && !token.value.trim())
77
+ throw new Error(`--${token.name} cannot be empty.`);
78
+ }
79
+ if (operands.some((value) => !value.trim()))
80
+ throw new Error("Arguments cannot be empty.");
81
+ if (command === "capture") {
82
+ if (values.page && (values.title || values.parent))
83
+ throw new Error("--page appends to an existing page; do not combine it with --title or --parent.");
84
+ if (operands.length && values.file)
85
+ throw new Error("Pass capture text or --file, not both.");
86
+ }
87
+ if (values.port)
88
+ integer(values.port, "port", 1, 65535);
89
+ if (values.limit)
90
+ integer(values.limit, "limit", 1, 200);
91
+ if (values.filter && !["open", "completed", "all"].includes(values.filter))
92
+ throw new Error("--filter must be open, completed, or all.");
93
+ if (values.scope && !["mine", "everyone"].includes(values.scope))
94
+ throw new Error("--scope must be mine or everyone.");
95
+ if (values.due) {
96
+ if (command === "tasks list") {
97
+ if (!["today", "overdue", "upcoming"].includes(values.due))
98
+ throw new Error("--due must be today, overdue, or upcoming for tasks list.");
99
+ } 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) {
100
+ throw new Error("--due must be a valid date in YYYY-MM-DD format for tasks add.");
101
+ }
102
+ }
103
+ return { command, operands, values, help: false };
104
+ }
105
+ function integer(value, flag, min, max) {
106
+ if (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max)
107
+ throw new Error(`--${flag} must be an integer from ${min} to ${max}.`);
108
+ }
109
+ var HELP = `Haunter — pages, notes, and tasks from your terminal
110
+
111
+ Usage: haunter <command> [options]
112
+
113
+ login Sign in through your browser
114
+ --no-browser Print the sign-in URL without opening it
115
+ --port <port> Loopback callback port (default: 43821)
116
+ logout Remove locally saved sign-in credentials
117
+ status Show local sign-in and workspace settings
118
+ workspaces list List workspaces you approved
119
+ workspaces use <id> Save the default workspace for this server
120
+ pages list List pages
121
+ pages search <query> Search pages (quote phrases)
122
+ pages read <id> Print a page as Markdown
123
+ capture [text] Create a page from text, a file, or stdin
124
+ --title <title> New page title (default: first nonempty line)
125
+ --file <path> Read Markdown from a file; use - for stdin
126
+ --parent <id> Create under an existing page
127
+ --page <id> Append to an existing page instead
128
+ tasks list List your open tasks
129
+ --filter <open|completed|all>
130
+ --scope <mine|everyone>
131
+ --due <today|overdue|upcoming>
132
+ --limit <1-200> Maximum tasks (default: 50)
133
+ tasks add <title> Create a task; optional --due YYYY-MM-DD
134
+ tasks done <id> Complete a task
135
+
136
+ Options:
137
+ -w, --workspace <id> Override the saved workspace for a command
138
+ --server <url> Haunter URL (default: https://www.haunter.app/mcp)
139
+ --json Write JSON to stdout; errors go to stderr
140
+ -h, --help Show this help
141
+ -v, --version Show the CLI version
142
+
143
+ Examples:
144
+ haunter login
145
+ haunter workspaces use <id>
146
+ haunter capture "An idea for later"
147
+ cat meeting.md | haunter capture --title "Meeting notes"
148
+ haunter capture --file notes.md --page <id>
149
+ haunter tasks list --due today --json
150
+
151
+ Environment: HAUNTER_SERVER, HAUNTER_WORKSPACE, HAUNTER_CONFIG_DIR
152
+ `;
153
+
154
+ // src/auth.ts
155
+ import { spawn } from "node:child_process";
156
+ import { randomBytes, timingSafeEqual } from "node:crypto";
157
+ import { createServer } from "node:http";
158
+ import {
159
+ auth
160
+ } from "@modelcontextprotocol/sdk/client/auth.js";
161
+ var LOGIN_REQUIRED = "Sign in with haunter login, then try again.";
162
+
163
+ class AuthProvider {
164
+ store;
165
+ profile;
166
+ authorize;
167
+ oauthState;
168
+ verifier;
169
+ constructor(store, profile, authorize, oauthState = randomBytes(32).toString("base64url")) {
170
+ this.store = store;
171
+ this.profile = profile;
172
+ this.authorize = authorize;
173
+ this.oauthState = oauthState;
174
+ }
175
+ get redirectUrl() {
176
+ return this.profile.redirectUrl ?? "http://127.0.0.1:43821/callback";
177
+ }
178
+ get clientMetadata() {
179
+ return {
180
+ client_name: "Haunter CLI",
181
+ client_uri: "https://www.haunter.app",
182
+ redirect_uris: [this.redirectUrl],
183
+ grant_types: ["authorization_code", "refresh_token"],
184
+ response_types: ["code"],
185
+ token_endpoint_auth_method: "none",
186
+ scope: "haunter:mcp offline_access"
187
+ };
188
+ }
189
+ state() {
190
+ return this.oauthState;
191
+ }
192
+ clientInformation() {
193
+ return this.profile.client;
194
+ }
195
+ async saveClientInformation(client) {
196
+ this.profile.client = { ...this.clientMetadata, ...client };
197
+ await this.store.save(this.profile);
198
+ }
199
+ tokens() {
200
+ return this.profile.tokens;
201
+ }
202
+ async saveTokens(tokens) {
203
+ this.profile.tokens = tokens;
204
+ await this.store.save(this.profile);
205
+ }
206
+ async redirectToAuthorization(url) {
207
+ if (!this.authorize)
208
+ throw new Error(LOGIN_REQUIRED);
209
+ await this.authorize(url);
210
+ }
211
+ saveCodeVerifier(verifier) {
212
+ if (!this.authorize)
213
+ throw new Error(LOGIN_REQUIRED);
214
+ this.verifier = verifier;
215
+ }
216
+ codeVerifier() {
217
+ if (!this.verifier)
218
+ throw new Error("The sign-in attempt expired. Run haunter login again.");
219
+ return this.verifier;
220
+ }
221
+ async invalidateCredentials(scope) {
222
+ if (scope === "all" || scope === "client")
223
+ delete this.profile.client;
224
+ if (scope === "all" || scope === "tokens")
225
+ delete this.profile.tokens;
226
+ if (scope === "all" || scope === "verifier")
227
+ this.verifier = undefined;
228
+ await this.store.save(this.profile);
229
+ }
230
+ }
231
+ async function oauthCallback(state, port, signal) {
232
+ let succeed;
233
+ let fail;
234
+ const code = new Promise((resolve, reject) => {
235
+ succeed = resolve;
236
+ fail = reject;
237
+ });
238
+ code.catch(() => {});
239
+ let accepted = false;
240
+ const server = createServer((request, response) => {
241
+ response.setHeader("Cache-Control", "no-store");
242
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
243
+ let url;
244
+ try {
245
+ url = new URL(request.url ?? "/", "http://127.0.0.1");
246
+ } catch {
247
+ response.writeHead(400).end("Invalid callback URL");
248
+ return;
249
+ }
250
+ const address = server.address();
251
+ if (request.method !== "GET" || url.pathname !== "/callback" || !address || typeof address === "string" || request.headers.host !== `127.0.0.1:${address.port}`) {
252
+ response.writeHead(404).end("Not found");
253
+ return;
254
+ }
255
+ const received = Buffer.from(url.searchParams.get("state") ?? "");
256
+ const expected = Buffer.from(state);
257
+ if (accepted || received.length !== expected.length || !timingSafeEqual(received, expected)) {
258
+ response.writeHead(400).end("Invalid sign-in state. Return to the terminal and try again.");
259
+ return;
260
+ }
261
+ if (url.searchParams.has("error")) {
262
+ accepted = true;
263
+ response.writeHead(400).end("Sign-in was not approved. Return to your terminal.");
264
+ fail(new Error("Sign-in was not approved."));
265
+ return;
266
+ }
267
+ const value = url.searchParams.get("code");
268
+ if (!value || value.length > 8192) {
269
+ response.writeHead(400).end("Missing authorization code");
270
+ return;
271
+ }
272
+ accepted = true;
273
+ response.end("Authorization received. You can return to your terminal.");
274
+ succeed(value);
275
+ });
276
+ await new Promise((resolve, reject) => {
277
+ server.once("error", reject);
278
+ server.listen(port, "127.0.0.1", () => {
279
+ server.removeListener("error", reject);
280
+ resolve();
281
+ });
282
+ });
283
+ const address = server.address();
284
+ if (!address || typeof address === "string")
285
+ throw new Error("Could not start the sign-in callback.");
286
+ const timer = setTimeout(() => fail(new Error("Sign-in timed out. Run haunter login again.")), 5 * 60000);
287
+ const abort = () => fail(new Error("Sign-in cancelled."));
288
+ signal?.addEventListener("abort", abort, { once: true });
289
+ if (signal?.aborted)
290
+ abort();
291
+ return {
292
+ url: `http://127.0.0.1:${address.port}/callback`,
293
+ code,
294
+ close: async () => {
295
+ clearTimeout(timer);
296
+ signal?.removeEventListener("abort", abort);
297
+ server.closeAllConnections();
298
+ await new Promise((resolve) => server.close(() => resolve()));
299
+ }
300
+ };
301
+ }
302
+ function openBrowser(url) {
303
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
304
+ throw new Error("The sign-in server returned an unsafe authorization URL.");
305
+ }
306
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
307
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url.href] : [url.href];
308
+ return new Promise((resolve, reject) => {
309
+ const child = spawn(command, args, { stdio: "ignore", shell: false });
310
+ child.once("error", reject);
311
+ child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open a browser.")));
312
+ });
313
+ }
314
+ function boundedFetch(signal) {
315
+ return (input, init) => fetch(input, {
316
+ ...init,
317
+ signal: AbortSignal.any([
318
+ AbortSignal.timeout(30000),
319
+ ...signal ? [signal] : [],
320
+ ...init?.signal ? [init.signal] : []
321
+ ])
322
+ });
323
+ }
324
+ async function login(store, profile, options) {
325
+ const state = randomBytes(32).toString("base64url");
326
+ const port = options.port ?? (profile.redirectUrl ? Number(new URL(profile.redirectUrl).port) : 43821);
327
+ const callback = await oauthCallback(state, port, options.signal).catch((error) => {
328
+ throw new Error("Could not listen for sign-in. Try haunter login --port 43822.", { cause: error });
329
+ });
330
+ try {
331
+ if (profile.redirectUrl !== callback.url)
332
+ delete profile.client;
333
+ profile.redirectUrl = callback.url;
334
+ delete profile.tokens;
335
+ delete profile.workspaceId;
336
+ await store.save(profile);
337
+ const provider = new AuthProvider(store, profile, async (url) => {
338
+ options.notice(`Open this URL to sign in and choose workspace access:
339
+ ${url.href}`);
340
+ if (!options.noBrowser)
341
+ await (options.open ?? openBrowser)(url).catch(() => options.notice("Open the URL above in your browser to continue."));
342
+ }, state);
343
+ const fetchFn = boundedFetch(options.signal);
344
+ const result = await auth(provider, {
345
+ serverUrl: store.server,
346
+ scope: "haunter:mcp offline_access",
347
+ fetchFn
348
+ });
349
+ if (result === "REDIRECT") {
350
+ const authorizationCode = await callback.code;
351
+ const finished = await auth(provider, {
352
+ serverUrl: store.server,
353
+ authorizationCode,
354
+ fetchFn
355
+ });
356
+ if (finished !== "AUTHORIZED")
357
+ throw new Error("Sign-in did not finish. Run haunter login again.");
358
+ }
359
+ if (!profile.tokens)
360
+ throw new Error("Sign-in returned no credentials.");
361
+ } finally {
362
+ await callback.close();
363
+ }
364
+ }
365
+
366
+ // src/client.ts
367
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
368
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
369
+ async function connect(store, profile, signal) {
370
+ if (!profile.tokens)
371
+ throw new Error(LOGIN_REQUIRED);
372
+ const client = new Client({ name: "haunter-cli", version }, { capabilities: {} });
373
+ const transport = new StreamableHTTPClientTransport(new URL(store.server), {
374
+ authProvider: new AuthProvider(store, profile),
375
+ fetch: boundedFetch(signal),
376
+ reconnectionOptions: {
377
+ maxRetries: 0,
378
+ initialReconnectionDelay: 1000,
379
+ maxReconnectionDelay: 1000,
380
+ reconnectionDelayGrowFactor: 1
381
+ }
382
+ });
383
+ try {
384
+ await client.connect(transport, { timeout: 30000, signal });
385
+ } catch (error) {
386
+ await client.close();
387
+ throw error;
388
+ }
389
+ return {
390
+ async call(name, args) {
391
+ const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 30000, signal });
392
+ const text = Array.isArray(result.content) ? result.content.filter((item) => item.type === "text").map((item) => item.text).join(`
393
+ `) : "";
394
+ if (result.isError)
395
+ throw new Error(text || `Haunter could not complete ${name}.`);
396
+ if (result.structuredContent)
397
+ return result.structuredContent;
398
+ try {
399
+ return JSON.parse(text);
400
+ } catch {
401
+ throw new Error(`Haunter returned an unexpected response for ${name}.`);
402
+ }
403
+ },
404
+ close: () => client.close()
405
+ };
406
+ }
407
+
408
+ // src/config.ts
409
+ import { createHash, randomUUID } from "node:crypto";
410
+ import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
411
+ import { homedir } from "node:os";
412
+ import { join, resolve } from "node:path";
413
+ import {
414
+ OAuthClientInformationFullSchema,
415
+ OAuthTokensSchema
416
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
417
+ import { z } from "zod";
418
+ var DEFAULT_SERVER = "https://www.haunter.app/mcp";
419
+ var profileSchema = z.object({
420
+ version: z.literal(1),
421
+ server: z.string(),
422
+ workspaceId: z.string().optional(),
423
+ redirectUrl: z.string().optional(),
424
+ client: OAuthClientInformationFullSchema.optional(),
425
+ tokens: OAuthTokensSchema.optional()
426
+ });
427
+ function serverUrl(value) {
428
+ const url = new URL(value);
429
+ const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
430
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.search || url.hash) {
431
+ throw new Error("Use an HTTPS server URL (HTTP is allowed only on localhost), without credentials, a query, or a fragment.");
432
+ }
433
+ if (url.pathname === "/")
434
+ url.pathname = "/mcp";
435
+ return url.toString().replace(/\/$/, "");
436
+ }
437
+ function configDirectory(env = process.env) {
438
+ if (env.HAUNTER_CONFIG_DIR)
439
+ return resolve(env.HAUNTER_CONFIG_DIR);
440
+ const base = process.platform === "win32" ? env.APPDATA ?? join(homedir(), "AppData", "Roaming") : env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
441
+ return join(base, "haunter");
442
+ }
443
+
444
+ class ProfileStore {
445
+ directory;
446
+ server;
447
+ path;
448
+ constructor(directory, server) {
449
+ this.directory = directory;
450
+ this.server = server;
451
+ this.path = join(directory, `${createHash("sha256").update(server).digest("hex")}.json`);
452
+ }
453
+ async load() {
454
+ try {
455
+ const profile = profileSchema.parse(JSON.parse(await readFile(this.path, "utf8")));
456
+ if (profile.server !== this.server)
457
+ throw new Error("Server mismatch");
458
+ return profile;
459
+ } catch (error) {
460
+ if (isFileError(error, "ENOENT"))
461
+ return { version: 1, server: this.server };
462
+ throw new Error(`Could not read CLI configuration at ${this.path}. Move it aside and run haunter login again.`, { cause: error });
463
+ }
464
+ }
465
+ async save(profile) {
466
+ profileSchema.parse(profile);
467
+ await mkdir(this.directory, { recursive: true, mode: 448 });
468
+ await chmod(this.directory, 448);
469
+ const temporary = `${this.path}.${randomUUID()}.tmp`;
470
+ const file = await open(temporary, "wx", 384);
471
+ try {
472
+ await file.writeFile(`${JSON.stringify(profile, null, 2)}
473
+ `);
474
+ await file.sync();
475
+ await file.close();
476
+ await rename(temporary, this.path);
477
+ } finally {
478
+ await file.close();
479
+ await unlink(temporary).catch((error) => {
480
+ if (!isFileError(error, "ENOENT"))
481
+ throw error;
482
+ });
483
+ }
484
+ }
485
+ async lock() {
486
+ await mkdir(this.directory, { recursive: true, mode: 448 });
487
+ const lockPath = `${this.path}.lock`;
488
+ try {
489
+ const file = await open(lockPath, "wx", 384);
490
+ await file.writeFile(String(process.pid));
491
+ await file.close();
492
+ } catch (error) {
493
+ if (!isFileError(error, "EEXIST"))
494
+ throw error;
495
+ throw new Error(`Another Haunter command is using this server. Wait for it to finish. If it was interrupted, remove ${lockPath} before retrying.`);
496
+ }
497
+ return () => unlink(lockPath).catch((error) => {
498
+ if (!isFileError(error, "ENOENT"))
499
+ throw error;
500
+ });
501
+ }
502
+ }
503
+ function isFileError(error, code) {
504
+ return error instanceof Error && "code" in error && error.code === code;
505
+ }
506
+
507
+ // src/cli.ts
508
+ var workspaceResult = z2.object({
509
+ workspaces: z2.array(z2.object({ id: z2.string(), name: z2.string(), role: z2.string() }).passthrough())
510
+ });
511
+ var pagesResult = z2.object({
512
+ pages: z2.array(z2.object({ pageId: z2.string(), title: z2.string() }).passthrough())
513
+ });
514
+ var pageResult = z2.object({ title: z2.string(), markdown: z2.string() });
515
+ var taskRow = z2.object({
516
+ taskId: z2.string(),
517
+ title: z2.string(),
518
+ completed: z2.boolean(),
519
+ dueDate: z2.string().nullable().optional()
520
+ }).passthrough();
521
+ var tasksResult = z2.object({ tasks: z2.array(taskRow), hasMore: z2.boolean() });
522
+ var savedPage = z2.object({ pageId: z2.string(), title: z2.string() });
523
+ async function runCli(args, io, dependencies = { connect, login }) {
524
+ let secrets = [];
525
+ let release;
526
+ let client;
527
+ let json = args.includes("--json");
528
+ const write = (data, text) => io.stdout(json ? `${JSON.stringify(data)}
529
+ ` : `${safeText(text)}
530
+ `);
531
+ try {
532
+ const { command, operands, values, help } = parseArguments(args);
533
+ json = Boolean(values.json);
534
+ if (values.version) {
535
+ write({ version }, version);
536
+ return 0;
537
+ }
538
+ if (help) {
539
+ write({ help: HELP }, HELP.trimEnd());
540
+ return 0;
541
+ }
542
+ const server = serverUrl(values.server ?? io.env.HAUNTER_SERVER ?? DEFAULT_SERVER);
543
+ const store = new ProfileStore(configDirectory(io.env), server);
544
+ release = await store.lock();
545
+ const profile = await store.load();
546
+ secrets = [
547
+ profile.tokens?.access_token,
548
+ profile.tokens?.refresh_token,
549
+ profile.client?.client_secret
550
+ ].filter((value) => Boolean(value));
551
+ if (command === "status") {
552
+ const status = {
553
+ server,
554
+ signedIn: Boolean(profile.tokens),
555
+ workspaceId: profile.workspaceId ?? null
556
+ };
557
+ write(status, `${status.signedIn ? "Credentials saved" : "Signed out"}
558
+ Server: ${server}
559
+ Workspace: ${status.workspaceId ?? "not selected"}`);
560
+ return 0;
561
+ }
562
+ if (command === "logout") {
563
+ delete profile.tokens;
564
+ delete profile.workspaceId;
565
+ await store.save(profile);
566
+ write({ signedIn: false, server }, "Local credentials removed. To revoke the connection, disconnect Haunter CLI in Haunter → Settings → Agents.");
567
+ return 0;
568
+ }
569
+ if (command === "login") {
570
+ await dependencies.login(store, profile, {
571
+ port: values.port ? Number(values.port) : undefined,
572
+ noBrowser: values["no-browser"],
573
+ signal: io.signal,
574
+ notice: (text) => io.stderr(`${safeText(text)}
575
+ `)
576
+ });
577
+ write({ signedIn: true, server }, "Signed in. Run haunter workspaces list, then haunter workspaces use <id>.");
578
+ return 0;
579
+ }
580
+ const markdown = command === "capture" ? await captureText(operands[0], values.file, io) : undefined;
581
+ client = await dependencies.connect(store, profile, io.signal);
582
+ if (command === "workspaces list" || command === "workspaces use") {
583
+ const result = workspaceResult.parse(await client.call("list_workspaces", {}));
584
+ if (command === "workspaces list")
585
+ write(result, result.workspaces.map((w) => `${w.id} ${oneLine(w.name)} ${w.role}`).join(`
586
+ `) || "No workspaces available. Run haunter login to review access.");
587
+ else {
588
+ const selected = result.workspaces.find((w) => w.id === operands[0]);
589
+ if (!selected)
590
+ throw new Error("Workspace not available. Choose an ID from haunter workspaces list.");
591
+ profile.workspaceId = selected.id;
592
+ await store.save(profile);
593
+ write(selected, `Using ${oneLine(selected.name)} (${selected.id}).`);
594
+ }
595
+ return 0;
596
+ }
597
+ let workspaceId = values.workspace ?? io.env.HAUNTER_WORKSPACE ?? profile.workspaceId;
598
+ if (!workspaceId) {
599
+ const result = workspaceResult.parse(await client.call("list_workspaces", {}));
600
+ if (result.workspaces.length !== 1)
601
+ throw new Error("Select a workspace with haunter workspaces use <id>, or pass --workspace <id>.");
602
+ workspaceId = result.workspaces[0].id;
603
+ }
604
+ const workspace = { workspaceId };
605
+ if (command === "pages list" || command === "pages search") {
606
+ const result = pagesResult.parse(await client.call(command === "pages list" ? "list_pages" : "search_pages", {
607
+ ...workspace,
608
+ ...command === "pages search" ? { query: operands[0] } : {}
609
+ }));
610
+ write(result, result.pages.map((p) => `${p.pageId} ${oneLine(p.title || "Untitled")}`).join(`
611
+ `) || "No pages found.");
612
+ } else if (command === "pages read") {
613
+ const result = await client.call("read_page", {
614
+ ...workspace,
615
+ pageId: uuid(operands[0]),
616
+ format: "markdown"
617
+ });
618
+ write(result, pageResult.parse(result).markdown);
619
+ } else if (command === "capture") {
620
+ const title = (values.title ?? markdown?.split(/\r?\n/).find((line) => line.trim())?.replace(/^#+\s*/, "").trim().slice(0, 120)) || "Untitled";
621
+ const result = await client.call(values.page ? "append_to_page" : "create_page", {
622
+ ...workspace,
623
+ markdown,
624
+ ...values.page ? { pageId: uuid(values.page) } : {
625
+ title,
626
+ ...values.parent ? { parentPageId: uuid(values.parent) } : {}
627
+ }
628
+ });
629
+ const page = savedPage.parse(result);
630
+ write(result, `${values.page ? "Appended to" : "Created"} ${oneLine(page.title)}
631
+ ${page.pageId}`);
632
+ } else if (command === "tasks list") {
633
+ const result = tasksResult.parse(await client.call("list_tasks", {
634
+ ...workspace,
635
+ filter: values.filter ?? "open",
636
+ scope: values.scope ?? "mine",
637
+ limit: values.limit ? Number(values.limit) : 50,
638
+ ...values.due ? { due: values.due } : {}
639
+ }));
640
+ write(result, result.tasks.map((t) => `${t.completed ? "[x]" : "[ ]"} ${t.taskId} ${oneLine(t.title)}${t.dueDate ? ` ${t.dueDate}` : ""}`).join(`
641
+ `) || "No tasks found.");
642
+ if (result.hasMore && !json)
643
+ io.stderr(`More tasks are available. Narrow the filters or increase --limit (maximum 200).
644
+ `);
645
+ } else {
646
+ const result = await client.call(command === "tasks add" ? "create_task" : "complete_task", {
647
+ ...workspace,
648
+ ...command === "tasks add" ? {
649
+ title: operands[0],
650
+ ...values.due ? { dueDate: values.due } : {}
651
+ } : { taskId: uuid(operands[0]) }
652
+ });
653
+ const task = taskRow.parse(result);
654
+ write(result, `${command === "tasks add" ? "Created" : "Completed"} ${oneLine(task.title)}
655
+ ${task.taskId}`);
656
+ }
657
+ return 0;
658
+ } catch (error) {
659
+ 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.";
660
+ for (const secret of secrets)
661
+ message = message.replaceAll(secret, "[redacted]");
662
+ io.stderr(json ? `${JSON.stringify({ error: safeText(message) })}
663
+ ` : `haunter: ${safeText(message)}
664
+ `);
665
+ return io.signal?.aborted ? 130 : 1;
666
+ } finally {
667
+ await client?.close().catch(() => {});
668
+ await release?.().catch(() => {
669
+ const error = "Could not remove the command lock. Check the configuration directory before retrying.";
670
+ io.stderr(json ? `${JSON.stringify({ error })}
671
+ ` : `haunter: ${error}
672
+ `);
673
+ });
674
+ }
675
+ }
676
+ function uuid(value) {
677
+ if (!z2.uuid().safeParse(value).success)
678
+ throw new Error("Expected a page or task ID in UUID format.");
679
+ return value;
680
+ }
681
+ async function captureText(text, file, io) {
682
+ if (text !== undefined) {
683
+ if (text.length > 1e5)
684
+ throw new Error("Capture text exceeds 100,000 characters.");
685
+ return text;
686
+ }
687
+ if (!file && io.isTTY)
688
+ throw new Error("Provide capture text, --file <path>, or pipe Markdown into haunter capture.");
689
+ const input = file && file !== "-" ? createReadStream(file) : io.stdin;
690
+ const chunks = [];
691
+ let bytes = 0;
692
+ for await (const chunk of input) {
693
+ io.signal?.throwIfAborted();
694
+ const buffer = Buffer.from(chunk);
695
+ bytes += buffer.length;
696
+ if (bytes > 400000)
697
+ throw new Error("Capture text exceeds 100,000 characters.");
698
+ chunks.push(buffer);
699
+ }
700
+ const markdown = Buffer.concat(chunks).toString("utf8");
701
+ if (!markdown.trim())
702
+ throw new Error("There is no text to capture.");
703
+ if (markdown.length > 1e5)
704
+ throw new Error("Capture text exceeds 100,000 characters.");
705
+ return markdown;
706
+ }
707
+ function safeText(value) {
708
+ return stripVTControlCharacters(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
709
+ }
710
+ function oneLine(value) {
711
+ return safeText(value).replace(/\s+/g, " ");
712
+ }
713
+
714
+ // src/index.ts
715
+ var controller = new AbortController;
716
+ var brokenPipe = false;
717
+ var cancel = () => {
718
+ controller.abort();
719
+ process.stdin.destroy();
720
+ };
721
+ process.once("SIGINT", cancel);
722
+ process.once("SIGTERM", cancel);
723
+ process.stdout.on("error", (error) => {
724
+ if (error.code === "EPIPE") {
725
+ brokenPipe = true;
726
+ controller.abort();
727
+ process.exitCode = 0;
728
+ } else
729
+ throw error;
730
+ });
731
+ process.exitCode = await runCli(process.argv.slice(2), {
732
+ stdout: (text) => {
733
+ process.stdout.write(text);
734
+ },
735
+ stderr: (text) => {
736
+ process.stderr.write(text);
737
+ },
738
+ stdin: process.stdin,
739
+ isTTY: Boolean(process.stdin.isTTY),
740
+ env: process.env,
741
+ signal: controller.signal
742
+ });
743
+ process.removeListener("SIGINT", cancel);
744
+ process.removeListener("SIGTERM", cancel);
745
+ if (brokenPipe)
746
+ process.exitCode = 0;
package/package.json CHANGED
@@ -1,6 +1,57 @@
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.1.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
+ "@modelcontextprotocol/sdk": "1.32.1",
50
+ "zod": "4.6.7"
51
+ },
52
+ "devDependencies": {
53
+ "@types/bun": "1.4.3",
54
+ "@types/node": "26.6.5",
55
+ "typescript": "7.0.2"
56
+ }
57
+ }