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.
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/dist/index.js +746 -0
- 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
|
-
#
|
|
1
|
+
# Haunter CLI
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|