@quonfig/cli 0.2.3 → 0.3.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 +195 -60
- package/dist/agent/install-skill.d.ts +37 -0
- package/dist/agent/install-skill.js +110 -0
- package/dist/agent/skill-bundle.generated.d.ts +6 -0
- package/dist/agent/skill-bundle.generated.js +21 -0
- package/dist/codegen/json-schema-to-zod.js +99 -24
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +4 -2
- package/dist/commands/agent/install-skill.d.ts +12 -0
- package/dist/commands/agent/install-skill.js +83 -0
- package/dist/commands/cleanup/list.js +2 -1
- package/dist/commands/cleanup/remove.js +17 -6
- package/dist/commands/cleanup/status.js +2 -2
- package/dist/commands/cleanup.js +4 -3
- package/dist/commands/config-schema.js +3 -0
- package/dist/commands/contexts/search.d.ts +15 -0
- package/dist/commands/contexts/search.js +78 -0
- package/dist/commands/contexts.d.ts +6 -0
- package/dist/commands/contexts.js +10 -0
- package/dist/commands/set-default.js +5 -1
- package/dist/init/templates.js +17 -36
- package/dist/util/encryption.js +3 -1
- package/dist/verify/schema-error-reshape.d.ts +5 -0
- package/dist/verify/schema-error-reshape.js +23 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +239 -6
- package/package.json +2 -3
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install the vendored Quonfig agent skill into an application repo.
|
|
3
|
+
*
|
|
4
|
+
* The skill text is embedded at build time from github.com/quonfig/skills
|
|
5
|
+
* (see scripts/sync-agent-skill.mjs) — nothing is fetched at runtime.
|
|
6
|
+
*
|
|
7
|
+
* Ownership: a `.quonfig-skill.json` manifest next to SKILL.md records the
|
|
8
|
+
* sha256 of every file as we wrote it. On re-run, a file whose bytes still
|
|
9
|
+
* match the manifest is ours to upgrade; a file that differs from both the
|
|
10
|
+
* manifest and the new content was edited by the customer and is only
|
|
11
|
+
* overwritten with `force`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MANIFEST_FILE = ".quonfig-skill.json";
|
|
14
|
+
export declare const DEFAULT_SKILLS_DIR: string;
|
|
15
|
+
export type FileStatus = 'conflict' | 'created' | 'removed' | 'unchanged' | 'updated';
|
|
16
|
+
export type InstallPlan = {
|
|
17
|
+
conflicts: string[];
|
|
18
|
+
files: {
|
|
19
|
+
content?: string;
|
|
20
|
+
path: string;
|
|
21
|
+
status: FileStatus;
|
|
22
|
+
}[];
|
|
23
|
+
skillDir: string;
|
|
24
|
+
version: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function planInstall({ force, skillsDir }: {
|
|
27
|
+
force: boolean;
|
|
28
|
+
skillsDir: string;
|
|
29
|
+
}): InstallPlan;
|
|
30
|
+
export declare function executeInstall(plan: InstallPlan): void;
|
|
31
|
+
/** The one line an AGENTS.md needs so non-Claude agents (Codex, Cursor, ...) find the skill. */
|
|
32
|
+
export declare function pointerLine(skillDir: string): string;
|
|
33
|
+
export type PointerResult = 'added' | 'present';
|
|
34
|
+
/** True when ./AGENTS.md already points at the installed skill. */
|
|
35
|
+
export declare function hasPointer(repoDir: string, skillDir: string): boolean;
|
|
36
|
+
/** Append the pointer to AGENTS.md (creating it if needed). Idempotent. */
|
|
37
|
+
export declare function addPointer(repoDir: string, skillDir: string): PointerResult;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { SKILL_FILES, SKILL_NAME, SKILL_VERSION } from './skill-bundle.generated.js';
|
|
5
|
+
/**
|
|
6
|
+
* Install the vendored Quonfig agent skill into an application repo.
|
|
7
|
+
*
|
|
8
|
+
* The skill text is embedded at build time from github.com/quonfig/skills
|
|
9
|
+
* (see scripts/sync-agent-skill.mjs) — nothing is fetched at runtime.
|
|
10
|
+
*
|
|
11
|
+
* Ownership: a `.quonfig-skill.json` manifest next to SKILL.md records the
|
|
12
|
+
* sha256 of every file as we wrote it. On re-run, a file whose bytes still
|
|
13
|
+
* match the manifest is ours to upgrade; a file that differs from both the
|
|
14
|
+
* manifest and the new content was edited by the customer and is only
|
|
15
|
+
* overwritten with `force`.
|
|
16
|
+
*/
|
|
17
|
+
export const MANIFEST_FILE = '.quonfig-skill.json';
|
|
18
|
+
export const DEFAULT_SKILLS_DIR = path.join('.claude', 'skills');
|
|
19
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
20
|
+
function readManifest(skillDir) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(path.join(skillDir, MANIFEST_FILE), 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function planInstall({ force, skillsDir }) {
|
|
29
|
+
const skillDir = path.join(skillsDir, SKILL_NAME);
|
|
30
|
+
const manifest = readManifest(skillDir);
|
|
31
|
+
const owned = manifest?.files ?? {};
|
|
32
|
+
const files = [];
|
|
33
|
+
const conflicts = [];
|
|
34
|
+
for (const file of SKILL_FILES) {
|
|
35
|
+
const target = path.join(skillDir, file.path);
|
|
36
|
+
if (!fs.existsSync(target)) {
|
|
37
|
+
files.push({ content: file.content, path: file.path, status: 'created' });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const current = fs.readFileSync(target, 'utf8');
|
|
41
|
+
if (current === file.content) {
|
|
42
|
+
files.push({ path: file.path, status: 'unchanged' });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const ours = owned[file.path] !== undefined && owned[file.path] === sha256(current);
|
|
46
|
+
if (ours || force) {
|
|
47
|
+
files.push({ content: file.content, path: file.path, status: 'updated' });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
files.push({ path: file.path, status: 'conflict' });
|
|
51
|
+
conflicts.push(file.path);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Files an earlier version installed that this version no longer ships:
|
|
55
|
+
// remove them only if they are still byte-for-byte what we wrote.
|
|
56
|
+
const shipped = new Set(SKILL_FILES.map((f) => f.path));
|
|
57
|
+
for (const [rel, hash] of Object.entries(owned)) {
|
|
58
|
+
if (shipped.has(rel))
|
|
59
|
+
continue;
|
|
60
|
+
const target = path.join(skillDir, rel);
|
|
61
|
+
if (fs.existsSync(target) && sha256(fs.readFileSync(target, 'utf8')) === hash) {
|
|
62
|
+
files.push({ path: rel, status: 'removed' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { conflicts, files, skillDir, version: SKILL_VERSION };
|
|
66
|
+
}
|
|
67
|
+
export function executeInstall(plan) {
|
|
68
|
+
for (const file of plan.files) {
|
|
69
|
+
const target = path.join(plan.skillDir, file.path);
|
|
70
|
+
if (file.status === 'created' || file.status === 'updated') {
|
|
71
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
72
|
+
fs.writeFileSync(target, file.content);
|
|
73
|
+
}
|
|
74
|
+
else if (file.status === 'removed') {
|
|
75
|
+
fs.rmSync(target);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const manifest = {
|
|
79
|
+
files: Object.fromEntries(SKILL_FILES.map((f) => [f.path, sha256(f.content)])),
|
|
80
|
+
skill: SKILL_NAME,
|
|
81
|
+
version: SKILL_VERSION,
|
|
82
|
+
};
|
|
83
|
+
fs.mkdirSync(plan.skillDir, { recursive: true });
|
|
84
|
+
fs.writeFileSync(path.join(plan.skillDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2) + '\n');
|
|
85
|
+
}
|
|
86
|
+
/** The one line an AGENTS.md needs so non-Claude agents (Codex, Cursor, ...) find the skill. */
|
|
87
|
+
export function pointerLine(skillDir) {
|
|
88
|
+
const rel = skillDir.split(path.sep).join('/');
|
|
89
|
+
return `- **Quonfig (feature flags, config, log levels):** before adding, reading, or changing a flag or config, read \`${rel}/SKILL.md\` and follow it. (Installed by \`qfg agent install-skill\`.)`;
|
|
90
|
+
}
|
|
91
|
+
function agentsMdState(repoDir, skillDir) {
|
|
92
|
+
const agentsMd = path.join(repoDir, 'AGENTS.md');
|
|
93
|
+
const marker = `${path.relative(repoDir, skillDir).split(path.sep).join('/')}/SKILL.md`;
|
|
94
|
+
const existing = fs.existsSync(agentsMd) ? fs.readFileSync(agentsMd, 'utf8') : '';
|
|
95
|
+
return { existing, marker, path: agentsMd };
|
|
96
|
+
}
|
|
97
|
+
/** True when ./AGENTS.md already points at the installed skill. */
|
|
98
|
+
export function hasPointer(repoDir, skillDir) {
|
|
99
|
+
const { existing, marker } = agentsMdState(repoDir, skillDir);
|
|
100
|
+
return existing.includes(marker);
|
|
101
|
+
}
|
|
102
|
+
/** Append the pointer to AGENTS.md (creating it if needed). Idempotent. */
|
|
103
|
+
export function addPointer(repoDir, skillDir) {
|
|
104
|
+
const { existing, marker, path: agentsMd } = agentsMdState(repoDir, skillDir);
|
|
105
|
+
if (existing.includes(marker))
|
|
106
|
+
return 'present';
|
|
107
|
+
const prefix = existing === '' ? '# Agent Instructions\n\n' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
108
|
+
fs.writeFileSync(agentsMd, existing + prefix + pointerLine(path.relative(repoDir, skillDir)) + '\n');
|
|
109
|
+
return 'added';
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-agent-skill.mjs from github.com/quonfig/skills
|
|
2
|
+
// (plugins/quonfig/skills/quonfig). Do not edit by hand: change the skills repo,
|
|
3
|
+
// then run `node scripts/sync-agent-skill.mjs`.
|
|
4
|
+
/* eslint-disable */
|
|
5
|
+
export const SKILL_NAME = "quonfig";
|
|
6
|
+
export const SKILL_VERSION = "1.0.1";
|
|
7
|
+
export const SKILL_FILES = [
|
|
8
|
+
{ path: "SKILL.md", content: "---\nname: quonfig\ndescription: Use Quonfig feature flags, dynamic config, and log levels correctly from an application codebase. Covers picking and wiring up the right Quonfig SDK (Node, browser JS, React, React Native, Go, Python, Ruby, Java, .NET, Swift), creating flags and configs with the qfg CLI, gating code with the SDK and the right context, rolling out per environment, typed clients, and retiring flags — plus the Quonfig MCP tools when there is no checkout. Use this skill whenever the user wants to add, check, roll out, toggle, read, or remove a feature flag or remote config value, put code behind a flag, add a kill switch, change a log level at runtime, or mentions Quonfig, qfg, @quonfig/*, QUONFIG_BACKEND_SDK_KEY, or a flag key in this repo.\n---\n\n# Quonfig\n\nQuonfig stores feature flags, configs, segments, and log levels as JSON files in\na git repo (the **workspace**). Your application reads them through a Quonfig\nSDK; people and agents change them with the `qfg` CLI, the Quonfig MCP server,\nor the web app. Every change is a git commit.\n\nThis skill is for an **application repo** (code that *reads* flags). If the\ncurrent repo is itself a workspace (it has `quonfig.json` and `feature-flags/`,\n`configs/` directories), follow that repo's `AGENTS.md`/`CLAUDE.md` instead:\nedit the JSON there and run `qfg verify`.\n\n## 1. Orient before writing code\n\n- **Find the SDK.** Look for an existing client before adding one: grep for\n `@quonfig/`, `quonfig` in `go.mod` / `Gemfile` / `pyproject.toml` /\n `requirements.txt` / `pom.xml` / `build.gradle*` / `*.csproj` /\n `Package.swift`, and for where the client is constructed. Reuse it — one\n client per process.\n- **Read the reference for this language** (only the one you need):\n\n | Stack | Reference |\n |-------|-----------|\n | Node.js server (`@quonfig/node`) | [references/sdk-node.md](references/sdk-node.md) |\n | Browser JS, React, React Native | [references/sdk-browser.md](references/sdk-browser.md) |\n | Go | [references/sdk-go.md](references/sdk-go.md) |\n | Python | [references/sdk-python.md](references/sdk-python.md) |\n | Ruby | [references/sdk-ruby.md](references/sdk-ruby.md) |\n | Java / Kotlin | [references/sdk-java.md](references/sdk-java.md) |\n | .NET | [references/sdk-dotnet.md](references/sdk-dotnet.md) |\n | Swift (iOS/macOS) | [references/sdk-swift.md](references/sdk-swift.md) |\n\n- **Pick the surface for changing flags.** You have a shell and a checkout, so\n use `qfg` (check with `qfg whoami`; install with `npm install -g @quonfig/cli`,\n sign in with `qfg login` — the human does the browser step). Use the MCP\n tools only when `qfg` is unavailable or for a quick read of live state.\n Details: [references/writes.md](references/writes.md).\n\n## 2. Adding a feature flag (the core loop)\n\n1. **Name it.** Keys are lowercase, dot-separated, grouped by area:\n `checkout.new-flow`, `search.semantic-ranking`. Check it isn't taken and\n isn't already what you need: `qfg list --feature-flags --json`.\n2. **Create it, off.** `qfg create checkout.new-flow --type boolean-flag --value=false`.\n If it already exists, stop and ask — never invent a variant of the key.\n3. **Gate the code** with the SDK's flag call and a context that identifies\n who is asking (at minimum `user.key`, so rollouts and targeting work):\n - Node: `quonfig.isEnabled(\"checkout.new-flow\", { user: { key: user.id } })`\n - Go: `on, _ := client.FeatureIsOn(\"checkout.new-flow\", ctx)`\n - Python: `client.is_feature_enabled(\"checkout.new-flow\", contexts={\"user\": {\"key\": user.id}})`\n - others: see the language reference.\n Keep the off path identical to today's behavior, so the flag can ship dark.\n4. **Turn it on where it is safe** — normally development first:\n `qfg set-default checkout.new-flow --value=true --environment=development --confirm`.\n Always name the environment. (`--confirm` skips the interactive prompt —\n without a terminal, `set-default` and `set-rollout` refuse to run without it.) Do not change production (or roll out there)\n unless the human asked for it.\n5. **Tell the human** the key, which environments serve what, and the rollout\n command for later (`qfg set-rollout checkout.new-flow --environment=production --true-percent 10 --confirm`).\n\n## 3. Configs and log levels\n\n- A dynamic value instead of a constant: `qfg create checkout.timeout --type int --value=30`,\n then read it with the SDK's typed getter (see the language reference).\n Types: `string`, `int`, `double`, `boolean`, `string-list`, `json`, `duration`.\n- Create a key **before** shipping code that reads it — most server SDKs throw\n on a missing key with no default.\n- Runtime log levels: one `log-level.<service>` key per service, wired into\n the app's logger via the SDK's `loggerKey`; change one logger with\n `qfg log-level log-level.checkout-api --target=Checkout.Payments --value=DEBUG --environment=staging`.\n- TypeScript apps can generate a typed client: `qfg generate --targets node-ts`\n (server) or `--targets react-ts` (React).\n\n## 4. Rules that prevent the classic mistakes\n\n- **Name the environment** in every write and every answer about state. A flag\n is usually on in development and off in production.\n- **Setting a value keeps targeting rules.** `set-default`/`set-rollout` change\n only the fallback rule; `--replace-targeting` deletes the environment's\n targeting and needs a human's agreement first. Read with `qfg info <key>`\n before you write. See [references/writes.md](references/writes.md).\n- **Backend vs frontend keys.** Server SDKs use `QUONFIG_BACKEND_SDK_KEY` and\n evaluate locally; browser/mobile SDKs use a frontend key and get values\n evaluated for one context. Never ship a backend key to a browser or app.\n Never commit any key.\n- **The SDK key decides the environment** in server SDKs. Set\n `QUONFIG_ENVIRONMENT` / `environment` only for local datadir mode.\n\n## 5. More\n\n- Full `qfg` cheat sheet (create types, rollouts, SDK keys, overrides, `qfg run`):\n [references/cli.md](references/cli.md)\n- Quonfig MCP tools (Slack, claude.ai, no checkout; undo with `set_document`):\n [references/mcp.md](references/mcp.md)\n- Removing a flag once its rollout is done (`qfg cleanup`):\n [references/retire-a-flag.md](references/retire-a-flag.md)\n- Docs: https://docs.quonfig.com\n" },
|
|
9
|
+
{ path: "references/cli.md", content: "# `qfg` command reference (app-developer loop)\n\nInstall: `npm install -g @quonfig/cli`. Every command takes `--json` for\nstructured output; prompts turn off automatically when stdin is not a TTY, so\npass every required flag — including `--confirm` on `set-default` /\n`set-rollout` and `--yes` on `push` / `delete`, which otherwise refuse to run\nwithout a terminal. `qfg <command> --help` is authoritative.\n\n## Auth and workspace\n\n```bash\nqfg login # browser device sign-in, once per machine\nqfg whoami # who am I, which orgs\nqfg workspace # active workspace + everything reachable\nqfg workspace switch acme/production\nqfg list -w acme/production # -w <org>/<workspace> on any API command\n```\n\nCI: set `QUONFIG_API_KEY` **and** `QUONFIG_WORKSPACE=<org>/<workspace>`.\n\n## Look before you create\n\n```bash\nqfg list --feature-flags --json # what exists (also --configs, --log-levels, --segments)\nqfg info checkout.new-flow # every environment's rules + 24h evaluations\nqfg get checkout.timeout --environment production # evaluated value in one env\n```\n\n## Create\n\n```bash\nqfg create checkout.new-flow --type boolean-flag --value=false\nqfg create checkout.banner-text --type string --value=\"Free shipping today\"\nqfg create checkout.timeout --type int --value=30\nqfg create checkout.retry-window --type duration --value PT90S\nqfg create log-level.checkout-api --type log_level --value WARN\n```\n\n- `--type`: `boolean-flag | boolean | string | int | double | string-list | json | duration | log_level`.\n Use `boolean-flag` for a feature flag (it is also sent to client SDKs);\n `boolean` is a plain config.\n- `create` sets the default for all environments; there is no `--environment`.\n- If the key already exists the command fails (\"... already exists\"). Do not\n retry with a tweaked key — use the existing one or agree a new name with a\n human.\n\n## Change what an environment serves\n\n```bash\nqfg set-default checkout.new-flow --value=true --environment=development --confirm\nqfg set-rollout checkout.new-flow --environment=staging --true-percent 20 --confirm\nqfg set-rollout checkout.layout --environment=production --weights \"control:50,treatment:50\" --confirm\nqfg set-default checkout.new-flow --value=false --environment=production --replace-targeting --confirm # kill switch: off for everyone\n```\n\n`--environment` is required when not interactive. These keep targeting rules\nunless you pass `--replace-targeting` — see [writes.md](writes.md).\n\n## Log levels\n\n```bash\nqfg log-level log-level.checkout-api --value=WARN # creates the key (fails if it exists)\nqfg set-default log-level.checkout-api --value=INFO --environment=production --confirm # change an existing default\nqfg log-level log-level.checkout-api --target=Checkout.Payments --value=DEBUG --environment=staging\n```\n\n## SDK keys\n\n```bash\nqfg sdk-key create --environment production --type server # backend: QUONFIG_BACKEND_SDK_KEY\nqfg sdk-key create --environment development --type browser # frontend SDKs\nqfg sdk-key list\n```\n\nNever commit a key; put it in the app's secret store / `.env` (gitignored).\n\n## Typed clients (TypeScript only)\n\n```bash\nqfg generate --targets node-ts -o generated # @quonfig/node\nqfg generate --targets react-ts -o generated # @quonfig/react (default target)\n```\n\n## Run a process with config injected as env vars\n\n```bash\nqfg run --env DATABASE_URL=db.url --environment=staging -- npm run migrate\n```\n\n## Personal override (only you see it)\n\n```bash\nqfg override checkout.new-flow true --env=development\nqfg override checkout.new-flow --remove --env=development\n```\n\nFires only for SDK clients that send the `quonfig-user.email` context.\n\n## Workspace repo (editing JSON directly)\n\n```bash\nqfg pull --dir ./quonfig-workspace # clone/refresh the workspace repo\nqfg verify ./quonfig-workspace # validate (local, no login)\nqfg push --dir ./quonfig-workspace --yes # publish committed edits\nqfg config-schema # operators, field names, value types\n```\n\nInside a workspace repo, follow that repo's `AGENTS.md` / `CLAUDE.md`.\n" },
|
|
10
|
+
{ path: "references/mcp.md", content: "# Quonfig MCP server\n\n`https://mcp.quonfig.com/mcp` — streamable HTTP. Use it where there is no repo\ncheckout or shell (Slack via Claude Tag, claude.ai), or to answer \"what is\nproduction serving right now?\" from inside a coding session. The plugin\ninstall registers it as `quonfig`; elsewhere add it with\n`claude mcp add --transport http quonfig https://mcp.quonfig.com/mcp`.\nDevelopers sign in through the browser (OAuth); shared bots use a `qf_sa_`\nservice-account key. Either way the caller can only do what its Quonfig roles\nallow.\n\nFourteen read tools and six write tools. Full list:\nhttps://docs.quonfig.com/docs/api/mcp-server#tools\n\n## Answering questions\n\n- Current state of one flag: `get_flag` (returns per-environment rules,\n rollout percentages, and variants). Do not infer state from `list_flags`\n alone.\n- A rule with the `IN_SEG` operator names a segment, it does not list its\n members. Call `get_segment` before claiming who receives the flag.\n- \"Who changed this, and when\": `get_flag_history` / `get_config_history`.\n For workspace-wide questions use `get_recent_changes`. For \"when did this\n last change\" across many items, read `lastModified` off the `list_flags` /\n `list_configs` rows instead of calling a history tool per item.\n- Always name the environment in your answer. A flag is usually on in\n development and off in production; an answer that omits the environment is\n wrong more often than right.\n\n## Changing a flag or config\n\n- `set_flag` and `set_config` are the everyday writes, and each sets the\n scope's FALLBACK: the unconditional rule at the end of its rule list, what\n users receive when no targeting rule matches. Targeting rules above it are\n kept — say so, naming the `preservedTargetingRuleCount` the write returns.\n A scope with no rules of its own is copied from the default rules first, so\n inherited targeting is kept too.\n- Read the item first, then say plainly what will change and in which scope\n before you call it.\n- `replaceTargeting: true` is a different request: it sets the value for\n everyone, deleting the scope's targeting rules. Show the rules and ask a\n human; send it only after someone agrees — neither tool can put those rules\n back afterwards. Report the returned `previousCommitSha` and\n `replacedTargetingRuleCount`.\n- Never guess an environment name. Call `list_environments`. A scope is\n either one of those names or the literal `\"default\"` — the rules every\n environment without its own entry inherits, which for most configs is the\n only place a value is stored. Naming an environment SHADOWS the default\n there; if the request is ambiguous (\"turn it off\"), ask which was meant.\n\n## Log levels\n\n- `set_log_level` is SURGICAL, unlike the two above: it adds or overwrites\n exactly one rule and leaves every sibling in place, so it never needs a\n confirmation to avoid destroying targeting.\n- `key` is the SERVICE (`log-level.api-delivery`) — there is one document\n per service. Pass `target` (a logger path prefix) to change one logger, or\n omit it to set the scope's fallback level. `list_log_levels` shows what is\n stored.\n\n## Creating\n\n- `create_flag` and `create_config` only ADD. A new flag is created OFF in\n every environment — enabling it is a separate `set_flag` call. A new\n config needs an explicit `valueType`.\n- `ALREADY_EXISTS` means the key is taken; read `collidingType` to see by\n what. NEVER retry a create with a mutated key — agree a different key\n with a human instead.\n\n## Raw documents and undo\n\n- `get_document` / `set_document` read and replace the stored JSON for a\n flag, config, or log level (`type`: `flag`, `config`, or `log-level` —\n note the hyphen; `get_recent_changes` spells the same kind `log_level`).\n Use them for edits the write verbs cannot express — multi-rule targeting,\n variants, metadata — and to undo any write.\n- `set_document` is a FULL REPLACEMENT: a field you omit is deleted. Always\n start from a `get_document` result and edit it; never build the document\n by hand.\n- To undo: `get_document` with `at` set to the bad write's\n `previousCommitSha`, then `get_document` again without `at` for the\n current `commitSha`, then `set_document` the old document pinned to that\n CURRENT sha. Say what you are restoring before you write it.\n- A `STALE_COMMIT_SHA` failure means someone else changed the item. Re-read\n and re-apply — never retry the same `set_document` arguments.\n\n## More than one workspace\n\nWith several workspaces, the first call comes back asking which one and\nlisting the options; retry with the workspace the human names.\n" },
|
|
11
|
+
{ path: "references/retire-a-flag.md", content: "# Retiring a flag\n\nFlags are meant to be temporary. When a rollout is finished, take the flag out\nof the code first, then delete its definition. Walk the `qfg cleanup`\nlifecycle instead of jumping to `qfg delete`.\n\n```bash\nqfg cleanup list # flags an owner marked readyForCleanup, with eval volume\nqfg cleanup status my.flag # telemetry + per-environment rules\nqfg cleanup remove my.flag # safety gate (no evals in 2 days), writes .qf/cleanup/my.flag.json\n```\n\nThen remove the call sites, using the payload at `.qf/cleanup/<key>.json`\n(`type`, per-environment rules, `grepPatterns`):\n\n1. **Find every call site.** Grep for the key together with the SDK methods in\n `grepPatterns` (`isEnabled`, `get`, `FeatureIsOn`, ...). Also look for\n wrappers the codebase built around the SDK (a `flagEnabled(\"...\")` helper,\n a `<FlagGate flag=\"...\">` component) and follow them to their callers.\n2. **Ask the engineer once which value wins** — \"keep the true branch and\n delete the false branch?\", or for a variant/config \"which of these values\n wins?\" (list the values per environment from the payload). Do not infer it.\n3. **Apply it mechanically.** The SDK call becomes the literal value, the\n unreachable branch is deleted, a helper that existed only for this flag is\n deleted. Do not refactor unrelated code.\n4. **Run the repo's formatter and tests.**\n5. **Open one PR** for this flag in this repo, naming the flag, the chosen\n direction, and the call sites changed. If the payload says `forced: true`,\n say so. Never auto-merge.\n\nAfter the PR merges and the service redeploys:\n\n```bash\nqfg cleanup verify my.flag # 7-day zero-evaluation gate\nqfg delete my.flag --yes # only after the engineer agrees\n```\n" },
|
|
12
|
+
{ path: "references/sdk-browser.md", content: "# Browser JavaScript, React, React Native\n\nFrontend SDKs **evaluate on the server**: the client sends its context and gets\nback already-evaluated values for that context. Use a **frontend (browser) SDK\nkey** (`qfg sdk-key create --environment <env> --type browser`), never the\nbackend key. Only flags/configs sent to client SDKs are visible here\n(`qfg create --type boolean-flag` sets that by default).\n\nContext is fixed per client: set it at init, then change it with\n`updateContext` (JS) or the `contextAttributes` prop (React). There is no\nper-call context argument.\n\n## Plain JavaScript — `@quonfig/javascript`\n\n```bash\nnpm install @quonfig/javascript\n```\n\n```js\nimport { quonfig } from \"@quonfig/javascript\";\n\nawait quonfig.init({\n sdkKey: \"your-frontend-sdk-key\",\n context: { user: { key: \"user-123\" } }, // required\n});\n\nif (quonfig.isEnabled(\"my-flag\")) {\n // ...\n}\nconst color = quonfig.get(\"button-color\"); // undefined until loaded\n\nawait quonfig.updateContext({ user: { key: \"user-456\" } }); // re-fetches\nawait quonfig.poll({ frequencyInMs: 60000 }); // optional: keep values fresh\n```\n\nBesides `get` and `isEnabled` there are only `getDuration` and `getDetails` —\nno typed getters, and no default argument on `get`. Supply your own fallback (`quonfig.get(\"greeting\") ?? \"Hello\"`).\n\n## React — `@quonfig/react`\n\n```bash\nnpm install @quonfig/react\n```\n\n```jsx\nimport { QuonfigProvider, useQuonfig, useFlag } from \"@quonfig/react\";\n\nconst WrappedApp = () => (\n <QuonfigProvider\n sdkKey={process.env.NEXT_PUBLIC_QUONFIG_API_KEY}\n contextAttributes={{ user: { key: user.id, email: user.email } }}\n onError={(error) => console.error(error)}\n >\n <App />\n </QuonfigProvider>\n);\n\nconst Logo = () => {\n const { isEnabled, get, loading } = useQuonfig();\n return isEnabled(\"new-logo\") ? <NewLogo /> : <OldLogo />;\n};\n\nconst Banner = () => {\n const showBanner = useFlag(\"new-banner\");\n // ...\n};\n```\n\n- Changing `contextAttributes` re-fetches. `pollInterval` (ms) opts into polling.\n- `loading` is true until values arrive; `initialFlags` hydrates SSR without a\n flicker.\n- Tests: wrap components in `QuonfigTestProvider config={{ \"my-flag\": true }}`.\n- Typed hooks: `qfg generate --targets react-ts -o ./generated`, then\n `import { useQuonfig } from \"./generated/quonfig-client\"` and read\n `q.flagSmoke` style accessors.\n\n## React Native — `@quonfig/react-native`\n\n```bash\nnpm install @quonfig/react-native @quonfig/react @quonfig/javascript base-64 react-native-get-random-values\n```\n\nImport `\"@quonfig/react-native\"` first in `index.js` (it installs polyfills),\nthen use `QuonfigProvider` / `useQuonfig` from `@quonfig/react-native` exactly\nas in React above.\n" },
|
|
13
|
+
{ path: "references/sdk-dotnet.md", content: "# .NET — `Quonfig.Sdk`\n\nServer-side SDK; evaluates in-process.\n\n## Install\n\n```bash\ndotnet add package Quonfig.Sdk\n```\n\nCompanions: `Quonfig.Sdk.AspNetCore`, `Quonfig.Sdk.Extensions.Logging`,\n`Quonfig.Sdk.Serilog`.\n\n## Initialize once\n\n`SdkKey` and `Environment` fall back to `QUONFIG_BACKEND_SDK_KEY` and\n`QUONFIG_ENVIRONMENT`.\n\n```csharp\nusing Quonfig.Sdk;\n\nawait using var quonfig = new Quonfig.Sdk.Quonfig(new QuonfigOptions\n{\n SdkKey = Environment.GetEnvironmentVariable(\"QUONFIG_BACKEND_SDK_KEY\"),\n});\nawait quonfig.InitAsync(); // await before relying on the sync getters\n```\n\nLocal / offline mode (the SDK does not read `QUONFIG_DIR`; set it):\n\n```csharp\nvar options = new QuonfigOptions { Datadir = \"./workspace-data\", Environment = \"development\" };\n```\n\nASP.NET Core (`using Quonfig.Sdk.AspNetCore;`): `builder.Services.AddQuonfig(opts => ...)` registers it with a\nhosted service that drives init and shutdown.\n\n## Context\n\n```csharp\n// Context values are string, int, long, double, bool, or string[] (call .ToString() on a Guid).\nvar ctx = new ContextSet\n{\n [\"user\"] = new ContextProperties { [\"key\"] = user.Id, [\"email\"] = user.Email },\n};\nvar bound = quonfig.WithContext(ctx); // optional\n```\n\n## Feature flag\n\n```csharp\nif (quonfig.IsFeatureEnabled(\"new-dashboard\", ctx))\n{\n // new path\n}\n```\n\n## Config values\n\nArgument order is **(key, contexts, defaultValue)** — context before default:\n\n```csharp\nvar s = quonfig.GetString(\"config.string\", ctx);\nvar n = quonfig.GetInt(\"rate-limit\", ctx);\n```\n\nAlso `GetLong`, `GetBool`, `GetDouble`, `GetStringList`, `GetJson`,\n`GetDuration`. With no default, a missing key throws (`OnNoDefault = Throw`).\n\n## Log levels\n\nSet `LoggerKey = \"log-level.my-app\"` on the options, then\n`builder.Logging.AddQuonfigFilter(quonfig);` (`using Quonfig.Sdk.Extensions.Logging;`).\n\nNo `qfg generate` target for .NET.\n" },
|
|
14
|
+
{ path: "references/sdk-go.md", content: "# Go — `github.com/quonfig/sdk-go`\n\nServer-side SDK; evaluates in-process.\n\n## Install\n\n```bash\ngo get github.com/quonfig/sdk-go\n```\n\n## Initialize once\n\n```go\nimport quonfig \"github.com/quonfig/sdk-go\"\n\n// With no WithSdkKey option, the SDK reads $QUONFIG_BACKEND_SDK_KEY.\n// (WithSdkKey(\"\") is an error, so don't pass an unset variable through it.)\nclient, err := quonfig.NewClient()\nif err != nil {\n log.Fatal(err)\n}\ndefer client.Close()\n```\n\n`NewClient` blocks for the first load (up to `WithInitTimeout`, default 10s).\nThe backend key decides the environment.\n\nLocal / offline mode (the SDK does not read `QUONFIG_DIR`; pass it):\n\n```go\nclient, err := quonfig.NewClient(\n quonfig.WithDataDir(\"./workspace-data\"), // or os.Getenv(\"QUONFIG_DIR\"), if set\n quonfig.WithEnvironment(\"development\"), // or $QUONFIG_ENVIRONMENT\n)\n```\n\n## Context\n\n```go\nctx := quonfig.NewContextSet().\n WithNamedContextValues(\"user\", map[string]interface{}{\n \"key\": user.ID,\n \"email\": user.Email,\n })\n```\n\nPass it per call (`nil` = no context), or bind it:\n`bound := client.WithContext(ctx)` gives methods without the ctx argument\n(`bound.FeatureIsOn(key)`, `bound.GetStringValue(key)`).\n\n## Feature flag\n\n```go\non, found := client.FeatureIsOn(\"new-dashboard\", ctx)\nif on {\n // new path\n}\n```\n\nReturns `false` (and `found == false`) when the key does not exist.\n\n## Config values\n\n```go\ns, found, err := client.GetStringValue(\"config.string\", ctx)\nn, found, err := client.GetIntValue(\"rate-limit\", ctx) // int64\n```\n\nAlso `GetBoolValue`, `GetFloatValue`, `GetStringSliceValue`,\n`GetDurationValue`, `GetJSONValue` — all `(key, *ContextSet)` returning\n`(value, found, err)`. No default argument: handle `found == false` yourself.\n\n## Log levels\n\n`quonfig.WithLoggerKey(\"log-level.my-app\")` plus\n`slog.New(quonfig.NewQuonfigHandler(client, inner, loggerPath))`.\n\nNo `qfg generate` target for Go.\n" },
|
|
15
|
+
{ path: "references/sdk-java.md", content: "# Java — `com.quonfig:sdk-java`\n\nServer-side SDK; evaluates in-process.\n\n## Install\n\n```kotlin\nimplementation(\"com.quonfig:sdk-java:1.5.0\")\n```\n\n```xml\n<dependency>\n <groupId>com.quonfig</groupId>\n <artifactId>sdk-java</artifactId>\n <version>1.5.0</version>\n</dependency>\n```\n\nCheck Maven Central for the latest version. Logging add-ons:\n`sdk-java-logback`, `sdk-java-log4j2`.\n\n## Initialize once\n\n`Options` reads `QUONFIG_BACKEND_SDK_KEY` and `QUONFIG_ENVIRONMENT` from the\nenvironment.\n\n```java\nimport com.quonfig.sdk.BoundQuonfig;\nimport com.quonfig.sdk.Options;\nimport com.quonfig.sdk.Quonfig;\nimport com.quonfig.sdk.eval.ContextSet;\n\nOptions opts = Options.builder()\n .sdkKey(System.getenv(\"QUONFIG_BACKEND_SDK_KEY\"))\n .build();\nQuonfig quonfig = new Quonfig(opts);\nquonfig.initFuture().get(); // wait for the first load\n```\n\nLocal / offline mode (the SDK does not read `QUONFIG_DIR`; `environment` is\nrequired):\n\n```java\nOptions opts = Options.builder()\n .datadir(\"./workspace-data\")\n .environment(\"development\")\n .build();\n```\n\n`Quonfig` is `AutoCloseable` — `close()` on shutdown.\n\n## Context\n\n```java\nContextSet ctx = new ContextSet()\n .withNamedContext(\"user\", Map.of(\"key\", user.getId(), \"email\", user.getEmail()));\nBoundQuonfig bound = quonfig.withContext(ctx); // optional\n```\n\n## Feature flag\n\n```java\nif (quonfig.featureIsOn(\"new-dashboard\", ctx)) {\n // new path\n}\n```\n\n## Config values\n\nThe default is a required argument; context is the optional third:\n\n```java\nString s = quonfig.getString(\"config.string\", \"<missing>\", ctx);\nLong n = quonfig.getInt(\"rate-limit\", 100L, ctx);\n```\n\nAlso `getBool`, `getDouble`, `getStringList`, `getDuration`, `getJson`.\n\n## Log levels\n\n`.loggerKey(\"log-level.my-app\")` on the builder, then\n`QuonfigLogbackTurboFilter.install(quonfig)` (`com.quonfig.sdk.logback`) or\n`QuonfigLog4j2Filter.install(quonfig)` (`com.quonfig.sdk.log4j2`).\n\nNo `qfg generate` target for Java.\n" },
|
|
16
|
+
{ path: "references/sdk-node.md", content: "# Node.js — `@quonfig/node`\n\nServer-side SDK. Downloads the whole workspace and evaluates every check\nin-process (no network call per evaluation).\n\n## Install\n\n```bash\nnpm install @quonfig/node\n```\n\n## Initialize once, at startup\n\n```typescript\nimport { Quonfig } from \"@quonfig/node\";\n\n// sdkKey falls back to process.env.QUONFIG_BACKEND_SDK_KEY when omitted.\nconst quonfig = new Quonfig({ sdkKey: process.env.QUONFIG_BACKEND_SDK_KEY });\nawait quonfig.init();\n```\n\n- The **backend SDK key decides the environment**. Do not also pin\n `environment` in SDK-key mode (the SDK warns and ignores it).\n- Create one client per process and share it (export it from a module). If the\n app already has one, reuse it — grep for `new Quonfig(` first.\n- Local / offline mode reads a workspace checkout instead of the network. Here an\n environment is required (`environment` option or `QUONFIG_ENVIRONMENT`), and\n Node does **not** read `QUONFIG_DIR` itself:\n\n```typescript\nconst quonfig = new Quonfig({\n datadir: \"./workspace-data\",\n environment: \"development\",\n dataDirAutoReload: true, // off by default — must be opted in\n});\nawait quonfig.init();\n```\n\n## Feature flag\n\n```typescript\nif (quonfig.isEnabled(\"new-dashboard\", { user: { key: user.id, email: user.email } })) {\n // new path\n}\n```\n\nSignature: `isEnabled(key, contexts?)`. `isFeatureEnabled` is a deprecated alias\n— write `isEnabled` in new code.\n\n## Config values\n\n```typescript\nconst limit = quonfig.getNumber(\"rate-limit\");\nconst regions = quonfig.getStringList(\"allowed-regions\");\nconst hero = quonfig.get(\"homepage-hero\", { user: { key: \"user-123\", country: \"US\" } });\nconst timeout = quonfig.get(\"api.timeout\", undefined, 30); // only get() takes a default\n```\n\nTyped getters: `getString`, `getNumber`, `getBool`, `getStringList`,\n`getDuration`, `getJSON` — each `(key, contexts?)`, no default argument. With\nthe default `onNoDefault: \"error\"`, reading a key that does not exist (and has\nno default) throws, so create the key before shipping code that reads it.\n\n## Context\n\n```typescript\n// Bound context for repeated lookups (e.g. per request)\nconst userClient = quonfig.withContext({ user: { key: \"user-123\", plan: \"pro\" } });\nuserClient.get(\"feature-x\");\nuserClient.isEnabled(\"beta-feature\");\n```\n\n`inContext` is a deprecated alias of `withContext`. The `globalContext`\nconstructor option applies a context to every evaluation (e.g. service name).\n\n## Typed client (optional)\n\n`qfg generate --targets node-ts -o generated` writes\n`generated/quonfig-server.ts`, a typed wrapper with one accessor per key:\n\n```typescript\nimport { QuonfigTypesafeNode } from \"./generated/quonfig-server\";\n\nconst typed = new QuonfigTypesafeNode(quonfig);\ntyped.flagSmoke({ user: { key: user.id } }); // flag.smoke\n```\n\nIf any config uses templates, the generated code imports `mustache` (qfg\nprints a note); add it as a dependency then.\nRe-run `qfg generate` after creating keys.\n\n## Log levels\n\nPass `loggerKey: \"log-level.my-app\"` to the constructor, then wire the logger:\n`createWinstonFormat` from `@quonfig/node/winston` or `createPinoHooks` from\n`@quonfig/node/pino`. Read the package README for the exact wiring.\n\n## Shutdown\n\n`await quonfig.close()` on shutdown; `await quonfig.flush()` at the end of a\nserverless invocation to send telemetry.\n" },
|
|
17
|
+
{ path: "references/sdk-python.md", content: "# Python — `quonfig`\n\nServer-side SDK; evaluates in-process.\n\n## Install\n\n```bash\npip install quonfig\n```\n\n## Initialize once\n\n```python\nimport os\n\nfrom quonfig import Quonfig\n\nclient = Quonfig(sdk_key=os.environ[\"QUONFIG_BACKEND_SDK_KEY\"])\nclient.init()\n```\n\nEnv fallbacks read by the SDK: `sdk_key` <- `QUONFIG_BACKEND_SDK_KEY`,\n`environment` <- `QUONFIG_ENVIRONMENT`, `datadir` <- `QUONFIG_DIR`.\n\nLocal / offline mode:\n\n```python\nclient = Quonfig(datadir=\"/path/to/workspace\", environment=\"development\")\nclient.init()\n```\n\n## Feature flag\n\n```python\nenabled = client.is_feature_enabled(\"my.flag\", contexts={\"user\": {\"key\": user.id}})\n```\n\nSignature: `is_feature_enabled(key, default=False, contexts=None)`. The second\npositional argument is the **default**, so always pass `contexts=` by keyword.\n\n## Config values\n\n```python\nvalue = client.get_string(\"my.key\", default=\"fallback\")\nlimit = client.get_int(\"rate-limit\", default=100, contexts={\"user\": {\"plan\": \"pro\"}})\n```\n\nGetters: `get`, `get_string`, `get_int`, `get_float`, `get_bool`,\n`get_string_list`, `get_json`, `get_duration` — all\n`(key, default=..., contexts=None)`. With no default, a missing key follows\n`on_no_default` (raises by default).\n\n## Context\n\n```python\n# Bound context (request handlers etc.)\nuser_client = client.with_context({\"user\": {\"key\": \"u123\", \"plan\": \"pro\"}})\nenabled = user_client.is_feature_enabled(\"my.flag\")\n\n# Scoped: per thread / asyncio task, restored on exit\nwith client.scoped_context({\"user\": {\"key\": \"u123\"}}):\n enabled = client.is_feature_enabled(\"my.flag\")\n```\n\n## Log levels\n\n`logger_key=\"log-level.my-app\"` plus `QuonfigLoggerFilter(client)` for stdlib\nlogging or `QuonfigLoggerProcessor` for structlog.\n\nShutdown: `client.close()`; `client.flush()` in serverless. No `qfg generate`\ntarget for Python.\n" },
|
|
18
|
+
{ path: "references/sdk-ruby.md", content: "# Ruby — `quonfig` gem\n\nServer-side SDK; evaluates in-process.\n\n## Install\n\n```ruby\ngem 'quonfig'\n```\n\n## Initialize once\n\n```ruby\nrequire 'quonfig'\n\nclient = Quonfig::Client.new(sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'])\n```\n\nOr as a process singleton (e.g. a Rails initializer), then `Quonfig.instance`:\n\n```ruby\nQuonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))\n```\n\nThe constructor blocks on the first load. The backend key decides the\nenvironment. Local / offline mode reads `QUONFIG_DIR` + `QUONFIG_ENVIRONMENT`:\n\n```ruby\nclient = Quonfig::Client.new(datadir: '/path/to/workspace', environment: 'development')\n```\n\n## Feature flag\n\n```ruby\nif client.enabled?('new-dashboard', user: { key: current_user.id })\n # new path\nend\n```\n\n## Config values\n\n```ruby\nclient.get_string('app.display-name')\nclient.get_int('rate-limit', default: 100, context: { user: { key: 'user-123' } })\nclient.get_bool('flags.new-checkout', default: false)\n```\n\nAlso `get_float`, `get_string_list`, `get_duration` (ms), `get_json`. A missing\nkey returns `default:`; with no default, `on_no_default` (`:raise` by default)\ndecides.\n\n## Context\n\n```ruby\nclient.with_context(user: { key: 'user-123', plan: 'pro' }) do |bound|\n bound.enabled?('beta-feature')\nend\n```\n\n`in_context` is a deprecated alias. `global_context:` is a constructor option.\n\n## Log levels\n\nSemanticLogger: `filter: Quonfig.instance.semantic_logger_filter(config_key: 'log-level.my-app')`.\nStdlib Logger: `logger_key:` option plus `client.stdlib_formatter(logger_name:)`.\n\nShutdown: `client.stop`. No `qfg generate` target for Ruby.\n" },
|
|
19
|
+
{ path: "references/sdk-swift.md", content: "# Swift (iOS 15+ / macOS 12+) — `Quonfig`\n\nA frontend SDK: it evaluates on the server for one context, so use a\n**frontend (browser) SDK key** (`qfg sdk-key create --environment <env> --type browser`),\nnever the backend key.\n\n## Install (SwiftPM)\n\n```swift\ndependencies: [\n .package(url: \"https://github.com/quonfig/sdk-swift.git\", from: \"0.2.0\"),\n],\ntargets: [\n .target(\n name: \"YourApp\",\n dependencies: [\n .product(name: \"Quonfig\", package: \"sdk-swift\"),\n ]\n ),\n]\n```\n\n## Initialize once\n\n```swift\nimport Quonfig\n\nlet quonfig = try await Quonfig.initialize(\n context: QuonfigContext([\n \"user\": [\"key\": .string(\"u_123\"), \"email\": .string(\"a@b.com\")],\n ]),\n options: .init(sdkKey: frontendSdkKey, domain: \"quonfig.com\")\n)\n```\n\nThe SDK reads no environment variables; the key decides the environment.\n\n## Flags and values\n\n```swift\nlet on = quonfig.isEnabled(\"new-checkout\") // Bool\nlet color = quonfig.string(\"button-color\", default: \"blue\") // String\nlet limit = quonfig.int(\"rate-limit\", default: 100) // Int\nlet hosts = quonfig.stringList(\"allowed-hosts\", default: []) // [String]\n```\n\nReads are synchronous and return the default until values arrive.\n\n## Context\n\n```swift\ntry await quonfig.updateContext(\n QuonfigContext([\"user\": [\"key\": .string(\"u_456\")]])\n)\n```\n\n## Log levels and shutdown\n\n`QuonfigLogger(quonfig: quonfig, loggerKey: \"log-level.my-app\")`, or\n`quonfig.shouldLog(.debug, loggerKey: \"log-level.my-app\")`.\n`await quonfig.shutdown()` when done.\n" },
|
|
20
|
+
{ path: "references/writes.md", content: "## `qfg` or the Quonfig MCP?\n\n- **A repo is checked out and you have a shell** -> use `qfg`. It is scriptable,\n prints JSON with `--json`, and works next to the code you are changing.\n- **Slack, claude.ai, or anywhere without a checkout** -> use the Quonfig MCP\n server (`https://mcp.quonfig.com/mcp`). It reads and writes the hosted\n workspace directly. Do not try to shell out to `qfg` from a surface that has\n no repo.\n\nBoth surfaces cover the same everyday loop — create a flag or config, change\nwhat an environment serves, set a log level — and both land as a git commit in\nthe workspace repo.\n\n## Setting a value KEEPS that environment's targeting rules\n\nBoth surfaces write the same way, so learn it once:\n\n`qfg set-default` (alias `toggle`), `qfg set-rollout`, and the MCP\n`set_flag`/`set_config` verbs replace exactly ONE rule — the environment's\n**fallback**, the unconditional rule at the end of its rule list that decides\nwhat users receive when no targeting rule matches. Every targeting rule above\nit is kept, and both surfaces report how many. An environment with no rules of\nits own has them copied from default first, so inherited targeting is kept too.\n\n| Surface | By default | To set the value for EVERYONE |\n|---------|------------|-------------------------------|\n| `qfg set-default` / `qfg set-rollout` | Keeps targeting; reports \"Kept N targeting rule(s)\" | add `--replace-targeting` |\n| MCP `set_flag` / `set_config` | Keeps targeting; reports the kept count in its result | send `replaceTargeting: true` |\n\nSetting the value for everyone DELETES that environment's targeting rules. They\nstay in git history, and the write returns a `previousCommitSha` to restore from.\nShow the rules to a human and get agreement before you send it.\n\n**Still read before you write.** `qfg info <key>` (or MCP `get_flag`) shows the\nrules your value is about to sit next to. For anything a fallback cannot express —\nmulti-rule targeting, reordering, editing one targeting rule — edit the JSON\nin the workspace repo or use MCP `set_document`.\n\n`qfg log-level --target ...` and MCP `set_log_level` are **surgical** in the same\nway: they upsert the level for the loggers you name and leave every other rule in\nplace.\n" },
|
|
21
|
+
];
|
|
@@ -15,6 +15,75 @@ function toMeta(schema) {
|
|
|
15
15
|
function applyMeta(schema, meta) {
|
|
16
16
|
return meta ? schema.meta(meta) : schema;
|
|
17
17
|
}
|
|
18
|
+
function unresolvedRef(reason) {
|
|
19
|
+
// The reason becomes a /** */ comment in generated code; a ref containing `*/` must not end it.
|
|
20
|
+
return z.unknown().meta({ description: reason.replaceAll('*/', '*\\/') });
|
|
21
|
+
}
|
|
22
|
+
function decodePointerToken(token) {
|
|
23
|
+
return token.replaceAll('~1', '/').replaceAll('~0', '~');
|
|
24
|
+
}
|
|
25
|
+
/** The schema a local ref points at, or undefined when the ref is not local or its target is missing. */
|
|
26
|
+
function lookupLocalRef(ref, root) {
|
|
27
|
+
if (!ref.startsWith('#')) {
|
|
28
|
+
return { found: false };
|
|
29
|
+
}
|
|
30
|
+
let pointer;
|
|
31
|
+
try {
|
|
32
|
+
pointer = decodeURIComponent(ref.slice(1));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { found: false };
|
|
36
|
+
}
|
|
37
|
+
if (pointer === '') {
|
|
38
|
+
return { found: true, schema: root };
|
|
39
|
+
}
|
|
40
|
+
if (!pointer.startsWith('/')) {
|
|
41
|
+
return { found: false };
|
|
42
|
+
}
|
|
43
|
+
let current = root;
|
|
44
|
+
for (const token of pointer
|
|
45
|
+
.slice(1)
|
|
46
|
+
.split('/')
|
|
47
|
+
.map((part) => decodePointerToken(part))) {
|
|
48
|
+
if (Array.isArray(current) && /^(0|[1-9]\d*)$/.test(token) && Number(token) < current.length) {
|
|
49
|
+
current = current[Number(token)];
|
|
50
|
+
}
|
|
51
|
+
else if (isObject(current) && Object.hasOwn(current, token)) {
|
|
52
|
+
current = current[token];
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
return { found: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { found: true, schema: current };
|
|
59
|
+
}
|
|
60
|
+
function schemaFromRef(ref, ctx) {
|
|
61
|
+
if (!ref.startsWith('#')) {
|
|
62
|
+
return unresolvedRef(`$ref ${ref} is not local to this schema and is not fetched, so it is typed unknown.`);
|
|
63
|
+
}
|
|
64
|
+
if (ctx.resolving.includes(ref)) {
|
|
65
|
+
return unresolvedRef(`Recursive $ref ${ref}, typed unknown at this depth.`);
|
|
66
|
+
}
|
|
67
|
+
const target = lookupLocalRef(ref, ctx.root);
|
|
68
|
+
if (!target.found) {
|
|
69
|
+
return unresolvedRef(`$ref ${ref} does not point at a schema in this document, so it is typed unknown.`);
|
|
70
|
+
}
|
|
71
|
+
return schemaToZod(target.schema, { ...ctx, resolving: [...ctx.resolving, ref] });
|
|
72
|
+
}
|
|
73
|
+
/** Follows a chain of local `$ref`s to the JSON schema it ends at (for reading a branch's shape). */
|
|
74
|
+
function followLocalRefs(schema, ctx) {
|
|
75
|
+
let current = schema;
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
while (isObject(current) && typeof current.$ref === 'string' && !seen.has(current.$ref)) {
|
|
78
|
+
seen.add(current.$ref);
|
|
79
|
+
const target = lookupLocalRef(current.$ref, ctx.root);
|
|
80
|
+
if (!target.found) {
|
|
81
|
+
return current;
|
|
82
|
+
}
|
|
83
|
+
current = target.schema;
|
|
84
|
+
}
|
|
85
|
+
return current;
|
|
86
|
+
}
|
|
18
87
|
function literalFromEnum(values) {
|
|
19
88
|
if (values.length === 0) {
|
|
20
89
|
return z.never();
|
|
@@ -28,25 +97,25 @@ function literalFromEnum(values) {
|
|
|
28
97
|
const literals = values.map((value) => z.literal(value));
|
|
29
98
|
return z.union(literals);
|
|
30
99
|
}
|
|
31
|
-
function schemaFromTypeArray(schema, types) {
|
|
100
|
+
function schemaFromTypeArray(schema, types, ctx) {
|
|
32
101
|
const nonNullTypes = types.filter((item) => item !== 'null');
|
|
33
102
|
const includesNull = nonNullTypes.length !== types.length;
|
|
34
103
|
if (nonNullTypes.length === 0 && includesNull) {
|
|
35
104
|
return z.null();
|
|
36
105
|
}
|
|
37
106
|
if (nonNullTypes.length === 1) {
|
|
38
|
-
const resolved = schemaToZod({ ...schema, type: nonNullTypes[0] });
|
|
107
|
+
const resolved = schemaToZod({ ...schema, type: nonNullTypes[0] }, ctx);
|
|
39
108
|
return includesNull ? resolved.nullable() : resolved;
|
|
40
109
|
}
|
|
41
|
-
const resolved = z.union(nonNullTypes.map((type) => schemaToZod({ ...schema, type })));
|
|
110
|
+
const resolved = z.union(nonNullTypes.map((type) => schemaToZod({ ...schema, type }, ctx)));
|
|
42
111
|
return includesNull ? resolved.nullable() : resolved;
|
|
43
112
|
}
|
|
44
|
-
function schemaFromObject(schema) {
|
|
113
|
+
function schemaFromObject(schema, ctx) {
|
|
45
114
|
const properties = isObject(schema.properties) ? schema.properties : {};
|
|
46
115
|
const required = new Set(Array.isArray(schema.required) ? schema.required.filter((item) => typeof item === 'string') : []);
|
|
47
116
|
const shape = {};
|
|
48
117
|
for (const [key, value] of Object.entries(properties)) {
|
|
49
|
-
const propertySchema = schemaToZod(value);
|
|
118
|
+
const propertySchema = schemaToZod(value, ctx);
|
|
50
119
|
shape[key] = required.has(key) ? propertySchema : propertySchema.optional();
|
|
51
120
|
}
|
|
52
121
|
const base = z.object(shape);
|
|
@@ -54,7 +123,7 @@ function schemaFromObject(schema) {
|
|
|
54
123
|
return base.strict();
|
|
55
124
|
}
|
|
56
125
|
if (isObject(schema.additionalProperties) && Object.keys(properties).length === 0) {
|
|
57
|
-
return z.record(z.string(), schemaToZod(schema.additionalProperties));
|
|
126
|
+
return z.record(z.string(), schemaToZod(schema.additionalProperties, ctx));
|
|
58
127
|
}
|
|
59
128
|
return base;
|
|
60
129
|
}
|
|
@@ -112,15 +181,16 @@ function findUnionTag(branches) {
|
|
|
112
181
|
});
|
|
113
182
|
return candidates.length === 1 ? candidates[0] : undefined;
|
|
114
183
|
}
|
|
115
|
-
function schemaFromUnion(branches) {
|
|
184
|
+
function schemaFromUnion(branches, ctx) {
|
|
116
185
|
if (branches.length === 0) {
|
|
117
186
|
return z.never();
|
|
118
187
|
}
|
|
119
|
-
const options = branches.map((item) => schemaToZod(item));
|
|
188
|
+
const options = branches.map((item) => schemaToZod(item, ctx));
|
|
120
189
|
if (options.length === 1) {
|
|
121
190
|
return options[0];
|
|
122
191
|
}
|
|
123
|
-
|
|
192
|
+
// Tag detection reads the branch JSON, so look through a `$ref` branch to the schema it names.
|
|
193
|
+
const tag = findUnionTag(branches.map((branch) => followLocalRefs(branch, ctx)));
|
|
124
194
|
// discriminatedUnion needs every option to be a plain object schema (no default/other wrapper)
|
|
125
195
|
if (tag && options.every((option) => option instanceof z.ZodObject)) {
|
|
126
196
|
return z.discriminatedUnion(tag, options);
|
|
@@ -150,21 +220,21 @@ function hasStructuralBranch(branches) {
|
|
|
150
220
|
* oneOf/anyOf, checked before `type` so a union next to `type: "object"` (or no type) isn't lost.
|
|
151
221
|
* Sibling `properties` are kept as an intersection with the union.
|
|
152
222
|
*/
|
|
153
|
-
function schemaFromCombinator(schema, branches) {
|
|
154
|
-
const union = schemaFromUnion(branches);
|
|
223
|
+
function schemaFromCombinator(schema, branches, ctx) {
|
|
224
|
+
const union = schemaFromUnion(branches, ctx);
|
|
155
225
|
// schemaFromObject reads only properties/required/additionalProperties, so the combinator isn't re-entered
|
|
156
226
|
if (isObject(schema.properties) && Object.keys(schema.properties).length > 0) {
|
|
157
|
-
return z.intersection(schemaFromObject(schema), union);
|
|
227
|
+
return z.intersection(schemaFromObject(schema, ctx), union);
|
|
158
228
|
}
|
|
159
229
|
return union;
|
|
160
230
|
}
|
|
161
231
|
const MAX_TUPLE_MIN_ITEMS = 8;
|
|
162
|
-
function schemaFromArray(schema) {
|
|
232
|
+
function schemaFromArray(schema, ctx) {
|
|
163
233
|
if (Array.isArray(schema.prefixItems)) {
|
|
164
|
-
const items = schema.prefixItems.map((item) => schemaToZod(item));
|
|
234
|
+
const items = schema.prefixItems.map((item) => schemaToZod(item, ctx));
|
|
165
235
|
return z.tuple(items);
|
|
166
236
|
}
|
|
167
|
-
const item = schema.items === undefined ? z.any() : schemaToZod(schema.items);
|
|
237
|
+
const item = schema.items === undefined ? z.any() : schemaToZod(schema.items, ctx);
|
|
168
238
|
const { maxItems, minItems } = schema;
|
|
169
239
|
// `minItems: n` with no upper bound is a tuple of n items plus a rest: [T, T, ...T[]].
|
|
170
240
|
// Above MAX_TUPLE_MIN_ITEMS the tuple would be unreadable, so it stays a plain array.
|
|
@@ -178,26 +248,30 @@ function schemaFromArray(schema) {
|
|
|
178
248
|
}
|
|
179
249
|
return z.array(item);
|
|
180
250
|
}
|
|
181
|
-
function schemaToZod(schema) {
|
|
251
|
+
function schemaToZod(schema, ctx) {
|
|
182
252
|
if (!isObject(schema)) {
|
|
183
253
|
return z.any();
|
|
184
254
|
}
|
|
185
255
|
const meta = toMeta(schema);
|
|
186
256
|
let result;
|
|
187
|
-
if (
|
|
257
|
+
if (typeof schema.$ref === 'string') {
|
|
258
|
+
// The referencing schema's own title/description/default (applied below) win over the target's.
|
|
259
|
+
result = schemaFromRef(schema.$ref, ctx);
|
|
260
|
+
}
|
|
261
|
+
else if (Array.isArray(schema.enum)) {
|
|
188
262
|
result = literalFromEnum(schema.enum);
|
|
189
263
|
}
|
|
190
264
|
else if (schema.const !== undefined) {
|
|
191
265
|
result = z.literal(schema.const);
|
|
192
266
|
}
|
|
193
267
|
else if (Array.isArray(schema.oneOf) && hasStructuralBranch(schema.oneOf)) {
|
|
194
|
-
result = schemaFromCombinator(schema, schema.oneOf);
|
|
268
|
+
result = schemaFromCombinator(schema, schema.oneOf, ctx);
|
|
195
269
|
}
|
|
196
270
|
else if (Array.isArray(schema.anyOf) && hasStructuralBranch(schema.anyOf)) {
|
|
197
|
-
result = schemaFromCombinator(schema, schema.anyOf);
|
|
271
|
+
result = schemaFromCombinator(schema, schema.anyOf, ctx);
|
|
198
272
|
}
|
|
199
273
|
else if (Array.isArray(schema.type)) {
|
|
200
|
-
result = schemaFromTypeArray(schema, schema.type);
|
|
274
|
+
result = schemaFromTypeArray(schema, schema.type, ctx);
|
|
201
275
|
}
|
|
202
276
|
else {
|
|
203
277
|
switch (schema.type) {
|
|
@@ -222,13 +296,13 @@ function schemaToZod(schema) {
|
|
|
222
296
|
break;
|
|
223
297
|
}
|
|
224
298
|
case 'array': {
|
|
225
|
-
result = schemaFromArray(schema);
|
|
299
|
+
result = schemaFromArray(schema, ctx);
|
|
226
300
|
break;
|
|
227
301
|
}
|
|
228
302
|
case 'object':
|
|
229
303
|
case undefined: {
|
|
230
304
|
if (schema.properties || schema.additionalProperties !== undefined || schema.required) {
|
|
231
|
-
result = schemaFromObject(schema);
|
|
305
|
+
result = schemaFromObject(schema, ctx);
|
|
232
306
|
}
|
|
233
307
|
else {
|
|
234
308
|
result = z.object({});
|
|
@@ -237,7 +311,7 @@ function schemaToZod(schema) {
|
|
|
237
311
|
}
|
|
238
312
|
default: {
|
|
239
313
|
if (Array.isArray(schema.allOf) && schema.allOf.length === 1) {
|
|
240
|
-
result = schemaToZod(schema.allOf[0]);
|
|
314
|
+
result = schemaToZod(schema.allOf[0], ctx);
|
|
241
315
|
break;
|
|
242
316
|
}
|
|
243
317
|
if (schema.not === false) {
|
|
@@ -273,5 +347,6 @@ export function isLegacySchemaWrapper(schema) {
|
|
|
273
347
|
return typeof value.schema.schema === 'string';
|
|
274
348
|
}
|
|
275
349
|
export function jsonSchemaToZod(schema) {
|
|
276
|
-
|
|
350
|
+
// The document root is pointer '#'; it is "being resolved" from the start, so `$ref: "#"` is recursion.
|
|
351
|
+
return schemaToZod(schema, { resolving: ['#'], root: schema });
|
|
277
352
|
}
|
|
@@ -28,9 +28,11 @@ export class ZodToTypescriptMapper extends ZodBaseMapper {
|
|
|
28
28
|
}
|
|
29
29
|
function(args, returns) {
|
|
30
30
|
// When in raw mode, we return a string type for functions,
|
|
31
|
-
// as this is what comes back from the server directly
|
|
31
|
+
// as this is what comes back from the server directly. Optionality is
|
|
32
|
+
// carried by the optional() wrapper (`?` on a field), never by the
|
|
33
|
+
// template itself (qfg-v7s8).
|
|
32
34
|
if (this.target === 'raw') {
|
|
33
|
-
return 'string
|
|
35
|
+
return 'string';
|
|
34
36
|
}
|
|
35
37
|
return `(...params: ${args}) => ${returns}`;
|
|
36
38
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { BaseCommand } from '../../index.js';
|
|
2
|
+
import { JsonObj } from '../../result.js';
|
|
3
|
+
export default class AgentInstallSkill extends BaseCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
'add-pointer': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
8
|
+
dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
9
|
+
force: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<JsonObj | void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DEFAULT_SKILLS_DIR, addPointer, executeInstall, hasPointer, planInstall, pointerLine, } from '../../agent/install-skill.js';
|
|
4
|
+
import { BaseCommand } from '../../index.js';
|
|
5
|
+
export default class AgentInstallSkill extends BaseCommand {
|
|
6
|
+
static description = `Install the Quonfig agent skill into this repo so coding agents use Quonfig correctly.
|
|
7
|
+
|
|
8
|
+
Writes .claude/skills/quonfig/ (SKILL.md + references/) — the same files as
|
|
9
|
+
\`claude plugin install quonfig@quonfig\` from github.com/quonfig/skills, embedded
|
|
10
|
+
in this qfg build (no network). Claude Code picks the skill up automatically;
|
|
11
|
+
for Codex, Cursor and other agents that read AGENTS.md, add --add-pointer.
|
|
12
|
+
|
|
13
|
+
Safe to re-run: files still as qfg wrote them are upgraded in place, files you
|
|
14
|
+
edited are left alone (the command fails and lists them) unless you pass --force.
|
|
15
|
+
|
|
16
|
+
This skill is for APPLICATION repos (SDK + qfg + MCP usage). A workspace repo
|
|
17
|
+
created by \`qfg init\` has its own AGENTS.md/CLAUDE.md for editing config JSON.`;
|
|
18
|
+
static examples = [
|
|
19
|
+
'<%= config.bin %> <%= command.id %>',
|
|
20
|
+
'<%= config.bin %> <%= command.id %> --add-pointer',
|
|
21
|
+
'<%= config.bin %> <%= command.id %> --dir .agents/skills',
|
|
22
|
+
'<%= config.bin %> <%= command.id %> --force',
|
|
23
|
+
];
|
|
24
|
+
static flags = {
|
|
25
|
+
'add-pointer': Flags.boolean({
|
|
26
|
+
default: false,
|
|
27
|
+
description: 'Also append a one-line pointer to the skill in ./AGENTS.md (created if missing)',
|
|
28
|
+
}),
|
|
29
|
+
dir: Flags.string({
|
|
30
|
+
default: DEFAULT_SKILLS_DIR,
|
|
31
|
+
description: 'Skills directory to install into, relative to the current directory',
|
|
32
|
+
}),
|
|
33
|
+
force: Flags.boolean({
|
|
34
|
+
default: false,
|
|
35
|
+
description: 'Overwrite skill files you have edited locally',
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
async run() {
|
|
39
|
+
const { flags } = await this.parse(AgentInstallSkill);
|
|
40
|
+
const repoDir = process.cwd();
|
|
41
|
+
const skillsDir = path.resolve(repoDir, flags.dir);
|
|
42
|
+
const plan = planInstall({ force: flags.force, skillsDir });
|
|
43
|
+
const relSkillDir = path.relative(repoDir, plan.skillDir) || '.';
|
|
44
|
+
if (plan.conflicts.length > 0) {
|
|
45
|
+
const list = plan.conflicts.map((p) => ` ${path.join(relSkillDir, p)}`).join('\n');
|
|
46
|
+
return this.err(`These Quonfig skill files were edited locally and differ from skill v${plan.version}:\n${list}\nNothing was written. Re-run with --force to overwrite them.`, { conflicts: plan.conflicts, skillDir: relSkillDir, version: plan.version });
|
|
47
|
+
}
|
|
48
|
+
executeInstall(plan);
|
|
49
|
+
const counts = {};
|
|
50
|
+
for (const f of plan.files)
|
|
51
|
+
counts[f.status] = (counts[f.status] ?? 0) + 1;
|
|
52
|
+
let pointer;
|
|
53
|
+
if (flags['add-pointer']) {
|
|
54
|
+
pointer = addPointer(repoDir, plan.skillDir);
|
|
55
|
+
}
|
|
56
|
+
if (!this.jsonEnabled()) {
|
|
57
|
+
const changed = plan.files.filter((f) => f.status !== 'unchanged');
|
|
58
|
+
if (changed.length === 0) {
|
|
59
|
+
this.log(`Quonfig skill v${plan.version} already up to date in ${relSkillDir}`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
this.log(`Quonfig skill v${plan.version} -> ${relSkillDir}`);
|
|
63
|
+
for (const f of changed)
|
|
64
|
+
this.log(` ${f.status.padEnd(8)} ${f.path}`);
|
|
65
|
+
}
|
|
66
|
+
if (pointer === 'added')
|
|
67
|
+
this.log('Added a pointer to the skill in AGENTS.md');
|
|
68
|
+
else if (pointer === 'present')
|
|
69
|
+
this.log('AGENTS.md already points at the skill');
|
|
70
|
+
else if (!hasPointer(repoDir, plan.skillDir)) {
|
|
71
|
+
this.log('\nUsing Codex, Cursor, or another agent that reads AGENTS.md? Re-run with --add-pointer, or add:');
|
|
72
|
+
this.log(` ${pointerLine(path.relative(repoDir, plan.skillDir))}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
counts,
|
|
77
|
+
files: plan.files.map((f) => ({ path: f.path, status: f.status })),
|
|
78
|
+
pointer: pointer ?? null,
|
|
79
|
+
skillDir: relSkillDir,
|
|
80
|
+
version: plan.version,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -52,7 +52,8 @@ Pass --json for the structured object (rows[]). Each row carries the columns
|
|
|
52
52
|
above plus the raw eval counts your agent can reason about itself.
|
|
53
53
|
|
|
54
54
|
Once you've picked a candidate, run \`qfg cleanup status <key>\` for the
|
|
55
|
-
drill-in or hand
|
|
55
|
+
drill-in, or \`qfg cleanup remove <key>\` to hand the removal to your coding agent
|
|
56
|
+
with the Quonfig agent skill.`;
|
|
56
57
|
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json'];
|
|
57
58
|
static flags = {
|
|
58
59
|
json: Flags.boolean({ default: false, description: 'Return structured rows for agent consumption' }),
|