@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.
@@ -0,0 +1,236 @@
1
+ import { UsageError } from "../errors.js";
2
+ const range = (ctx) => ({
3
+ ...ctx.scope(),
4
+ from: ctx.flag("from"),
5
+ to: ctx.flag("to"),
6
+ });
7
+ function events(ctx) {
8
+ const body = ctx.body({});
9
+ const list = Array.isArray(body.events) ? body.events : undefined;
10
+ if (!list)
11
+ throw new UsageError("--file must hold { events: [...] }.");
12
+ return list;
13
+ }
14
+ /** Projects, their budgets and who is in them. */
15
+ export const projects = {
16
+ name: "project",
17
+ summary: "projects, budgets and members",
18
+ commands: [
19
+ { name: "list", summary: "every project you can see", run: (ctx) => ctx.sg.projects.list() },
20
+ {
21
+ name: "create",
22
+ summary: "a new project",
23
+ run: (ctx) => ctx.sg.projects.create({ name: ctx.flag("name") ?? ctx.need(0, "name") }),
24
+ },
25
+ {
26
+ name: "budget",
27
+ summary: "what the project is allowed to spend",
28
+ usage: "<projectId>",
29
+ run: (ctx) => ctx.sg.projects.budget(ctx.need(0, "projectId")),
30
+ },
31
+ {
32
+ name: "set-budget",
33
+ summary: "change the budget, from flags or --file",
34
+ usage: "<projectId>",
35
+ run: (ctx) => ctx.sg.projects.setBudget(ctx.need(0, "projectId"), ctx.body({ limitMicros: ctx.num("limit-micros") })),
36
+ },
37
+ {
38
+ name: "members",
39
+ summary: "who is in the project",
40
+ usage: "<projectId>",
41
+ run: (ctx) => ctx.sg.projects.members(ctx.need(0, "projectId")),
42
+ },
43
+ {
44
+ name: "add-member",
45
+ summary: "add someone, from flags or --file",
46
+ usage: "<projectId>",
47
+ run: (ctx) => ctx.sg.projects.addMember(ctx.need(0, "projectId"), ctx.body({ userId: ctx.flag("user"), role: ctx.flag("role") })),
48
+ },
49
+ {
50
+ name: "remove-member",
51
+ summary: "take someone out",
52
+ usage: "<projectId> <userId>",
53
+ run: (ctx) => ctx.sg.projects.removeMember(ctx.need(0, "projectId"), ctx.need(1, "userId")),
54
+ },
55
+ {
56
+ name: "invite",
57
+ summary: "invite someone by email",
58
+ usage: "<projectId>",
59
+ run: (ctx) => ctx.sg.projects.invite(ctx.need(0, "projectId"), ctx.body({ email: ctx.flag("email"), role: ctx.flag("role") })),
60
+ },
61
+ {
62
+ name: "revoke-invite",
63
+ summary: "withdraw an invite",
64
+ usage: "<projectId> <inviteId>",
65
+ run: (ctx) => ctx.sg.projects.revokeInvite(ctx.need(0, "projectId"), ctx.need(1, "inviteId")),
66
+ },
67
+ {
68
+ name: "invite-preview",
69
+ summary: "what an invite token points at",
70
+ usage: "<token>",
71
+ run: (ctx) => ctx.sg.invites.preview(ctx.need(0, "token")),
72
+ },
73
+ {
74
+ name: "invite-accept",
75
+ summary: "accept an invite",
76
+ usage: "<token>",
77
+ run: (ctx) => ctx.sg.invites.accept(ctx.need(0, "token")),
78
+ },
79
+ ],
80
+ };
81
+ /** API keys. Minting one needs a dashboard session, never a key. */
82
+ export const keys = {
83
+ name: "key",
84
+ summary: "api keys (session only)",
85
+ commands: [
86
+ {
87
+ name: "list",
88
+ summary: "every key in the project",
89
+ run: (ctx) => ctx.sg.keys.list(ctx.scope()),
90
+ },
91
+ {
92
+ name: "create",
93
+ summary: "mint a key; the plaintext is shown once and never again",
94
+ run: (ctx) => ctx.sg.keys.create({
95
+ projectId: ctx.project(),
96
+ name: ctx.flag("name") ?? ctx.need(0, "name"),
97
+ }),
98
+ },
99
+ {
100
+ name: "revoke",
101
+ summary: "revoke a key",
102
+ usage: "<keyId>",
103
+ run: (ctx) => ctx.sg.keys.revoke(ctx.need(0, "keyId")),
104
+ },
105
+ ],
106
+ };
107
+ /** Provider keys the server calls out with. Session only, deliberately. */
108
+ export const credentials = {
109
+ name: "credential",
110
+ summary: "provider keys the server holds (session only)",
111
+ commands: [
112
+ { name: "list", summary: "which providers are set", run: (ctx) => ctx.sg.credentials.list() },
113
+ {
114
+ name: "set",
115
+ summary: "store a provider key",
116
+ run: (ctx) => ctx.sg.credentials.set({
117
+ provider: ctx.flag("provider") ?? ctx.need(0, "provider"),
118
+ apiKey: ctx.flag("key") ?? ctx.need(1, "key"),
119
+ }),
120
+ },
121
+ {
122
+ name: "remove",
123
+ summary: "forget a provider key",
124
+ usage: "<provider>",
125
+ run: (ctx) => ctx.sg.credentials.remove(ctx.need(0, "provider")),
126
+ },
127
+ ],
128
+ };
129
+ /** What a model costs, and what the catalogue knows. */
130
+ export const pricing = {
131
+ name: "pricing",
132
+ summary: "model prices, coverage and the catalogue",
133
+ commands: [
134
+ { name: "list", summary: "every price row", run: (ctx) => ctx.sg.pricing.list() },
135
+ {
136
+ name: "override",
137
+ summary: "set your own price for a model, from --file",
138
+ usage: "<model>",
139
+ run: (ctx) => ctx.sg.pricing.override(ctx.need(0, "model"), ctx.body({})),
140
+ },
141
+ {
142
+ name: "coverage",
143
+ summary: "which models have a price",
144
+ run: (ctx) => ctx.sg.pricing.coverage(),
145
+ },
146
+ {
147
+ name: "readiness",
148
+ summary: "whether pricing is usable",
149
+ run: (ctx) => ctx.sg.pricing.readiness(),
150
+ },
151
+ {
152
+ name: "sync",
153
+ summary: "refresh from upstream",
154
+ run: (ctx) => ctx.sg.pricing.sync(ctx.body({})),
155
+ },
156
+ {
157
+ name: "models",
158
+ summary: "the model catalogue",
159
+ run: (ctx) => ctx.sg.models.list({ creator: ctx.flag("creator"), limit: ctx.num("limit") }),
160
+ },
161
+ {
162
+ name: "offers",
163
+ summary: "who serves a model, and at what price",
164
+ run: (ctx) => ctx.sg.models.offers({ model: ctx.flag("model"), q: ctx.flag("q") }),
165
+ },
166
+ {
167
+ name: "compare",
168
+ summary: "price one workload across models or providers",
169
+ run: (ctx) => ctx.sg.models.compare({
170
+ models: ctx.flag("models"),
171
+ offers: ctx.flag("offers"),
172
+ baseline: ctx.flag("baseline"),
173
+ benchmark: ctx.flag("benchmark"),
174
+ in: ctx.num("in"),
175
+ out: ctx.num("out"),
176
+ requests: ctx.num("requests"),
177
+ }),
178
+ },
179
+ ],
180
+ };
181
+ /** What was spent, by whom, on what. */
182
+ export const spend = {
183
+ name: "spend",
184
+ summary: "usage, stats, events and alerts",
185
+ commands: [
186
+ { name: "summary", summary: "the totals", run: (ctx) => ctx.sg.stats.summary(range(ctx)) },
187
+ {
188
+ name: "timeseries",
189
+ summary: "spend over time",
190
+ run: (ctx) => ctx.sg.stats.timeseries({
191
+ ...range(ctx),
192
+ bucket: ctx.flag("bucket"),
193
+ }),
194
+ },
195
+ {
196
+ name: "by-model",
197
+ summary: "spend per model",
198
+ run: (ctx) => ctx.sg.stats.byModel(range(ctx)),
199
+ },
200
+ { name: "by-key", summary: "spend per api key", run: (ctx) => ctx.sg.stats.byKey(range(ctx)) },
201
+ {
202
+ name: "by-tag",
203
+ summary: "spend per tag",
204
+ run: (ctx) => ctx.sg.stats.byTag({ ...range(ctx), key: ctx.flag("key") }),
205
+ },
206
+ {
207
+ name: "events",
208
+ summary: "individual usage events",
209
+ run: (ctx) => ctx.sg.events.list({
210
+ ...range(ctx),
211
+ model: ctx.flag("model"),
212
+ key: ctx.flag("key"),
213
+ q: ctx.flag("q"),
214
+ sort: ctx.flag("sort"),
215
+ limit: ctx.num("limit"),
216
+ cursor: ctx.flag("cursor"),
217
+ format: ctx.flag("format"),
218
+ }),
219
+ },
220
+ {
221
+ name: "alerts",
222
+ summary: "budget alerts that have fired",
223
+ run: (ctx) => ctx.sg.alerts.list({ ...ctx.scope(), limit: ctx.num("limit") }),
224
+ },
225
+ {
226
+ name: "ingest",
227
+ summary: "post usage events from --file, and say what went unpriced",
228
+ run: (ctx) => ctx.sg.ingest.report(events(ctx)),
229
+ },
230
+ {
231
+ name: "playground",
232
+ summary: "run a one-off call server-side, from --file",
233
+ run: (ctx) => ctx.sg.playground.run(ctx.body({})),
234
+ },
235
+ ],
236
+ };
@@ -0,0 +1,3 @@
1
+ import type { Group } from "../types.js";
2
+ /** Stored prompts: writing them, versioning them, running them and reading what they cost. */
3
+ export declare const prompts: Group;
@@ -0,0 +1,133 @@
1
+ import { scoped } from "../context.js";
2
+ import { UsageError } from "../errors.js";
3
+ const saved = (ctx) => scoped(ctx, ctx.body({
4
+ name: ctx.flag("name"),
5
+ question: ctx.flag("question"),
6
+ models: ctx.list("models"),
7
+ temperature: ctx.num("temperature"),
8
+ maxTokens: ctx.num("max-tokens"),
9
+ }));
10
+ function cases(ctx) {
11
+ const body = ctx.body({});
12
+ const list = body.cases;
13
+ if (!Array.isArray(list))
14
+ throw new UsageError("--file must hold { cases: [...] }.");
15
+ return list;
16
+ }
17
+ /** Stored prompts: writing them, versioning them, running them and reading what they cost. */
18
+ export const prompts = {
19
+ name: "prompt",
20
+ summary: "stored prompts, their versions, datasets and rollouts",
21
+ commands: [
22
+ {
23
+ name: "list",
24
+ summary: "every prompt in the project",
25
+ run: (ctx) => ctx.sg.prompts.list({
26
+ ...ctx.scope(),
27
+ limit: ctx.num("limit"),
28
+ cursor: ctx.flag("cursor"),
29
+ archived: ctx.bool("archived") || undefined,
30
+ }),
31
+ },
32
+ {
33
+ name: "get",
34
+ summary: "one prompt with its current version",
35
+ usage: "<promptId>",
36
+ run: (ctx) => ctx.sg.prompts.get(ctx.need(0, "promptId"), { ...ctx.scope(), runs: ctx.num("runs") }),
37
+ },
38
+ {
39
+ name: "create",
40
+ summary: "write a new prompt",
41
+ run: (ctx) => ctx.sg.prompts.create(saved(ctx)),
42
+ },
43
+ {
44
+ name: "update",
45
+ summary: "write a new version of a prompt",
46
+ usage: "<promptId>",
47
+ run: (ctx) => ctx.sg.prompts.update(ctx.need(0, "promptId"), saved(ctx), ctx.scope()),
48
+ },
49
+ {
50
+ name: "versions",
51
+ summary: "every stored version",
52
+ usage: "<promptId>",
53
+ run: (ctx) => ctx.sg.prompts.versions(ctx.need(0, "promptId"), {
54
+ ...ctx.scope(),
55
+ limit: ctx.num("limit"),
56
+ origin: ctx.flag("origin"),
57
+ }),
58
+ },
59
+ {
60
+ name: "promote",
61
+ summary: "make a stored version the current one",
62
+ usage: "<promptId> <versionId>",
63
+ run: (ctx) => ctx.sg.prompts.promote(ctx.need(0, "promptId"), ctx.need(1, "versionId"), ctx.scope()),
64
+ },
65
+ {
66
+ name: "cases",
67
+ summary: "the evaluation dataset",
68
+ usage: "<promptId>",
69
+ run: (ctx) => ctx.sg.prompts.cases(ctx.need(0, "promptId"), ctx.scope()),
70
+ },
71
+ {
72
+ name: "put-cases",
73
+ summary: "replace the evaluation dataset from --file",
74
+ usage: "<promptId>",
75
+ run: (ctx) => ctx.sg.prompts.putCases(ctx.need(0, "promptId"), cases(ctx), ctx.scope()),
76
+ },
77
+ {
78
+ name: "rollouts",
79
+ summary: "what the prompt has been run for, and what it cost",
80
+ usage: "<promptId>",
81
+ run: (ctx) => ctx.sg.prompts.rollouts(ctx.need(0, "promptId"), {
82
+ ...ctx.scope(),
83
+ limit: ctx.num("limit"),
84
+ cursor: ctx.flag("cursor"),
85
+ evaluation: ctx.bool("evaluation") || undefined,
86
+ }),
87
+ },
88
+ {
89
+ name: "report",
90
+ summary: "record a rollout run elsewhere, from --file",
91
+ usage: "<promptId>",
92
+ run: (ctx) => ctx.sg.prompts.report(ctx.need(0, "promptId"), ctx.body({}), ctx.scope()),
93
+ },
94
+ {
95
+ name: "run",
96
+ summary: "run the prompt server-side",
97
+ usage: "<promptId>",
98
+ run: (ctx) => ctx.sg.prompts.run(ctx.need(0, "promptId"), ctx.body({
99
+ model: ctx.flag("model"),
100
+ variables: undefined,
101
+ }), ctx.scope()),
102
+ },
103
+ {
104
+ name: "publish",
105
+ summary: "publish a draft",
106
+ usage: "<promptId>",
107
+ run: (ctx) => ctx.sg.promptsAdmin.publish(ctx.need(0, "promptId"), ctx.body({})),
108
+ },
109
+ {
110
+ name: "archive",
111
+ summary: "take a prompt out of the list",
112
+ usage: "<promptId>",
113
+ run: (ctx) => ctx.sg.promptsAdmin.archive(ctx.need(0, "promptId"), ctx.scope()),
114
+ },
115
+ {
116
+ name: "assay",
117
+ summary: "start an optimization run; --file must carry the budget",
118
+ usage: "<promptId>",
119
+ run: (ctx) => ctx.sg.promptsAdmin.assay(ctx.need(0, "promptId"), ctx.body({})),
120
+ },
121
+ {
122
+ name: "assays",
123
+ summary: "optimization runs in the project",
124
+ run: (ctx) => ctx.sg.promptsAdmin.runs({ ...ctx.scope(), limit: ctx.num("limit") }),
125
+ },
126
+ {
127
+ name: "assay-get",
128
+ summary: "one optimization run",
129
+ usage: "<runId>",
130
+ run: (ctx) => ctx.sg.promptsAdmin.run(ctx.need(0, "runId")),
131
+ },
132
+ ],
133
+ };
@@ -0,0 +1,8 @@
1
+ import type { Group } from "../types.js";
2
+ /**
3
+ * The `skill` group, which documents itself alongside the groups it is given.
4
+ *
5
+ * Taking the groups as an argument is what keeps the reference generated rather
6
+ * than written down twice: a command added anywhere appears here on the next run.
7
+ */
8
+ export declare function skillCommands(groups: Group[]): Group;
@@ -0,0 +1,68 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { UsageError } from "../errors.js";
4
+ import { catalogue, document } from "../skills.js";
5
+ const DEFAULT_DIR = ".claude/skills";
6
+ function named(skills, name) {
7
+ const found = skills.find((skill) => skill.name === name);
8
+ if (!found) {
9
+ throw new UsageError(`No skill "${name}". Try: ${skills.map((s) => s.name).join(", ")}.`);
10
+ }
11
+ return found;
12
+ }
13
+ function write(ctx, skills) {
14
+ const dir = ctx.flag("dir") ?? DEFAULT_DIR;
15
+ const only = ctx.arg(0);
16
+ const chosen = only ? [named(skills, only)] : skills;
17
+ const force = ctx.bool("force");
18
+ return chosen.map((skill) => {
19
+ const path = join(dir, skill.name, "SKILL.md");
20
+ if (existsSync(path) && !force)
21
+ return { skill: skill.name, path, wrote: false };
22
+ mkdirSync(join(dir, skill.name), { recursive: true });
23
+ writeFileSync(path, document(skill), "utf8");
24
+ return { skill: skill.name, path, wrote: true };
25
+ });
26
+ }
27
+ /**
28
+ * The `skill` group, which documents itself alongside the groups it is given.
29
+ *
30
+ * Taking the groups as an argument is what keeps the reference generated rather
31
+ * than written down twice: a command added anywhere appears here on the next run.
32
+ */
33
+ export function skillCommands(groups) {
34
+ const self = {
35
+ name: "skill",
36
+ summary: "agent skills describing this CLI, generated from its own commands",
37
+ commands: [],
38
+ };
39
+ const skills = () => catalogue([...groups, self]);
40
+ self.commands = [
41
+ {
42
+ name: "list",
43
+ summary: "the skills this CLI can hand an agent",
44
+ local: true,
45
+ run: async (ctx) => ({
46
+ skills: skills().map((skill) => ({
47
+ name: skill.name,
48
+ description: ctx.bool("full") ? skill.description : `${skill.description.slice(0, 80)}…`,
49
+ })),
50
+ }),
51
+ },
52
+ {
53
+ name: "show",
54
+ summary: "one skill as SKILL.md, on stdout",
55
+ usage: "<name>",
56
+ local: true,
57
+ run: async (ctx) => document(named(skills(), ctx.need(0, "name"))),
58
+ },
59
+ {
60
+ name: "install",
61
+ summary: "write the skills to --dir (default .claude/skills); --force overwrites",
62
+ usage: "[name]",
63
+ local: true,
64
+ run: async (ctx) => ({ installed: write(ctx, skills()) }),
65
+ },
66
+ ];
67
+ return self;
68
+ }
@@ -0,0 +1,3 @@
1
+ import type { Group } from "../types.js";
2
+ /** Tools the model can be offered: declaring them, describing them, retiring them. */
3
+ export declare const tools: Group;
@@ -0,0 +1,70 @@
1
+ import { scoped } from "../context.js";
2
+ import { UsageError } from "../errors.js";
3
+ const EFFECTS = new Set(["readonly", "idempotent", "destructive"]);
4
+ function effect(ctx) {
5
+ const raw = ctx.flag("effect");
6
+ if (raw === undefined)
7
+ return undefined;
8
+ if (!EFFECTS.has(raw)) {
9
+ throw new UsageError(`--effect must be one of ${[...EFFECTS].join(", ")}.`);
10
+ }
11
+ return raw;
12
+ }
13
+ function args(ctx) {
14
+ const raw = ctx.list("arg");
15
+ if (raw === undefined)
16
+ return undefined;
17
+ return raw.map((one) => {
18
+ const [name, type, required] = one.split(":");
19
+ if (!name || !type)
20
+ throw new UsageError(`--arg wants name:type, saw "${one}".`);
21
+ return { name, type, required: required === "required" };
22
+ });
23
+ }
24
+ const saved = (ctx) => scoped(ctx, ctx.body({
25
+ name: ctx.flag("name"),
26
+ description: ctx.flag("description"),
27
+ args: args(ctx),
28
+ effect: effect(ctx),
29
+ pinned: ctx.bool("pinned") || undefined,
30
+ }));
31
+ /** Tools the model can be offered: declaring them, describing them, retiring them. */
32
+ export const tools = {
33
+ name: "tool",
34
+ summary: "tools the model can be offered",
35
+ commands: [
36
+ {
37
+ name: "list",
38
+ summary: "every tool in the project",
39
+ run: (ctx) => ctx.sg.tools.list({
40
+ ...ctx.scope(),
41
+ limit: ctx.num("limit"),
42
+ cursor: ctx.flag("cursor"),
43
+ archived: ctx.flag("archived"),
44
+ }),
45
+ },
46
+ {
47
+ name: "get",
48
+ summary: "one tool, by uuid or by the name the model calls",
49
+ usage: "<name>",
50
+ run: (ctx) => ctx.sg.tools.get(ctx.need(0, "name"), ctx.scope()),
51
+ },
52
+ {
53
+ name: "create",
54
+ summary: "declare a new tool",
55
+ run: (ctx) => ctx.sg.tools.create(saved(ctx)),
56
+ },
57
+ {
58
+ name: "update",
59
+ summary: "rewrite a tool's description, args or effect",
60
+ usage: "<id>",
61
+ run: (ctx) => ctx.sg.tools.update(ctx.need(0, "id"), saved(ctx), ctx.scope()),
62
+ },
63
+ {
64
+ name: "archive",
65
+ summary: "take a tool out of the list; --no-archived brings it back",
66
+ usage: "<id>",
67
+ run: (ctx) => ctx.sg.tools.archive(ctx.need(0, "id"), ctx.parsed.flags.archived !== false, ctx.scope()),
68
+ },
69
+ ],
70
+ };
@@ -0,0 +1,19 @@
1
+ import { Spendgraph } from "@spendgraph/sdk";
2
+ import type { Parsed } from "./args.js";
3
+ import type { Env } from "./env.js";
4
+ /** What the CLI proves itself with, and where it points. */
5
+ export interface Credentials {
6
+ baseUrl: string;
7
+ apiKey?: string;
8
+ session?: string;
9
+ project?: string;
10
+ }
11
+ /**
12
+ * Reads the credentials from flags first, the environment second.
13
+ *
14
+ * Both an `sg_` key and a dashboard session may be set. Which half of the API
15
+ * each one opens is the server's decision, not this file's.
16
+ */
17
+ export declare function credentials(parsed: Parsed, env?: Env): Credentials;
18
+ /** The client every command runs against. `fetch` is injected by tests. */
19
+ export declare function connect(creds: Credentials, fetchImpl?: typeof fetch): Spendgraph;
@@ -0,0 +1,38 @@
1
+ import { Spendgraph } from "@spendgraph/sdk";
2
+ import { UsageError } from "./errors.js";
3
+ const pick = (parsed, name) => {
4
+ const value = parsed.flags[name];
5
+ return typeof value === "string" ? value : undefined;
6
+ };
7
+ /**
8
+ * Reads the credentials from flags first, the environment second.
9
+ *
10
+ * Both an `sg_` key and a dashboard session may be set. Which half of the API
11
+ * each one opens is the server's decision, not this file's.
12
+ */
13
+ export function credentials(parsed, env = process.env) {
14
+ const baseUrl = pick(parsed, "base-url") ?? env.SPENDGRAPH_BASE_URL;
15
+ if (!baseUrl)
16
+ throw new UsageError("Set --base-url or SPENDGRAPH_BASE_URL.");
17
+ const apiKey = pick(parsed, "api-key") ?? env.SPENDGRAPH_API_KEY;
18
+ const session = pick(parsed, "session") ?? env.SPENDGRAPH_SESSION;
19
+ if (!apiKey && !session) {
20
+ throw new UsageError("Set SPENDGRAPH_API_KEY, or SPENDGRAPH_SESSION for keys, projects, pricing and credentials.");
21
+ }
22
+ return {
23
+ baseUrl,
24
+ apiKey,
25
+ session,
26
+ project: pick(parsed, "project") ?? env.SPENDGRAPH_PROJECT,
27
+ };
28
+ }
29
+ /** The client every command runs against. `fetch` is injected by tests. */
30
+ export function connect(creds, fetchImpl) {
31
+ return new Spendgraph({
32
+ baseUrl: creds.baseUrl,
33
+ apiKey: creds.apiKey,
34
+ session: creds.session,
35
+ project: creds.project,
36
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
37
+ });
38
+ }
@@ -0,0 +1,13 @@
1
+ import type { Spendgraph } from "@spendgraph/sdk";
2
+ import type { Parsed } from "./args.js";
3
+ import type { Loaded } from "./env.js";
4
+ import type { Ctx } from "./types.js";
5
+ /** Builds the `Ctx` a command runs against. `sg` is null for a command that stays local. */
6
+ export declare function context(sg: Spendgraph | null, parsed: Parsed, positional: string[], loaded: Loaded): Ctx;
7
+ /**
8
+ * A save body with its `projectId` settled.
9
+ *
10
+ * `--file` carrying one is enough on its own, so a definition checked into a
11
+ * repo applies without the project being named twice.
12
+ */
13
+ export declare function scoped(ctx: Ctx, body: Record<string, unknown>): Record<string, unknown>;