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
96 changes: 46 additions & 50 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@types/node": "^25.6.0",
"@valibot/to-json-schema": "^1.6.0",
"ajv": "^8.20.0",
"argc": "github:ethan-huo/argc#v7.5.0",
"argc": "github:ethan-huo/argc#v7.8.0",
"bun-types": "^1.3.13",
"dataforseo-client": "^2.0.25",
"dotenv": "^17.2.2",
Expand Down
4 changes: 2 additions & 2 deletions packages/gkit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gkit",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"description": "Profile-bound, agent-first growth provider CLI.",
"bin": {
Expand Down Expand Up @@ -36,7 +36,7 @@
"@standard-schema/spec": "^1.0.0",
"@valibot/to-json-schema": "^1.6.0",
"ajv": "^8.20.0",
"argc": "github:ethan-huo/argc#v7.5.0",
"argc": "github:ethan-huo/argc#v7.8.0",
"dotenv": "^17.2.2",
"google-auth-library": "^10.9.0",
"valibot": "^1.2.0",
Expand Down
11 changes: 11 additions & 0 deletions packages/gkit/skills/gkit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
name: gkit
description: >-
Call growth providers through the profile-bound gkit CLI (DataForSEO,
PostHog, Google Ads, GSC, Bing). Activate when discovering capabilities,
running doctor, or making a live/dry-run provider request for an App
profile.
---

Run `gkit @skill` now for the full usage guide.
Read a referenced file with `gkit @skill <path>`.
47 changes: 47 additions & 0 deletions packages/gkit/src/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# gkit

Profile-bound CLI for agent-first access to growth providers (DataForSEO,
PostHog, Google Ads, Google Search Console, Bing Webmaster). 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.

gkit uses argc only to render the offline schema. The public dispatcher is
spaced commands (`gkit gsc api call`), not argc dotted paths or `@run`.

## Discover Capabilities First

This skill is a recipe guide, not a complete capability list. Discovery is
offline and does not load a profile or resolve secrets:

```bash
gkit --schema
gkit --schema gsc
gkit describe --id gsc.search-analytics.query
gkit docs --provider gsc
```

## Core Workflow

Create `$XDG_CONFIG_HOME/gkit/profiles/<app>.json` (or
`~/.config/gkit/profiles/<app>.json`) with provider config and `env:` secret
references only. Inject the real values at runtime. Then:

```bash
gkit --profile my-app gsc doctor
gkit --profile my-app gsc api call --operation-id gsc.properties.list --input @request.json --out result.json --dry-run
```

`--profile` wins over `GKIT_PROFILE`. DataForSEO spend calls also need
`--allow-spend` and `--max-spend-usd`. Default artifact behavior is
no-replace; add `--force` only after reviewing the destination.

## Anti-Patterns

| Don't | Do | Why |
| --- | --- | --- |
| Call `gkit gsc.api.call` or `@run` | Use spaced `gkit gsc api call` | argc's dispatcher is not the public surface |
| Put tokens in the profile, repo, or argv | `env:NAME` references, inject at runtime | Profiles hold non-secret defaults |
| Skip doctor before a live request | `gkit --profile <app> <provider> doctor` | Fail closed on missing config or secrets |
| Pipe a provider payload into context | Persist with `--out` and re-read | stdout is an envelope; the bulk is a file |
| Merge two App profiles in one process | One profile per invocation | Binding is exclusive |
5 changes: 5 additions & 0 deletions packages/gkit/src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@ describe("gkit argv parser", () => {

it("keeps discovery commands profile-free", () => {
expect(parseArgs(["--schema"])).toEqual({ kind: "schema", selector: null });
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({
kind: "describe",
id: "capability",
});
expect(() => parseArgs(["--profile", "app-a", "--schema"])).toThrow(
"--schema does not load a profile",
);
expect(() => parseArgs(["--profile", "app-a", "@skill"])).toThrow(
"@skill does not load a profile",
);
expect(parseArgs(["ledger"])).toEqual({ kind: "ledger-status" });
expect(parseArgs(["ledger", "status"])).toEqual({ kind: "ledger-status" });
});
Expand Down
10 changes: 10 additions & 0 deletions packages/gkit/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GkitFailure } from "./envelope";
export type ParsedCommand =
| { kind: "help" }
| { kind: "schema"; selector: string | null }
| { kind: "skill"; path: string | null }
| { kind: "describe"; id: string }
| { kind: "docs"; provider: string | null }
| { kind: "ledger-status" }
Expand Down Expand Up @@ -166,6 +167,14 @@ export function parseArgs(argv: string[]): ParsedCommand {
return { kind: "schema", selector };
}

// First-token builtin matching argc's @skill contract; gkit keeps its own
// dispatcher, so the skill must be served here rather than via cli().
if (rest[0] === "@skill") {
if (profileFlag) invalid("@skill does not load a profile.");
if (rest.length > 2) invalid("@skill takes at most one path.");
return { kind: "skill", path: rest[1] ?? null };
}

if (rest[0] === "describe") {
if (profileFlag) invalid("describe does not load a profile.");
const flags = parseFlags(rest.slice(1), new Set());
Expand Down Expand Up @@ -287,6 +296,7 @@ export function renderHelp(): string {
"",
"Discovery:",
" gkit --schema [selector]",
" gkit @skill [path]",
" gkit describe --id <capability-id>",
" gkit docs [--provider <provider>]",
"",
Expand Down
41 changes: 41 additions & 0 deletions packages/gkit/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { fileURLToPath } from "node:url";

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

import { parseArgs, renderHelp } from "./args";
import { embedSkill } from "./skill.embed";
import { describeCapability } from "./describe";
import {
runBingDoctor,
Expand Down Expand Up @@ -121,6 +124,12 @@ export async function main(
return;
}

if (command.kind === "skill") {
const exitCode = await renderEmbeddedSkill(command.path, emitter);
process.exitCode = abortController.signal.aborted ? 130 : exitCode;
return;
}

if (command.kind === "docs") {
if (
command.provider &&
Expand Down Expand Up @@ -316,6 +325,38 @@ export async function main(
}
}

async function renderEmbeddedSkill(
path: string | null,
emitter: TerminalEmitter,
): Promise<0 | 1> {
const vfs = await embedSkill();
if (path === null) {
const body = vfs["SKILL.md"];
if (body === undefined) {
throw new GkitFailure({
code: "INTERNAL_ERROR",
message: "The embedded skill is missing SKILL.md.",
});
}
await emitter.writeText(formatBareSkill(body, Object.keys(vfs), "gkit"));
return 0;
}
const content = vfs[path];
if (content === undefined) {
// Match argc's UNKNOWN_SKILL_FILE envelope so agents can correct the path
// in one shot. This builtin is not a gkit JSON envelope.
const files = Object.keys(vfs).sort();
process.stderr.write(
`error: UNKNOWN_SKILL_FILE\ngot: ${path}\nfiles:\n${files
.map((file) => ` - ${file}`)
.join("\n")}\n`,
);
return 1;
}
await emitter.writeText(ensureTrailingNewline(content));
return 0;
}

async function loadDiscoveryManifests(options: {
bingManifestPath?: string;
manifestPath?: string;
Expand Down
34 changes: 31 additions & 3 deletions packages/gkit/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,31 @@ function schemaPreamble(): string {
}

function compactRootSchema(generated: string): string {
return generated
.replace(/^\s*\/\*\* (?:\d+ reviewed reads?|Profile check\.|Native API\.) \*\/\n/gm, "")
.replace(/\n{2,}/g, "\n");
return (
generated
// argc 7.6+ promotes authored examples into JSDoc blocks. Keep the 7.5
// public surface: drop doctor noise, collapse everything else that is
// not describe / docs / ledger.reconcile.
.replace(
/\n[ \t]*\/\*\*\n[ \t]*\* Profile check\.\n(?:[ \t]*\*\n[ \t]*\* @example\n(?:[ \t]*\* .+\n)+)?[ \t]*\*\/\n/g,
"\n",
)
.replace(
/^([ \t]*)\/\*\*\n[ \t]*\* ([^\n]+)\n[ \t]*\*\n[ \t]*\* @example\n(?:[ \t]*\* .+\n)+[ \t]*\*\//gm,
(block, indent: string, description: string) => {
if (
description.startsWith("Capability details.") ||
description.startsWith("Provider docs directory.") ||
description.startsWith("Manual settlement.")
) {
return block;
}
return `${indent}/** ${description} */`;
},
)
.replace(/^\s*\/\*\* (?:\d+ reviewed reads?|Profile check\.|Native API\.) \*\/\n/gm, "")
.replace(/\n{2,}/g, "\n")
);
}

function rewriteArgcExamples(
Expand All @@ -251,7 +273,13 @@ function rewriteArgcExamples(
): string {
return generated
.replace(/gkit describe "\{ id: 'value' \}"/g, "gkit describe --id <capability-id>")
.replace(/gkit describe --id \S+/g, "gkit describe --id <capability-id>")
.replace(/gkit docs "\{ provider: 'value' \}"/g, "gkit docs --provider <provider>")
.replace(/gkit docs --provider \S+/g, "gkit docs --provider <provider>")
.replace(
/gkit ledger reconcile --attempt <id> --outcome confirmed_not_charged --evidence-ref ticket:123/g,
"gkit ledger reconcile --attempt <id> --outcome <outcome> --evidence-ref <ref> [--cost-usd <decimal>]",
)
.replace(
/gkit bing\.api\.call "[^"]*"/g,
"gkit --profile <app> bing api call --operation-id <id> --input @request.json --out <path> --dry-run",
Expand Down
6 changes: 6 additions & 0 deletions packages/gkit/src/skill.embed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Runtime read, not a Bun macro: gkit ships as a source tarball installed
// into node_modules, and Bun refuses to run macros from there.
export async function embedSkill(): Promise<Record<string, string>> {
const text = await Bun.file(new URL("./SKILL.md", import.meta.url)).text();
return { "SKILL.md": text };
}
Loading