@sprid/cli 0.1.5 → 0.1.7
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/CHANGELOG.md +24 -0
- package/README.md +13 -2
- package/bin.mjs +1 -1
- package/package.json +2 -2
- package/src/args.mjs +22 -1
- package/src/cli.mjs +27 -5
- package/src/clipboard.mjs +75 -0
- package/src/commands/ads.mjs +110 -0
- package/src/commands/connect.mjs +99 -26
- package/src/commands/events.mjs +60 -0
- package/src/commands/inbox.mjs +124 -0
- package/src/commands/post.mjs +2 -0
- package/src/commands/publishing.mjs +109 -0
- package/src/commands/status.mjs +9 -1
- package/src/commands/tool.mjs +59 -0
- package/src/docs/commands.mjs +33 -0
- package/src/docs/guides.generated.mjs +299 -52
- package/src/docs/help.mjs +1 -0
- package/src/telemetry.mjs +140 -0
- package/src/tools.mjs +85 -0
package/src/docs/help.mjs
CHANGED
|
@@ -7,6 +7,7 @@ export const HELP_TOPICS = {
|
|
|
7
7
|
'SPRID_PAT: personal access token; overrides ~/.sprid/credentials.json.',
|
|
8
8
|
'SPRID_URL: API address; overrides the saved address. HTTPS except on loopback.',
|
|
9
9
|
'SPRID_APP_URL: browser app address; otherwise derived from the API address.',
|
|
10
|
+
'DO_NOT_TRACK=1 or SPRID_TELEMETRY=0: send no usage telemetry, whatever sprid telemetry says.',
|
|
10
11
|
'Workspace: --workspace/-w > .sprid/app.json workspaceId > saved choice > sole workspace > local workspace slug.',
|
|
11
12
|
'Local media can pin tokenEnv in sprid.config; that token is required when configured.',
|
|
12
13
|
'Never paste credentials into a command example or an agent conversation. Connect with --key <file>.',
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Usage telemetry: after a command finishes, one small POST to the Sprid API
|
|
2
|
+
// saying which command ran, how it ended and how long it took. What is sent
|
|
3
|
+
// is exactly `commandEvent`'s fields: the command, a subcommand only when it
|
|
4
|
+
// is one this CLI documents, the exit code, an error KIND (an HTTP status or
|
|
5
|
+
// usage/network/other, never the message), the duration, the CLI and Node
|
|
6
|
+
// versions, the OS and whether it ran in CI. Never arguments, file paths,
|
|
7
|
+
// slugs, output or anything read from disk.
|
|
8
|
+
//
|
|
9
|
+
// It goes to the API the CLI is already signed in to, under the same login,
|
|
10
|
+
// and nowhere else. Signed out means nothing is sent. Off with
|
|
11
|
+
// `sprid telemetry off`, DO_NOT_TRACK=1 or SPRID_TELEMETRY=0. The first run
|
|
12
|
+
// that could send only prints the notice; nothing leaves before the person
|
|
13
|
+
// has been told.
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { COMMAND_GROUPS } from './docs/commands.mjs';
|
|
19
|
+
import { UsageError } from './args.mjs';
|
|
20
|
+
|
|
21
|
+
const SEND_TIMEOUT_MS = 1000;
|
|
22
|
+
/** Commands that report nothing: they are about the CLI itself, or run for hours. */
|
|
23
|
+
const SILENT = new Set(['help', 'version', 'completion', 'mcp', 'telemetry']);
|
|
24
|
+
|
|
25
|
+
export const NOTICE = 'Sprid CLI sends usage telemetry to your Sprid account: the command name, exit code and duration, never arguments or file contents. Turn it off with `sprid telemetry off` or DO_NOT_TRACK=1.';
|
|
26
|
+
|
|
27
|
+
export function settingsPath(env = process.env) {
|
|
28
|
+
return join(env.HOME || homedir(), '.sprid', 'telemetry.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readSettings(env = process.env) {
|
|
32
|
+
try { return JSON.parse(readFileSync(settingsPath(env), 'utf8')) ?? {}; } catch { return {}; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeSettings(settings, env = process.env) {
|
|
36
|
+
const path = settingsPath(env);
|
|
37
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
38
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const truthy = value => ['1', 'true', 'yes'].includes(String(value ?? '').trim().toLowerCase());
|
|
42
|
+
const falsy = value => ['0', 'false', 'no', 'off'].includes(String(value ?? '').trim().toLowerCase());
|
|
43
|
+
|
|
44
|
+
/** Whether telemetry is on, and which setting decided it. The environment wins over the file. */
|
|
45
|
+
export function telemetryState(env = process.env) {
|
|
46
|
+
if (truthy(env.DO_NOT_TRACK)) return { enabled: false, reason: 'DO_NOT_TRACK is set' };
|
|
47
|
+
if (falsy(env.SPRID_TELEMETRY)) return { enabled: false, reason: 'SPRID_TELEMETRY is off' };
|
|
48
|
+
const settings = readSettings(env);
|
|
49
|
+
if (settings.enabled === false) return { enabled: false, reason: 'turned off with sprid telemetry off' };
|
|
50
|
+
return { enabled: true, reason: settings.enabled === true ? 'turned on with sprid telemetry on' : 'on by default', noticed: Boolean(settings.noticedAt) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let documented;
|
|
54
|
+
/** The subcommands the help catalog names for each command, e.g. post → create, get, build... */
|
|
55
|
+
export function documentedSubcommands() {
|
|
56
|
+
if (documented) return documented;
|
|
57
|
+
documented = new Map();
|
|
58
|
+
for (const { command, usage } of COMMAND_GROUPS.flatMap(group => group.entries)) {
|
|
59
|
+
const second = usage.split(/\s+/)[2] ?? '';
|
|
60
|
+
const words = second.replace(/[[\]]/g, '').split('|').filter(word => /^[a-z][a-z0-9-]*$/.test(word));
|
|
61
|
+
if (!documented.has(command)) documented.set(command, new Set());
|
|
62
|
+
for (const word of words) documented.get(command).add(word);
|
|
63
|
+
}
|
|
64
|
+
return documented;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function subcommandOf(command, words) {
|
|
68
|
+
const first = (words ?? []).find(word => !String(word).startsWith('-'));
|
|
69
|
+
return first && documentedSubcommands().get(command)?.has(first) ? first : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function errorKind(error) {
|
|
73
|
+
if (!error) return null;
|
|
74
|
+
if (error.name === 'UsageError' || error.exitCode === 2) return 'usage';
|
|
75
|
+
if (typeof error.status === 'number') return error.status === 0 ? 'network' : error.status;
|
|
76
|
+
return 'other';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function commandEvent({ command, words, exitCode, durationMs, error, version, env = process.env, workspaceId = null, now = new Date() }) {
|
|
80
|
+
return {
|
|
81
|
+
command,
|
|
82
|
+
subcommand: subcommandOf(command, words),
|
|
83
|
+
exitCode: Math.max(0, Math.min(255, Number(exitCode) || 0)),
|
|
84
|
+
durationMs: Math.max(0, Math.round(durationMs)),
|
|
85
|
+
error: errorKind(error),
|
|
86
|
+
version,
|
|
87
|
+
platform: process.platform,
|
|
88
|
+
node: process.version,
|
|
89
|
+
ci: Boolean(env.CI && !falsy(env.CI)),
|
|
90
|
+
workspaceId,
|
|
91
|
+
at: now.toISOString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Report one finished command. Never throws and never waits longer than
|
|
97
|
+
* SEND_TIMEOUT_MS: a slow network costs the person at most a second, and a
|
|
98
|
+
* failure costs them nothing.
|
|
99
|
+
*/
|
|
100
|
+
export async function reportCommand(ctx, { command, words, exitCode, durationMs, error }) {
|
|
101
|
+
try {
|
|
102
|
+
if (SILENT.has(command)) return false;
|
|
103
|
+
const state = telemetryState(ctx.env);
|
|
104
|
+
if (!state.enabled) return false;
|
|
105
|
+
let auth;
|
|
106
|
+
try { auth = ctx.auth(); } catch { return false; }
|
|
107
|
+
if (!state.noticed) {
|
|
108
|
+
ctx.warn(` ${NOTICE}`);
|
|
109
|
+
try { writeSettings({ ...readSettings(ctx.env), noticedAt: new Date().toISOString() }, ctx.env); } catch { /* A read-only home only means the notice repeats. */ }
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const workspaceId = ctx._workspace?.id ?? auth.workspace?.id ?? null;
|
|
113
|
+
const event = commandEvent({ command, words, exitCode, durationMs, error, version: ctx.version, env: ctx.env, workspaceId });
|
|
114
|
+
const res = await ctx.fetch(`${auth.apiUrl}/api/cli/events`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
redirect: 'error',
|
|
117
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
118
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${auth.token}`, 'User-Agent': `sprid/${ctx.version}` },
|
|
119
|
+
body: JSON.stringify({ events: [event] }),
|
|
120
|
+
});
|
|
121
|
+
await res.body?.cancel?.().catch?.(() => {});
|
|
122
|
+
return res.ok;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** sprid telemetry [status|on|off] */
|
|
129
|
+
export async function telemetry(ctx) {
|
|
130
|
+
const action = ctx.positionals[0] ?? 'status';
|
|
131
|
+
if (action === 'on' || action === 'off') {
|
|
132
|
+
writeSettings({ ...readSettings(ctx.env), enabled: action === 'on', noticedAt: readSettings(ctx.env).noticedAt ?? new Date().toISOString() }, ctx.env);
|
|
133
|
+
} else if (action !== 'status') {
|
|
134
|
+
throw new UsageError(`Unknown telemetry action "${action}". Use status, on or off.`);
|
|
135
|
+
}
|
|
136
|
+
const state = telemetryState(ctx.env);
|
|
137
|
+
if (ctx.json) ctx.out({ enabled: state.enabled, reason: state.reason, settings: settingsPath(ctx.env) });
|
|
138
|
+
else ctx.print(` Telemetry is ${state.enabled ? 'on' : 'off'} (${state.reason}).${state.enabled ? ' Sent: command, exit code, duration, versions, OS. Never arguments or file contents.' : ''}`);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The server's tools, called the way an MCP client calls them. Every command
|
|
2
|
+
// built on this runs through the same handler, scopes and tenant checks as an
|
|
3
|
+
// agent's call, so the CLI cannot drift from what MCP does: REST = MCP = CLI.
|
|
4
|
+
//
|
|
5
|
+
// /api/mcp is stateless JSON-RPC, so a single POST per call is enough; no
|
|
6
|
+
// initialize handshake is needed.
|
|
7
|
+
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { UsageError } from "./args.mjs";
|
|
11
|
+
import { ApiError } from "./http.mjs";
|
|
12
|
+
|
|
13
|
+
const MCP_PATH = "/api/mcp?surface=all";
|
|
14
|
+
|
|
15
|
+
async function rpc(ctx, method, params) {
|
|
16
|
+
const res = await ctx.api().post(MCP_PATH, { jsonrpc: "2.0", id: 1, method, params });
|
|
17
|
+
if (res?.error) throw new ApiError(0, res.error.message ?? `The server refused ${method}.`);
|
|
18
|
+
return res?.result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One tool's result, exactly as its handler returned it. */
|
|
22
|
+
export async function callTool(ctx, name, args = {}) {
|
|
23
|
+
const result = await rpc(ctx, "tools/call", { name, arguments: args });
|
|
24
|
+
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
25
|
+
if (text === undefined) return result?.structuredContent ?? null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
} catch {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every tool this token's scopes allow, with its input schema. */
|
|
34
|
+
export async function listTools(ctx) {
|
|
35
|
+
return (await rpc(ctx, "tools/list", {}))?.tools ?? [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `--file args.json`, `--args '{"a":1}'`, or both (the flag wins per key). */
|
|
39
|
+
export async function toolArgs(ctx) {
|
|
40
|
+
let args = {};
|
|
41
|
+
if (typeof ctx.flags.file === "string") args = await readJson(ctx, ctx.flags.file);
|
|
42
|
+
if (typeof ctx.flags.args === "string") {
|
|
43
|
+
try {
|
|
44
|
+
args = { ...args, ...JSON.parse(ctx.flags.args) };
|
|
45
|
+
} catch {
|
|
46
|
+
throw new UsageError("--args must be a JSON object, such as --args '{\"account\":\"myapp\"}'.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return args;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function readJson(ctx, path) {
|
|
53
|
+
const full = resolve(ctx.cwd, path);
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
text = await readFile(full, "utf8");
|
|
57
|
+
} catch {
|
|
58
|
+
throw new UsageError(`Cannot read ${path}.`);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new UsageError(`${path} is not valid JSON.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A comma list flag as an array; undefined when the flag is absent. */
|
|
68
|
+
export function listFlag(value) {
|
|
69
|
+
if (typeof value !== "string") return undefined;
|
|
70
|
+
const items = value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
71
|
+
return items.length ? items : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A positive integer positional, or a usage error naming the command. */
|
|
75
|
+
export function needInt(arg, usage) {
|
|
76
|
+
const n = Number(arg);
|
|
77
|
+
if (!Number.isInteger(n) || n <= 0) throw new UsageError(usage);
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Human output for results with no bespoke renderer. */
|
|
82
|
+
export function show(ctx, result) {
|
|
83
|
+
if (ctx.json) ctx.out(result);
|
|
84
|
+
else ctx.print(JSON.stringify(result, null, 2));
|
|
85
|
+
}
|