Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/gkit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gkit",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"description": "Profile-bound, agent-first growth provider CLI.",
"bin": {
Expand Down
2 changes: 2 additions & 0 deletions packages/gkit/src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ describe("gkit argv parser", () => {

it("keeps discovery commands profile-free", () => {
expect(parseArgs(["--schema"])).toEqual({ kind: "schema", selector: null });
expect(parseArgs(["operations", "--limit", "5"])).toEqual({ kind: "operations-list", limit: 5 });
expect(parseArgs(["--version"])).toEqual({ kind: "version" });
expect(parseArgs(["@skill"])).toEqual({ kind: "skill", path: null });
expect(parseArgs(["@skill", "SKILL.md"])).toEqual({ kind: "skill", path: "SKILL.md" });
expect(parseArgs(["describe", "--id", "capability"])).toEqual({
Expand Down
9 changes: 8 additions & 1 deletion packages/gkit/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GkitFailure } from "./envelope";
export type ParsedCommand =
| SiteCommand
| { kind: "help" }
| { kind: "version" }
| { kind: "schema"; selector: string | null }
| { kind: "skill"; path: string | null }
| { kind: "describe"; id: string }
Expand Down Expand Up @@ -182,6 +183,11 @@ export function parseArgs(argv: string[]): ParsedCommand {
return { kind: "help" };
}

if (rest[0] === "--version") {
if (profileFlag || rest.length > 1) invalid("--version takes no other arguments.");
return { kind: "version" };
}

if (rest[0] === "--schema" || rest[0]!.startsWith("--schema=")) {
if (profileFlag) invalid("--schema does not load a profile.");
const first = rest[0]!;
Expand Down Expand Up @@ -219,7 +225,7 @@ export function parseArgs(argv: string[]): ParsedCommand {

if (rest[0] === "operations") {
if (profileFlag) invalid("operations commands do not load a profile.");
if (rest.length === 1 || rest[1] === "list") {
if (rest.length === 1 || rest[1] === "list" || rest[1]!.startsWith("--")) {
const flags = parseFlags(rest.slice(rest[1] === "list" ? 2 : 1), new Set());
requireOnly(flags, ["--limit"]);
const limitText = optionalString(flags, "--limit") ?? "20";
Expand Down Expand Up @@ -377,6 +383,7 @@ export function renderHelp(): string {
"Discovery:",
" gkit --schema [selector]",
" gkit @skill [path]",
" gkit --version",
" gkit describe --id <capability-id>",
" gkit docs [--provider <provider>]",
"",
Expand Down
27 changes: 26 additions & 1 deletion packages/gkit/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("gkit process contract", () => {
expect(result.stdout).toContain("gkit --profile <app> hubspot api call");
expect(result.stdout).toContain("### pagespeed.api.call");
expect(result.stdout).not.toContain("pagespeed.doctor");
expect(result.stdout).toContain("gkit describe --id <capability-id>");
expect(result.stdout).toContain("### describe");
expect(result.stdout).toContain("argc dotted commands are intentionally not exposed");
expect(result.stdout).not.toContain("gkit dataforseo.api.call");
expect(result.stdout).not.toContain("gkit google-ads.api.call");
Expand All @@ -58,6 +58,31 @@ describe("gkit process contract", () => {
expect(hubspot.stdout).not.toContain("posthog");
});

it("prints its package version and serves the site reference through @skill", async () => {
const fixture = await createCliFixture();
const { version } = (await import("../package.json", { with: { type: "json" } })).default;
const result = await runCli(["--version"], fixture.env);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(`${version}\n`);

const reference = await runCli(["@skill", "references/site.md"], fixture.env);
expect(reference.exitCode).toBe(0);
expect(reference.stdout).toContain("SiteSnapshot CLI");
});

it("lists capability ids and parser-spelled flags in a provider slice", async () => {
const fixture = await createCliFixture();
const gsc = await runCli(["--schema", "gsc"], fixture.env);
expect(gsc.stdout).toContain('"gsc.sitemaps.submit"');
expect(gsc.stdout).toContain('"capability-id"');
expect(gsc.stdout).toContain('"allow-write"?: boolean');
expect(gsc.stdout).toContain('"dry-run"?: boolean');

const reconcile = await runCli(["--schema", "operations"], fixture.env);
expect(reconcile.stdout).toContain('"evidence-ref": string');
expect(reconcile.stdout).not.toContain("evidenceRef");
});

it("dispatches HubSpot through the common api call envelope and artifact contract", async () => {
const fixture = await createCliFixture();
const outPath = join(fixture.root, "hubspot-contacts.json");
Expand Down
7 changes: 7 additions & 0 deletions packages/gkit/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolveAssetRoot } from "./assets";

import { ensureTrailingNewline, formatBareSkill } from "@celados/argc/skill";

import packageJson from "../package.json" with { type: "json" };
import { parseArgs, renderHelp } from "./args";
import { embedSkill } from "./skill.embed";
import { describeCapability } from "./describe";
Expand Down Expand Up @@ -127,6 +128,12 @@ export async function main(
return;
}

if (command.kind === "version") {
await emitter.writeText(`${packageJson.version}\n`);
process.exitCode = 0;
return;
}

if (command.kind === "skill") {
const exitCode = await renderEmbeddedSkill(command.path, emitter);
process.exitCode = abortController.signal.aborted ? 130 : exitCode;
Expand Down
8 changes: 6 additions & 2 deletions packages/gkit/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Profile-bound CLI for agent-first access to growth providers (DataForSEO, PostHog, Google Ads, Google Search Console, Bing Webmaster, HubSpot, PageSpeed). One invocation binds exactly one App profile. It does not scaffold credentials, merge profiles or publish to an npm registry: install from GitHub Release tarballs. Preflight: `gkit --profile <app> <provider> doctor` fails closed on missing config or secrets, so run it before a live request.

Commands are spaced (`gkit gsc api call`), not argc dotted paths, and gkit parses its own flags: argc's whole-input forms (`-` stdin, YAML, inline `'{…}'`) don't apply. `--input @request.json` and `--rules @rules.json` are gkit's own file-reference flags and take JSON files.
Commands are spaced (`gkit gsc api call`), not argc dotted paths, and gkit parses its own flags: argc's whole-input forms (`-` stdin, YAML, inline `'{…}'`) don't apply. `--input @request.json`, `--rules @rules.json` and the other `@file` flag values (`--sitemaps`, `--routes`) are gkit's own file references and take JSON files.

Read the section for your task, then only that slice: `gkit --schema gsc`, or `gkit --schema operations`. Discovery is offline and loads no profile or secrets; `gkit describe --id <capability-id>` and `gkit docs --provider <provider>` go deeper on one capability or provider.
Read the section for your task, then only that slice: `gkit --schema gsc`, `gkit --schema operations` or `gkit --schema site`. A provider slice lists its capability ids and the flags `api call` takes. Discovery is offline and loads no profile or secrets; `gkit describe --id <capability-id>` and `gkit docs --provider <provider>` go deeper on one capability or provider.

## Profiles

Expand All @@ -18,6 +18,10 @@ Providers: dataforseo, bing, posthog, google-ads, gsc, hubspot, pagespeed.
- DataForSEO spend calls also need `--allow-spend` and `--max-spend-usd`.
- Write capabilities (such as `gsc.sitemaps.submit`) need `--allow-write`, even on `--dry-run`. Add it only for a reviewed write after its dry-run, never to reads or by habit.

## Site facts — `gkit --schema site`

`site` turns a build or the public site into SiteSnapshot directories, then audits and diffs them offline; `site explain` adds an optional Git-backed Grok explanation. The caller chooses and stores snapshots: nothing is selected or recorded implicitly. Contract, route discovery, profile `site.json`, artifact layout and traps: `gkit @skill references/site.md`.

## Operation journal — `gkit --schema operations`

Every live call (not dry-runs, nor calls rejected before dispatch) is recorded in `$XDG_STATE_HOME/gkit/operations.jsonl` (or `~/.local/state/gkit/operations.jsonl`) and identified by `meta.operationId`. `operations list` shows a closed `state`: `succeeded` (provider success plus a recorded artifact), `failed`, `not_dispatched`, or `unknown` (the attempt crossed the provider seam without a confirmed result).
Expand Down
123 changes: 85 additions & 38 deletions packages/gkit/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import type { LoadedExecutableManifest } from "./manifest";

const s = toStandardJsonSchema;

export const providerNames = [
"dataforseo",
"bing",
"posthog",
"google-ads",
"gsc",
"hubspot",
"pagespeed",
] as const;
const providerName = v.picklist(providerNames);

export function buildGkitSchema(
manifest: LoadedExecutableManifest | readonly LoadedExecutableManifest[],
{ compact = false }: { compact?: boolean } = {},
Expand Down Expand Up @@ -39,19 +50,49 @@ export function buildGkitSchema(
});
}

const provider = (description: string, doctorProfile: string, name: string, callFlags: string) =>
const capabilityIds = (name: string): string[] =>
capabilities.filter((capability) => capability.provider === name).map(({ id }) => id);

// `--schema <provider>` lists the flags `api call` accepts, in the spelling the
// parser reads; the root view stays compact (no fields) to fit its token budget.
const callInput = (name: string, extra: v.ObjectEntries) => {
const ids = capabilityIds(name);
return v.strictObject({
"capability-id": ids.length > 0 ? v.picklist(ids as [string, ...string[]]) : v.string(),
input: v.pipe(
v.string(),
v.description("Request JSON: inline, or `@request.json` to read it from a file"),
),
out: v.optional(
v.pipe(
v.string(),
v.description("File for the provider payload (stdout carries only the envelope)"),
),
),
force: v.optional(v.pipe(v.boolean(), v.description("Replace an existing --out file"))),
"dry-run": v.optional(v.pipe(v.boolean(), v.description("Validate and gate, no dispatch"))),
...extra,
});
};
const profile = v.optional(
v.pipe(v.string(), v.description("App profile; wins over GKIT_PROFILE")),
);

const provider = (
description: string,
name: string,
callFlags: string,
extra: v.ObjectEntries = {},
) =>
group(
{ description },
{
...(compact
? {}
: {
doctor: c
.meta({
description: "Profile check.",
examples: [`gkit --profile ${doctorProfile} ${name} doctor`],
})
.input(s(v.strictObject({}))),
.meta({ description: "Profile check; fails closed on missing config or secrets." })
.input(s(v.strictObject({ profile }))),
}),
api: group(
{},
Expand All @@ -63,86 +104,92 @@ export function buildGkitSchema(
`gkit --profile <app> ${name} api call ${callFlags} --out <path> --dry-run`,
],
})
.input(s(v.strictObject({}))),
.input(s(compact ? v.strictObject({}) : callInput(name, { profile, ...extra }))),
},
),
},
);
const idFlags = "--capability-id <id> --input @request.json";
const allowWrite = {
"allow-write": v.optional(
v.pipe(v.boolean(), v.description("Required for write capabilities, even with --dry-run")),
),
};

return {
site: siteRouter,
describe: c
.meta({
description: "Capability details.",
examples: ["gkit describe --id <capability-id>"],
})
.meta({ description: "Details of one reviewed capability: effects, cost, input schema." })
.input(s(v.strictObject({ id: v.string() }))),
docs: c
.meta({
description: "Provider docs directory.",
examples: ["gkit docs --provider <provider>"],
})
.input(s(v.strictObject({ provider: v.optional(v.string()) }))),
.meta({ description: "Directory of the generated provider docs." })
.input(s(v.strictObject({ provider: v.optional(providerName) }))),
operations: group(
{ description: "Journal." },
{ description: "Journal of live calls." },
{
list: c.meta({ description: "Summary." }).input(s(v.strictObject({}))),
list: c
.meta({ description: "Summary of recent operations, newest first." })
.input(
s(v.strictObject({ limit: v.optional(v.pipe(v.number(), v.integer()), 20) })),
),
show: c
.meta({ description: "One operation." })
.input(s(v.strictObject({ id: v.string() }))),
reconcile: c
.meta({
description: "Manual settlement.",
examples: [
"gkit operations reconcile --id <operation-id> [--result <result>] [--spend-outcome <outcome> [--cost-usd <decimal>]] --evidence-ref <ref>",
],
description:
"Settle an unknown result or spend with provider evidence; needs --result, --spend-outcome, or both.",
})
.input(
s(
v.strictObject({
id: v.string(),
result: v.optional(v.picklist(["succeeded", "failed", "not_dispatched"])),
spendOutcome: v.optional(
"spend-outcome": v.optional(
v.picklist(["confirmed_charged", "confirmed_not_charged"]),
),
evidenceRef: v.string(),
costUsd: v.optional(v.string()),
providerRequestId: v.optional(v.string()),
"cost-usd": v.optional(
v.pipe(v.string(), v.description("Decimal USD; with --spend-outcome")),
),
"evidence-ref": v.pipe(
v.string(),
v.description("Where the provider evidence can be inspected"),
),
"provider-request-id": v.optional(v.string()),
}),
),
),
},
),
dataforseo: provider(
`${dataForSeoCapabilityCount} reviewed reads.`,
"app-a",
"dataforseo",
`${idFlags} --allow-spend --max-spend-usd <decimal>`,
{
"allow-spend": v.optional(
v.pipe(v.boolean(), v.description("Required for capabilities that spend")),
),
"max-spend-usd": v.optional(
v.pipe(v.string(), v.description("Decimal USD ceiling for this call")),
),
},
),
bing: provider(`${bingCapabilityCount} reviewed reads.`, "app-a", "bing", idFlags),
bing: provider(`${bingCapabilityCount} reviewed reads.`, "bing", idFlags, allowWrite),
posthog: provider(
"1 reviewed read.",
"app-a",
"posthog",
"--capability-id posthog.query.run --input @request.json",
),
"google-ads": provider(
`${googleAdsCapabilityCount} reviewed reads.`,
"openclaw-web",
"google-ads",
idFlags,
),
"google-ads": provider(`${googleAdsCapabilityCount} reviewed reads.`, "google-ads", idFlags),
gsc: provider(
`${gscCapabilityCount} reviewed capabilities; writes need --allow-write.`,
"openclaw-web",
"gsc",
idFlags,
allowWrite,
),
hubspot: provider(`${hubSpotCapabilityCount} reviewed reads.`, "app-a", "hubspot", idFlags),
hubspot: provider(`${hubSpotCapabilityCount} reviewed reads.`, "hubspot", idFlags),
pagespeed: provider(
`${pageSpeedCapabilityCount} reviewed reads.`,
"app-a",
"pagespeed",
"--capability-id pagespeed.run --input @request.json",
),
Expand Down
Loading
Loading