@cluesmith/multisage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/dist/index.js +321 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @multisage/cli
|
|
2
|
+
|
|
3
|
+
Query [Multisage](https://multisage.ai) from your terminal. Get answers from a panel of AI experts (Claude, GPT, Gemini) with a single command.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @multisage/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @multisage/cli "What's the best programming language for beginners?"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
1. Create an API key at [multisage.ai/settings](https://multisage.ai/settings)
|
|
20
|
+
2. Set the environment variable:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export MULTISAGE_API_KEY=msk_your_key_here
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Ask a question
|
|
30
|
+
multisage "React vs Vue for a new project?"
|
|
31
|
+
|
|
32
|
+
# Get full expert details and stages
|
|
33
|
+
multisage --full "Should I use TypeScript?"
|
|
34
|
+
|
|
35
|
+
# Output as JSON (for scripting)
|
|
36
|
+
multisage --json "Best laptop for developers?"
|
|
37
|
+
|
|
38
|
+
# Full details as JSON
|
|
39
|
+
multisage --json --full "React vs Vue?"
|
|
40
|
+
|
|
41
|
+
# Pipe to a markdown renderer
|
|
42
|
+
multisage "Explain async/await" | glow
|
|
43
|
+
|
|
44
|
+
# Save to file
|
|
45
|
+
multisage "Best practices for REST APIs" > advice.md
|
|
46
|
+
|
|
47
|
+
# Use with a different server
|
|
48
|
+
multisage --api-url https://staging.multisage.ai "test question"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Options
|
|
52
|
+
|
|
53
|
+
| Flag | Short | Description | Default |
|
|
54
|
+
|------|-------|-------------|---------|
|
|
55
|
+
| `--api-key <key>` | `-k` | API key (overrides env var) | `$MULTISAGE_API_KEY` |
|
|
56
|
+
| `--api-url <url>` | `-u` | Base URL for API | `https://multisage.ai` |
|
|
57
|
+
| `--full` | `-f` | Include expert details and all stages | `false` |
|
|
58
|
+
| `--json` | `-j` | Output as structured JSON | `false` |
|
|
59
|
+
| `--quiet` | `-q` | Suppress progress spinner | `false` |
|
|
60
|
+
| `--version` | | Show version | |
|
|
61
|
+
| `--help` | | Show help | |
|
|
62
|
+
|
|
63
|
+
## Output Modes
|
|
64
|
+
|
|
65
|
+
### Default (Markdown)
|
|
66
|
+
Prints the synthesized answer as markdown to stdout.
|
|
67
|
+
|
|
68
|
+
### Full (`--full`)
|
|
69
|
+
Prints all stages: Quick Answer, Expert Responses (per expert), Synthesis, and Debate (if triggered).
|
|
70
|
+
|
|
71
|
+
### JSON (`--json`)
|
|
72
|
+
Outputs the API response as JSON to stdout. Combine with `--full` for the complete response schema.
|
|
73
|
+
|
|
74
|
+
## Error Codes
|
|
75
|
+
|
|
76
|
+
| Error | Message |
|
|
77
|
+
|-------|---------|
|
|
78
|
+
| Invalid API key | "Invalid API key. Check your key at: https://multisage.ai/settings" |
|
|
79
|
+
| Insufficient credits | "Insufficient credits. Purchase more at: https://multisage.ai/settings" |
|
|
80
|
+
| Rate limited | "Rate limit exceeded. Try again in N seconds." |
|
|
81
|
+
| Concurrent limit | "Too many concurrent requests. Try again in a few seconds." |
|
|
82
|
+
| Network error | "Could not connect to {url}." |
|
|
83
|
+
| Timeout | "Request timed out after 300 seconds." |
|
|
84
|
+
|
|
85
|
+
All errors exit with code 1. With `--json`, errors are also output as JSON to stdout for programmatic handling.
|
|
86
|
+
|
|
87
|
+
## Requirements
|
|
88
|
+
|
|
89
|
+
- Node.js 18 or later
|
|
90
|
+
- A Multisage API key ([get one here](https://multisage.ai/settings))
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
var MultisageError = class extends Error {
|
|
9
|
+
constructor(message, code, exitCode = 1, rateLimitMeta) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.exitCode = exitCode;
|
|
13
|
+
this.name = "MultisageError";
|
|
14
|
+
this.rateLimitMeta = rateLimitMeta;
|
|
15
|
+
}
|
|
16
|
+
rateLimitMeta;
|
|
17
|
+
};
|
|
18
|
+
async function handleApiError(response) {
|
|
19
|
+
const status = response.status;
|
|
20
|
+
if (status === 404) {
|
|
21
|
+
return new MultisageError(
|
|
22
|
+
"API not available at this URL. Check --api-url or try https://multisage.ai",
|
|
23
|
+
"not_found"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
let errorCode = "unknown";
|
|
27
|
+
let errorMessage = "";
|
|
28
|
+
try {
|
|
29
|
+
const body = await response.json();
|
|
30
|
+
errorCode = body?.error?.code || "unknown";
|
|
31
|
+
errorMessage = body?.error?.message || "";
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
switch (status) {
|
|
35
|
+
case 400:
|
|
36
|
+
return new MultisageError(
|
|
37
|
+
`Invalid request: ${errorMessage || "Bad request"}`,
|
|
38
|
+
"invalid_request"
|
|
39
|
+
);
|
|
40
|
+
case 401:
|
|
41
|
+
return new MultisageError(
|
|
42
|
+
"Invalid API key. Check your key at: https://multisage.ai/settings",
|
|
43
|
+
"invalid_key"
|
|
44
|
+
);
|
|
45
|
+
case 402:
|
|
46
|
+
return new MultisageError(
|
|
47
|
+
"Insufficient credits. Purchase more at: https://multisage.ai/settings",
|
|
48
|
+
"insufficient_credits"
|
|
49
|
+
);
|
|
50
|
+
case 429: {
|
|
51
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
52
|
+
const rateLimitMeta = {};
|
|
53
|
+
if (retryAfter) rateLimitMeta.retryAfter = parseInt(retryAfter, 10);
|
|
54
|
+
const limitHeader = response.headers.get("X-RateLimit-Limit");
|
|
55
|
+
if (limitHeader) rateLimitMeta.limit = parseInt(limitHeader, 10);
|
|
56
|
+
const remainingHeader = response.headers.get("X-RateLimit-Remaining");
|
|
57
|
+
if (remainingHeader) rateLimitMeta.remaining = parseInt(remainingHeader, 10);
|
|
58
|
+
const resetHeader = response.headers.get("X-RateLimit-Reset");
|
|
59
|
+
if (resetHeader) rateLimitMeta.reset = parseInt(resetHeader, 10);
|
|
60
|
+
if (errorCode === "concurrency_limited") {
|
|
61
|
+
return new MultisageError(
|
|
62
|
+
"Too many concurrent requests. Try again in a few seconds.",
|
|
63
|
+
"concurrency_limited",
|
|
64
|
+
1,
|
|
65
|
+
Object.keys(rateLimitMeta).length > 0 ? rateLimitMeta : void 0
|
|
66
|
+
);
|
|
67
|
+
} else {
|
|
68
|
+
const retryMsg = retryAfter ? `Rate limit exceeded. Try again in ${retryAfter} seconds.` : "Rate limit exceeded. Try again shortly.";
|
|
69
|
+
return new MultisageError(
|
|
70
|
+
retryMsg,
|
|
71
|
+
"rate_limited",
|
|
72
|
+
1,
|
|
73
|
+
Object.keys(rateLimitMeta).length > 0 ? rateLimitMeta : void 0
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
case 500:
|
|
78
|
+
return new MultisageError(
|
|
79
|
+
"Server error. Please try again later.",
|
|
80
|
+
"internal_error"
|
|
81
|
+
);
|
|
82
|
+
default:
|
|
83
|
+
return new MultisageError(
|
|
84
|
+
`Unexpected error (HTTP ${status}). Please try again later.`,
|
|
85
|
+
"unknown"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function networkError(url) {
|
|
90
|
+
return new MultisageError(
|
|
91
|
+
`Could not connect to ${url}. Check your internet connection and --api-url.`,
|
|
92
|
+
"network_error"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
function timeoutError() {
|
|
96
|
+
return new MultisageError(
|
|
97
|
+
"Request timed out after 300 seconds. The server may be overloaded.",
|
|
98
|
+
"timeout"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
function validateApiKey(key) {
|
|
102
|
+
if (!key) {
|
|
103
|
+
return "No API key provided. Set MULTISAGE_API_KEY or use --api-key.\nGet your key at: https://multisage.ai/settings";
|
|
104
|
+
}
|
|
105
|
+
if (!key.startsWith("msk_")) {
|
|
106
|
+
return 'Invalid API key format. Keys must start with "msk_".\nGet your key at: https://multisage.ai/settings';
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
function validateUrl(url) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = new URL(url);
|
|
113
|
+
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
114
|
+
if (parsed.protocol === "http:" && !isLocalhost) {
|
|
115
|
+
return "API URL must use HTTPS. Only localhost URLs are allowed over HTTP.";
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
} catch {
|
|
119
|
+
return `Invalid URL: ${url}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/client.ts
|
|
124
|
+
async function queryMultisage(options) {
|
|
125
|
+
const url = `${options.apiUrl.replace(/\/$/, "")}/api/v1/query`;
|
|
126
|
+
const body = { question: options.question };
|
|
127
|
+
if (options.detail) {
|
|
128
|
+
body.detail = options.detail;
|
|
129
|
+
}
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await fetch(url, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
"Authorization": `Bearer ${options.apiKey}`
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(body),
|
|
139
|
+
signal: AbortSignal.timeout(3e5)
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
143
|
+
throw timeoutError();
|
|
144
|
+
}
|
|
145
|
+
if (err instanceof TypeError) {
|
|
146
|
+
throw networkError(options.apiUrl);
|
|
147
|
+
}
|
|
148
|
+
throw new MultisageError(
|
|
149
|
+
`Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
|
|
150
|
+
"unknown"
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw await handleApiError(response);
|
|
155
|
+
}
|
|
156
|
+
let data;
|
|
157
|
+
try {
|
|
158
|
+
data = await response.json();
|
|
159
|
+
} catch {
|
|
160
|
+
throw new MultisageError(
|
|
161
|
+
"Received malformed response from server.",
|
|
162
|
+
"malformed_response"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (!data || typeof data !== "object" || !("answer" in data)) {
|
|
166
|
+
throw new MultisageError(
|
|
167
|
+
"Received malformed response from server.",
|
|
168
|
+
"malformed_response"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/format.ts
|
|
175
|
+
function isFullResponse(response) {
|
|
176
|
+
return "stages" in response && response.stages != null;
|
|
177
|
+
}
|
|
178
|
+
function formatDefault(response) {
|
|
179
|
+
return response.answer;
|
|
180
|
+
}
|
|
181
|
+
function formatFullMarkdown(response) {
|
|
182
|
+
if (!isFullResponse(response)) {
|
|
183
|
+
return `# Answer
|
|
184
|
+
|
|
185
|
+
${response.answer}`;
|
|
186
|
+
}
|
|
187
|
+
const sections = [];
|
|
188
|
+
if (response.stages.quickAnswer) {
|
|
189
|
+
sections.push(`# Quick Answer
|
|
190
|
+
|
|
191
|
+
${response.stages.quickAnswer}`);
|
|
192
|
+
}
|
|
193
|
+
const experts = response.experts;
|
|
194
|
+
if (experts.length > 0) {
|
|
195
|
+
const expertSections = experts.map(
|
|
196
|
+
(e) => `## ${displayName(e.name)}
|
|
197
|
+
|
|
198
|
+
${e.response}`
|
|
199
|
+
);
|
|
200
|
+
sections.push(`# Expert Responses
|
|
201
|
+
|
|
202
|
+
${expertSections.join("\n\n")}`);
|
|
203
|
+
}
|
|
204
|
+
if (response.stages.synthesis) {
|
|
205
|
+
sections.push(`# Synthesis
|
|
206
|
+
|
|
207
|
+
${response.stages.synthesis}`);
|
|
208
|
+
}
|
|
209
|
+
if (response.stages.debate) {
|
|
210
|
+
sections.push(`# Debate
|
|
211
|
+
|
|
212
|
+
${response.stages.debate}`);
|
|
213
|
+
}
|
|
214
|
+
return sections.join("\n\n---\n\n");
|
|
215
|
+
}
|
|
216
|
+
function formatJson(response) {
|
|
217
|
+
return JSON.stringify(response, null, 2);
|
|
218
|
+
}
|
|
219
|
+
function formatJsonError(code, message, rateLimitMeta) {
|
|
220
|
+
const error = { code, message };
|
|
221
|
+
if (rateLimitMeta?.retryAfter != null) {
|
|
222
|
+
error.retryAfter = rateLimitMeta.retryAfter;
|
|
223
|
+
error.rateLimit = {
|
|
224
|
+
limit: rateLimitMeta.limit,
|
|
225
|
+
remaining: rateLimitMeta.remaining,
|
|
226
|
+
reset: rateLimitMeta.reset
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return JSON.stringify({ error }, null, 2);
|
|
230
|
+
}
|
|
231
|
+
var DISPLAY_NAMES = {
|
|
232
|
+
gpt: "GPT",
|
|
233
|
+
claude: "Claude",
|
|
234
|
+
gemini: "Gemini"
|
|
235
|
+
};
|
|
236
|
+
function displayName(name) {
|
|
237
|
+
return DISPLAY_NAMES[name] || name.charAt(0).toUpperCase() + name.slice(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/index.ts
|
|
241
|
+
var DEFAULT_API_URL = "https://multisage.ai";
|
|
242
|
+
var program = new Command();
|
|
243
|
+
program.name("multisage").description("Query Multisage from your terminal").version("0.1.0").argument("[question]", "The question to ask").option("-k, --api-key <key>", "API key (overrides MULTISAGE_API_KEY env var)").option("-u, --api-url <url>", "Base URL for API", DEFAULT_API_URL).option("-f, --full", "Include all stages and expert details", false).option("-j, --json", "Output as structured JSON", false).option("-q, --quiet", "Suppress progress indicator", false).action(async (question, options) => {
|
|
244
|
+
if (!question) {
|
|
245
|
+
if (process.stdin.isTTY) {
|
|
246
|
+
program.help();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
process.stderr.write("Error: No question provided. Usage: multisage <question>\n");
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
const apiKey = options.apiKey || process.env.MULTISAGE_API_KEY || "";
|
|
253
|
+
if (options.apiKey) {
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
"Warning: API key passed via command line may be visible in shell history. Consider using MULTISAGE_API_KEY env var instead.\n"
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const keyError = validateApiKey(apiKey);
|
|
259
|
+
if (keyError) {
|
|
260
|
+
process.stderr.write(`Error: ${keyError}
|
|
261
|
+
`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
const urlError = validateUrl(options.apiUrl);
|
|
265
|
+
if (urlError) {
|
|
266
|
+
process.stderr.write(`Error: ${urlError}
|
|
267
|
+
`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
if (question.length > 1e3) {
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
`Warning: Question is ${question.length} characters. The server truncates to 1000 characters.
|
|
273
|
+
`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const showSpinner = !!(process.stdout.isTTY && !options.quiet);
|
|
277
|
+
const spinner = showSpinner ? ora({ text: "Thinking... (asking 3 experts)", stream: process.stderr }).start() : null;
|
|
278
|
+
const sigintHandler = () => {
|
|
279
|
+
if (spinner) spinner.stop();
|
|
280
|
+
process.exit(130);
|
|
281
|
+
};
|
|
282
|
+
process.on("SIGINT", sigintHandler);
|
|
283
|
+
try {
|
|
284
|
+
const response = await queryMultisage({
|
|
285
|
+
question,
|
|
286
|
+
apiKey,
|
|
287
|
+
apiUrl: options.apiUrl,
|
|
288
|
+
detail: options.full ? "full" : void 0
|
|
289
|
+
});
|
|
290
|
+
if (spinner) spinner.stop();
|
|
291
|
+
let output;
|
|
292
|
+
if (options.json) {
|
|
293
|
+
output = formatJson(response);
|
|
294
|
+
} else if (options.full) {
|
|
295
|
+
output = formatFullMarkdown(response);
|
|
296
|
+
} else {
|
|
297
|
+
output = formatDefault(response);
|
|
298
|
+
}
|
|
299
|
+
process.stdout.write(output + "\n");
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (spinner) spinner.stop();
|
|
302
|
+
if (err instanceof MultisageError) {
|
|
303
|
+
if (options.json) {
|
|
304
|
+
process.stdout.write(formatJsonError(err.code, err.message, err.rateLimitMeta) + "\n");
|
|
305
|
+
}
|
|
306
|
+
process.stderr.write(`Error: ${err.message}
|
|
307
|
+
`);
|
|
308
|
+
process.exit(err.exitCode);
|
|
309
|
+
}
|
|
310
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
311
|
+
if (options.json) {
|
|
312
|
+
process.stdout.write(formatJsonError("unknown", message) + "\n");
|
|
313
|
+
}
|
|
314
|
+
process.stderr.write(`Error: ${message}
|
|
315
|
+
`);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
} finally {
|
|
318
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cluesmith/multisage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI client for Multisage — ask questions from your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"multisage": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"pretest": "tsup",
|
|
18
|
+
"test": "node --experimental-vm-modules ../../node_modules/.bin/jest --config jest.config.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"commander": "^12.1.0",
|
|
22
|
+
"ora": "^8.1.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/jest": "^29.5.0",
|
|
26
|
+
"jest": "^29.7.0",
|
|
27
|
+
"ts-jest": "^29.2.0",
|
|
28
|
+
"tsup": "^8.3.5",
|
|
29
|
+
"typescript": "^5.7.0"
|
|
30
|
+
}
|
|
31
|
+
}
|