stpr 1.0.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/dist/cli.js +810 -0
- package/package.json +23 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import * as path2 from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
|
|
8
|
+
// src/auth.ts
|
|
9
|
+
import * as childProcess from "child_process";
|
|
10
|
+
import * as crypto from "crypto";
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { createServer } from "http";
|
|
15
|
+
|
|
16
|
+
// src/client.ts
|
|
17
|
+
var DEFAULT_BASE_URL = "https://mcp.stepper.io";
|
|
18
|
+
var StepperClient = class {
|
|
19
|
+
token;
|
|
20
|
+
baseUrl;
|
|
21
|
+
requestId = 0;
|
|
22
|
+
constructor(token, baseUrl) {
|
|
23
|
+
this.token = token;
|
|
24
|
+
this.baseUrl = (baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
async rpc(method, params) {
|
|
27
|
+
const id = ++this.requestId;
|
|
28
|
+
const body = {
|
|
29
|
+
jsonrpc: "2.0",
|
|
30
|
+
method,
|
|
31
|
+
params: params ?? {},
|
|
32
|
+
id
|
|
33
|
+
};
|
|
34
|
+
const response = await fetch(`${this.baseUrl}/skill-sets/mcp`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
Authorization: `Bearer ${this.token}`
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify(body)
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const text = await response.text();
|
|
44
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
45
|
+
}
|
|
46
|
+
return await response.json();
|
|
47
|
+
}
|
|
48
|
+
async initialize() {
|
|
49
|
+
const res = await this.rpc("initialize");
|
|
50
|
+
if (res.error) {
|
|
51
|
+
throw new Error(`Initialize failed: ${res.error.message}`);
|
|
52
|
+
}
|
|
53
|
+
await this.rpc("notifications/initialized");
|
|
54
|
+
}
|
|
55
|
+
async getServerInfo() {
|
|
56
|
+
const res = await this.rpc("initialize");
|
|
57
|
+
if (res.error) {
|
|
58
|
+
throw new Error(`Initialize failed: ${res.error.message}`);
|
|
59
|
+
}
|
|
60
|
+
const serverInfo = res.result?.serverInfo ?? {};
|
|
61
|
+
return {
|
|
62
|
+
name: serverInfo.name ?? "Unknown",
|
|
63
|
+
version: serverInfo.version ?? "1.0.0"
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async listTools({ service }) {
|
|
67
|
+
const res = await this.rpc("tools/list");
|
|
68
|
+
if (res.error) {
|
|
69
|
+
throw new Error(`tools/list failed: ${res.error.message}`);
|
|
70
|
+
}
|
|
71
|
+
const tools = res.result.tools;
|
|
72
|
+
return tools.map(
|
|
73
|
+
(tool) => ({
|
|
74
|
+
service: tool.name.split(".")[0],
|
|
75
|
+
action: tool.name.split(".")[1],
|
|
76
|
+
description: tool.description,
|
|
77
|
+
inputSchema: tool.inputSchema
|
|
78
|
+
})
|
|
79
|
+
).filter(
|
|
80
|
+
(tool) => service ? tool.service === service : !tool.service.startsWith("__")
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
async callTool(name, args) {
|
|
84
|
+
const res = await this.rpc("tools/call", { name, arguments: args });
|
|
85
|
+
if (res.error) {
|
|
86
|
+
throw new Error(`tools/call failed: ${res.error.message}`);
|
|
87
|
+
}
|
|
88
|
+
return res.result;
|
|
89
|
+
}
|
|
90
|
+
async getToolParams(toolName, args) {
|
|
91
|
+
const res = await this.rpc("tools/params", {
|
|
92
|
+
name: toolName,
|
|
93
|
+
arguments: args
|
|
94
|
+
});
|
|
95
|
+
if (res.error) {
|
|
96
|
+
throw new Error(`tools/params failed: ${res.error.message}`);
|
|
97
|
+
}
|
|
98
|
+
return res.result;
|
|
99
|
+
}
|
|
100
|
+
async getParameterOptions(toolName, parameterId, opts) {
|
|
101
|
+
const res = await this.rpc("tools/options", {
|
|
102
|
+
name: toolName,
|
|
103
|
+
parameter_id: parameterId,
|
|
104
|
+
current_parameter_values: opts?.currentParameterValues ?? {},
|
|
105
|
+
search: opts?.search ?? null,
|
|
106
|
+
cursor: opts?.cursor ?? null
|
|
107
|
+
});
|
|
108
|
+
if (res.error) {
|
|
109
|
+
throw new Error(`tools/options failed: ${res.error.message}`);
|
|
110
|
+
}
|
|
111
|
+
return res.result;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// src/auth.ts
|
|
116
|
+
function openBrowser(url) {
|
|
117
|
+
const platform2 = os.platform();
|
|
118
|
+
if (platform2 === "darwin") {
|
|
119
|
+
childProcess.spawn("open", [url], { stdio: "ignore", detached: true });
|
|
120
|
+
} else if (platform2 === "win32") {
|
|
121
|
+
childProcess.spawn("cmd", ["/c", "start", "", url], {
|
|
122
|
+
stdio: "ignore",
|
|
123
|
+
detached: true
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
childProcess.spawn("xdg-open", [url], { stdio: "ignore", detached: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
var DEFAULT_BASE_URL2 = "https://mcp.stepper.io";
|
|
130
|
+
var OAUTH_CALLBACK_PORT = 3847;
|
|
131
|
+
var CALLBACK_PATH = "/callback";
|
|
132
|
+
var CONFIG_DIR = path.join(os.homedir(), ".config", "stepper-skillsets");
|
|
133
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
134
|
+
var LEGACY_CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
135
|
+
function generatePKCE() {
|
|
136
|
+
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
137
|
+
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest().toString("base64url");
|
|
138
|
+
return { codeVerifier, codeChallenge };
|
|
139
|
+
}
|
|
140
|
+
function loadConfig() {
|
|
141
|
+
try {
|
|
142
|
+
const data = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
143
|
+
return JSON.parse(data);
|
|
144
|
+
} catch {
|
|
145
|
+
return { active: null, skillsets: {} };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function saveConfig(config) {
|
|
149
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
150
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
|
151
|
+
mode: 384
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function migrateLegacyCredentials() {
|
|
155
|
+
try {
|
|
156
|
+
const data = fs.readFileSync(LEGACY_CREDENTIALS_FILE, "utf-8");
|
|
157
|
+
const creds = JSON.parse(data);
|
|
158
|
+
const config = {
|
|
159
|
+
active: "default",
|
|
160
|
+
skillsets: { default: creds }
|
|
161
|
+
};
|
|
162
|
+
saveConfig(config);
|
|
163
|
+
fs.unlinkSync(LEGACY_CREDENTIALS_FILE);
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function getConfigPathForDisplay() {
|
|
168
|
+
return CONFIG_FILE;
|
|
169
|
+
}
|
|
170
|
+
function getActiveSkillset() {
|
|
171
|
+
migrateLegacyCredentials();
|
|
172
|
+
const config = loadConfig();
|
|
173
|
+
return config.active;
|
|
174
|
+
}
|
|
175
|
+
function setActiveSkillset(name) {
|
|
176
|
+
migrateLegacyCredentials();
|
|
177
|
+
const config = loadConfig();
|
|
178
|
+
if (!(name in config.skillsets)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
config.active = name;
|
|
182
|
+
saveConfig(config);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
function listSkillsets() {
|
|
186
|
+
migrateLegacyCredentials();
|
|
187
|
+
const config = loadConfig();
|
|
188
|
+
return Object.entries(config.skillsets).map(([name, creds]) => ({
|
|
189
|
+
name,
|
|
190
|
+
baseUrl: creds.baseUrl,
|
|
191
|
+
isActive: config.active === name
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
function getCredentials(name) {
|
|
195
|
+
migrateLegacyCredentials();
|
|
196
|
+
const config = loadConfig();
|
|
197
|
+
return config.skillsets[name] ?? null;
|
|
198
|
+
}
|
|
199
|
+
function saveCredentials(name, creds) {
|
|
200
|
+
migrateLegacyCredentials();
|
|
201
|
+
const config = loadConfig();
|
|
202
|
+
config.skillsets[name] = creds;
|
|
203
|
+
if (!config.active || !(config.active in config.skillsets)) {
|
|
204
|
+
config.active = name;
|
|
205
|
+
}
|
|
206
|
+
saveConfig(config);
|
|
207
|
+
}
|
|
208
|
+
function deleteSkillset(name) {
|
|
209
|
+
migrateLegacyCredentials();
|
|
210
|
+
const config = loadConfig();
|
|
211
|
+
if (!(name in config.skillsets)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
delete config.skillsets[name];
|
|
215
|
+
if (config.active === name) {
|
|
216
|
+
config.active = Object.keys(config.skillsets)[0] ?? null;
|
|
217
|
+
}
|
|
218
|
+
saveConfig(config);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
function deleteAllSkillsets() {
|
|
222
|
+
migrateLegacyCredentials();
|
|
223
|
+
const config = loadConfig();
|
|
224
|
+
const count = Object.keys(config.skillsets).length;
|
|
225
|
+
config.skillsets = {};
|
|
226
|
+
config.active = null;
|
|
227
|
+
saveConfig(config);
|
|
228
|
+
return count;
|
|
229
|
+
}
|
|
230
|
+
async function fetchMetadata(baseUrl) {
|
|
231
|
+
const url = `${baseUrl.replace(/\/$/, "")}/.well-known/oauth-authorization-server`;
|
|
232
|
+
const res = await fetch(url);
|
|
233
|
+
if (!res.ok) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Failed to fetch OAuth metadata: ${res.status} ${await res.text()}`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return await res.json();
|
|
239
|
+
}
|
|
240
|
+
async function registerClient(baseUrl, redirectUri) {
|
|
241
|
+
const url = `${baseUrl.replace(/\/$/, "")}/register`;
|
|
242
|
+
const res = await fetch(url, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
client_name: "Skills CLI",
|
|
247
|
+
redirect_uris: [redirectUri],
|
|
248
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
249
|
+
token_endpoint_auth_method: "none",
|
|
250
|
+
response_types: ["code"]
|
|
251
|
+
})
|
|
252
|
+
});
|
|
253
|
+
if (!res.ok) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Failed to register client: ${res.status} ${await res.text()}`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const data = await res.json();
|
|
259
|
+
return data.client_id;
|
|
260
|
+
}
|
|
261
|
+
async function exchangeCodeForTokens(baseUrl, clientId, redirectUri, code, codeVerifier) {
|
|
262
|
+
const url = `${baseUrl.replace(/\/$/, "")}/token`;
|
|
263
|
+
const res = await fetch(url, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
266
|
+
body: new URLSearchParams({
|
|
267
|
+
grant_type: "authorization_code",
|
|
268
|
+
client_id: clientId,
|
|
269
|
+
redirect_uri: redirectUri,
|
|
270
|
+
code,
|
|
271
|
+
code_verifier: codeVerifier
|
|
272
|
+
}).toString()
|
|
273
|
+
});
|
|
274
|
+
if (!res.ok) {
|
|
275
|
+
const text = await res.text();
|
|
276
|
+
throw new Error(`Token exchange failed: ${res.status} ${text}`);
|
|
277
|
+
}
|
|
278
|
+
const data = await res.json();
|
|
279
|
+
return {
|
|
280
|
+
accessToken: data.access_token,
|
|
281
|
+
refreshToken: data.refresh_token,
|
|
282
|
+
expiresIn: data.expires_in ?? 3600
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function extractSkillSetNameFromServerName(serverName) {
|
|
286
|
+
const prefix = "Stepper MCP Server - ";
|
|
287
|
+
if (serverName.startsWith(prefix)) {
|
|
288
|
+
return serverName.slice(prefix.length).trim() || serverName;
|
|
289
|
+
}
|
|
290
|
+
return serverName;
|
|
291
|
+
}
|
|
292
|
+
async function runLoginFlow(baseUrl) {
|
|
293
|
+
const mcpBaseUrl = baseUrl ?? process.env.STEPPER_URL ?? DEFAULT_BASE_URL2;
|
|
294
|
+
const normalizedBaseUrl = mcpBaseUrl.replace(/\/$/, "");
|
|
295
|
+
const redirectUri = `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
296
|
+
const metadata = await fetchMetadata(normalizedBaseUrl);
|
|
297
|
+
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
298
|
+
const clientId = await registerClient(normalizedBaseUrl, redirectUri);
|
|
299
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
300
|
+
const authUrl = new URL(metadata.authorization_endpoint);
|
|
301
|
+
authUrl.searchParams.set("response_type", "code");
|
|
302
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
303
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
304
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
305
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
306
|
+
authUrl.searchParams.set("state", state);
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
const server = createServer(
|
|
309
|
+
(req, res) => {
|
|
310
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
311
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
312
|
+
res.writeHead(404);
|
|
313
|
+
res.end("Not found");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const code = url.searchParams.get("code");
|
|
317
|
+
const returnedState = url.searchParams.get("state");
|
|
318
|
+
const error = url.searchParams.get("error");
|
|
319
|
+
if (error) {
|
|
320
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
321
|
+
res.end(
|
|
322
|
+
`<html><body><h1>Login failed</h1><p>${error}: ${url.searchParams.get("error_description") ?? "Unknown error"}</p><p>You can close this tab.</p></body></html>`
|
|
323
|
+
);
|
|
324
|
+
server.close();
|
|
325
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (returnedState !== state) {
|
|
329
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
330
|
+
res.end(
|
|
331
|
+
"<html><body><h1>Login failed</h1><p>State mismatch</p><p>You can close this tab.</p></body></html>"
|
|
332
|
+
);
|
|
333
|
+
server.close();
|
|
334
|
+
reject(new Error("OAuth state mismatch"));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!code) {
|
|
338
|
+
res.writeHead(400);
|
|
339
|
+
res.end("Missing authorization code");
|
|
340
|
+
server.close();
|
|
341
|
+
reject(new Error("Missing authorization code"));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
345
|
+
res.end(
|
|
346
|
+
"<html><body><h1>Login successful</h1><p>You can close this tab and return to the terminal.</p></body></html>"
|
|
347
|
+
);
|
|
348
|
+
exchangeCodeForTokens(
|
|
349
|
+
normalizedBaseUrl,
|
|
350
|
+
clientId,
|
|
351
|
+
redirectUri,
|
|
352
|
+
code,
|
|
353
|
+
codeVerifier
|
|
354
|
+
).then(async ({ accessToken, refreshToken, expiresIn }) => {
|
|
355
|
+
const creds = {
|
|
356
|
+
baseUrl: normalizedBaseUrl,
|
|
357
|
+
clientId,
|
|
358
|
+
accessToken,
|
|
359
|
+
refreshToken,
|
|
360
|
+
expiresAt: new Date(Date.now() + expiresIn * 1e3).toISOString()
|
|
361
|
+
};
|
|
362
|
+
let name;
|
|
363
|
+
try {
|
|
364
|
+
const client = new StepperClient(accessToken, normalizedBaseUrl);
|
|
365
|
+
const serverInfo = await client.getServerInfo();
|
|
366
|
+
name = extractSkillSetNameFromServerName(serverInfo.name);
|
|
367
|
+
} catch {
|
|
368
|
+
name = `unknown-${Date.now()}`;
|
|
369
|
+
}
|
|
370
|
+
saveCredentials(name, creds);
|
|
371
|
+
resolve({ credentials: creds, name });
|
|
372
|
+
}).catch(reject).finally(() => server.close());
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
server.listen(OAUTH_CALLBACK_PORT, "127.0.0.1", () => {
|
|
376
|
+
openBrowser(authUrl.toString());
|
|
377
|
+
});
|
|
378
|
+
server.on("error", reject);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async function refreshAccessToken(baseUrl, clientId, refreshToken) {
|
|
382
|
+
const url = `${baseUrl.replace(/\/$/, "")}/token`;
|
|
383
|
+
const res = await fetch(url, {
|
|
384
|
+
method: "POST",
|
|
385
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
386
|
+
body: new URLSearchParams({
|
|
387
|
+
grant_type: "refresh_token",
|
|
388
|
+
client_id: clientId,
|
|
389
|
+
refresh_token: refreshToken
|
|
390
|
+
}).toString()
|
|
391
|
+
});
|
|
392
|
+
if (!res.ok) {
|
|
393
|
+
throw new Error(`Token refresh failed: ${res.status}`);
|
|
394
|
+
}
|
|
395
|
+
const data = await res.json();
|
|
396
|
+
return {
|
|
397
|
+
accessToken: data.access_token,
|
|
398
|
+
refreshToken: data.refresh_token,
|
|
399
|
+
expiresIn: data.expires_in ?? 3600
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
async function getValidToken(skillsetName, baseUrl) {
|
|
403
|
+
migrateLegacyCredentials();
|
|
404
|
+
const config = loadConfig();
|
|
405
|
+
const name = skillsetName ?? config.active;
|
|
406
|
+
if (!name || !(name in config.skillsets)) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const creds = config.skillsets[name];
|
|
410
|
+
const resolvedBaseUrl = baseUrl ?? process.env.STEPPER_URL ?? creds.baseUrl;
|
|
411
|
+
if (creds.baseUrl !== resolvedBaseUrl) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const expiresAt = new Date(creds.expiresAt).getTime();
|
|
415
|
+
const now = Date.now();
|
|
416
|
+
const bufferSeconds = 60;
|
|
417
|
+
if (expiresAt > now + bufferSeconds * 1e3) {
|
|
418
|
+
return {
|
|
419
|
+
token: creds.accessToken,
|
|
420
|
+
baseUrl: creds.baseUrl,
|
|
421
|
+
skillsetName: name
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (!creds.clientId || !creds.refreshToken) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const refreshed = await refreshAccessToken(
|
|
429
|
+
creds.baseUrl,
|
|
430
|
+
creds.clientId,
|
|
431
|
+
creds.refreshToken
|
|
432
|
+
);
|
|
433
|
+
const newCreds = {
|
|
434
|
+
...creds,
|
|
435
|
+
accessToken: refreshed.accessToken,
|
|
436
|
+
refreshToken: refreshed.refreshToken,
|
|
437
|
+
expiresAt: new Date(
|
|
438
|
+
Date.now() + refreshed.expiresIn * 1e3
|
|
439
|
+
).toISOString()
|
|
440
|
+
};
|
|
441
|
+
saveCredentials(name, newCreds);
|
|
442
|
+
return {
|
|
443
|
+
token: newCreds.accessToken,
|
|
444
|
+
baseUrl: creds.baseUrl,
|
|
445
|
+
skillsetName: name
|
|
446
|
+
};
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/cli.ts
|
|
453
|
+
var __dirname = path2.dirname(fileURLToPath(import.meta.url));
|
|
454
|
+
var pkg = createRequire(import.meta.url)(
|
|
455
|
+
path2.join(__dirname, "..", "package.json")
|
|
456
|
+
);
|
|
457
|
+
var VERSION = pkg.version ?? "0.0.0";
|
|
458
|
+
function readStdin() {
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
const chunks = [];
|
|
461
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
462
|
+
process.stdin.on(
|
|
463
|
+
"end",
|
|
464
|
+
() => resolve(Buffer.concat(chunks).toString("utf-8"))
|
|
465
|
+
);
|
|
466
|
+
process.stdin.on("error", reject);
|
|
467
|
+
if (process.stdin.isTTY) {
|
|
468
|
+
resolve("");
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
function parseArgs(argv) {
|
|
473
|
+
const flags = {};
|
|
474
|
+
const boolFlags = {};
|
|
475
|
+
const positional = [];
|
|
476
|
+
for (let i = 0; i < argv.length; i++) {
|
|
477
|
+
const arg = argv[i];
|
|
478
|
+
if (arg === "--token" && i + 1 < argv.length) {
|
|
479
|
+
flags.token = argv[++i];
|
|
480
|
+
} else if (arg === "--base-url" && i + 1 < argv.length) {
|
|
481
|
+
flags.baseUrl = argv[++i];
|
|
482
|
+
} else if (arg === "--skillset" && i + 1 < argv.length) {
|
|
483
|
+
flags.skillset = argv[++i];
|
|
484
|
+
} else if ((arg === "--input" || arg === "-i") && i + 1 < argv.length) {
|
|
485
|
+
flags.input = argv[++i];
|
|
486
|
+
} else if (arg === "--search" && i + 1 < argv.length) {
|
|
487
|
+
flags.search = argv[++i];
|
|
488
|
+
} else if (arg === "--cursor" && i + 1 < argv.length) {
|
|
489
|
+
flags.cursor = argv[++i];
|
|
490
|
+
} else if (arg === "--call") {
|
|
491
|
+
boolFlags.call = true;
|
|
492
|
+
} else if (arg === "--verbose") {
|
|
493
|
+
boolFlags.verbose = true;
|
|
494
|
+
} else if (arg === "--options" && i + 1 < argv.length) {
|
|
495
|
+
flags.options = argv[++i];
|
|
496
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
497
|
+
positional.unshift("help");
|
|
498
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
499
|
+
positional.unshift("version");
|
|
500
|
+
} else {
|
|
501
|
+
positional.push(arg);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
subcommand: positional[0] ?? "help",
|
|
506
|
+
args: positional.slice(1),
|
|
507
|
+
token: flags.token,
|
|
508
|
+
baseUrl: flags.baseUrl,
|
|
509
|
+
skillset: flags.skillset,
|
|
510
|
+
input: flags.input,
|
|
511
|
+
search: flags.search,
|
|
512
|
+
cursor: flags.cursor,
|
|
513
|
+
call: boolFlags.call ?? false,
|
|
514
|
+
verbose: boolFlags.verbose ?? false,
|
|
515
|
+
options: flags.options
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function toToolName(service, action) {
|
|
519
|
+
return `${service}.${action}`;
|
|
520
|
+
}
|
|
521
|
+
function formatToolsForList(tools, verbose) {
|
|
522
|
+
if (verbose) {
|
|
523
|
+
return tools;
|
|
524
|
+
}
|
|
525
|
+
const out = {};
|
|
526
|
+
for (const tool of tools) {
|
|
527
|
+
out[tool.service] = [...out[tool.service] || [], tool.action];
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
function printUsage() {
|
|
532
|
+
console.error(`stpr v${VERSION}
|
|
533
|
+
|
|
534
|
+
Usage: stpr <command> [options]
|
|
535
|
+
|
|
536
|
+
Commands:
|
|
537
|
+
version Show version
|
|
538
|
+
login Log in via OAuth (opens browser). Named after the skillset from Stepper
|
|
539
|
+
logout [name] Log out and remove a skillset. Omit name to remove all skillsets.
|
|
540
|
+
profiles List all stored skillsets
|
|
541
|
+
use <name> Switch active skillset
|
|
542
|
+
whoami Show active skillset and server info
|
|
543
|
+
list [<service>] List available skills (optionally for a service). Use --verbose for inputSchema.
|
|
544
|
+
<service> List available skills for that service. Use --verbose for inputSchema.
|
|
545
|
+
|
|
546
|
+
<service> <action> (-i <json> | stdin)
|
|
547
|
+
Get the current parameters for a skill (default, for dynamic parameters)
|
|
548
|
+
<service> <action> --call (-i <json> | stdin)
|
|
549
|
+
Call a skill (JSON via --input, -i, or stdin)
|
|
550
|
+
<service> <action> --options <parameter> (--search <query> --cursor <cursor> -i <json>)
|
|
551
|
+
Fetch dynamic dropdown options for a parameter
|
|
552
|
+
|
|
553
|
+
Options:
|
|
554
|
+
--token <token> Auth token (or set STEPPER_SKILL_TOKEN env var)
|
|
555
|
+
--base-url <url> Override base URL (or set STEPPER_URL env var)
|
|
556
|
+
--skillset <name> Use a specific skill set
|
|
557
|
+
--call Execute the skill (default is to fetch parameters only)
|
|
558
|
+
--verbose Include inputSchema when listing skills
|
|
559
|
+
-i, --input <json> JSON input for call, parameters or options (alternative to stdin)
|
|
560
|
+
--search <query> Search query for dynamic dropdown options
|
|
561
|
+
--cursor <cursor> Pagination cursor for dynamic dropdown options
|
|
562
|
+
-h, --help Show this help message
|
|
563
|
+
-v, --version Show version
|
|
564
|
+
|
|
565
|
+
Examples:
|
|
566
|
+
|
|
567
|
+
Auth (optional, can use STEPPER_SKILL_TOKEN env var, or --token <token> from https://app.stepper.io/flow/skill-sets):
|
|
568
|
+
stpr login
|
|
569
|
+
|
|
570
|
+
List available skills:
|
|
571
|
+
stpr list
|
|
572
|
+
stpr stripe
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
Load parameters:
|
|
576
|
+
By default, requesting a skill returns the current parameters only; it does
|
|
577
|
+
not call the skill. Use --call to execute the action.
|
|
578
|
+
|
|
579
|
+
skillset google-sheets add_row -i '{"spreadsheet_id": "abc123"}'
|
|
580
|
+
|
|
581
|
+
Call a skill:
|
|
582
|
+
skillset stripe create_customer --call -i '{"email": "test@example.com"}'
|
|
583
|
+
|
|
584
|
+
Dynamic dropdown options:
|
|
585
|
+
Some actions have dynamic dropdown options, which change depending on the
|
|
586
|
+
value of other parameters. You can request the current dropdown options for
|
|
587
|
+
a skill by using the --options flag. This will return the current dropdown
|
|
588
|
+
options only, it will not call the skill. Dynamic dropdowns are also often
|
|
589
|
+
searchable and paginated via a cursor.
|
|
590
|
+
|
|
591
|
+
stpr google-sheets addRow --options worksheet_id -i '{"spreadsheet_id": "abc123"}'
|
|
592
|
+
stpr google-sheets addRow --options worksheet_id --search "Sheet" --cursor "next_page"
|
|
593
|
+
|
|
594
|
+
Profiles:
|
|
595
|
+
Multiple skill sets stored in ~/.config/stepper-skillsets/
|
|
596
|
+
Precedence: --token > STEPPER_SKILL_TOKEN > --skillset > active skill set`);
|
|
597
|
+
}
|
|
598
|
+
function die(message) {
|
|
599
|
+
process.stderr.write(`\x1B[31m[Error] ${message} \x1B[0m
|
|
600
|
+
`);
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
async function main() {
|
|
604
|
+
const {
|
|
605
|
+
subcommand,
|
|
606
|
+
args,
|
|
607
|
+
token: flagToken,
|
|
608
|
+
baseUrl,
|
|
609
|
+
skillset: flagSkillset,
|
|
610
|
+
input: inputFlag,
|
|
611
|
+
search: searchFlag,
|
|
612
|
+
cursor: cursorFlag,
|
|
613
|
+
call: callFlag,
|
|
614
|
+
verbose: verboseFlag,
|
|
615
|
+
options: optionsFlag
|
|
616
|
+
} = parseArgs(process.argv.slice(2));
|
|
617
|
+
if (subcommand === "help") {
|
|
618
|
+
printUsage();
|
|
619
|
+
process.exit(0);
|
|
620
|
+
}
|
|
621
|
+
if (subcommand === "version" || subcommand === "-v" || subcommand === "--version") {
|
|
622
|
+
console.log(VERSION);
|
|
623
|
+
process.exit(0);
|
|
624
|
+
}
|
|
625
|
+
if (subcommand === "login") {
|
|
626
|
+
try {
|
|
627
|
+
process.stderr.write("Opening browser for authentication...\n");
|
|
628
|
+
const { name } = await runLoginFlow(baseUrl);
|
|
629
|
+
process.stderr.write(
|
|
630
|
+
`Logged in successfully. Credentials saved as "${name}" in `
|
|
631
|
+
);
|
|
632
|
+
process.stderr.write(`${getConfigPathForDisplay()}
|
|
633
|
+
`);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
die(err instanceof Error ? err.message : String(err));
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (subcommand === "logout") {
|
|
640
|
+
const name = args[0];
|
|
641
|
+
if (name) {
|
|
642
|
+
const removed = deleteSkillset(name);
|
|
643
|
+
process.stderr.write(
|
|
644
|
+
removed ? `Removed skillset "${name}".
|
|
645
|
+
` : `Skillset "${name}" not found.
|
|
646
|
+
`
|
|
647
|
+
);
|
|
648
|
+
} else {
|
|
649
|
+
const count = deleteAllSkillsets();
|
|
650
|
+
if (count > 0) {
|
|
651
|
+
process.stderr.write(
|
|
652
|
+
`Removed ${count} skillset${count > 1 ? "s" : ""}.
|
|
653
|
+
`
|
|
654
|
+
);
|
|
655
|
+
} else {
|
|
656
|
+
process.stderr.write("No skillset to remove.\n");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (subcommand === "use") {
|
|
662
|
+
const name = args[0];
|
|
663
|
+
if (!name) {
|
|
664
|
+
die("Usage: skillset use <name>");
|
|
665
|
+
}
|
|
666
|
+
const ok = setActiveSkillset(name);
|
|
667
|
+
if (!ok) {
|
|
668
|
+
die(`Skillset "${name}" not found. Run "skillset profiles" to list.`);
|
|
669
|
+
}
|
|
670
|
+
process.stderr.write(`Switched to skillset "${name}".
|
|
671
|
+
`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (subcommand === "whoami") {
|
|
675
|
+
const skillsetName2 = flagSkillset ?? getActiveSkillset();
|
|
676
|
+
if (!skillsetName2) {
|
|
677
|
+
die('No active skillset. Run "stpr login" or "stpr use <name>".');
|
|
678
|
+
}
|
|
679
|
+
const stored = await getValidToken(skillsetName2);
|
|
680
|
+
if (!stored) {
|
|
681
|
+
const creds = getCredentials(skillsetName2);
|
|
682
|
+
if (!creds) {
|
|
683
|
+
die(`Skillset "${skillsetName2}" not found.`);
|
|
684
|
+
}
|
|
685
|
+
die(
|
|
686
|
+
`Skillset "${skillsetName2}" has expired or invalid token. Run "skills login" to re-authenticate.`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const client2 = new StepperClient(stored.token, stored.baseUrl);
|
|
690
|
+
try {
|
|
691
|
+
const serverInfo = await client2.getServerInfo();
|
|
692
|
+
console.log(
|
|
693
|
+
JSON.stringify(
|
|
694
|
+
{
|
|
695
|
+
skillSet: stored.skillsetName,
|
|
696
|
+
baseUrl: stored.baseUrl,
|
|
697
|
+
serverName: serverInfo.name,
|
|
698
|
+
serverVersion: serverInfo.version
|
|
699
|
+
},
|
|
700
|
+
null,
|
|
701
|
+
2
|
|
702
|
+
)
|
|
703
|
+
);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
console.log(
|
|
706
|
+
JSON.stringify(
|
|
707
|
+
{
|
|
708
|
+
skillSet: stored.skillsetName,
|
|
709
|
+
baseUrl: stored.baseUrl,
|
|
710
|
+
error: err instanceof Error ? err.message : String(err)
|
|
711
|
+
},
|
|
712
|
+
null,
|
|
713
|
+
2
|
|
714
|
+
)
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (subcommand === "profiles" || subcommand === "skillsets") {
|
|
720
|
+
const sets = listSkillsets();
|
|
721
|
+
if (sets.length === 0) {
|
|
722
|
+
process.stderr.write(
|
|
723
|
+
'No skillset configured. Run "skillset login" to add one.\n'
|
|
724
|
+
);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
for (const s of sets) {
|
|
728
|
+
const marker = s.isActive ? " (active)" : "";
|
|
729
|
+
process.stderr.write(`${s.name}${marker}: ${s.baseUrl}
|
|
730
|
+
`);
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
let token = flagToken ?? process.env.STEPPER_SKILL_TOKEN;
|
|
735
|
+
let resolvedBaseUrl = baseUrl ?? process.env.STEPPER_URL;
|
|
736
|
+
const skillsetName = flagSkillset ?? getActiveSkillset();
|
|
737
|
+
if (!token) {
|
|
738
|
+
const stored = await getValidToken(
|
|
739
|
+
skillsetName ?? void 0,
|
|
740
|
+
resolvedBaseUrl
|
|
741
|
+
);
|
|
742
|
+
if (stored) {
|
|
743
|
+
token = stored.token;
|
|
744
|
+
resolvedBaseUrl = stored.baseUrl;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (!token) {
|
|
748
|
+
die(
|
|
749
|
+
'No token provided. Run "skillset login" or use --token or set STEPPER_SKILL_TOKEN env var.'
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
const client = new StepperClient(token, resolvedBaseUrl);
|
|
753
|
+
await client.initialize();
|
|
754
|
+
if (subcommand === "list") {
|
|
755
|
+
const service2 = args[0];
|
|
756
|
+
const tools = await client.listTools({ service: service2 ?? void 0 });
|
|
757
|
+
const formatted = formatToolsForList(tools, verboseFlag);
|
|
758
|
+
console.log(JSON.stringify(formatted, null, 2));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const service = subcommand;
|
|
762
|
+
const action = args[0];
|
|
763
|
+
if (service.startsWith("--")) {
|
|
764
|
+
printUsage();
|
|
765
|
+
process.exit(1);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (!action) {
|
|
769
|
+
const tools = await client.listTools({ service });
|
|
770
|
+
const formatted = verboseFlag ? formatToolsForList(tools, verboseFlag) : tools.map(({ action: action2 }) => action2);
|
|
771
|
+
console.log(JSON.stringify(formatted, null, 2));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const toolName = toToolName(service, action);
|
|
775
|
+
const rawInput = inputFlag ?? await readStdin();
|
|
776
|
+
let input = {};
|
|
777
|
+
if (rawInput.trim()) {
|
|
778
|
+
try {
|
|
779
|
+
input = JSON.parse(rawInput);
|
|
780
|
+
} catch {
|
|
781
|
+
die("Invalid JSON input.");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (optionsFlag) {
|
|
785
|
+
const result2 = await client.getParameterOptions(toolName, optionsFlag, {
|
|
786
|
+
currentParameterValues: input,
|
|
787
|
+
search: searchFlag,
|
|
788
|
+
cursor: cursorFlag
|
|
789
|
+
});
|
|
790
|
+
console.log(JSON.stringify(result2, null, 2));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (callFlag) {
|
|
794
|
+
const result2 = await client.callTool(toolName, input);
|
|
795
|
+
if (result2.isError) {
|
|
796
|
+
die(result2.content.map((c) => c.text).join("\n"));
|
|
797
|
+
}
|
|
798
|
+
console.log(result2.content.map((c) => c.text ?? "").join("\n"));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const result = await client.getToolParams(toolName, input);
|
|
802
|
+
if (result.isError) {
|
|
803
|
+
die(result.content?.map((c) => c.text).join("\n") ?? "Unknown error");
|
|
804
|
+
}
|
|
805
|
+
console.log(JSON.stringify(result, null, 2));
|
|
806
|
+
}
|
|
807
|
+
main().then(() => process.exit(0)).catch((err) => {
|
|
808
|
+
console.error(`Error: ${err.message}`);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stpr",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI for Stepper skill sets",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"bin": {
|
|
10
|
+
"stpr": "./dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"tsup": "^8.0.0",
|
|
21
|
+
"typescript": "^5.4.0"
|
|
22
|
+
}
|
|
23
|
+
}
|