haunter 0.1.0 → 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 (3) hide show
  1. package/README.md +85 -2
  2. package/dist/index.js +244 -19
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -56,6 +56,35 @@ haunter pages read PAGE_ID
56
56
  Lists show page IDs and titles. Reading a page prints its Markdown. Quote search
57
57
  phrases and task titles containing spaces.
58
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
+
59
88
  ## Manage tasks
60
89
 
61
90
  ```bash
@@ -63,6 +92,9 @@ haunter tasks list
63
92
  haunter tasks list --due today
64
93
  haunter tasks add "Review proposal" --due 2026-12-12
65
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
66
98
  ```
67
99
 
68
100
  Task lists default to your open tasks, with a limit of 50. Use
@@ -70,6 +102,56 @@ Task lists default to your open tasks, with a limit of 50. Use
70
102
  `--due today|overdue|upcoming`, and `--limit 1..200` to change the selection.
71
103
  Creating and completing tasks prints the task ID.
72
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
+
73
155
  ## Use the CLI in scripts
74
156
 
75
157
  Add `--json` to get one JSON value on stdout:
@@ -90,8 +172,9 @@ may have been saved.
90
172
 
91
173
  ## Choose a workspace or server
92
174
 
93
- Pass `--workspace WORKSPACE_ID` (or `-w WORKSPACE_ID`) to a page, capture, or task
94
- command to override the saved workspace.
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.
95
178
 
96
179
  | Setting | Flag | Environment variable | Default |
97
180
  | --- | --- | --- | --- |
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createReadStream } from "node:fs";
4
+ import { createReadStream as createReadStream2 } from "node:fs";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
  import { z as z2 } from "zod";
7
7
  // package.json
8
- var version = "0.1.0";
8
+ var version = "0.2.0";
9
9
 
10
10
  // src/args.ts
11
11
  import { parseArgs } from "node:util";
@@ -19,7 +19,11 @@ var options = {
19
19
  page: { type: "string" },
20
20
  parent: { type: "string" },
21
21
  file: { type: "string" },
22
+ input: { type: "string" },
22
23
  due: { type: "string" },
24
+ "clear-due": { type: "boolean" },
25
+ kind: { type: "string" },
26
+ cursor: { type: "string" },
23
27
  filter: { type: "string" },
24
28
  scope: { type: "string" },
25
29
  limit: { type: "string" },
@@ -30,18 +34,29 @@ var commands = {
30
34
  login: { args: 0, flags: ["port", "no-browser"] },
31
35
  logout: { args: 0, flags: [] },
32
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"] },
33
42
  "workspaces list": { args: 0, flags: [] },
34
43
  "workspaces use": { args: 1, flags: [] },
35
44
  "pages list": { args: 0, flags: ["workspace"] },
36
45
  "pages search": { args: 1, flags: ["workspace"] },
37
46
  "pages read": { args: 1, flags: ["workspace"] },
47
+ "pages rename": { args: 2, flags: ["workspace"] },
38
48
  capture: { args: 1, flags: ["workspace", "title", "page", "parent", "file"] },
39
49
  "tasks list": {
40
50
  args: 0,
41
51
  flags: ["workspace", "filter", "scope", "limit", "due"]
42
52
  },
43
53
  "tasks add": { args: 1, flags: ["workspace", "due"] },
44
- "tasks done": { args: 1, flags: ["workspace"] }
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
+ }
45
60
  };
46
61
  function parseArguments(args) {
47
62
  const parsed = parseArgs({
@@ -53,7 +68,13 @@ function parseArguments(args) {
53
68
  });
54
69
  const { values, positionals, tokens } = parsed;
55
70
  const group = positionals[0];
56
- const grouped = ["pages", "tasks", "workspaces"].includes(group);
71
+ const grouped = [
72
+ "pages",
73
+ "tasks",
74
+ "workspaces",
75
+ "tools",
76
+ "canvases"
77
+ ].includes(group);
57
78
  const command = grouped ? `${group} ${positionals[1] ?? ""}`.trim() : group;
58
79
  const operands = positionals.slice(grouped ? 2 : 1);
59
80
  if (values.help || values.version || !group)
@@ -62,7 +83,7 @@ function parseArguments(args) {
62
83
  if (!spec)
63
84
  throw new Error(`Unknown command: ${command}. Run haunter --help.`);
64
85
  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.`);
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.`);
66
87
  }
67
88
  const seen = new Set;
68
89
  for (const token of tokens) {
@@ -87,7 +108,25 @@ function parseArguments(args) {
87
108
  if (values.port)
88
109
  integer(values.port, "port", 1, 65535);
89
110
  if (values.limit)
90
- integer(values.limit, "limit", 1, 200);
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
+ }
91
130
  if (values.filter && !["open", "completed", "all"].includes(values.filter))
92
131
  throw new Error("--filter must be open, completed, or all.");
93
132
  if (values.scope && !["mine", "everyone"].includes(values.scope))
@@ -97,7 +136,7 @@ function parseArguments(args) {
97
136
  if (!["today", "overdue", "upcoming"].includes(values.due))
98
137
  throw new Error("--due must be today, overdue, or upcoming for tasks list.");
99
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) {
100
- throw new Error("--due must be a valid date in YYYY-MM-DD format for tasks add.");
139
+ throw new Error("--due must be a valid date in YYYY-MM-DD format for tasks add or update.");
101
140
  }
102
141
  }
103
142
  return { command, operands, values, help: false };
@@ -117,9 +156,15 @@ Usage: haunter <command> [options]
117
156
  status Show local sign-in and workspace settings
118
157
  workspaces list List workspaces you approved
119
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
120
164
  pages list List pages
121
165
  pages search <query> Search pages (quote phrases)
122
166
  pages read <id> Print a page as Markdown
167
+ pages rename <id> <title> Rename a page (quote titles containing spaces)
123
168
  capture [text] Create a page from text, a file, or stdin
124
169
  --title <title> New page title (default: first nonempty line)
125
170
  --file <path> Read Markdown from a file; use - for stdin
@@ -132,6 +177,15 @@ Usage: haunter <command> [options]
132
177
  --limit <1-200> Maximum tasks (default: 50)
133
178
  tasks add <title> Create a task; optional --due YYYY-MM-DD
134
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
135
189
 
136
190
  Options:
137
191
  -w, --workspace <id> Override the saved workspace for a command
@@ -366,6 +420,10 @@ ${url.href}`);
366
420
  // src/client.ts
367
421
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
368
422
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
423
+ import {
424
+ CallToolResultSchema,
425
+ ListToolsResultSchema
426
+ } from "@modelcontextprotocol/sdk/types.js";
369
427
  async function connect(store, profile, signal) {
370
428
  if (!profile.tokens)
371
429
  throw new Error(LOGIN_REQUIRED);
@@ -387,6 +445,32 @@ async function connect(store, profile, signal) {
387
445
  throw error;
388
446
  }
389
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 }),
390
474
  async call(name, args) {
391
475
  const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 30000, signal });
392
476
  const text = Array.isArray(result.content) ? result.content.filter((item) => item.type === "text").map((item) => item.text).join(`
@@ -405,6 +489,50 @@ async function connect(store, profile, signal) {
405
489
  };
406
490
  }
407
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
+
408
536
  // src/config.ts
409
537
  import { createHash, randomUUID } from "node:crypto";
410
538
  import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
@@ -520,6 +648,24 @@ var taskRow = z2.object({
520
648
  }).passthrough();
521
649
  var tasksResult = z2.object({ tasks: z2.array(taskRow), hasMore: z2.boolean() });
522
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
+ };
523
669
  async function runCli(args, io, dependencies = { connect, login }) {
524
670
  let secrets = [];
525
671
  let release;
@@ -539,6 +685,8 @@ async function runCli(args, io, dependencies = { connect, login }) {
539
685
  write({ help: HELP }, HELP.trimEnd());
540
686
  return 0;
541
687
  }
688
+ if (["pages rename", "tasks update", "tasks reopen"].includes(command))
689
+ uuid(operands[0]);
542
690
  const server = serverUrl(values.server ?? io.env.HAUNTER_SERVER ?? DEFAULT_SERVER);
543
691
  const store = new ProfileStore(configDirectory(io.env), server);
544
692
  release = await store.lock();
@@ -578,7 +726,46 @@ Workspace: ${status.workspaceId ?? "not selected"}`);
578
726
  return 0;
579
727
  }
580
728
  const markdown = command === "capture" ? await captureText(operands[0], values.file, io) : undefined;
729
+ const toolInput = command === "tools call" ? await readToolInput(values.input, io) : {};
581
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
+ }
582
769
  if (command === "workspaces list" || command === "workspaces use") {
583
770
  const result = workspaceResult.parse(await client.call("list_workspaces", {}));
584
771
  if (command === "workspaces list")
@@ -594,15 +781,37 @@ Workspace: ${status.workspaceId ?? "not selected"}`);
594
781
  }
595
782
  return 0;
596
783
  }
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
- }
784
+ const workspaceId = await resolveWorkspace(client, values.workspace ?? io.env.HAUNTER_WORKSPACE ?? profile.workspaceId);
604
785
  const workspace = { workspaceId };
605
- if (command === "pages list" || command === "pages search") {
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") {
606
815
  const result = pagesResult.parse(await client.call(command === "pages list" ? "list_pages" : "search_pages", {
607
816
  ...workspace,
608
817
  ...command === "pages search" ? { query: operands[0] } : {}
@@ -643,15 +852,23 @@ ${page.pageId}`);
643
852
  io.stderr(`More tasks are available. Narrow the filters or increase --limit (maximum 200).
644
853
  `);
645
854
  } else {
646
- const result = await client.call(command === "tasks add" ? "create_task" : "complete_task", {
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, {
647
859
  ...workspace,
648
860
  ...command === "tasks add" ? {
649
861
  title: operands[0],
650
862
  ...values.due ? { dueDate: values.due } : {}
651
- } : { taskId: uuid(operands[0]) }
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
+ } : {}
652
869
  });
653
870
  const task = taskRow.parse(result);
654
- write(result, `${command === "tasks add" ? "Created" : "Completed"} ${oneLine(task.title)}
871
+ write(result, `${action.label} ${oneLine(task.title)}
655
872
  ${task.taskId}`);
656
873
  }
657
874
  return 0;
@@ -673,6 +890,14 @@ ${task.taskId}`);
673
890
  });
674
891
  }
675
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
+ }
676
901
  function uuid(value) {
677
902
  if (!z2.uuid().safeParse(value).success)
678
903
  throw new Error("Expected a page or task ID in UUID format.");
@@ -686,7 +911,7 @@ async function captureText(text, file, io) {
686
911
  }
687
912
  if (!file && io.isTTY)
688
913
  throw new Error("Provide capture text, --file <path>, or pipe Markdown into haunter capture.");
689
- const input = file && file !== "-" ? createReadStream(file) : io.stdin;
914
+ const input = file && file !== "-" ? createReadStream2(file) : io.stdin;
690
915
  const chunks = [];
691
916
  let bytes = 0;
692
917
  for await (const chunk of input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haunter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pages, notes, and tasks in Haunter from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,7 @@
46
46
  "prepack": "bun run check"
47
47
  },
48
48
  "dependencies": {
49
+ "@cfworker/json-schema": "4.1.1",
49
50
  "@modelcontextprotocol/sdk": "1.32.1",
50
51
  "zod": "4.6.7"
51
52
  },