@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 ADDED
@@ -0,0 +1,149 @@
1
+ # @spendgraph/cli
2
+
3
+ `sg` drives the spendgraph dashboard from a terminal: write a prompt, declare a
4
+ tool, mint a key, read what a project spent.
5
+
6
+ It is a front-end over `@spendgraph/sdk` and nothing else. Every command is one
7
+ SDK call, so the base URL, auth, retries and error shapes are decided in one
8
+ place and this package only decides what to call and how to print it.
9
+
10
+ ## Credentials
11
+
12
+ Two halves, and which one you hold decides what you reach.
13
+
14
+ ```sh
15
+ export SPENDGRAPH_BASE_URL=https://your-app.workers.dev
16
+ export SPENDGRAPH_API_KEY=sg_… # prompts, tools, usage, stats
17
+ export SPENDGRAPH_SESSION=… # keys, projects, pricing, credentials
18
+ export SPENDGRAPH_PROJECT=prj_… # the default --project
19
+ ```
20
+
21
+ Better, set them once and forget them:
22
+
23
+ ```sh
24
+ sg config set --base-url https://your-app.workers.dev --api-key sg_…
25
+ ```
26
+
27
+ That writes `~/.config/spendgraph/config.json` (or under `$XDG_CONFIG_HOME`),
28
+ mode `0600` because it holds a key. After it, `sg` works from any directory with
29
+ nothing exported and no `.env` anywhere.
30
+
31
+ Values resolve in layers, highest first:
32
+
33
+ | | |
34
+ | --- | --- |
35
+ | 1 | a `--flag` |
36
+ | 2 | the exported environment |
37
+ | 3 | the nearest `.env`, searching upward from the working directory |
38
+ | 4 | the global config |
39
+
40
+ So a project that needs different credentials puts them in its own `.env` and
41
+ they beat the global default, the way a local git config does. Only `.env` is
42
+ read, never `.env.local`; `--env-file <path>` replaces the search.
43
+
44
+ ```sh
45
+ sg config show # what is in effect, and which layer each value came from
46
+ sg config path # where the config and the .env in effect live
47
+ sg config reaches # which groups your credentials actually open
48
+ sg config unset SPENDGRAPH_API_KEY
49
+ sg config clear --yes
50
+ ```
51
+
52
+ `show` and `set` mask the key rather than echoing it.
53
+
54
+ **Reading is not writing.** An `sg_` key lists and gets prompts and tools and
55
+ writes usage, but every *write* to a prompt or a tool — `create`, `update`,
56
+ `promote` — calls a route that requires a signed-in session, as does everything
57
+ under `key`, `project`, `pricing` and `credential`. `sg config reaches` prints
58
+ the split for the credentials you actually hold.
59
+
60
+ There is no API-key path to the session half, deliberately: `credential` holds
61
+ provider keys and `key` mints API keys. A 401 there is the server saying the
62
+ session is missing, and `sg` says so rather than repeating the status.
63
+
64
+ Any flag beats the environment: `--base-url`, `--api-key`, `--session`,
65
+ `--project`.
66
+
67
+ ## Shape
68
+
69
+ ```sh
70
+ sg # the groups
71
+ sg tool # a group's commands
72
+ sg tool create --help # the same, with usage
73
+ ```
74
+
75
+ Simple fields are flags. Anything structured — a prompt's `blocks`, a tool's
76
+ `args`, a dataset — goes in `--file`, which takes a JSON object. Flags given
77
+ alongside `--file` win, so one field changes without restating the rest:
78
+
79
+ ```sh
80
+ sg tool create --file ./tools/deeprecall.json --description "Searches the KB"
81
+ ```
82
+
83
+ `projectId` in the file is enough on its own; `--project` is only needed when
84
+ the file does not carry one.
85
+
86
+ ## The four things you probably came for
87
+
88
+ ```sh
89
+ # declare a tool
90
+ sg tool create --name deeprecall \
91
+ --description "Searches the knowledge base and answers from it." \
92
+ --effect readonly --arg question:string:required
93
+
94
+ # change one
95
+ sg tool update tl_123 --file ./tools/deeprecall.json
96
+
97
+ # write a prompt
98
+ sg prompt create --name decompose --file ./prompts/decompose.json
99
+
100
+ # write a new version of one, then make it current
101
+ sg prompt update pr_123 --file ./prompts/decompose.json
102
+ sg prompt promote pr_123 pv_456
103
+ ```
104
+
105
+ `--arg` is `name:type` or `name:type:required`, comma-separated for more than
106
+ one. It covers a plain argument; a `FieldSpec` with an enum or a description
107
+ belongs in `--file` under `args`.
108
+
109
+ ## Output
110
+
111
+ A list of flat rows prints as a table, everything else as JSON. `--json` forces
112
+ JSON whole, which is what you want in a pipe:
113
+
114
+ ```sh
115
+ sg tool list --json | jq '.tools[] | select(.effect == "destructive")'
116
+ ```
117
+
118
+ ## Skills, for an agent
119
+
120
+ `sg` ships its own operating manual as agent skills, generated from the command
121
+ registry rather than written down beside it — a command added anywhere appears
122
+ in the reference on the next run, so it cannot go stale.
123
+
124
+ ```sh
125
+ sg skill list # what is on offer
126
+ sg skill show spendgraph-cli # the SKILL.md, on stdout
127
+ sg skill install # write them under .claude/skills
128
+ sg skill install --dir ./skills --force
129
+ ```
130
+
131
+ | skill | for |
132
+ | --- | --- |
133
+ | `spendgraph-cli` | the whole CLI: credentials, flags, output, exit codes, every command |
134
+ | `spendgraph-prompt-authoring` | writing and versioning prompts, declaring tools, the JSON shapes |
135
+
136
+ These three commands never reach the API, so they work with no credentials set —
137
+ an agent can read the manual before it has anything to authenticate with.
138
+ An existing `SKILL.md` is left alone unless `--force` says otherwise.
139
+
140
+ ## Exit codes
141
+
142
+ | | |
143
+ | --- | --- |
144
+ | 0 | the server answered |
145
+ | 1 | the server refused |
146
+ | 2 | the command line was wrong, and nothing was sent |
147
+
148
+ The difference between 1 and 2 is worth scripting against: a 2 never reached the
149
+ network, so retrying it changes nothing.
package/dist/args.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** One command line, split into the words that name a command and the flags that configure it. */
2
+ export interface Parsed {
3
+ words: string[];
4
+ flags: Record<string, string | boolean>;
5
+ }
6
+ /**
7
+ * Splits argv into words and flags.
8
+ *
9
+ * `--name value`, `--name=value` and a bare `--name` all set `name`; `--no-name`
10
+ * clears it. A value starting with a single dash is still a value, so a negative
11
+ * number reaches the command rather than being read as the next flag.
12
+ */
13
+ export declare function parse(argv: string[]): Parsed;
package/dist/args.js ADDED
@@ -0,0 +1,44 @@
1
+ const isFlag = (token) => token.startsWith("--");
2
+ /**
3
+ * Splits argv into words and flags.
4
+ *
5
+ * `--name value`, `--name=value` and a bare `--name` all set `name`; `--no-name`
6
+ * clears it. A value starting with a single dash is still a value, so a negative
7
+ * number reaches the command rather than being read as the next flag.
8
+ */
9
+ export function parse(argv) {
10
+ const words = [];
11
+ const flags = {};
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const token = argv[i];
14
+ if (token === "-h") {
15
+ flags.help = true;
16
+ continue;
17
+ }
18
+ if (!isFlag(token)) {
19
+ words.push(token);
20
+ continue;
21
+ }
22
+ const [name, inline] = split(token.slice(2));
23
+ if (inline !== undefined) {
24
+ flags[name] = inline;
25
+ continue;
26
+ }
27
+ if (name.startsWith("no-")) {
28
+ flags[name.slice(3)] = false;
29
+ continue;
30
+ }
31
+ const next = argv[i + 1];
32
+ if (next !== undefined && !isFlag(next)) {
33
+ flags[name] = next;
34
+ i++;
35
+ continue;
36
+ }
37
+ flags[name] = true;
38
+ }
39
+ return { words, flags };
40
+ }
41
+ function split(token) {
42
+ const at = token.indexOf("=");
43
+ return at === -1 ? [token, undefined] : [token.slice(0, at), token.slice(at + 1)];
44
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { type Env } from "./env.js";
2
+ /** Where the CLI writes. Injected so a test reads the output instead of the terminal. */
3
+ export interface Io {
4
+ out(text: string): void;
5
+ err(text: string): void;
6
+ }
7
+ /** What a caller may replace: where the environment comes from, and how requests are sent. */
8
+ export interface Options {
9
+ env?: Env;
10
+ fetch?: typeof fetch;
11
+ /** Where to look for a `.env`, searching upward. Left unset, none is read. */
12
+ cwd?: string;
13
+ /** The global config file. Left unset, the one under `$XDG_CONFIG_HOME` is used. */
14
+ store?: string;
15
+ }
16
+ /**
17
+ * Runs one command line and returns the exit code.
18
+ *
19
+ * 0 is a reply, 1 is the server refusing, 2 is the line being wrong — so a
20
+ * script can tell a bad request from a bad password without parsing text.
21
+ */
22
+ export declare function main(argv: string[], io: Io, opts?: Options): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ import { SpendgraphError } from "@spendgraph/sdk";
2
+ import { parse } from "./args.js";
3
+ import { connect, credentials } from "./connect.js";
4
+ import { loadEnv } from "./env.js";
5
+ import { readStore, storePath } from "./store.js";
6
+ import { context } from "./context.js";
7
+ import { UsageError } from "./errors.js";
8
+ import { groupHelp, overview } from "./help.js";
9
+ import { render } from "./output.js";
10
+ import { commandNamed, groupNamed } from "./registry.js";
11
+ const HINT = "A dashboard session is needed: for everything under key, project, pricing and credential, and for every write to a prompt or a tool. An sg_ key reads prompts and tools and writes usage. Set SPENDGRAPH_SESSION.";
12
+ /**
13
+ * Runs one command line and returns the exit code.
14
+ *
15
+ * 0 is a reply, 1 is the server refusing, 2 is the line being wrong — so a
16
+ * script can tell a bad request from a bad password without parsing text.
17
+ */
18
+ export async function main(argv, io, opts = {}) {
19
+ const parsed = parse(argv);
20
+ const [groupName, commandName, ...positional] = parsed.words;
21
+ const wantsHelp = parsed.flags.help === true;
22
+ if (!groupName || groupName === "help") {
23
+ io.out(overview());
24
+ return 0;
25
+ }
26
+ const group = groupNamed(groupName);
27
+ if (!group) {
28
+ io.err(`No such group "${groupName}". Run \`sg\` for the list.`);
29
+ return 2;
30
+ }
31
+ if (!commandName || wantsHelp) {
32
+ io.out(groupHelp(group));
33
+ return 0;
34
+ }
35
+ const command = commandNamed(group, commandName);
36
+ if (!command) {
37
+ io.err(`No such command "${groupName} ${commandName}".`);
38
+ io.err(groupHelp(group));
39
+ return 2;
40
+ }
41
+ try {
42
+ const base = opts.env ?? process.env;
43
+ const loaded = opts.cwd
44
+ ? loadEnv({
45
+ cwd: opts.cwd,
46
+ base,
47
+ explicit: flagged(parsed.flags["env-file"]),
48
+ store: store(base, flagged(parsed.flags.store) ?? opts.store),
49
+ })
50
+ : { env: base, source: {} };
51
+ const sg = command.local ? null : connect(credentials(parsed, loaded.env), opts.fetch);
52
+ const reply = await command.run(context(sg, parsed, positional, loaded));
53
+ io.out(render(reply, parsed.flags.json === true));
54
+ return 0;
55
+ }
56
+ catch (err) {
57
+ return failed(err, io);
58
+ }
59
+ }
60
+ const flagged = (value) => typeof value === "string" ? value : undefined;
61
+ function store(base, override) {
62
+ const path = override ?? storePath(base);
63
+ return { path, values: readStore(path) };
64
+ }
65
+ function failed(err, io) {
66
+ if (err instanceof UsageError) {
67
+ io.err(err.message);
68
+ return 2;
69
+ }
70
+ if (err instanceof SpendgraphError) {
71
+ io.err(`${err.status} ${err.code}: ${err.message}`);
72
+ if (err.status === 401 || err.status === 403)
73
+ io.err(HINT);
74
+ return 1;
75
+ }
76
+ io.err(err instanceof Error ? err.message : String(err));
77
+ return 1;
78
+ }
@@ -0,0 +1,3 @@
1
+ import type { Group } from "../types.js";
2
+ /** The global config, and what the CLI resolved from it, a `.env` and the environment. */
3
+ export declare const config: Group;
@@ -0,0 +1,135 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { UsageError } from "../errors.js";
3
+ import { readStore, SETTABLE, storePath, writeStore } from "../store.js";
4
+ const SECRET = new Set(["SPENDGRAPH_API_KEY", "SPENDGRAPH_SESSION"]);
5
+ const FLAGS = {
6
+ SPENDGRAPH_BASE_URL: "base-url",
7
+ SPENDGRAPH_API_KEY: "api-key",
8
+ SPENDGRAPH_SESSION: "session",
9
+ SPENDGRAPH_PROJECT: "project",
10
+ };
11
+ const mask = (value) => value.length <= 8 ? "…" : `${value.slice(0, 4)}…${value.slice(-4)}`;
12
+ const shown = (variable, value) => (SECRET.has(variable) ? mask(value) : value);
13
+ const where = (ctx) => ctx.flag("store") ?? storePath(ctx.loaded.env);
14
+ const NEEDS = {
15
+ either: "an api key or a session",
16
+ session: "a dashboard session",
17
+ key: "an api key",
18
+ };
19
+ const ACCESS = [
20
+ { group: "prompt", read: "either", write: "session" },
21
+ { group: "tool", read: "either", write: "session" },
22
+ { group: "spend", read: "either", write: "key" },
23
+ { group: "key", read: "session", write: "session" },
24
+ { group: "project", read: "session", write: "session" },
25
+ { group: "pricing", read: "session", write: "session" },
26
+ { group: "credential", read: "session", write: "session" },
27
+ ];
28
+ function set(ctx) {
29
+ const path = where(ctx);
30
+ const current = readStore(path);
31
+ const given = Object.fromEntries(SETTABLE.map((variable) => [variable, ctx.flag(FLAGS[variable])]).filter(([, v]) => v));
32
+ if (!Object.keys(given).length) {
33
+ throw new UsageError(`Give at least one of ${SETTABLE.map((v) => `--${FLAGS[v]}`).join(", ")}.`);
34
+ }
35
+ const merged = { ...current, ...given };
36
+ writeStore(path, merged);
37
+ return {
38
+ saved: Object.keys(given).map((variable) => ({
39
+ variable,
40
+ value: shown(variable, merged[variable]),
41
+ file: path,
42
+ })),
43
+ };
44
+ }
45
+ function unset(ctx) {
46
+ const path = where(ctx);
47
+ const variable = ctx.need(0, "VARIABLE").toUpperCase();
48
+ if (!SETTABLE.includes(variable)) {
49
+ throw new UsageError(`Not a setting: ${variable}. One of ${SETTABLE.join(", ")}.`);
50
+ }
51
+ const current = readStore(path);
52
+ delete current[variable];
53
+ writeStore(path, current);
54
+ return { removed: [{ variable, file: path }] };
55
+ }
56
+ /** The global config, and what the CLI resolved from it, a `.env` and the environment. */
57
+ export const config = {
58
+ name: "config",
59
+ summary: "credentials kept globally, and what the CLI resolved",
60
+ commands: [
61
+ {
62
+ name: "show",
63
+ summary: "the credentials in effect and which layer each came from",
64
+ local: true,
65
+ run: async (ctx) => ({
66
+ settings: SETTABLE.map((variable) => {
67
+ const value = ctx.loaded.env[variable];
68
+ return {
69
+ variable,
70
+ value: value ? shown(variable, value) : "(unset)",
71
+ from: value ? (ctx.loaded.source[variable] ?? "environment") : "-",
72
+ };
73
+ }),
74
+ }),
75
+ },
76
+ {
77
+ name: "set",
78
+ summary: "keep a credential globally, so no .env is needed",
79
+ local: true,
80
+ run: async (ctx) => set(ctx),
81
+ },
82
+ {
83
+ name: "unset",
84
+ summary: "drop one from the global config",
85
+ usage: "<VARIABLE>",
86
+ local: true,
87
+ run: async (ctx) => unset(ctx),
88
+ },
89
+ {
90
+ name: "path",
91
+ summary: "where the global config lives, and the .env in effect",
92
+ local: true,
93
+ run: async (ctx) => {
94
+ const path = where(ctx);
95
+ return {
96
+ files: [
97
+ { file: "config", path, exists: existsSync(path) },
98
+ { file: ".env", path: ctx.loaded.file ?? "(none found)", exists: !!ctx.loaded.file },
99
+ ],
100
+ };
101
+ },
102
+ },
103
+ {
104
+ name: "clear",
105
+ summary: "delete the global config entirely",
106
+ local: true,
107
+ run: async (ctx) => {
108
+ const path = where(ctx);
109
+ if (!ctx.bool("yes"))
110
+ throw new UsageError(`Pass --yes to delete ${path}.`);
111
+ if (existsSync(path))
112
+ rmSync(path);
113
+ return { cleared: [{ path }] };
114
+ },
115
+ },
116
+ {
117
+ name: "reaches",
118
+ summary: "which groups your credentials can read, and which they can write",
119
+ local: true,
120
+ run: async (ctx) => {
121
+ const key = !!ctx.loaded.env.SPENDGRAPH_API_KEY;
122
+ const session = !!ctx.loaded.env.SPENDGRAPH_SESSION;
123
+ const held = { key, session, either: key || session };
124
+ return {
125
+ access: ACCESS.map((row) => ({
126
+ group: row.group,
127
+ read: held[row.read],
128
+ write: held[row.write],
129
+ "write needs": NEEDS[row.write],
130
+ })),
131
+ };
132
+ },
133
+ },
134
+ ],
135
+ };
@@ -0,0 +1,11 @@
1
+ import type { Group } from "../types.js";
2
+ /** Projects, their budgets and who is in them. */
3
+ export declare const projects: Group;
4
+ /** API keys. Minting one needs a dashboard session, never a key. */
5
+ export declare const keys: Group;
6
+ /** Provider keys the server calls out with. Session only, deliberately. */
7
+ export declare const credentials: Group;
8
+ /** What a model costs, and what the catalogue knows. */
9
+ export declare const pricing: Group;
10
+ /** What was spent, by whom, on what. */
11
+ export declare const spend: Group;