@spendgraph/cli 0.2.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 +149 -0
- package/dist/args.d.ts +13 -0
- package/dist/args.js +44 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +78 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +135 -0
- package/dist/commands/dashboard.d.ts +11 -0
- package/dist/commands/dashboard.js +236 -0
- package/dist/commands/prompts.d.ts +3 -0
- package/dist/commands/prompts.js +133 -0
- package/dist/commands/skills.d.ts +8 -0
- package/dist/commands/skills.js +68 -0
- package/dist/commands/tools.d.ts +3 -0
- package/dist/commands/tools.js +70 -0
- package/dist/connect.d.ts +19 -0
- package/dist/connect.js +38 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +89 -0
- package/dist/env.d.ts +42 -0
- package/dist/env.js +69 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +3 -0
- package/dist/help.d.ts +5 -0
- package/dist/help.js +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/output.d.ts +8 -0
- package/dist/output.js +56 -0
- package/dist/registry.d.ts +7 -0
- package/dist/registry.js +12 -0
- package/dist/skills.d.ts +11 -0
- package/dist/skills.js +200 -0
- package/dist/store.d.ts +15 -0
- package/dist/store.js +47 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.js +1 -0
- package/package.json +55 -0
package/dist/context.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { UsageError } from "./errors.js";
|
|
3
|
+
/** Builds the `Ctx` a command runs against. `sg` is null for a command that stays local. */
|
|
4
|
+
export function context(sg, parsed, positional, loaded) {
|
|
5
|
+
const flag = (name) => {
|
|
6
|
+
const value = parsed.flags[name];
|
|
7
|
+
return typeof value === "string" ? value : undefined;
|
|
8
|
+
};
|
|
9
|
+
const ctx = {
|
|
10
|
+
loaded,
|
|
11
|
+
get sg() {
|
|
12
|
+
if (!sg)
|
|
13
|
+
throw new UsageError("That command does not reach the API.");
|
|
14
|
+
return sg;
|
|
15
|
+
},
|
|
16
|
+
parsed,
|
|
17
|
+
arg: (index) => positional[index],
|
|
18
|
+
need(index, name) {
|
|
19
|
+
const value = positional[index];
|
|
20
|
+
if (value === undefined)
|
|
21
|
+
throw new UsageError(`Missing <${name}>.`);
|
|
22
|
+
return value;
|
|
23
|
+
},
|
|
24
|
+
flag,
|
|
25
|
+
bool: (name) => parsed.flags[name] === true || parsed.flags[name] === "true",
|
|
26
|
+
num(name) {
|
|
27
|
+
const raw = flag(name);
|
|
28
|
+
if (raw === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
const value = Number(raw);
|
|
31
|
+
if (!Number.isFinite(value))
|
|
32
|
+
throw new UsageError(`--${name} must be a number.`);
|
|
33
|
+
return value;
|
|
34
|
+
},
|
|
35
|
+
list(name) {
|
|
36
|
+
const raw = flag(name);
|
|
37
|
+
return raw === undefined ? undefined : raw.split(",").map((one) => one.trim());
|
|
38
|
+
},
|
|
39
|
+
project() {
|
|
40
|
+
const id = flag("project") ?? loaded.env.SPENDGRAPH_PROJECT;
|
|
41
|
+
if (!id) {
|
|
42
|
+
throw new UsageError("Set --project or SPENDGRAPH_PROJECT, or put projectId in --file.");
|
|
43
|
+
}
|
|
44
|
+
return id;
|
|
45
|
+
},
|
|
46
|
+
scope() {
|
|
47
|
+
const id = flag("project") ?? loaded.env.SPENDGRAPH_PROJECT;
|
|
48
|
+
return id ? { project: id } : {};
|
|
49
|
+
},
|
|
50
|
+
body(fromFlags) {
|
|
51
|
+
const base = fileBody(flag("file"));
|
|
52
|
+
const given = Object.fromEntries(Object.entries(fromFlags).filter(([, value]) => value !== undefined));
|
|
53
|
+
return { ...base, ...given };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return ctx;
|
|
57
|
+
}
|
|
58
|
+
function fileBody(path) {
|
|
59
|
+
if (!path)
|
|
60
|
+
return {};
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = readFileSync(path, "utf8");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new UsageError(`Could not read --file ${path}.`);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(text);
|
|
70
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
71
|
+
throw new Error("not an object");
|
|
72
|
+
}
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new UsageError(`--file ${path} is not a JSON object.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A save body with its `projectId` settled.
|
|
81
|
+
*
|
|
82
|
+
* `--file` carrying one is enough on its own, so a definition checked into a
|
|
83
|
+
* repo applies without the project being named twice.
|
|
84
|
+
*/
|
|
85
|
+
export function scoped(ctx, body) {
|
|
86
|
+
return typeof body.projectId === "string" && body.projectId
|
|
87
|
+
? body
|
|
88
|
+
: { ...body, projectId: ctx.project() };
|
|
89
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** The environment the credentials are read from. */
|
|
2
|
+
export type Env = Record<string, string | undefined>;
|
|
3
|
+
/**
|
|
4
|
+
* Parses a `.env`: `KEY=value`, one per line.
|
|
5
|
+
*
|
|
6
|
+
* `export` prefixes, `#` comments, blank lines and surrounding quotes are all
|
|
7
|
+
* accepted, because every one of them appears in a file somebody wrote by hand.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseEnv(text: string): Env;
|
|
10
|
+
/** The nearest `.env` at or above `from`, or undefined where there is none. */
|
|
11
|
+
export declare function findEnvFile(from: string): string | undefined;
|
|
12
|
+
/** Which layer a value came from. */
|
|
13
|
+
export type Source = "environment" | ".env" | "config";
|
|
14
|
+
/** Where the values came from, so `config show` can say. */
|
|
15
|
+
export interface Loaded {
|
|
16
|
+
env: Env;
|
|
17
|
+
/** The `.env` that was read, where one was found. */
|
|
18
|
+
file?: string;
|
|
19
|
+
/** The global config that was read, where it exists. */
|
|
20
|
+
config?: string;
|
|
21
|
+
source: Record<string, Source>;
|
|
22
|
+
}
|
|
23
|
+
/** What `loadEnv` layers under the real environment. */
|
|
24
|
+
export interface Layers {
|
|
25
|
+
cwd: string;
|
|
26
|
+
base: Env;
|
|
27
|
+
/** `--env-file`, which replaces the upward search. */
|
|
28
|
+
explicit?: string;
|
|
29
|
+
/** The global config file, read last and beaten by everything. */
|
|
30
|
+
store?: {
|
|
31
|
+
path: string;
|
|
32
|
+
values: Env;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The environment a command runs against, layered.
|
|
37
|
+
*
|
|
38
|
+
* Highest wins: the real environment, then the nearest `.env`, then the global
|
|
39
|
+
* config. A variable already exported is never quietly overruled by a file, and
|
|
40
|
+
* a project's `.env` beats a global default the same way a local git config does.
|
|
41
|
+
*/
|
|
42
|
+
export declare function loadEnv(layers: Layers): Loaded;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { UsageError } from "./errors.js";
|
|
4
|
+
const QUOTED = /^(['"])([\s\S]*)\1$/;
|
|
5
|
+
/**
|
|
6
|
+
* Parses a `.env`: `KEY=value`, one per line.
|
|
7
|
+
*
|
|
8
|
+
* `export` prefixes, `#` comments, blank lines and surrounding quotes are all
|
|
9
|
+
* accepted, because every one of them appears in a file somebody wrote by hand.
|
|
10
|
+
*/
|
|
11
|
+
export function parseEnv(text) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
14
|
+
const line = raw.trim().replace(/^export\s+/, "");
|
|
15
|
+
if (!line || line.startsWith("#"))
|
|
16
|
+
continue;
|
|
17
|
+
const at = line.indexOf("=");
|
|
18
|
+
if (at < 1)
|
|
19
|
+
continue;
|
|
20
|
+
const key = line.slice(0, at).trim();
|
|
21
|
+
const value = line.slice(at + 1).trim();
|
|
22
|
+
const quoted = QUOTED.exec(value);
|
|
23
|
+
out[key] = quoted ? quoted[2] : value.replace(/\s+#.*$/, "");
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/** The nearest `.env` at or above `from`, or undefined where there is none. */
|
|
28
|
+
export function findEnvFile(from) {
|
|
29
|
+
let dir = resolve(from);
|
|
30
|
+
for (;;) {
|
|
31
|
+
const candidate = join(dir, ".env");
|
|
32
|
+
if (existsSync(candidate))
|
|
33
|
+
return candidate;
|
|
34
|
+
const parent = dirname(dir);
|
|
35
|
+
if (parent === dir)
|
|
36
|
+
return undefined;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The environment a command runs against, layered.
|
|
42
|
+
*
|
|
43
|
+
* Highest wins: the real environment, then the nearest `.env`, then the global
|
|
44
|
+
* config. A variable already exported is never quietly overruled by a file, and
|
|
45
|
+
* a project's `.env` beats a global default the same way a local git config does.
|
|
46
|
+
*/
|
|
47
|
+
export function loadEnv(layers) {
|
|
48
|
+
const { cwd, base, explicit, store } = layers;
|
|
49
|
+
const file = explicit ? resolve(explicit) : findEnvFile(cwd);
|
|
50
|
+
if (explicit && !existsSync(explicit))
|
|
51
|
+
throw new UsageError(`No such --env-file ${explicit}.`);
|
|
52
|
+
const dotenv = file ? parseEnv(readFileSync(file, "utf8")) : {};
|
|
53
|
+
const stored = store?.values ?? {};
|
|
54
|
+
const source = {};
|
|
55
|
+
for (const key of Object.keys(stored))
|
|
56
|
+
source[key] = "config";
|
|
57
|
+
for (const key of Object.keys(dotenv))
|
|
58
|
+
source[key] = ".env";
|
|
59
|
+
for (const key of Object.keys(base)) {
|
|
60
|
+
if (base[key] !== undefined)
|
|
61
|
+
source[key] = "environment";
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
env: { ...stored, ...dotenv, ...base },
|
|
65
|
+
...(file ? { file } : {}),
|
|
66
|
+
...(store && Object.keys(stored).length ? { config: store.path } : {}),
|
|
67
|
+
source,
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/help.d.ts
ADDED
package/dist/help.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { GROUPS } from "./registry.js";
|
|
2
|
+
const pad = (text, width) => text.padEnd(width);
|
|
3
|
+
/** What `sg` alone prints: the groups, and how to prove who you are. */
|
|
4
|
+
export function overview() {
|
|
5
|
+
const width = Math.max(...GROUPS.map((group) => group.name.length));
|
|
6
|
+
return [
|
|
7
|
+
"sg — drive the spendgraph dashboard from a terminal",
|
|
8
|
+
"",
|
|
9
|
+
"Usage: sg <group> <command> [args] [flags]",
|
|
10
|
+
"",
|
|
11
|
+
"Groups:",
|
|
12
|
+
...GROUPS.map((group) => ` ${pad(group.name, width)} ${group.summary}`),
|
|
13
|
+
"",
|
|
14
|
+
"Credentials:",
|
|
15
|
+
" SPENDGRAPH_BASE_URL where your app is deployed",
|
|
16
|
+
" SPENDGRAPH_API_KEY an sg_ key: prompts, tools, usage and stats",
|
|
17
|
+
" SPENDGRAPH_SESSION a dashboard session: keys, projects, pricing, credentials",
|
|
18
|
+
" SPENDGRAPH_PROJECT the default --project",
|
|
19
|
+
"",
|
|
20
|
+
"Flags on every command:",
|
|
21
|
+
" --file <path> a JSON body; flags given alongside it win",
|
|
22
|
+
" --json print the reply as JSON rather than a table",
|
|
23
|
+
" --project <id> the project to act in",
|
|
24
|
+
"",
|
|
25
|
+
"Run `sg <group>` to list its commands.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
/** What `sg <group>` prints. */
|
|
29
|
+
export function groupHelp(group) {
|
|
30
|
+
const width = Math.max(...group.commands.map((command) => command.name.length));
|
|
31
|
+
return [
|
|
32
|
+
`sg ${group.name} — ${group.summary}`,
|
|
33
|
+
"",
|
|
34
|
+
...group.commands.map((command) => ` ${pad(command.name, width)} ${command.summary}${command.usage
|
|
35
|
+
? `\n ${pad("", width)} usage: sg ${group.name} ${command.name} ${command.usage}`
|
|
36
|
+
: ""}`),
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a reply for a terminal, or as JSON where `--json` asked for it.
|
|
3
|
+
*
|
|
4
|
+
* A list of flat rows becomes a table because that is what a person reads;
|
|
5
|
+
* anything else stays JSON rather than being flattened into a shape that hides
|
|
6
|
+
* a nested field.
|
|
7
|
+
*/
|
|
8
|
+
export declare function render(value: unknown, json: boolean): string;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const MAX_CELL = 48;
|
|
2
|
+
const isRow = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
function rows(value) {
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return value.every(isRow) ? value : undefined;
|
|
6
|
+
if (!isRow(value))
|
|
7
|
+
return undefined;
|
|
8
|
+
const arrays = Object.values(value).filter(Array.isArray);
|
|
9
|
+
if (arrays.length !== 1)
|
|
10
|
+
return undefined;
|
|
11
|
+
const only = arrays[0];
|
|
12
|
+
return only.every(isRow) ? only : undefined;
|
|
13
|
+
}
|
|
14
|
+
function cell(value) {
|
|
15
|
+
if (value === null || value === undefined)
|
|
16
|
+
return "";
|
|
17
|
+
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
18
|
+
const flat = text.replace(/\s+/g, " ");
|
|
19
|
+
return flat.length > MAX_CELL ? `${flat.slice(0, MAX_CELL - 1)}…` : flat;
|
|
20
|
+
}
|
|
21
|
+
const MAX_COLUMNS = 8;
|
|
22
|
+
const blob = (list, column) => list.some((row) => typeof row[column] === "object" && row[column] !== null);
|
|
23
|
+
function table(list) {
|
|
24
|
+
const every = [...new Set(list.flatMap((row) => Object.keys(row)))];
|
|
25
|
+
const readable = every.filter((column) => !blob(list, column));
|
|
26
|
+
const columns = (readable.length ? readable : every).slice(0, MAX_COLUMNS);
|
|
27
|
+
const hidden = every.length - columns.length;
|
|
28
|
+
const widths = columns.map((column) => Math.max(column.length, ...list.map((row) => cell(row[column]).length)));
|
|
29
|
+
const line = (cells) => cells
|
|
30
|
+
.map((text, i) => text.padEnd(widths[i]))
|
|
31
|
+
.join(" ")
|
|
32
|
+
.trimEnd();
|
|
33
|
+
return [
|
|
34
|
+
line(columns),
|
|
35
|
+
line(widths.map((width) => "-".repeat(width))),
|
|
36
|
+
...list.map((row) => line(columns.map((column) => cell(row[column])))),
|
|
37
|
+
...(hidden > 0 ? [`(${hidden} more ${hidden === 1 ? "column" : "columns"} — use --json)`] : []),
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Renders a reply for a terminal, or as JSON where `--json` asked for it.
|
|
42
|
+
*
|
|
43
|
+
* A list of flat rows becomes a table because that is what a person reads;
|
|
44
|
+
* anything else stays JSON rather than being flattened into a shape that hides
|
|
45
|
+
* a nested field.
|
|
46
|
+
*/
|
|
47
|
+
export function render(value, json) {
|
|
48
|
+
if (json)
|
|
49
|
+
return JSON.stringify(value, null, 2);
|
|
50
|
+
const list = rows(value);
|
|
51
|
+
if (list?.length)
|
|
52
|
+
return table(list);
|
|
53
|
+
if (list)
|
|
54
|
+
return "(none)";
|
|
55
|
+
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
56
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Command, Group } from "./types.js";
|
|
2
|
+
/** Every group the CLI offers, in the order help lists them. */
|
|
3
|
+
export declare const GROUPS: Group[];
|
|
4
|
+
/** The group by that name, or undefined. */
|
|
5
|
+
export declare const groupNamed: (name: string) => Group | undefined;
|
|
6
|
+
/** The command by that name within a group, or undefined. */
|
|
7
|
+
export declare const commandNamed: (group: Group, name: string) => Command | undefined;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { config } from "./commands/config.js";
|
|
2
|
+
import { credentials, keys, pricing, projects, spend } from "./commands/dashboard.js";
|
|
3
|
+
import { prompts } from "./commands/prompts.js";
|
|
4
|
+
import { skillCommands } from "./commands/skills.js";
|
|
5
|
+
import { tools } from "./commands/tools.js";
|
|
6
|
+
const RESOURCES = [prompts, tools, projects, keys, credentials, pricing, spend, config];
|
|
7
|
+
/** Every group the CLI offers, in the order help lists them. */
|
|
8
|
+
export const GROUPS = [...RESOURCES, skillCommands(RESOURCES)];
|
|
9
|
+
/** The group by that name, or undefined. */
|
|
10
|
+
export const groupNamed = (name) => GROUPS.find((group) => group.name === name);
|
|
11
|
+
/** The command by that name within a group, or undefined. */
|
|
12
|
+
export const commandNamed = (group, name) => group.commands.find((command) => command.name === name);
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Group } from "./types.js";
|
|
2
|
+
/** An agent skill: the frontmatter that decides when it loads, and the body it loads. */
|
|
3
|
+
export interface Skill {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
body: string;
|
|
7
|
+
}
|
|
8
|
+
/** The skills this CLI ships, with the command reference rendered from the live registry. */
|
|
9
|
+
export declare const catalogue: (groups: Group[]) => Skill[];
|
|
10
|
+
/** One skill as the SKILL.md file an agent loads. */
|
|
11
|
+
export declare const document: (skill: Skill) => string;
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const AUTH = `## Credentials
|
|
2
|
+
|
|
3
|
+
Resolved in layers, highest first: a \`--flag\`, then the exported environment,
|
|
4
|
+
then the nearest \`.env\` searching upward from the working directory, then the
|
|
5
|
+
global config at \`~/.config/spendgraph/config.json\`.
|
|
6
|
+
|
|
7
|
+
\`\`\`sh
|
|
8
|
+
sg config set --base-url https://… --api-key sg_… # keep them globally, once
|
|
9
|
+
sg config show # what is in effect, and from which layer
|
|
10
|
+
sg config reaches # which groups those credentials open
|
|
11
|
+
\`\`\`
|
|
12
|
+
|
|
13
|
+
Once the global config is set, no \`.env\` and no exports are needed anywhere. A
|
|
14
|
+
project that wants different credentials puts them in its own \`.env\`, which
|
|
15
|
+
beats the global config the way a local git config does.
|
|
16
|
+
|
|
17
|
+
| variable | what it opens |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| \`SPENDGRAPH_BASE_URL\` | required, always |
|
|
20
|
+
| \`SPENDGRAPH_API_KEY\` | reading prompts, tools, stats and events; writing usage |
|
|
21
|
+
| \`SPENDGRAPH_SESSION\` | everything else, including **every write to a prompt or a tool** |
|
|
22
|
+
| \`SPENDGRAPH_PROJECT\` | the default \`--project\` |
|
|
23
|
+
|
|
24
|
+
**Reading is not writing.** An \`sg_\` key lists and gets prompts and tools, but
|
|
25
|
+
\`prompt create\`, \`prompt update\`, \`prompt promote\`, \`tool create\` and
|
|
26
|
+
\`tool update\` all call a route that requires a signed-in session, as does
|
|
27
|
+
everything under \`key\`, \`project\`, \`pricing\` and \`credential\`. Run
|
|
28
|
+
\`sg config reaches\` for the split on the credentials actually in effect.
|
|
29
|
+
|
|
30
|
+
There is no API-key path to the session half and adding one is not wanted:
|
|
31
|
+
\`credential\` holds provider keys and \`key\` mints API keys. A 401 there means the
|
|
32
|
+
session is missing, not that the command was wrong. Do not retry it and do not
|
|
33
|
+
look for a way around it — say what is missing.`;
|
|
34
|
+
const EXITS = `## Exit codes
|
|
35
|
+
|
|
36
|
+
| | |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| 0 | the server answered |
|
|
39
|
+
| 1 | the server refused |
|
|
40
|
+
| 2 | the command line was wrong, and **nothing was sent** |
|
|
41
|
+
|
|
42
|
+
A 2 never reached the network, so retrying it unchanged will fail identically.
|
|
43
|
+
Fix the line instead. A 1 is worth reading: the message carries the server's own
|
|
44
|
+
code.`;
|
|
45
|
+
const reference = (groups) => groups
|
|
46
|
+
.map((group) => [
|
|
47
|
+
`### ${group.name} — ${group.summary}`,
|
|
48
|
+
"",
|
|
49
|
+
...group.commands.map((command) => `- \`sg ${group.name} ${command.name}${command.usage ? ` ${command.usage}` : ""}\` — ${command.summary}`),
|
|
50
|
+
].join("\n"))
|
|
51
|
+
.join("\n\n");
|
|
52
|
+
const operating = (groups) => ({
|
|
53
|
+
name: "spendgraph-cli",
|
|
54
|
+
description: "Drive the spendgraph dashboard from a terminal with the `sg` CLI: stored prompts, tools, projects, API keys, provider credentials, model pricing and spend. Use when asked to read or change anything in spendgraph rather than in application code.",
|
|
55
|
+
body: `# Driving spendgraph with \`sg\`
|
|
56
|
+
|
|
57
|
+
\`sg\` is a front-end over \`@spendgraph/sdk\`. Every command is one API call.
|
|
58
|
+
|
|
59
|
+
\`\`\`sh
|
|
60
|
+
sg # the groups
|
|
61
|
+
sg <group> # a group's commands, with usage
|
|
62
|
+
sg <group> <command> --help
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
${AUTH}
|
|
66
|
+
|
|
67
|
+
## Flags, and \`--file\`
|
|
68
|
+
|
|
69
|
+
Simple fields are flags. Anything structured — a prompt's \`blocks\`, a tool's
|
|
70
|
+
\`args\`, a dataset — goes in \`--file\`, which takes a path to a JSON object.
|
|
71
|
+
|
|
72
|
+
A flag given alongside \`--file\` wins. That is the efficient way to change one
|
|
73
|
+
field of a stored definition without restating the rest:
|
|
74
|
+
|
|
75
|
+
\`\`\`sh
|
|
76
|
+
sg tool create --file ./tools/deeprecall.json --description "Searches the KB"
|
|
77
|
+
\`\`\`
|
|
78
|
+
|
|
79
|
+
\`projectId\` inside the file is enough on its own; pass \`--project\` only when the
|
|
80
|
+
file does not carry one.
|
|
81
|
+
|
|
82
|
+
## Reading the output
|
|
83
|
+
|
|
84
|
+
A list of flat rows prints as a table. **Pass \`--json\` whenever you intend to
|
|
85
|
+
parse the result** — the table truncates long cells and drops nested fields,
|
|
86
|
+
so parsing it will lose data:
|
|
87
|
+
|
|
88
|
+
\`\`\`sh
|
|
89
|
+
sg tool list --json | jq '.tools[] | select(.effect == "destructive") | .name'
|
|
90
|
+
\`\`\`
|
|
91
|
+
|
|
92
|
+
${EXITS}
|
|
93
|
+
|
|
94
|
+
## Working efficiently
|
|
95
|
+
|
|
96
|
+
- \`sg <group>\` is cheap and local. Read it before guessing a command name.
|
|
97
|
+
- Set \`SPENDGRAPH_PROJECT\` once rather than passing \`--project\` every time.
|
|
98
|
+
- Prefer one \`--file\` over many flags when writing a whole definition; prefer
|
|
99
|
+
flags over editing a file when changing one field.
|
|
100
|
+
- \`sg spend summary\` before \`sg spend events\`: the totals answer most questions
|
|
101
|
+
without paging through rows.
|
|
102
|
+
- Nothing here runs a model except \`sg prompt run\` and \`sg spend playground\`.
|
|
103
|
+
Every other command only reads or writes records, so it costs nothing.
|
|
104
|
+
|
|
105
|
+
## Every command
|
|
106
|
+
|
|
107
|
+
${reference(groups)}
|
|
108
|
+
`,
|
|
109
|
+
});
|
|
110
|
+
const authoring = () => ({
|
|
111
|
+
name: "spendgraph-prompt-authoring",
|
|
112
|
+
description: "Write, version and promote spendgraph stored prompts, and declare the tools a model may call, using the `sg` CLI and JSON definition files. Use when creating a new prompt or tool, changing an existing one, or promoting a prompt version to current.",
|
|
113
|
+
body: `# Authoring prompts and tools with \`sg\`
|
|
114
|
+
|
|
115
|
+
Both a prompt and a tool are a JSON object sent to a save route. Keep the object
|
|
116
|
+
in a file under version control and let \`sg\` apply it; that way the definition
|
|
117
|
+
has a history and the command line stays short.
|
|
118
|
+
|
|
119
|
+
## A prompt definition
|
|
120
|
+
|
|
121
|
+
\`\`\`json
|
|
122
|
+
{
|
|
123
|
+
"projectId": "prj_…",
|
|
124
|
+
"name": "decompose",
|
|
125
|
+
"blocks": [
|
|
126
|
+
{ "role": "system", "content": "You are a stage." },
|
|
127
|
+
{ "role": "user", "content": "{question}" }
|
|
128
|
+
],
|
|
129
|
+
"models": ["claude-sonnet-5"],
|
|
130
|
+
"temperature": 0,
|
|
131
|
+
"maxTokens": 2048
|
|
132
|
+
}
|
|
133
|
+
\`\`\`
|
|
134
|
+
|
|
135
|
+
Every prompt takes its request through \`{question}\`. Any other \`{name}\` is a
|
|
136
|
+
variable you must supply when the prompt is rendered or run.
|
|
137
|
+
|
|
138
|
+
\`\`\`sh
|
|
139
|
+
sg prompt create --file ./prompts/decompose.json
|
|
140
|
+
sg prompt update pr_123 --file ./prompts/decompose.json # writes a new version
|
|
141
|
+
sg prompt versions pr_123
|
|
142
|
+
sg prompt promote pr_123 pv_456 # make one current
|
|
143
|
+
sg prompt publish pr_123
|
|
144
|
+
\`\`\`
|
|
145
|
+
|
|
146
|
+
\`update\` writes a version; it does not make it current. **Promote is a separate
|
|
147
|
+
step**, and unchanged text is a no-op rather than a fork.
|
|
148
|
+
|
|
149
|
+
## A tool definition
|
|
150
|
+
|
|
151
|
+
\`\`\`json
|
|
152
|
+
{
|
|
153
|
+
"projectId": "prj_…",
|
|
154
|
+
"name": "deeprecall",
|
|
155
|
+
"description": "Searches the knowledge base and answers from it.",
|
|
156
|
+
"args": [
|
|
157
|
+
{ "name": "question", "type": "string", "required": true }
|
|
158
|
+
],
|
|
159
|
+
"effect": "readonly"
|
|
160
|
+
}
|
|
161
|
+
\`\`\`
|
|
162
|
+
|
|
163
|
+
\`effect\` is one of \`readonly\`, \`idempotent\`, \`destructive\`. The description is
|
|
164
|
+
what the model reads when it decides whether to call the tool, so write it for
|
|
165
|
+
that reader: say what it returns and when it is the right choice, not how it is
|
|
166
|
+
implemented.
|
|
167
|
+
|
|
168
|
+
\`\`\`sh
|
|
169
|
+
sg tool create --file ./tools/deeprecall.json
|
|
170
|
+
sg tool update tl_123 --description "Searches the knowledge base."
|
|
171
|
+
sg tool archive tl_123 # out of the list; --no-archived brings it back
|
|
172
|
+
\`\`\`
|
|
173
|
+
|
|
174
|
+
For a tool with no structured arguments, \`--arg\` avoids a file entirely. It is
|
|
175
|
+
\`name:type\` or \`name:type:required\`, comma-separated for more than one:
|
|
176
|
+
|
|
177
|
+
\`\`\`sh
|
|
178
|
+
sg tool create --name deeprecall --description "Searches the KB." \\
|
|
179
|
+
--effect readonly --arg question:string:required
|
|
180
|
+
\`\`\`
|
|
181
|
+
|
|
182
|
+
Anything richer — an enum, a per-argument description — needs \`--file\`.
|
|
183
|
+
|
|
184
|
+
## Checking the result
|
|
185
|
+
|
|
186
|
+
\`\`\`sh
|
|
187
|
+
sg prompt get pr_123 --json # the current version
|
|
188
|
+
sg prompt rollouts pr_123 # what it has been run for, and the cost
|
|
189
|
+
sg prompt cases pr_123 # the evaluation dataset
|
|
190
|
+
sg prompt put-cases pr_123 --file ./cases.json
|
|
191
|
+
\`\`\`
|
|
192
|
+
|
|
193
|
+
\`put-cases\` **replaces** the dataset rather than adding to it. Read \`cases\`
|
|
194
|
+
first if you mean to append.
|
|
195
|
+
`,
|
|
196
|
+
});
|
|
197
|
+
/** The skills this CLI ships, with the command reference rendered from the live registry. */
|
|
198
|
+
export const catalogue = (groups) => [operating(groups), authoring()];
|
|
199
|
+
/** One skill as the SKILL.md file an agent loads. */
|
|
200
|
+
export const document = (skill) => `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.body}`;
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Env } from "./env.js";
|
|
2
|
+
/** The variables the global config may hold. Nothing else is written. */
|
|
3
|
+
export declare const SETTABLE: readonly ["SPENDGRAPH_BASE_URL", "SPENDGRAPH_API_KEY", "SPENDGRAPH_SESSION", "SPENDGRAPH_PROJECT"];
|
|
4
|
+
export type Settable = (typeof SETTABLE)[number];
|
|
5
|
+
/** Where the global config lives: `$XDG_CONFIG_HOME/spendgraph/config.json`, or under `~/.config`. */
|
|
6
|
+
export declare function storePath(env?: Env): string;
|
|
7
|
+
/** What the global config holds, or nothing where there is no file. */
|
|
8
|
+
export declare function readStore(path: string): Env;
|
|
9
|
+
/**
|
|
10
|
+
* Writes the global config, readable only by its owner.
|
|
11
|
+
*
|
|
12
|
+
* It holds an API key, so the mode is set after the write as well as during it —
|
|
13
|
+
* an existing file keeps whatever mode it already had otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export declare function writeStore(path: string, values: Env): string;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { UsageError } from "./errors.js";
|
|
5
|
+
/** The variables the global config may hold. Nothing else is written. */
|
|
6
|
+
export const SETTABLE = [
|
|
7
|
+
"SPENDGRAPH_BASE_URL",
|
|
8
|
+
"SPENDGRAPH_API_KEY",
|
|
9
|
+
"SPENDGRAPH_SESSION",
|
|
10
|
+
"SPENDGRAPH_PROJECT",
|
|
11
|
+
];
|
|
12
|
+
/** Where the global config lives: `$XDG_CONFIG_HOME/spendgraph/config.json`, or under `~/.config`. */
|
|
13
|
+
export function storePath(env = process.env) {
|
|
14
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
15
|
+
return join(base, "spendgraph", "config.json");
|
|
16
|
+
}
|
|
17
|
+
/** What the global config holds, or nothing where there is no file. */
|
|
18
|
+
export function readStore(path) {
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return {};
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new UsageError(`${path} is not valid JSON. Fix or delete it.`);
|
|
27
|
+
}
|
|
28
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
29
|
+
throw new UsageError(`${path} must hold a JSON object.`);
|
|
30
|
+
}
|
|
31
|
+
return Object.fromEntries(Object.entries(parsed)
|
|
32
|
+
.filter(([key]) => SETTABLE.includes(key))
|
|
33
|
+
.map(([key, value]) => [key, String(value)]));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Writes the global config, readable only by its owner.
|
|
37
|
+
*
|
|
38
|
+
* It holds an API key, so the mode is set after the write as well as during it —
|
|
39
|
+
* an existing file keeps whatever mode it already had otherwise.
|
|
40
|
+
*/
|
|
41
|
+
export function writeStore(path, values) {
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
const kept = Object.fromEntries(Object.entries(values).filter(([, value]) => value));
|
|
44
|
+
writeFileSync(path, `${JSON.stringify(kept, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
chmodSync(path, 0o600);
|
|
46
|
+
return path;
|
|
47
|
+
}
|