@jclvsh/xmark 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ npx -y -p @jclvsh/xmark xmark --help
21
21
  Set your xmark API key (get one at [/settings/api](https://xmark.dev/settings/api)):
22
22
 
23
23
  ```bash
24
- export XMARK_API_KEY=xm_live_...
24
+ export XMARK_API_KEY=nt_live_...
25
25
  ```
26
26
 
27
27
  Optionally override the API host (default is `https://api.xmark.dev`):
@@ -39,7 +39,11 @@ export XMARK_API_URL=https://api.xmark.dev
39
39
  | `sync_bookmarks` | Pull the latest bookmarks from X. |
40
40
  | `list_bookmarks` | Paginated bookmark list. |
41
41
  | `get_bookmark` | Fetch a single bookmark by ID. |
42
+ | `delete_bookmark` | Remove a bookmark (local-only, or also on X with `unbookmark`). |
42
43
  | `list_conversations` | Past chat conversations. |
44
+ | `get_conversation_messages` | A conversation's message history. |
45
+ | `delete_conversation` | Permanently delete a conversation. |
46
+ | `get_usage` | Plan + quota usage (chat this month, syncs today, bookmark count). |
43
47
 
44
48
  ## CLI commands
45
49
 
@@ -48,12 +52,21 @@ xmark search "what did I save about Claude?"
48
52
  xmark chat "summarize what I saved this month about agents"
49
53
  xmark sync
50
54
  xmark list --page 1 --page-size 20
55
+ xmark get <bookmark-id>
56
+ xmark delete <bookmark-id> --unbookmark
51
57
  xmark conversations
58
+ xmark messages <conversation-id>
59
+ xmark delete-conversation <conversation-id>
60
+ xmark usage
52
61
  ```
53
62
 
63
+ Global flags: `--format table` renders paginated lists as a table (default is `json`).
64
+
65
+ Structured exit codes for scripting: `2` validation, `3` rate limit, `4` auth, `5` payment/billing, `6` not found, `1` anything else.
66
+
54
67
  ## Pricing
55
68
 
56
- xmark is $5/month flat. The same subscription covers the web app, CLI, MCP server, and developer REST API — no separate API tier.
69
+ Free tier: 20 chat messages/month, 1 sync/day. Pro is $5/month — unlimited chat and sync. The same plan covers the web app, CLI, MCP server, and developer REST API — no separate API tier.
57
70
 
58
71
  ## Links
59
72
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shared CLI + MCP output helpers.
3
+ *
4
+ * The CLI consumes these for terminal output (table / JSON / structured exit
5
+ * codes). MCP tools consume `formatMcpError` so error responses surface the
6
+ * structured `{success, error: {code, message}}` envelope to the LLM instead
7
+ * of a flattened message string — lets the model branch on `error.code`
8
+ * without regex-extracting the bracketed prefix.
9
+ */
10
+ export declare function formatOutput(data: unknown, format?: string): void;
11
+ /**
12
+ * Map a fleet error-code prefix to a CLI exit code.
13
+ *
14
+ * Convention so shell scripts can branch on failure type without parsing stderr:
15
+ * - 0: success (not handled here)
16
+ * - 1: generic / unknown
17
+ * - 2: input / validation (SERVER_VALIDATION_FAILED, SERVER_BAD_REQUEST)
18
+ * - 3: rate limit
19
+ * - 4: auth (AUTH_*, APIKEY_*)
20
+ * - 5: payment / billing (PAYMENT_*, BILLING_*)
21
+ * - 6: not found (any *_NOT_FOUND)
22
+ *
23
+ * Domain-specific codes (e.g. CAMP_001, LAUNCH_001) are categorized by their
24
+ * suffix shape — *_NOT_FOUND maps to 6, others fall through to 1.
25
+ */
26
+ export declare function exitCodeForMessage(message: string): number;
27
+ export declare function handleError(error: unknown): never;
28
+ /**
29
+ * Parse the `[CODE] message (field: x)` string format that the API client's
30
+ * handleError builds, recovering the structured fields.
31
+ *
32
+ * Returns `{code, message, field?}`. Falls back to `{code: "UNKNOWN_ERROR", message}`
33
+ * when no bracketed code is present (shouldn't happen for API errors, but safe
34
+ * for client-side throws like network timeouts).
35
+ */
36
+ export declare function parseErrorMessage(raw: string): {
37
+ code: string;
38
+ message: string;
39
+ field?: string;
40
+ };
41
+ /**
42
+ * Format an error caught in an MCP tool handler as the structured response
43
+ * the MCP SDK returns to the model.
44
+ *
45
+ * Returns the canonical v1 error envelope `{success: false, error: {code, message, field?}}`
46
+ * as JSON text inside the MCP `content` block, plus `isError: true` so the SDK
47
+ * surfaces it as an error to the model. Lets the LLM read `error.code`
48
+ * structurally instead of regexing it out of a flattened message.
49
+ */
50
+ export declare function formatMcpError(error: unknown): {
51
+ content: Array<{
52
+ type: "text";
53
+ text: string;
54
+ }>;
55
+ isError: true;
56
+ };
57
+ export declare function getFormat(opts: Record<string, unknown>): string;
58
+ export declare function parseList(value: string): string[];
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared CLI + MCP output helpers.
3
+ *
4
+ * The CLI consumes these for terminal output (table / JSON / structured exit
5
+ * codes). MCP tools consume `formatMcpError` so error responses surface the
6
+ * structured `{success, error: {code, message}}` envelope to the LLM instead
7
+ * of a flattened message string — lets the model branch on `error.code`
8
+ * without regex-extracting the bracketed prefix.
9
+ */
10
+ export function formatOutput(data, format = "json") {
11
+ if (format === "table" && typeof data === "object" && data !== null) {
12
+ const obj = data;
13
+ if (Array.isArray(obj.data)) {
14
+ console.table(obj.data);
15
+ if (obj.pagination) {
16
+ const p = obj.pagination;
17
+ console.error(`\npage ${p.page}/${p.total_pages} (${p.total} total)`);
18
+ }
19
+ return;
20
+ }
21
+ if (obj.data && typeof obj.data === "object") {
22
+ console.log(JSON.stringify(obj.data, null, 2));
23
+ return;
24
+ }
25
+ }
26
+ console.log(JSON.stringify(data, null, 2));
27
+ }
28
+ /**
29
+ * Map a fleet error-code prefix to a CLI exit code.
30
+ *
31
+ * Convention so shell scripts can branch on failure type without parsing stderr:
32
+ * - 0: success (not handled here)
33
+ * - 1: generic / unknown
34
+ * - 2: input / validation (SERVER_VALIDATION_FAILED, SERVER_BAD_REQUEST)
35
+ * - 3: rate limit
36
+ * - 4: auth (AUTH_*, APIKEY_*)
37
+ * - 5: payment / billing (PAYMENT_*, BILLING_*)
38
+ * - 6: not found (any *_NOT_FOUND)
39
+ *
40
+ * Domain-specific codes (e.g. CAMP_001, LAUNCH_001) are categorized by their
41
+ * suffix shape — *_NOT_FOUND maps to 6, others fall through to 1.
42
+ */
43
+ export function exitCodeForMessage(message) {
44
+ const codeMatch = message.match(/\[([A-Z]+)_(\d+|[A-Z_]+)\]/);
45
+ if (!codeMatch)
46
+ return 1;
47
+ const prefix = codeMatch[1];
48
+ const suffix = codeMatch[2];
49
+ // Suffix-based: *_NOT_FOUND → 6 regardless of prefix
50
+ if (suffix.includes("NOT_FOUND"))
51
+ return 6;
52
+ switch (prefix) {
53
+ case "AUTH":
54
+ case "APIKEY":
55
+ return 4;
56
+ case "RATE":
57
+ return 3;
58
+ case "PAYMENT":
59
+ case "BILLING":
60
+ return 5;
61
+ case "SERVER":
62
+ // SERVER_002/005 = validation; SERVER_003 = not found; rest = generic
63
+ if (suffix === "002" || suffix === "005")
64
+ return 2;
65
+ if (suffix === "003")
66
+ return 6;
67
+ return 1;
68
+ default:
69
+ return 1;
70
+ }
71
+ }
72
+ export function handleError(error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ console.error(`error: ${message}`);
75
+ process.exit(exitCodeForMessage(message));
76
+ }
77
+ /**
78
+ * Parse the `[CODE] message (field: x)` string format that the API client's
79
+ * handleError builds, recovering the structured fields.
80
+ *
81
+ * Returns `{code, message, field?}`. Falls back to `{code: "UNKNOWN_ERROR", message}`
82
+ * when no bracketed code is present (shouldn't happen for API errors, but safe
83
+ * for client-side throws like network timeouts).
84
+ */
85
+ export function parseErrorMessage(raw) {
86
+ // Format: "[CODE] message (field: x)" — code optional, field optional
87
+ const match = raw.match(/^\[([A-Z_0-9]+)\]\s+(.*?)(?:\s+\(field:\s+([^)]+)\))?$/);
88
+ if (!match)
89
+ return { code: "UNKNOWN_ERROR", message: raw };
90
+ return {
91
+ code: match[1],
92
+ message: match[2],
93
+ ...(match[3] && { field: match[3] }),
94
+ };
95
+ }
96
+ /**
97
+ * Format an error caught in an MCP tool handler as the structured response
98
+ * the MCP SDK returns to the model.
99
+ *
100
+ * Returns the canonical v1 error envelope `{success: false, error: {code, message, field?}}`
101
+ * as JSON text inside the MCP `content` block, plus `isError: true` so the SDK
102
+ * surfaces it as an error to the model. Lets the LLM read `error.code`
103
+ * structurally instead of regexing it out of a flattened message.
104
+ */
105
+ export function formatMcpError(error) {
106
+ const raw = error instanceof Error ? error.message : String(error);
107
+ const parsed = parseErrorMessage(raw);
108
+ const envelope = {
109
+ success: false,
110
+ error: parsed,
111
+ };
112
+ return {
113
+ content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }],
114
+ isError: true,
115
+ };
116
+ }
117
+ export function getFormat(opts) {
118
+ if (opts.json)
119
+ return "json";
120
+ return opts.format || "json";
121
+ }
122
+ export function parseList(value) {
123
+ return value
124
+ .split(",")
125
+ .map((s) => s.trim())
126
+ .filter(Boolean);
127
+ }
package/dist/cli.js CHANGED
@@ -1,68 +1,37 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Command } from "commander";
3
4
  import { XmarkClient } from "./client.js";
4
- const program = new Command();
5
- program
5
+ import { handleError } from "./cli-utils.js";
6
+ import { registerBookmarkCommands } from "./commands/bookmarks.js";
7
+ import { registerChatCommands } from "./commands/chat.js";
8
+ import { registerUsageCommands } from "./commands/usage.js";
9
+ const pkg = createRequire(import.meta.url)("../package.json");
10
+ const program = new Command()
6
11
  .name("xmark")
7
12
  .description("xmark CLI — chat with your X bookmarks from the terminal")
8
- .version("0.1.0");
9
- function client() {
10
- return new XmarkClient();
11
- }
12
- program
13
- .command("search <query>")
14
- .description("Semantic search over your bookmarks ($0.04/call for agents)")
15
- .option("-l, --limit <n>", "max results", "10")
16
- .action(async (query, opts) => {
17
- const result = await client().post("/bookmarks/search", {
18
- query,
19
- limit: parseInt(opts.limit, 10),
20
- });
21
- console.log(JSON.stringify(result, null, 2));
22
- });
23
- program
24
- .command("chat <question...>")
25
- .description("Ask a question about your bookmarks ($0.20/call for agents)")
26
- .option("-c, --conversation <id>", "continue an existing conversation")
27
- .action(async (questionParts, opts) => {
28
- const question = questionParts.join(" ");
29
- const result = await client().post("/chat", {
30
- question,
31
- ...(opts.conversation && { conversationId: opts.conversation }),
32
- });
33
- console.log(result.answer);
34
- console.log("\nCitations:");
35
- for (const c of result.citations) {
36
- console.log(` @${c.author} — ${c.snippet}\n ${c.tweet_url}`);
37
- }
38
- });
39
- program
40
- .command("sync")
41
- .description("Trigger a fresh sync from X")
42
- .action(async () => {
43
- const result = await client().post("/bookmarks/sync");
44
- console.log(`Synced ${result.count} bookmarks (${result.newCount} new, ${result.removedCount} removed) in ${result.durationMs}ms.`);
45
- });
46
- program
47
- .command("list")
48
- .description("List your bookmarks")
49
- .option("-p, --page <n>", "page number", "1")
50
- .option("-s, --page-size <n>", "items per page", "50")
51
- .action(async (opts) => {
52
- const result = await client().get("/bookmarks", {
53
- page: parseInt(opts.page, 10),
54
- pageSize: parseInt(opts.pageSize, 10),
13
+ .version(pkg.version)
14
+ .option("--json", "output as JSON (default)")
15
+ .option("--format <fmt>", "output format: json, table", "json");
16
+ try {
17
+ // Lazy client: defer the XmarkClient constructor (which throws when
18
+ // XMARK_API_KEY is missing) until a command actually issues an HTTP call.
19
+ // `xmark -V`, `xmark --help`, `xmark chat --help` etc. never touch the API,
20
+ // so they exit cleanly without an API key set.
21
+ let realClient = null;
22
+ const client = new Proxy({}, {
23
+ get(_target, prop, receiver) {
24
+ if (!realClient)
25
+ realClient = new XmarkClient();
26
+ const value = Reflect.get(realClient, prop, receiver);
27
+ return typeof value === "function" ? value.bind(realClient) : value;
28
+ },
55
29
  });
56
- console.log(JSON.stringify(result, null, 2));
57
- });
58
- program
59
- .command("conversations")
60
- .description("List your past chat conversations")
61
- .action(async () => {
62
- const result = await client().get("/conversations");
63
- console.log(JSON.stringify(result, null, 2));
64
- });
65
- program.parseAsync(process.argv).catch((err) => {
66
- console.error(err instanceof Error ? err.message : err);
67
- process.exit(1);
68
- });
30
+ registerBookmarkCommands(program, client);
31
+ registerChatCommands(program, client);
32
+ registerUsageCommands(program, client);
33
+ await program.parseAsync(process.argv);
34
+ }
35
+ catch (error) {
36
+ handleError(error);
37
+ }
package/dist/client.d.ts CHANGED
@@ -1,10 +1,23 @@
1
+ import type { Paginated } from "./types.js";
1
2
  export declare class XmarkClient {
2
3
  private apiKey;
3
4
  private baseUrl;
4
5
  constructor(apiKey?: string, baseUrl?: string);
5
6
  get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T>;
7
+ /**
8
+ * GET a paginated list endpoint, preserving the pagination envelope
9
+ * (`get`/`request` unwrap to `data`, which silently drops it).
10
+ */
11
+ getPage<T>(path: string, params?: Record<string, string | number | undefined>): Promise<Paginated<T>>;
6
12
  post<T>(path: string, body?: unknown): Promise<T>;
7
13
  delete(path: string): Promise<void>;
8
14
  private request;
15
+ /** fetch with auth + UA + timeout, translating timeouts and network failures to readable errors. */
16
+ private fetchWithGuards;
17
+ /**
18
+ * Build the fleet-standard `[CODE] message (field: x)` error, the format
19
+ * cli-utils' exitCodeForMessage / parseErrorMessage recover structurally.
20
+ */
21
+ private apiError;
9
22
  private handleError;
10
23
  }
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const REQUEST_TIMEOUT_MS = 30_000;
2
2
  const DEFAULT_BASE_URL = "https://api.xmark.dev";
3
+ const USER_AGENT = "xmark-mcp/0.4.0";
3
4
  export class XmarkClient {
4
5
  apiKey;
5
6
  baseUrl;
@@ -21,6 +22,28 @@ export class XmarkClient {
21
22
  }
22
23
  return this.request(url.toString(), { method: "GET" });
23
24
  }
25
+ /**
26
+ * GET a paginated list endpoint, preserving the pagination envelope
27
+ * (`get`/`request` unwrap to `data`, which silently drops it).
28
+ */
29
+ async getPage(path, params) {
30
+ const url = new URL(`${this.baseUrl}${path}`);
31
+ if (params) {
32
+ for (const [k, v] of Object.entries(params)) {
33
+ if (v !== undefined && v !== "")
34
+ url.searchParams.set(k, String(v));
35
+ }
36
+ }
37
+ const response = await this.fetchWithGuards(url.toString(), { method: "GET" });
38
+ if (!response.ok) {
39
+ await this.handleError(response);
40
+ }
41
+ const json = (await response.json());
42
+ if (!json.success) {
43
+ throw this.apiError(json.error);
44
+ }
45
+ return { data: json.data, pagination: json.pagination };
46
+ }
24
47
  async post(path, body) {
25
48
  return this.request(`${this.baseUrl}${path}`, {
26
49
  method: "POST",
@@ -29,45 +52,76 @@ export class XmarkClient {
29
52
  });
30
53
  }
31
54
  async delete(path) {
32
- const response = await fetch(`${this.baseUrl}${path}`, {
33
- method: "DELETE",
34
- headers: { Authorization: `Bearer ${this.apiKey}` },
35
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
36
- });
55
+ const response = await this.fetchWithGuards(`${this.baseUrl}${path}`, { method: "DELETE" });
37
56
  if (!response.ok && response.status !== 204) {
38
57
  await this.handleError(response);
39
58
  }
40
59
  }
41
60
  async request(url, init) {
42
- const response = await fetch(url, {
43
- ...init,
44
- headers: {
45
- ...init.headers,
46
- Authorization: `Bearer ${this.apiKey}`,
47
- "User-Agent": "xmark-mcp/0.1.0",
48
- },
49
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
50
- });
61
+ const response = await this.fetchWithGuards(url, init);
51
62
  if (!response.ok) {
52
63
  await this.handleError(response);
53
64
  }
54
65
  const json = (await response.json());
55
66
  if (!json.success) {
56
- throw new Error(`${json.error.code}: ${json.error.message}`);
67
+ throw this.apiError(json.error);
57
68
  }
58
69
  return json.data;
59
70
  }
71
+ /** fetch with auth + UA + timeout, translating timeouts and network failures to readable errors. */
72
+ async fetchWithGuards(url, init) {
73
+ try {
74
+ return await fetch(url, {
75
+ ...init,
76
+ headers: {
77
+ ...(init.headers || {}),
78
+ Authorization: `Bearer ${this.apiKey}`,
79
+ "User-Agent": USER_AGENT,
80
+ },
81
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
82
+ });
83
+ }
84
+ catch (error) {
85
+ if (error instanceof Error && error.name === "TimeoutError") {
86
+ throw new Error("request timed out after 30s");
87
+ }
88
+ if (error instanceof TypeError) {
89
+ throw new Error(`network error: ${error.message}. check XMARK_API_URL and your connection`);
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+ /**
95
+ * Build the fleet-standard `[CODE] message (field: x)` error, the format
96
+ * cli-utils' exitCodeForMessage / parseErrorMessage recover structurally.
97
+ */
98
+ apiError(error) {
99
+ let message = `[${error.code}] ${error.message}`;
100
+ if (error.field) {
101
+ message += ` (field: ${error.field})`;
102
+ }
103
+ return new Error(message);
104
+ }
60
105
  async handleError(response) {
61
- let detail = "";
106
+ let errorMessage;
62
107
  try {
63
108
  const body = (await response.json());
64
- if (body?.error) {
65
- detail = `${body.error.code}: ${body.error.message}`;
109
+ if (body.error) {
110
+ errorMessage = this.apiError(body.error).message;
111
+ }
112
+ else {
113
+ errorMessage = `API error: ${response.status} ${response.statusText}`;
66
114
  }
67
115
  }
68
116
  catch {
69
- detail = await response.text().catch(() => "");
117
+ errorMessage = `API error: ${response.status} ${response.statusText}`;
118
+ }
119
+ if (response.status === 429) {
120
+ const retryAfter = response.headers.get("Retry-After");
121
+ if (retryAfter) {
122
+ errorMessage += ` — retry after ${retryAfter}s`;
123
+ }
70
124
  }
71
- throw new Error(`xmark API ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ""}`);
125
+ throw new Error(errorMessage);
72
126
  }
73
127
  }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { XmarkClient } from "../client.js";
3
+ export declare function registerBookmarkCommands(program: Command, client: XmarkClient): void;
@@ -0,0 +1,73 @@
1
+ import { formatOutput, handleError, getFormat } from "../cli-utils.js";
2
+ export function registerBookmarkCommands(program, client) {
3
+ program
4
+ .command("search <query>")
5
+ .description("Semantic search over your bookmarks")
6
+ .option("-l, --limit <n>", "max results", "10")
7
+ .action(async (query, opts) => {
8
+ try {
9
+ const result = await client.post("/bookmarks/search", {
10
+ query,
11
+ limit: parseInt(opts.limit, 10),
12
+ });
13
+ formatOutput(result, getFormat(program.opts()));
14
+ }
15
+ catch (error) {
16
+ handleError(error);
17
+ }
18
+ });
19
+ program
20
+ .command("sync")
21
+ .description("Trigger a fresh sync from X")
22
+ .action(async () => {
23
+ try {
24
+ const result = await client.post("/bookmarks/sync");
25
+ console.log(`Synced ${result.count} bookmarks (${result.newCount} new, ${result.removedCount} removed) in ${result.durationMs}ms.`);
26
+ }
27
+ catch (error) {
28
+ handleError(error);
29
+ }
30
+ });
31
+ program
32
+ .command("list")
33
+ .description("List your bookmarks")
34
+ .option("-p, --page <n>", "page number", "1")
35
+ .option("-s, --page-size <n>", "items per page", "50")
36
+ .action(async (opts) => {
37
+ try {
38
+ const result = await client.getPage("/bookmarks", {
39
+ page: parseInt(opts.page, 10),
40
+ pageSize: parseInt(opts.pageSize, 10),
41
+ });
42
+ formatOutput(result, getFormat(program.opts()));
43
+ }
44
+ catch (error) {
45
+ handleError(error);
46
+ }
47
+ });
48
+ program
49
+ .command("get <id>")
50
+ .description("Fetch a single bookmark by ID")
51
+ .action(async (id) => {
52
+ try {
53
+ const result = await client.get(`/bookmarks/${id}`);
54
+ formatOutput(result, getFormat(program.opts()));
55
+ }
56
+ catch (error) {
57
+ handleError(error);
58
+ }
59
+ });
60
+ program
61
+ .command("delete <id>")
62
+ .description("Remove a bookmark (local-only unless --unbookmark)")
63
+ .option("-u, --unbookmark", "also remove the bookmark on X, not just locally")
64
+ .action(async (id, opts) => {
65
+ try {
66
+ await client.delete(`/bookmarks/${id}${opts.unbookmark ? "?unbookmark=true" : ""}`);
67
+ console.log(opts.unbookmark ? "Removed from xmark and X." : "Removed from xmark (local only).");
68
+ }
69
+ catch (error) {
70
+ handleError(error);
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { XmarkClient } from "../client.js";
3
+ export declare function registerChatCommands(program: Command, client: XmarkClient): void;
@@ -0,0 +1,60 @@
1
+ import { formatOutput, handleError, getFormat } from "../cli-utils.js";
2
+ export function registerChatCommands(program, client) {
3
+ program
4
+ .command("chat <question...>")
5
+ .description("Ask a question about your bookmarks")
6
+ .option("-c, --conversation <id>", "continue an existing conversation")
7
+ .action(async (questionParts, opts) => {
8
+ try {
9
+ const question = questionParts.join(" ");
10
+ const result = await client.post("/chat", {
11
+ question,
12
+ ...(opts.conversation && { conversationId: opts.conversation }),
13
+ });
14
+ console.log(result.answer);
15
+ console.log("\nCitations:");
16
+ for (const c of result.citations) {
17
+ console.log(` @${c.author} — ${c.snippet}\n ${c.tweet_url}`);
18
+ }
19
+ }
20
+ catch (error) {
21
+ handleError(error);
22
+ }
23
+ });
24
+ program
25
+ .command("conversations")
26
+ .description("List your past chat conversations")
27
+ .action(async () => {
28
+ try {
29
+ const result = await client.getPage("/conversations");
30
+ formatOutput(result, getFormat(program.opts()));
31
+ }
32
+ catch (error) {
33
+ handleError(error);
34
+ }
35
+ });
36
+ program
37
+ .command("messages <conversationId>")
38
+ .description("A conversation's message history, oldest first")
39
+ .action(async (conversationId) => {
40
+ try {
41
+ const result = await client.get(`/conversations/${conversationId}/messages`);
42
+ formatOutput(result, getFormat(program.opts()));
43
+ }
44
+ catch (error) {
45
+ handleError(error);
46
+ }
47
+ });
48
+ program
49
+ .command("delete-conversation <id>")
50
+ .description("Permanently delete a conversation and its messages")
51
+ .action(async (id) => {
52
+ try {
53
+ await client.delete(`/conversations/${id}`);
54
+ console.log("Conversation deleted.");
55
+ }
56
+ catch (error) {
57
+ handleError(error);
58
+ }
59
+ });
60
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { XmarkClient } from "../client.js";
3
+ export declare function registerUsageCommands(program: Command, client: XmarkClient): void;
@@ -0,0 +1,15 @@
1
+ import { formatOutput, handleError, getFormat } from "../cli-utils.js";
2
+ export function registerUsageCommands(program, client) {
3
+ program
4
+ .command("usage")
5
+ .description("Your plan + quota usage (chat this month, syncs today, bookmark count)")
6
+ .action(async () => {
7
+ try {
8
+ const result = await client.get("/usage");
9
+ formatOutput(result, getFormat(program.opts()));
10
+ }
11
+ catch (error) {
12
+ handleError(error);
13
+ }
14
+ });
15
+ }
package/dist/index.js CHANGED
@@ -1,15 +1,33 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { XmarkClient } from "./client.js";
5
6
  import { registerBookmarkTools } from "./tools/bookmarks.js";
6
7
  import { registerChatTools } from "./tools/chat.js";
8
+ import { registerUsageTools } from "./tools/usage.js";
9
+ const requireFromHere = createRequire(import.meta.url);
10
+ const pkg = requireFromHere("../package.json");
7
11
  const server = new McpServer({
8
12
  name: "xmark",
9
- version: "0.1.0",
13
+ version: pkg.version,
14
+ });
15
+ // Lazy client: defer the XmarkClient constructor (which throws when
16
+ // XMARK_API_KEY is missing) until a tool invocation actually issues an HTTP
17
+ // call. MCP discovery (initialize, tools/list) never touches the API, so the
18
+ // server boots cleanly without an API key set and surfaces a structured error
19
+ // only when a tool is invoked.
20
+ let realClient = null;
21
+ const client = new Proxy({}, {
22
+ get(_target, prop, receiver) {
23
+ if (!realClient)
24
+ realClient = new XmarkClient();
25
+ const value = Reflect.get(realClient, prop, receiver);
26
+ return typeof value === "function" ? value.bind(realClient) : value;
27
+ },
10
28
  });
11
- const client = new XmarkClient();
12
29
  registerBookmarkTools(server, client);
13
30
  registerChatTools(server, client);
31
+ registerUsageTools(server, client);
14
32
  const transport = new StdioServerTransport();
15
33
  await server.connect(transport);
@@ -1,48 +1,85 @@
1
1
  import { z } from "zod";
2
+ import { formatMcpError } from "../cli-utils.js";
2
3
  export function registerBookmarkTools(server, client) {
3
4
  server.tool("list_bookmarks", "List the authenticated user's X bookmarks. Paginated.", {
4
5
  page: z.number().int().min(1).default(1).describe("Page number (1-indexed)"),
5
6
  pageSize: z.number().int().min(1).max(100).default(50).describe("Items per page (max 100)"),
6
7
  }, async ({ page, pageSize }) => {
7
- const result = await client.get("/bookmarks", {
8
- page,
9
- pageSize,
10
- });
11
- return {
12
- content: [
13
- {
14
- type: "text",
15
- text: JSON.stringify(result, null, 2),
16
- },
17
- ],
18
- };
8
+ try {
9
+ const result = await client.getPage("/bookmarks", { page, pageSize });
10
+ return {
11
+ content: [
12
+ {
13
+ type: "text",
14
+ text: JSON.stringify(result, null, 2),
15
+ },
16
+ ],
17
+ };
18
+ }
19
+ catch (error) {
20
+ return formatMcpError(error);
21
+ }
19
22
  });
20
23
  server.tool("get_bookmark", "Fetch a single bookmark by ID.", {
21
24
  id: z.string().uuid().describe("Bookmark UUID"),
22
25
  }, async ({ id }) => {
23
- const result = await client.get(`/bookmarks/${id}`);
24
- return {
25
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
26
- };
26
+ try {
27
+ const result = await client.get(`/bookmarks/${id}`);
28
+ return {
29
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
30
+ };
31
+ }
32
+ catch (error) {
33
+ return formatMcpError(error);
34
+ }
27
35
  });
28
- server.tool("search_bookmarks", "Semantic search over the user's bookmarks. Returns ranked matches with relevance scores. Charged at $0.04/call for agents (first 50/month free).", {
36
+ server.tool("search_bookmarks", "Semantic search over the user's bookmarks. Returns ranked matches with relevance scores.", {
29
37
  query: z.string().min(1).describe("Natural-language search query"),
30
38
  limit: z.number().int().min(1).max(50).default(10).describe("Max results to return"),
31
39
  }, async ({ query, limit }) => {
32
- const result = await client.post("/bookmarks/search", { query, limit });
33
- return {
34
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
35
- };
40
+ try {
41
+ const result = await client.post("/bookmarks/search", { query, limit });
42
+ return {
43
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
44
+ };
45
+ }
46
+ catch (error) {
47
+ return formatMcpError(error);
48
+ }
36
49
  });
37
- server.tool("sync_bookmarks", "Trigger a fresh sync from X. Pulls latest bookmarks, embeds new ones for semantic search.", {}, async () => {
38
- const result = await client.post("/bookmarks/sync");
39
- return {
40
- content: [
41
- {
42
- type: "text",
43
- text: `Synced ${result.count} bookmarks (${result.newCount} new, ${result.removedCount} removed) in ${result.durationMs}ms.`,
44
- },
45
- ],
46
- };
50
+ server.tool("sync_bookmarks", "Trigger a fresh sync from X. Pulls latest bookmarks, embeds new ones for semantic search. Free tier allows one sync per day.", {}, async () => {
51
+ try {
52
+ const result = await client.post("/bookmarks/sync");
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: `Synced ${result.count} bookmarks (${result.newCount} new, ${result.removedCount} removed) in ${result.durationMs}ms.`,
58
+ },
59
+ ],
60
+ };
61
+ }
62
+ catch (error) {
63
+ return formatMcpError(error);
64
+ }
65
+ });
66
+ server.tool("delete_bookmark", "Remove a bookmark. Local-only by default — the next sync re-creates anything still bookmarked on X. Set unbookmark=true to also remove it on X (sticks across syncs; requires a connected X account).", {
67
+ id: z.string().uuid().describe("Bookmark UUID"),
68
+ unbookmark: z.boolean().default(false).describe("Also remove the bookmark on X, not just locally"),
69
+ }, async ({ id, unbookmark }) => {
70
+ try {
71
+ await client.delete(`/bookmarks/${id}${unbookmark ? "?unbookmark=true" : ""}`);
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: unbookmark ? "Bookmark removed from xmark and X." : "Bookmark removed from xmark (local only).",
77
+ },
78
+ ],
79
+ };
80
+ }
81
+ catch (error) {
82
+ return formatMcpError(error);
83
+ }
47
84
  });
48
85
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { formatMcpError } from "../cli-utils.js";
2
3
  export function registerChatTools(server, client) {
3
- server.tool("chat", "Ask a natural-language question about the user's X bookmarks. Returns an answer with citations back to the original tweets. Charged at $0.20/call for agents.", {
4
+ server.tool("chat", "Ask a natural-language question about the user's X bookmarks. Returns an answer with citations back to the original tweets. Free tier includes 20 chat messages per month.", {
4
5
  question: z.string().min(1).describe("The question to ask"),
5
6
  conversationId: z
6
7
  .string()
@@ -15,30 +16,66 @@ export function registerChatTools(server, client) {
15
16
  .default(5)
16
17
  .describe("Maximum citations to include in the answer"),
17
18
  }, async ({ question, conversationId, maxCitations }) => {
18
- const result = await client.post("/chat", {
19
- question,
20
- ...(conversationId && { conversationId }),
21
- maxCitations,
22
- });
23
- const citations = result.citations
24
- .map((c, i) => `[${i + 1}] @${c.author} — ${c.snippet}\n ${c.tweet_url}`)
25
- .join("\n");
26
- return {
27
- content: [
28
- {
29
- type: "text",
30
- text: `${result.answer}\n\nCitations:\n${citations}\n\nConversation ID: ${result.conversation_id}`,
31
- },
32
- ],
33
- };
19
+ try {
20
+ const result = await client.post("/chat", {
21
+ question,
22
+ ...(conversationId && { conversationId }),
23
+ maxCitations,
24
+ });
25
+ const citations = result.citations
26
+ .map((c, i) => `[${i + 1}] @${c.author} — ${c.snippet}\n ${c.tweet_url}`)
27
+ .join("\n");
28
+ return {
29
+ content: [
30
+ {
31
+ type: "text",
32
+ text: `${result.answer}\n\nCitations:\n${citations}\n\nConversation ID: ${result.conversation_id}`,
33
+ },
34
+ ],
35
+ };
36
+ }
37
+ catch (error) {
38
+ return formatMcpError(error);
39
+ }
34
40
  });
35
41
  server.tool("list_conversations", "List the user's past chat conversations. Paginated.", {
36
42
  page: z.number().int().min(1).default(1),
37
43
  pageSize: z.number().int().min(1).max(100).default(50),
38
44
  }, async ({ page, pageSize }) => {
39
- const result = await client.get("/conversations", { page, pageSize });
40
- return {
41
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
42
- };
45
+ try {
46
+ const result = await client.getPage("/conversations", { page, pageSize });
47
+ return {
48
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
49
+ };
50
+ }
51
+ catch (error) {
52
+ return formatMcpError(error);
53
+ }
54
+ });
55
+ server.tool("get_conversation_messages", "A conversation's message history, oldest first. Assistant messages carry the IDs of the bookmarks retrieved for that answer.", {
56
+ conversationId: z.string().uuid().describe("Conversation UUID"),
57
+ }, async ({ conversationId }) => {
58
+ try {
59
+ const result = await client.get(`/conversations/${conversationId}/messages`);
60
+ return {
61
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
62
+ };
63
+ }
64
+ catch (error) {
65
+ return formatMcpError(error);
66
+ }
67
+ });
68
+ server.tool("delete_conversation", "Permanently delete a conversation and its messages. Free tier allows one concurrent conversation, so deleting is how a free account starts a fresh chat.", {
69
+ conversationId: z.string().uuid().describe("Conversation UUID"),
70
+ }, async ({ conversationId }) => {
71
+ try {
72
+ await client.delete(`/conversations/${conversationId}`);
73
+ return {
74
+ content: [{ type: "text", text: "Conversation deleted." }],
75
+ };
76
+ }
77
+ catch (error) {
78
+ return formatMcpError(error);
79
+ }
43
80
  });
44
81
  }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { XmarkClient } from "../client.js";
3
+ export declare function registerUsageTools(server: McpServer, client: XmarkClient): void;
@@ -0,0 +1,14 @@
1
+ import { formatMcpError } from "../cli-utils.js";
2
+ export function registerUsageTools(server, client) {
3
+ server.tool("get_usage", "The authenticated user's plan and quota usage: chat messages this month, syncs today, bookmark count, and whether an X account is connected. Limits are null on unlimited plans.", {}, async () => {
4
+ try {
5
+ const result = await client.get("/usage");
6
+ return {
7
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
8
+ };
9
+ }
10
+ catch (error) {
11
+ return formatMcpError(error);
12
+ }
13
+ });
14
+ }
package/dist/types.d.ts CHANGED
@@ -20,12 +20,13 @@ export interface Bookmark {
20
20
  author: {
21
21
  username: string;
22
22
  name: string;
23
- id: string;
23
+ id?: string;
24
+ profile_image_url?: string | null;
24
25
  };
25
- bookmarked_at: string;
26
+ bookmarked_at: string | null;
26
27
  url?: string;
27
- media?: unknown[];
28
- topics?: string[];
28
+ public_metrics?: Record<string, number> | null;
29
+ media?: unknown[] | null;
29
30
  }
30
31
  export interface BookmarkSearchResult {
31
32
  bookmark: Bookmark;
@@ -50,9 +51,30 @@ export interface SyncResult {
50
51
  }
51
52
  export interface Conversation {
52
53
  id: string;
53
- title: string;
54
+ title: string | null;
54
55
  created_at: string;
55
- message_count: number;
56
+ updated_at: string;
57
+ }
58
+ export interface Message {
59
+ id: string;
60
+ role: string;
61
+ content: string;
62
+ retrieved_bookmark_ids: string[] | null;
63
+ created_at: string;
64
+ }
65
+ export interface QuotaUsage {
66
+ used: number;
67
+ /** null = unlimited */
68
+ limit: number | null;
69
+ }
70
+ export interface UsageData {
71
+ plan: string;
72
+ x_connected: boolean;
73
+ bookmarks: {
74
+ total: number;
75
+ };
76
+ chat: QuotaUsage;
77
+ syncs_today: QuotaUsage;
56
78
  }
57
79
  export interface PaginationMeta {
58
80
  page: number;
@@ -60,3 +82,7 @@ export interface PaginationMeta {
60
82
  total: number;
61
83
  totalPages: number;
62
84
  }
85
+ export interface Paginated<T> {
86
+ data: T[];
87
+ pagination: PaginationMeta;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jclvsh/xmark",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server and CLI for the xmark API — chat with your X bookmarks from inside Claude Code, Cursor, or any MCP client.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,7 +44,8 @@
44
44
  "license": "MIT",
45
45
  "repository": {
46
46
  "type": "git",
47
- "url": "git+https://github.com/jclvsh/xmark.git"
47
+ "url": "git+https://github.com/JCLVSH/monorepo.git",
48
+ "directory": "apps/xmark/packages/mcp"
48
49
  },
49
50
  "homepage": "https://xmark.dev"
50
51
  }