diff --git a/packages/gkit/package.json b/packages/gkit/package.json index 071df4e..7c5c9d6 100644 --- a/packages/gkit/package.json +++ b/packages/gkit/package.json @@ -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": { diff --git a/packages/gkit/src/args.test.ts b/packages/gkit/src/args.test.ts index 678b10a..d44892d 100644 --- a/packages/gkit/src/args.test.ts +++ b/packages/gkit/src/args.test.ts @@ -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({ diff --git a/packages/gkit/src/args.ts b/packages/gkit/src/args.ts index d793d5f..76df5ac 100644 --- a/packages/gkit/src/args.ts +++ b/packages/gkit/src/args.ts @@ -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 } @@ -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]!; @@ -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"; @@ -377,6 +383,7 @@ export function renderHelp(): string { "Discovery:", " gkit --schema [selector]", " gkit @skill [path]", + " gkit --version", " gkit describe --id ", " gkit docs [--provider ]", "", diff --git a/packages/gkit/src/cli.test.ts b/packages/gkit/src/cli.test.ts index 8e29dde..d98a788 100644 --- a/packages/gkit/src/cli.test.ts +++ b/packages/gkit/src/cli.test.ts @@ -34,7 +34,7 @@ describe("gkit process contract", () => { expect(result.stdout).toContain("gkit --profile hubspot api call"); expect(result.stdout).toContain("### pagespeed.api.call"); expect(result.stdout).not.toContain("pagespeed.doctor"); - expect(result.stdout).toContain("gkit describe --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"); @@ -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"); diff --git a/packages/gkit/src/cli.ts b/packages/gkit/src/cli.ts index e2b9cfa..f5b28d3 100644 --- a/packages/gkit/src/cli.ts +++ b/packages/gkit/src/cli.ts @@ -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"; @@ -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; diff --git a/packages/gkit/src/index.md b/packages/gkit/src/index.md index 0c335df..bd58203 100644 --- a/packages/gkit/src/index.md +++ b/packages/gkit/src/index.md @@ -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 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 ` and `gkit docs --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 ` and `gkit docs --provider ` go deeper on one capability or provider. ## Profiles @@ -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). diff --git a/packages/gkit/src/schema.ts b/packages/gkit/src/schema.ts index 73d6893..5787e22 100644 --- a/packages/gkit/src/schema.ts +++ b/packages/gkit/src/schema.ts @@ -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 } = {}, @@ -39,7 +50,40 @@ 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 ` 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 }, { @@ -47,11 +91,8 @@ export function buildGkitSchema( ? {} : { 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( {}, @@ -63,52 +104,58 @@ export function buildGkitSchema( `gkit --profile ${name} api call ${callFlags} --out --dry-run`, ], }) - .input(s(v.strictObject({}))), + .input(s(compact ? v.strictObject({}) : callInput(name, { profile, ...extra }))), }, ), }, ); const idFlags = "--capability-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 "], - }) + .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 "], - }) - .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 [--result ] [--spend-outcome [--cost-usd ]] --evidence-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()), }), ), ), @@ -116,33 +163,33 @@ export function buildGkitSchema( ), dataforseo: provider( `${dataForSeoCapabilityCount} reviewed reads.`, - "app-a", "dataforseo", `${idFlags} --allow-spend --max-spend-usd `, + { + "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", ), diff --git a/packages/gkit/src/site/schema.ts b/packages/gkit/src/site/schema.ts index d7394bc..8fb2a30 100644 --- a/packages/gkit/src/site/schema.ts +++ b/packages/gkit/src/site/schema.ts @@ -1,59 +1,81 @@ import { c, group } from "@celados/argc"; import { toStandardJsonSchema as s } from "@valibot/to-json-schema"; import * as v from "valibot"; -const command = (description: string, input: v.GenericSchema) => - c.meta({ description }).input(s(input)); + +// Field keys are the flag spellings `site/commands.ts` parses. Positional +// arguments are fields described as such, in call order. +const command = (description: string, input: v.GenericSchema, examples: string[] = []) => + c.meta({ description, examples }).input(s(input)); +const text = (description: string) => v.pipe(v.string(), v.description(description)); +const flag = (description: string) => v.pipe(v.boolean(), v.description(description)); +const snapshot = (description: string) => text(`Positional: ${description}`); +const dryRun = v.optional(flag("Offline: validate and write the bounded input only")); const output = { - out: v.optional(v.string()), - format: v.optional(v.picklist(["json", "markdown"])), - rules: v.optional(v.string()), + out: v.optional(text("Report file; required for markdown")), + format: v.optional(v.picklist(["json", "markdown"]), "json"), + rules: v.optional(text("Audit rules, `@rules.json`")), }; export const siteRouter = group( { description: "Portable SiteSnapshot artifacts. No implicit ledger or baseline." }, { doctor: command("Validate local profile/site configuration; no network.", v.strictObject({})), discover: command( - "gkit --profile site discover --out [--sitemaps @urls.json] [--sitemap-origins @origins.json]. Saves routes.json plus source XML and discovery coverage. Additional sitemap origins never widen the page scope. --dry-run is offline.", + "Save routes.json plus source XML and discovery coverage. Extra sitemap origins never widen the page scope.", v.strictObject({ - sitemaps: v.optional(v.string()), - sitemapOrigins: v.optional(v.string()), - maxSitemaps: v.optional(v.number()), - maxUrls: v.optional(v.number()), - out: v.string(), - dryRun: v.optional(v.boolean()), + sitemaps: v.optional(text("URL list, `@urls.json`")), + "sitemap-origins": v.optional(text("Extra origins, `@origins.json`")), + "max-sitemaps": v.optional(v.number(), 50), + "max-urls": v.optional(v.number(), 100000), + out: text("New directory"), + "dry-run": dryRun, }), + [ + "gkit --profile site discover --out --sitemaps @urls.json --dry-run", + ], ), snapshot: command( - "gkit --profile site snapshot --build .output --revision --context --out . Or --http for production observations. Writes manifest.json, pages.jsonl and content/; --dry-run is offline.", + "Write manifest.json, pages.jsonl and content/ from a build output or production observations. Exactly one of --build, --http.", v.strictObject({ - build: v.optional(v.string()), - http: v.optional(v.boolean()), - revision: v.optional(v.string()), - repo: v.optional(v.string()), - routes: v.optional(v.string()), - context: v.string(), - out: v.string(), - dryRun: v.optional(v.boolean()), + build: v.optional(text("Build output directory; needs --revision")), + http: v.optional(flag("Observe production over HTTP instead of a build")), + revision: v.optional(text("Commit or tag the build came from")), + repo: v.optional(text("Checkout for --revision")), + routes: v.optional(text("Routes file from `site discover`")), + context: text("Runtime-input label"), + out: text("New directory"), + "dry-run": dryRun, }), + [ + "gkit --profile site snapshot --build .output --revision --context