@jclvsh/xmark 0.3.0 → 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 +4 -0
- package/dist/cli-utils.d.ts +58 -0
- package/dist/cli-utils.js +127 -0
- package/dist/cli.js +31 -98
- package/dist/client.d.ts +7 -0
- package/dist/client.js +54 -27
- package/dist/commands/bookmarks.d.ts +3 -0
- package/dist/commands/bookmarks.js +73 -0
- package/dist/commands/chat.d.ts +3 -0
- package/dist/commands/chat.js +60 -0
- package/dist/commands/usage.d.ts +3 -0
- package/dist/commands/usage.js +15 -0
- package/dist/index.js +18 -2
- package/dist/tools/bookmarks.js +61 -35
- package/dist/tools/chat.js +49 -28
- package/dist/tools/usage.js +10 -4
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -60,6 +60,10 @@ xmark delete-conversation <conversation-id>
|
|
|
60
60
|
xmark usage
|
|
61
61
|
```
|
|
62
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
|
+
|
|
63
67
|
## Pricing
|
|
64
68
|
|
|
65
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.
|
|
@@ -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,104 +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
|
-
|
|
5
|
-
|
|
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(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.command("chat <question...>")
|
|
25
|
-
.description("Ask a question about your bookmarks")
|
|
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().getPage("/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
|
-
|
|
57
|
-
|
|
58
|
-
program
|
|
59
|
-
.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
program
|
|
66
|
-
.command("delete <id>")
|
|
67
|
-
.description("Remove a bookmark (local-only unless --unbookmark)")
|
|
68
|
-
.option("-u, --unbookmark", "also remove the bookmark on X, not just locally")
|
|
69
|
-
.action(async (id, opts) => {
|
|
70
|
-
await client().delete(`/bookmarks/${id}${opts.unbookmark ? "?unbookmark=true" : ""}`);
|
|
71
|
-
console.log(opts.unbookmark ? "Removed from xmark and X." : "Removed from xmark (local only).");
|
|
72
|
-
});
|
|
73
|
-
program
|
|
74
|
-
.command("conversations")
|
|
75
|
-
.description("List your past chat conversations")
|
|
76
|
-
.action(async () => {
|
|
77
|
-
const result = await client().getPage("/conversations");
|
|
78
|
-
console.log(JSON.stringify(result, null, 2));
|
|
79
|
-
});
|
|
80
|
-
program
|
|
81
|
-
.command("messages <conversationId>")
|
|
82
|
-
.description("A conversation's message history, oldest first")
|
|
83
|
-
.action(async (conversationId) => {
|
|
84
|
-
const result = await client().get(`/conversations/${conversationId}/messages`);
|
|
85
|
-
console.log(JSON.stringify(result, null, 2));
|
|
86
|
-
});
|
|
87
|
-
program
|
|
88
|
-
.command("delete-conversation <id>")
|
|
89
|
-
.description("Permanently delete a conversation and its messages")
|
|
90
|
-
.action(async (id) => {
|
|
91
|
-
await client().delete(`/conversations/${id}`);
|
|
92
|
-
console.log("Conversation deleted.");
|
|
93
|
-
});
|
|
94
|
-
program
|
|
95
|
-
.command("usage")
|
|
96
|
-
.description("Your plan + quota usage (chat this month, syncs today, bookmark count)")
|
|
97
|
-
.action(async () => {
|
|
98
|
-
const result = await client().get("/usage");
|
|
99
|
-
console.log(JSON.stringify(result, null, 2));
|
|
100
|
-
});
|
|
101
|
-
program.parseAsync(process.argv).catch((err) => {
|
|
102
|
-
console.error(err instanceof Error ? err.message : err);
|
|
103
|
-
process.exit(1);
|
|
104
|
-
});
|
|
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
|
@@ -12,5 +12,12 @@ export declare class XmarkClient {
|
|
|
12
12
|
post<T>(path: string, body?: unknown): Promise<T>;
|
|
13
13
|
delete(path: string): Promise<void>;
|
|
14
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;
|
|
15
22
|
private handleError;
|
|
16
23
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,6 +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.
|
|
3
|
+
const USER_AGENT = "xmark-mcp/0.4.0";
|
|
4
4
|
export class XmarkClient {
|
|
5
5
|
apiKey;
|
|
6
6
|
baseUrl;
|
|
@@ -34,17 +34,13 @@ export class XmarkClient {
|
|
|
34
34
|
url.searchParams.set(k, String(v));
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
const response = await
|
|
38
|
-
method: "GET",
|
|
39
|
-
headers: { Authorization: `Bearer ${this.apiKey}`, "User-Agent": USER_AGENT },
|
|
40
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
41
|
-
});
|
|
37
|
+
const response = await this.fetchWithGuards(url.toString(), { method: "GET" });
|
|
42
38
|
if (!response.ok) {
|
|
43
39
|
await this.handleError(response);
|
|
44
40
|
}
|
|
45
41
|
const json = (await response.json());
|
|
46
42
|
if (!json.success) {
|
|
47
|
-
throw
|
|
43
|
+
throw this.apiError(json.error);
|
|
48
44
|
}
|
|
49
45
|
return { data: json.data, pagination: json.pagination };
|
|
50
46
|
}
|
|
@@ -56,45 +52,76 @@ export class XmarkClient {
|
|
|
56
52
|
});
|
|
57
53
|
}
|
|
58
54
|
async delete(path) {
|
|
59
|
-
const response = await
|
|
60
|
-
method: "DELETE",
|
|
61
|
-
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
62
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
63
|
-
});
|
|
55
|
+
const response = await this.fetchWithGuards(`${this.baseUrl}${path}`, { method: "DELETE" });
|
|
64
56
|
if (!response.ok && response.status !== 204) {
|
|
65
57
|
await this.handleError(response);
|
|
66
58
|
}
|
|
67
59
|
}
|
|
68
60
|
async request(url, init) {
|
|
69
|
-
const response = await
|
|
70
|
-
...init,
|
|
71
|
-
headers: {
|
|
72
|
-
...init.headers,
|
|
73
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
74
|
-
"User-Agent": USER_AGENT,
|
|
75
|
-
},
|
|
76
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
77
|
-
});
|
|
61
|
+
const response = await this.fetchWithGuards(url, init);
|
|
78
62
|
if (!response.ok) {
|
|
79
63
|
await this.handleError(response);
|
|
80
64
|
}
|
|
81
65
|
const json = (await response.json());
|
|
82
66
|
if (!json.success) {
|
|
83
|
-
throw
|
|
67
|
+
throw this.apiError(json.error);
|
|
84
68
|
}
|
|
85
69
|
return json.data;
|
|
86
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
|
+
}
|
|
87
105
|
async handleError(response) {
|
|
88
|
-
let
|
|
106
|
+
let errorMessage;
|
|
89
107
|
try {
|
|
90
108
|
const body = (await response.json());
|
|
91
|
-
if (body
|
|
92
|
-
|
|
109
|
+
if (body.error) {
|
|
110
|
+
errorMessage = this.apiError(body.error).message;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
errorMessage = `API error: ${response.status} ${response.statusText}`;
|
|
93
114
|
}
|
|
94
115
|
}
|
|
95
116
|
catch {
|
|
96
|
-
|
|
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
|
+
}
|
|
97
124
|
}
|
|
98
|
-
throw new Error(
|
|
125
|
+
throw new Error(errorMessage);
|
|
99
126
|
}
|
|
100
127
|
}
|
|
@@ -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,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,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,31 @@
|
|
|
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";
|
|
7
8
|
import { registerUsageTools } from "./tools/usage.js";
|
|
9
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
10
|
+
const pkg = requireFromHere("../package.json");
|
|
8
11
|
const server = new McpServer({
|
|
9
12
|
name: "xmark",
|
|
10
|
-
version:
|
|
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
|
+
},
|
|
11
28
|
});
|
|
12
|
-
const client = new XmarkClient();
|
|
13
29
|
registerBookmarkTools(server, client);
|
|
14
30
|
registerChatTools(server, client);
|
|
15
31
|
registerUsageTools(server, client);
|
package/dist/tools/bookmarks.js
CHANGED
|
@@ -1,59 +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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|
|
16
22
|
});
|
|
17
23
|
server.tool("get_bookmark", "Fetch a single bookmark by ID.", {
|
|
18
24
|
id: z.string().uuid().describe("Bookmark UUID"),
|
|
19
25
|
}, async ({ id }) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
|
24
35
|
});
|
|
25
36
|
server.tool("search_bookmarks", "Semantic search over the user's bookmarks. Returns ranked matches with relevance scores.", {
|
|
26
37
|
query: z.string().min(1).describe("Natural-language search query"),
|
|
27
38
|
limit: z.number().int().min(1).max(50).default(10).describe("Max results to return"),
|
|
28
39
|
}, async ({ query, limit }) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
|
33
49
|
});
|
|
34
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 () => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
|
44
65
|
});
|
|
45
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).", {
|
|
46
67
|
id: z.string().uuid().describe("Bookmark UUID"),
|
|
47
68
|
unbookmark: z.boolean().default(false).describe("Also remove the bookmark on X, not just locally"),
|
|
48
69
|
}, async ({ id, unbookmark }) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
84
|
});
|
|
59
85
|
}
|
package/dist/tools/chat.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { formatMcpError } from "../cli-utils.js";
|
|
2
3
|
export function registerChatTools(server, client) {
|
|
3
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"),
|
|
@@ -15,46 +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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
43
54
|
});
|
|
44
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.", {
|
|
45
56
|
conversationId: z.string().uuid().describe("Conversation UUID"),
|
|
46
57
|
}, async ({ conversationId }) => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
51
67
|
});
|
|
52
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.", {
|
|
53
69
|
conversationId: z.string().uuid().describe("Conversation UUID"),
|
|
54
70
|
}, async ({ conversationId }) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|
|
59
80
|
});
|
|
60
81
|
}
|
package/dist/tools/usage.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { formatMcpError } from "../cli-utils.js";
|
|
1
2
|
export function registerUsageTools(server, client) {
|
|
2
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 () => {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
}
|
|
7
13
|
});
|
|
8
14
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jclvsh/xmark",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
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
|
}
|