From cc2f0984f74aae7674da4b3927751d8f2c6c02bb Mon Sep 17 00:00:00 2001 From: Nathan Agrin Date: Tue, 28 Apr 2026 15:26:36 -0700 Subject: [PATCH 1/5] sync spec: add fullyResolved param to model yaml GET/POST Picks up the fullyResolved query/body param on modelsYamlGet and modelsYamlCreate (only valid with mode=combined). Also pulls in modelsUpdate (PATCH /api/v1/models/{modelId}) for renames, the folder_paths content-validator param, and the cursor param on dbt-exposures as a side effect of the full sync. Fixes #47 Co-Authored-By: Claude Opus 4.7 (1M context) --- api/openapi.json | 259 ++++++++++++++++++++++++++++++++++++------ cmd/omni/openapi.json | 259 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 452 insertions(+), 66 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index e0708f9..0c63d6b 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3907,6 +3907,52 @@ "connectionId" ] }, + "ModelsUpdateResponse": { + "type": "object", + "properties": { + "model": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Model ID" + }, + "name": { + "type": "string", + "description": "Updated model name" + } + }, + "required": [ + "id", + "name" + ], + "description": "Updated model details" + }, + "success": { + "type": "boolean", + "description": "Whether the operation succeeded" + } + }, + "required": [ + "model", + "success" + ] + }, + "ModelsUpdateBody": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "New name for the model", + "example": "My Renamed Model" + } + }, + "required": [ + "name" + ] + }, "JobsGetStatusResponse": { "type": "object", "properties": { @@ -5286,6 +5332,15 @@ "model_id" ] }, + "ContentFilterMode": { + "type": "string", + "enum": [ + "ALL", + "WITH_ISSUES", + "NO_ISSUES" + ], + "description": "Filter documents by issue status. ALL (default) returns all documents with at least one query. WITH_ISSUES returns only documents with at least one query issue, dashboard filter issue, or document error. NO_ISSUES returns only documents with zero issues and no document errors." + }, "ModelsContentValidatorReplaceResponse": { "type": "object", "properties": { @@ -5335,10 +5390,16 @@ "enum": [ "FIELD", "TOPIC", - "VIEW", - "REPAIR" + "VIEW" ], - "description": "Type of find/replace operation. Must be FIELD, VIEW, or TOPIC (REPAIR is not supported via API)" + "description": "Type of find/replace operation." + }, + "folder_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict replacement to documents in matching folder paths (prefix match). Documents with no folder are excluded unless \"\" is specified." }, "include_personal_folders": { "type": "boolean", @@ -5430,6 +5491,11 @@ "type": "number", "description": "Timestamp when the file was fetched" }, + "fullyResolved": { + "type": "boolean", + "default": false, + "description": "Treat the posted YAML as fully resolved (with the extends chain expanded). Only valid with mode=combined." + }, "previousChecksum": { "type": "string", "description": "Previous checksum for conflict detection" @@ -11240,6 +11306,7 @@ }, "/api/v1/documents/{identifier}": { "get": { + "description": "Retrieves a document's configuration in a format compatible with PUT for round-trip editing. GET a document, modify the response, and PUT it back to update. Only dashboard documents are supported; analysis documents return 400.", "operationId": "documentsGet", "summary": "Get document", "tags": [ @@ -11269,11 +11336,14 @@ } } }, + "400": { + "description": "Analysis documents are not supported" + }, "401": { "description": "Authentication required" }, "403": { - "description": "Permission denied" + "description": "Insufficient permissions to view the document" }, "404": { "description": "Document not found" @@ -11281,6 +11351,7 @@ } }, "put": { + "description": "Updates a document with the specified identifier. This endpoint performs a full resource replacement — all required fields must be provided and existing query presentations are replaced entirely. Only dashboard documents are supported; analysis documents and documents without an associated dashboard return 400. For published documents, the update goes through a draft/publish workflow automatically; if a draft already exists, the request returns 409 unless `clearExistingDraft` is set to `true`.", "operationId": "documentsPut", "summary": "Replace document (full replacement)", "tags": [ @@ -11320,20 +11391,24 @@ } }, "400": { - "description": "Invalid request body" + "description": "Invalid request body, missing required fields, or validation error (also returned for analysis documents and documents without an associated dashboard)" }, "401": { "description": "Authentication required" }, "403": { - "description": "Permission denied" + "description": "Insufficient permissions to update the document" }, "404": { "description": "Document not found" + }, + "409": { + "description": "Draft already exists - set clearExistingDraft to true to discard it and proceed" } } }, "patch": { + "description": "Updates a document's name, description, and/or identifier. This is a partial update — only provided fields are modified, and at least one of `name`, `description`, or `identifier` must be supplied. When `identifier` is changed, the previous identifier is retained in the document identifier history and continues to redirect. For published documents, the update goes through a draft/publish workflow automatically.", "operationId": "documentsUpdate", "summary": "Rename document", "tags": [ @@ -11373,7 +11448,7 @@ } }, "400": { - "description": "Invalid name or description" + "description": "Invalid request body or validation error (e.g. missing name/description/identifier, name too long, identifier already in use)" }, "401": { "description": "Authentication required" @@ -11385,7 +11460,7 @@ "description": "Document not found" }, "409": { - "description": "Draft already exists - set clearExistingDraft to true" + "description": "Draft already exists - set clearExistingDraft to true to discard it and proceed" } } }, @@ -13466,6 +13541,64 @@ } } }, + "/api/v1/models/{modelId}": { + "patch": { + "description": "Update metadata for an existing model. Currently supports renaming the model via the `name` field.", + "operationId": "modelsUpdate", + "summary": "Update model", + "tags": [ + "Models" + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Model UUID", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "required": true, + "description": "Model UUID", + "name": "modelId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsUpdateBody" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsUpdateResponse" + } + } + } + }, + "400": { + "description": "Invalid request body" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Model not found" + } + } + } + }, "/api/v1/jobs/{jobId}/status": { "get": { "description": "Check status of a schema refresh job (initiated via POST /api/v1/models/{modelId}/refresh). Returns IN_PROGRESS, COMPLETED, or FAILED.", @@ -14557,43 +14690,64 @@ { "schema": { "type": "string", - "format": "uuid", - "description": "Branch ID to use for branch-aware operations", - "example": "123e4567-e89b-12d3-a456-426614174001" + "description": "Cursor for pagination (from previous response nextCursor)", + "example": "eyJpZCI6IjEyMzQ1In0" }, "required": false, - "description": "Branch ID to use for branch-aware operations", - "name": "branch_id", + "description": "Cursor for pagination (from previous response nextCursor)", + "name": "cursor", "in": "query" }, { "schema": { - "type": [ - "integer", - "null" + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Number of results per page (1-100)", + "example": 20 + }, + "required": false, + "description": "Number of results per page (1-100)", + "name": "pageSize", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" ], - "minimum": 0, - "default": 0, - "description": "Zero-indexed page number", - "example": 0 + "default": "desc", + "description": "Sort direction for results", + "example": "desc" }, "required": false, - "description": "Zero-indexed page number", - "name": "page", + "description": "Sort direction for results", + "name": "sortDirection", "in": "query" }, { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 1000, - "description": "Number of documents to process per page (1-1000). The number of records returned may be less than this if some dashboards do not reference dbt models.", - "example": 1000 + "type": "string", + "description": "Field to sort results by" }, "required": false, - "description": "Number of documents to process per page (1-1000). The number of records returned may be less than this if some dashboards do not reference dbt models.", - "name": "pageSize", + "description": "Field to sort results by", + "name": "sortField", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Branch ID to use for branch-aware operations", + "example": "123e4567-e89b-12d3-a456-426614174001" + }, + "required": false, + "description": "Branch ID to use for branch-aware operations", + "name": "branch_id", "in": "query" } ], @@ -15117,6 +15271,15 @@ "name": "branch_id", "in": "query" }, + { + "schema": { + "$ref": "#/components/schemas/ContentFilterMode" + }, + "required": false, + "description": "Filter documents by issue status. ALL (default) returns all documents with at least one query. WITH_ISSUES returns only documents with at least one query issue, dashboard filter issue, or document error. NO_ISSUES returns only documents with zero issues and no document errors.", + "name": "content_filter_mode", + "in": "query" + }, { "schema": { "type": "string", @@ -15134,8 +15297,7 @@ "enum": [ "FIELD", "TOPIC", - "VIEW", - "REPAIR" + "VIEW" ], "description": "Optional type of find operation (VIEW, FIELD, TOPIC). Requires find to be provided. FIELD values must be scoped by view name (e.g. view_name.field_name)." }, @@ -15144,6 +15306,19 @@ "name": "find_type", "in": "query" }, + { + "schema": { + "type": "array", + "description": "Prefix-match folder paths. \"/Finance\" matches \"/Finance/Reports\". Documents with no folder are excluded unless \"\" is specified.", + "items": { + "type": "string" + } + }, + "required": false, + "description": "Prefix-match folder paths. \"/Finance\" matches \"/Finance/Reports\". Documents with no folder are excluded unless \"\" is specified.", + "name": "folder_paths", + "in": "query" + }, { "schema": { "type": "boolean", @@ -15251,7 +15426,7 @@ } }, "400": { - "description": "Invalid request body or REPAIR type not supported" + "description": "Invalid request body" }, "401": { "description": "Authentication required" @@ -15324,6 +15499,24 @@ "name": "mode", "in": "query" }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ], + "default": false, + "description": "Resolve the model extends chain so the returned YAML reflects what runs at query time. Only valid with mode=combined." + }, + "required": false, + "description": "Resolve the model extends chain so the returned YAML reflects what runs at query time. Only valid with mode=combined.", + "name": "fullyResolved", + "in": "query" + }, { "schema": { "type": [ diff --git a/cmd/omni/openapi.json b/cmd/omni/openapi.json index e0708f9..0c63d6b 100644 --- a/cmd/omni/openapi.json +++ b/cmd/omni/openapi.json @@ -3907,6 +3907,52 @@ "connectionId" ] }, + "ModelsUpdateResponse": { + "type": "object", + "properties": { + "model": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Model ID" + }, + "name": { + "type": "string", + "description": "Updated model name" + } + }, + "required": [ + "id", + "name" + ], + "description": "Updated model details" + }, + "success": { + "type": "boolean", + "description": "Whether the operation succeeded" + } + }, + "required": [ + "model", + "success" + ] + }, + "ModelsUpdateBody": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "New name for the model", + "example": "My Renamed Model" + } + }, + "required": [ + "name" + ] + }, "JobsGetStatusResponse": { "type": "object", "properties": { @@ -5286,6 +5332,15 @@ "model_id" ] }, + "ContentFilterMode": { + "type": "string", + "enum": [ + "ALL", + "WITH_ISSUES", + "NO_ISSUES" + ], + "description": "Filter documents by issue status. ALL (default) returns all documents with at least one query. WITH_ISSUES returns only documents with at least one query issue, dashboard filter issue, or document error. NO_ISSUES returns only documents with zero issues and no document errors." + }, "ModelsContentValidatorReplaceResponse": { "type": "object", "properties": { @@ -5335,10 +5390,16 @@ "enum": [ "FIELD", "TOPIC", - "VIEW", - "REPAIR" + "VIEW" ], - "description": "Type of find/replace operation. Must be FIELD, VIEW, or TOPIC (REPAIR is not supported via API)" + "description": "Type of find/replace operation." + }, + "folder_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict replacement to documents in matching folder paths (prefix match). Documents with no folder are excluded unless \"\" is specified." }, "include_personal_folders": { "type": "boolean", @@ -5430,6 +5491,11 @@ "type": "number", "description": "Timestamp when the file was fetched" }, + "fullyResolved": { + "type": "boolean", + "default": false, + "description": "Treat the posted YAML as fully resolved (with the extends chain expanded). Only valid with mode=combined." + }, "previousChecksum": { "type": "string", "description": "Previous checksum for conflict detection" @@ -11240,6 +11306,7 @@ }, "/api/v1/documents/{identifier}": { "get": { + "description": "Retrieves a document's configuration in a format compatible with PUT for round-trip editing. GET a document, modify the response, and PUT it back to update. Only dashboard documents are supported; analysis documents return 400.", "operationId": "documentsGet", "summary": "Get document", "tags": [ @@ -11269,11 +11336,14 @@ } } }, + "400": { + "description": "Analysis documents are not supported" + }, "401": { "description": "Authentication required" }, "403": { - "description": "Permission denied" + "description": "Insufficient permissions to view the document" }, "404": { "description": "Document not found" @@ -11281,6 +11351,7 @@ } }, "put": { + "description": "Updates a document with the specified identifier. This endpoint performs a full resource replacement — all required fields must be provided and existing query presentations are replaced entirely. Only dashboard documents are supported; analysis documents and documents without an associated dashboard return 400. For published documents, the update goes through a draft/publish workflow automatically; if a draft already exists, the request returns 409 unless `clearExistingDraft` is set to `true`.", "operationId": "documentsPut", "summary": "Replace document (full replacement)", "tags": [ @@ -11320,20 +11391,24 @@ } }, "400": { - "description": "Invalid request body" + "description": "Invalid request body, missing required fields, or validation error (also returned for analysis documents and documents without an associated dashboard)" }, "401": { "description": "Authentication required" }, "403": { - "description": "Permission denied" + "description": "Insufficient permissions to update the document" }, "404": { "description": "Document not found" + }, + "409": { + "description": "Draft already exists - set clearExistingDraft to true to discard it and proceed" } } }, "patch": { + "description": "Updates a document's name, description, and/or identifier. This is a partial update — only provided fields are modified, and at least one of `name`, `description`, or `identifier` must be supplied. When `identifier` is changed, the previous identifier is retained in the document identifier history and continues to redirect. For published documents, the update goes through a draft/publish workflow automatically.", "operationId": "documentsUpdate", "summary": "Rename document", "tags": [ @@ -11373,7 +11448,7 @@ } }, "400": { - "description": "Invalid name or description" + "description": "Invalid request body or validation error (e.g. missing name/description/identifier, name too long, identifier already in use)" }, "401": { "description": "Authentication required" @@ -11385,7 +11460,7 @@ "description": "Document not found" }, "409": { - "description": "Draft already exists - set clearExistingDraft to true" + "description": "Draft already exists - set clearExistingDraft to true to discard it and proceed" } } }, @@ -13466,6 +13541,64 @@ } } }, + "/api/v1/models/{modelId}": { + "patch": { + "description": "Update metadata for an existing model. Currently supports renaming the model via the `name` field.", + "operationId": "modelsUpdate", + "summary": "Update model", + "tags": [ + "Models" + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Model UUID", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "required": true, + "description": "Model UUID", + "name": "modelId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsUpdateBody" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsUpdateResponse" + } + } + } + }, + "400": { + "description": "Invalid request body" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Model not found" + } + } + } + }, "/api/v1/jobs/{jobId}/status": { "get": { "description": "Check status of a schema refresh job (initiated via POST /api/v1/models/{modelId}/refresh). Returns IN_PROGRESS, COMPLETED, or FAILED.", @@ -14557,43 +14690,64 @@ { "schema": { "type": "string", - "format": "uuid", - "description": "Branch ID to use for branch-aware operations", - "example": "123e4567-e89b-12d3-a456-426614174001" + "description": "Cursor for pagination (from previous response nextCursor)", + "example": "eyJpZCI6IjEyMzQ1In0" }, "required": false, - "description": "Branch ID to use for branch-aware operations", - "name": "branch_id", + "description": "Cursor for pagination (from previous response nextCursor)", + "name": "cursor", "in": "query" }, { "schema": { - "type": [ - "integer", - "null" + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Number of results per page (1-100)", + "example": 20 + }, + "required": false, + "description": "Number of results per page (1-100)", + "name": "pageSize", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" ], - "minimum": 0, - "default": 0, - "description": "Zero-indexed page number", - "example": 0 + "default": "desc", + "description": "Sort direction for results", + "example": "desc" }, "required": false, - "description": "Zero-indexed page number", - "name": "page", + "description": "Sort direction for results", + "name": "sortDirection", "in": "query" }, { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 1000, - "description": "Number of documents to process per page (1-1000). The number of records returned may be less than this if some dashboards do not reference dbt models.", - "example": 1000 + "type": "string", + "description": "Field to sort results by" }, "required": false, - "description": "Number of documents to process per page (1-1000). The number of records returned may be less than this if some dashboards do not reference dbt models.", - "name": "pageSize", + "description": "Field to sort results by", + "name": "sortField", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Branch ID to use for branch-aware operations", + "example": "123e4567-e89b-12d3-a456-426614174001" + }, + "required": false, + "description": "Branch ID to use for branch-aware operations", + "name": "branch_id", "in": "query" } ], @@ -15117,6 +15271,15 @@ "name": "branch_id", "in": "query" }, + { + "schema": { + "$ref": "#/components/schemas/ContentFilterMode" + }, + "required": false, + "description": "Filter documents by issue status. ALL (default) returns all documents with at least one query. WITH_ISSUES returns only documents with at least one query issue, dashboard filter issue, or document error. NO_ISSUES returns only documents with zero issues and no document errors.", + "name": "content_filter_mode", + "in": "query" + }, { "schema": { "type": "string", @@ -15134,8 +15297,7 @@ "enum": [ "FIELD", "TOPIC", - "VIEW", - "REPAIR" + "VIEW" ], "description": "Optional type of find operation (VIEW, FIELD, TOPIC). Requires find to be provided. FIELD values must be scoped by view name (e.g. view_name.field_name)." }, @@ -15144,6 +15306,19 @@ "name": "find_type", "in": "query" }, + { + "schema": { + "type": "array", + "description": "Prefix-match folder paths. \"/Finance\" matches \"/Finance/Reports\". Documents with no folder are excluded unless \"\" is specified.", + "items": { + "type": "string" + } + }, + "required": false, + "description": "Prefix-match folder paths. \"/Finance\" matches \"/Finance/Reports\". Documents with no folder are excluded unless \"\" is specified.", + "name": "folder_paths", + "in": "query" + }, { "schema": { "type": "boolean", @@ -15251,7 +15426,7 @@ } }, "400": { - "description": "Invalid request body or REPAIR type not supported" + "description": "Invalid request body" }, "401": { "description": "Authentication required" @@ -15324,6 +15499,24 @@ "name": "mode", "in": "query" }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ], + "default": false, + "description": "Resolve the model extends chain so the returned YAML reflects what runs at query time. Only valid with mode=combined." + }, + "required": false, + "description": "Resolve the model extends chain so the returned YAML reflects what runs at query time. Only valid with mode=combined.", + "name": "fullyResolved", + "in": "query" + }, { "schema": { "type": [ From 93d81c230e0b11f92994f73da104bf7559f066c5 Mon Sep 17 00:00:00 2001 From: Nathan Agrin Date: Tue, 28 Apr 2026 15:37:19 -0700 Subject: [PATCH 2/5] Add /update-from-api-spec skill Captures the spec-sync flow as a reusable slash command: resolve the source spec, preview the diff, branch, sync, build, test, validate the new commands/flags, then commit and open a PR. Replaces the manual sequence we ran for #47. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/update-from-api-spec.md | 82 ++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .claude/commands/update-from-api-spec.md diff --git a/.claude/commands/update-from-api-spec.md b/.claude/commands/update-from-api-spec.md new file mode 100644 index 0000000..ad1f112 --- /dev/null +++ b/.claude/commands/update-from-api-spec.md @@ -0,0 +1,82 @@ +Sync the OpenAPI spec into this CLI, surface what changed, validate the build, and open a PR. + +## User context + +The user may provide context like a spec path, an issue number to fix, or a single endpoint they care about: + +> $ARGUMENTS + +Parse `$ARGUMENTS` for any of: +- A path ending in `openapi.json` → use as the source spec. +- `#` or `issues/` or a full issue URL → record as the issue to reference in the commit/PR. Run `gh issue view --repo exploreomni/cli` to read the body so you can speak to the specific endpoint or param being requested. +- Free text → treat as guidance for what the user expects to land (e.g. "add fullyResolved param"). + +If blank, infer the story from the diff. + +## Resolve the source spec + +The source of truth lives in the `exploreomni/omni` monorepo. Resolve the source path in this order (highest wins) and stop at the first that exists: + +1. Path passed in `$ARGUMENTS`. +2. `$OMNI_OPENAPI_SPEC` env var. +3. `~/src/omni/packages/bi-app/app/types/api/openapi/openapi.json` (common local checkout). + +If none resolve, stop and ask the user where their monorepo lives. Do NOT fetch from the network. + +## Steps + +1. **Pre-flight** — Confirm `git status` is clean. If there are uncommitted changes, stop and show them; the sync touches `api/openapi.json` and `cmd/omni/openapi.json`, and we don't want to mix unrelated edits in. + +2. **Preview the diff** — Without writing anything, run: + ``` + diff -u api/openapi.json "$SOURCE_SPEC" | head -400 + ``` + Then summarize for the user, focusing on the parts they'll care about: + - **New operations**: `grep -E '^\+.*"operationId":' api/openapi.json` after the copy, vs. before. (Or compute it from the diff: `git diff api/openapi.json | grep -E '^\+.*"operationId":'`.) + - **New params**: lines matching `^\+.*"name":` inside the diff that weren't present before. + - **Removed operations / params** (rare but important): `^-.*"operationId":` and `^-.*"name":`. + + If the user gave a specific endpoint or param in `$ARGUMENTS`, confirm it actually appears in the new diff. If it doesn't, stop and tell the user — the upstream change probably hasn't been merged into their monorepo checkout yet. + +3. **Branch** — Create a topic branch off the current default branch: + ``` + git checkout -b sync-spec- + ``` + The slug should describe the headline change (e.g. `sync-spec-fully-resolved`, `sync-spec-models-rename`). If `$ARGUMENTS` referenced an issue, use a slug derived from the issue title. + +4. **Sync** — Run the project's sync target. It copies into both `api/openapi.json` and `cmd/omni/openapi.json` (the latter is embedded into the binary): + ``` + OMNI_OPENAPI_SPEC= make sync-spec + ``` + +5. **Build & test** — These are the gate. If either fails, stop and show the errors: + ``` + make build + make test + ``` + +6. **Validate the new surface area** — For each new operation and each new query/path param surfaced in step 2, run `--help` to confirm cobra wired it up: + ``` + ./bin/omni --help + ``` + New body fields on POST/PATCH endpoints won't appear as flags (those endpoints take `--body`); note that in the PR body rather than treating it as missing. + + If the user asked to verify against a live API and has a profile configured, run a read-only call (typically a GET) that exercises the new param. Never write/mutate without explicit confirmation. + +7. **Commit** — One commit. Subject follows the project's convention (`sync spec: `), body lists the headline change first and any side-effect changes the full sync pulled in. Append the co-author trailer: + ``` + Co-Authored-By: Claude + ``` + If `$ARGUMENTS` referenced an issue, end the body with `Fixes #`. + +8. **Push & open the PR** — `git push -u origin HEAD`, then `gh pr create` with: + - Title: same as the commit subject, suffixed with `(#)` if there's a referenced issue. + - Body: a `## Summary` section calling out the headline change and any side-effect operations/params, with a link to the upstream monorepo PR if the issue body included one. Followed by a `## Test plan` checklist (`make test`, `--help` output, optional live verification). + +9. **Report** — Print the PR URL and a one-line summary of what landed. + +## Notes + +- This skill is read-only against the monorepo — it copies the spec out, never modifies it. +- Don't hand-edit the JSON. If the upstream spec is wrong, the fix belongs in the monorepo, not here. +- The full sync is intentional: a single PR per spec bump keeps the embedded JSON consistent. Surface side-effect changes in the PR body rather than trying to filter the diff. From 856535e001aa2acdd484b4b812046a9e79f09c6d Mon Sep 17 00:00:00 2001 From: Nathan Agrin Date: Tue, 28 Apr 2026 15:42:01 -0700 Subject: [PATCH 3/5] Address review on /update-from-api-spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the hardcoded ~/src/omni fallback. Resolution is now $ARGUMENTS → $OMNI_OPENAPI_SPEC → ask the user. Don't bake one developer's checkout layout into a shared skill. - Make the branch step worktree-aware: if already on a non-default branch (worktree, existing topic), stay on it instead of branching a branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/update-from-api-spec.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.claude/commands/update-from-api-spec.md b/.claude/commands/update-from-api-spec.md index ad1f112..524531b 100644 --- a/.claude/commands/update-from-api-spec.md +++ b/.claude/commands/update-from-api-spec.md @@ -19,9 +19,8 @@ The source of truth lives in the `exploreomni/omni` monorepo. Resolve the source 1. Path passed in `$ARGUMENTS`. 2. `$OMNI_OPENAPI_SPEC` env var. -3. `~/src/omni/packages/bi-app/app/types/api/openapi/openapi.json` (common local checkout). -If none resolve, stop and ask the user where their monorepo lives. Do NOT fetch from the network. +If neither resolves, stop and ask the user for the path to their monorepo's `openapi.json`. Do NOT fetch from the network and do NOT guess a path. ## Steps @@ -38,11 +37,13 @@ If none resolve, stop and ask the user where their monorepo lives. Do NOT fetch If the user gave a specific endpoint or param in `$ARGUMENTS`, confirm it actually appears in the new diff. If it doesn't, stop and tell the user — the upstream change probably hasn't been merged into their monorepo checkout yet. -3. **Branch** — Create a topic branch off the current default branch: - ``` - git checkout -b sync-spec- - ``` - The slug should describe the headline change (e.g. `sync-spec-fully-resolved`, `sync-spec-models-rename`). If `$ARGUMENTS` referenced an issue, use a slug derived from the issue title. +3. **Branch** — Check the current branch first (`git branch --show-current`). + - If you're already on a non-default branch — including a worktree branch (e.g. `claude/...`) or any existing topic branch — stay on it. Don't branch a branch. + - Only when you're on the default branch (`main`), create a topic branch: + ``` + git checkout -b sync-spec- + ``` + The slug should describe the headline change (e.g. `sync-spec-fully-resolved`, `sync-spec-models-rename`). If `$ARGUMENTS` referenced an issue, derive the slug from the issue title. 4. **Sync** — Run the project's sync target. It copies into both `api/openapi.json` and `cmd/omni/openapi.json` (the latter is embedded into the binary): ``` From 66c6083f9e8baf4ef5bf855ac6158d7dea784c97 Mon Sep 17 00:00:00 2001 From: Nathan Agrin Date: Tue, 28 Apr 2026 15:42:42 -0700 Subject: [PATCH 4/5] Document /update-from-api-spec in DEVELOPMENT.md Adds a subsection under "Updating the OpenAPI Spec" so the slash command is discoverable from the dev guide, with example invocations and a pointer to the skill file for editing. Co-Authored-By: Claude Opus 4.7 (1M context) --- DEVELOPMENT.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 428bdf1..980cb1b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,6 +27,18 @@ OMNI_OPENAPI_SPEC=/path/to/openapi.json make sync-spec This copies the spec into both `api/openapi.json` (source of truth) and `cmd/omni/openapi.json` (embedded copy). Rebuild after syncing. +### Claude Code: `/update-from-api-spec` + +If you use Claude Code, the project ships a `/update-from-api-spec` slash command that drives the whole spec-bump flow end to end: previewing the diff, syncing, building, validating that the new commands and flags appear in `--help`, then committing and opening a PR. Pass an issue reference and/or a spec path: + +``` +/update-from-api-spec #47 +/update-from-api-spec /path/to/openapi.json +/update-from-api-spec #47 add fullyResolved param +``` + +It resolves the source spec from the argument or `$OMNI_OPENAPI_SPEC`, and asks if neither is set. The skill is defined at [.claude/commands/update-from-api-spec.md](.claude/commands/update-from-api-spec.md) — edit it there. + ## Publishing a New Version Releases are fully automated via GitHub Actions and [GoReleaser](https://goreleaser.com/). From 15e6106d63de960304f6b15828ad49390f319159 Mon Sep 17 00:00:00 2001 From: Nathan Agrin Date: Tue, 28 Apr 2026 15:51:11 -0700 Subject: [PATCH 5/5] sync-spec defaults to gh fetch; rename skill to /update-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the requirement for a local omni-monorepo checkout. `make sync-spec` now fetches openapi.json from exploreomni/omni@main via `gh api` by default. Two overrides remain for the cases that actually need them: - OMNI_OPENAPI_SPEC=/local/path — test an unmerged spec change. - OMNI_OPENAPI_REF=branch-or-sha — pin to a non-default ref. Renames the slash command from /update-from-api-spec to /update-api and rewrites the skill so the make target owns resolution. The skill just drives it. DEVELOPMENT.md updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...{update-from-api-spec.md => update-api.md} | 57 +++++++++++-------- DEVELOPMENT.md | 24 +++++--- Makefile | 26 +++++++-- 3 files changed, 70 insertions(+), 37 deletions(-) rename .claude/commands/{update-from-api-spec.md => update-api.md} (56%) diff --git a/.claude/commands/update-from-api-spec.md b/.claude/commands/update-api.md similarity index 56% rename from .claude/commands/update-from-api-spec.md rename to .claude/commands/update-api.md index 524531b..f49e338 100644 --- a/.claude/commands/update-from-api-spec.md +++ b/.claude/commands/update-api.md @@ -2,42 +2,32 @@ Sync the OpenAPI spec into this CLI, surface what changed, validate the build, a ## User context -The user may provide context like a spec path, an issue number to fix, or a single endpoint they care about: +The user may provide context like a spec source, an issue number to fix, or a single endpoint they care about: > $ARGUMENTS Parse `$ARGUMENTS` for any of: -- A path ending in `openapi.json` → use as the source spec. +- A path ending in `openapi.json` → use as the source spec (sets `OMNI_OPENAPI_SPEC`). +- A bare ref name (e.g. `release-2026.04`) or `--ref ` → pin the upstream fetch to that branch/tag/commit (sets `OMNI_OPENAPI_REF`). - `#` or `issues/` or a full issue URL → record as the issue to reference in the commit/PR. Run `gh issue view --repo exploreomni/cli` to read the body so you can speak to the specific endpoint or param being requested. - Free text → treat as guidance for what the user expects to land (e.g. "add fullyResolved param"). -If blank, infer the story from the diff. +If blank, default to the gh-fetched `main` of `exploreomni/omni`. Don't ask for a source unless the default fails. -## Resolve the source spec +## How the spec is resolved -The source of truth lives in the `exploreomni/omni` monorepo. Resolve the source path in this order (highest wins) and stop at the first that exists: +`make sync-spec` (defined in the Makefile) handles resolution: -1. Path passed in `$ARGUMENTS`. -2. `$OMNI_OPENAPI_SPEC` env var. +1. If `OMNI_OPENAPI_SPEC` is set, it copies from that local path. Use this for testing unmerged spec changes against a local monorepo checkout. +2. Otherwise it fetches `exploreomni/omni`'s `openapi.json` via `gh api` (defaults to `main`; override with `OMNI_OPENAPI_REF`). -If neither resolves, stop and ask the user for the path to their monorepo's `openapi.json`. Do NOT fetch from the network and do NOT guess a path. +The skill's job is to drive that target, not re-implement resolution. ## Steps -1. **Pre-flight** — Confirm `git status` is clean. If there are uncommitted changes, stop and show them; the sync touches `api/openapi.json` and `cmd/omni/openapi.json`, and we don't want to mix unrelated edits in. +1. **Pre-flight** — Confirm `git status` is clean. If there are uncommitted changes, stop and show them; the sync touches `api/openapi.json` and `cmd/omni/openapi.json`, and we don't want to mix unrelated edits in. If relying on the gh-fetch default, also confirm `gh auth status` is good. -2. **Preview the diff** — Without writing anything, run: - ``` - diff -u api/openapi.json "$SOURCE_SPEC" | head -400 - ``` - Then summarize for the user, focusing on the parts they'll care about: - - **New operations**: `grep -E '^\+.*"operationId":' api/openapi.json` after the copy, vs. before. (Or compute it from the diff: `git diff api/openapi.json | grep -E '^\+.*"operationId":'`.) - - **New params**: lines matching `^\+.*"name":` inside the diff that weren't present before. - - **Removed operations / params** (rare but important): `^-.*"operationId":` and `^-.*"name":`. - - If the user gave a specific endpoint or param in `$ARGUMENTS`, confirm it actually appears in the new diff. If it doesn't, stop and tell the user — the upstream change probably hasn't been merged into their monorepo checkout yet. - -3. **Branch** — Check the current branch first (`git branch --show-current`). +2. **Branch** — Check the current branch first (`git branch --show-current`). - If you're already on a non-default branch — including a worktree branch (e.g. `claude/...`) or any existing topic branch — stay on it. Don't branch a branch. - Only when you're on the default branch (`main`), create a topic branch: ``` @@ -45,18 +35,35 @@ If neither resolves, stop and ask the user for the path to their monorepo's `ope ``` The slug should describe the headline change (e.g. `sync-spec-fully-resolved`, `sync-spec-models-rename`). If `$ARGUMENTS` referenced an issue, derive the slug from the issue title. -4. **Sync** — Run the project's sync target. It copies into both `api/openapi.json` and `cmd/omni/openapi.json` (the latter is embedded into the binary): +3. **Sync** — Invoke the make target. Pass `OMNI_OPENAPI_SPEC` and/or `OMNI_OPENAPI_REF` only if the user supplied them in `$ARGUMENTS`: ``` - OMNI_OPENAPI_SPEC= make sync-spec + make sync-spec + # or, with overrides: + OMNI_OPENAPI_SPEC=/path/to/openapi.json make sync-spec + OMNI_OPENAPI_REF=my-branch make sync-spec ``` +4. **Inspect the diff** — Now that the spec is in place, look at what actually changed and summarize it for the user: + ``` + git diff --stat api/openapi.json cmd/omni/openapi.json + git diff api/openapi.json | head -400 + ``` + Pull out the parts the user cares about: + - **New operations**: `git diff api/openapi.json | grep -E '^\+.*"operationId":'` + - **New params**: `git diff api/openapi.json | grep -E '^\+.*"name":'` (filter out body schema field names — those aren't query/path params) + - **Removed operations / params**: `^-.*"operationId":` and `^-.*"name":` (rare but important when present) + + If the user gave a specific endpoint or param in `$ARGUMENTS`, confirm it appears in the diff. If it doesn't, stop and tell the user — the upstream change probably hasn't been merged into the ref you fetched. Suggest passing `--ref ` or `OMNI_OPENAPI_SPEC=` to point at a checkout that has the change. + + If the diff is empty, stop early and report "spec already up to date" — no commit, no PR. + 5. **Build & test** — These are the gate. If either fails, stop and show the errors: ``` make build make test ``` -6. **Validate the new surface area** — For each new operation and each new query/path param surfaced in step 2, run `--help` to confirm cobra wired it up: +6. **Validate the new surface area** — For each new operation and each new query/path param surfaced in step 4, run `--help` to confirm cobra wired it up: ``` ./bin/omni --help ``` @@ -78,6 +85,6 @@ If neither resolves, stop and ask the user for the path to their monorepo's `ope ## Notes -- This skill is read-only against the monorepo — it copies the spec out, never modifies it. +- The default sync hits the network via `gh` — without network or `gh` auth, set `OMNI_OPENAPI_SPEC` to a local file. - Don't hand-edit the JSON. If the upstream spec is wrong, the fix belongs in the monorepo, not here. - The full sync is intentional: a single PR per spec bump keeps the embedded JSON consistent. Surface side-effect changes in the PR body rather than trying to filter the diff. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 980cb1b..688c3d8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -19,25 +19,33 @@ make clean # Remove built binary ## Updating the OpenAPI Spec -The CLI auto-generates commands from the embedded OpenAPI spec. To update it: +The CLI auto-generates commands from the embedded OpenAPI spec. The default sync fetches it from `exploreomni/omni@main` via `gh`: ```bash +make sync-spec +``` + +This requires `gh auth` with read access to the monorepo. To pin to a different branch/tag/commit, set `OMNI_OPENAPI_REF`. To sync from a local checkout instead — useful when testing an unmerged spec change — set `OMNI_OPENAPI_SPEC`: + +```bash +OMNI_OPENAPI_REF=my-branch make sync-spec OMNI_OPENAPI_SPEC=/path/to/openapi.json make sync-spec ``` -This copies the spec into both `api/openapi.json` (source of truth) and `cmd/omni/openapi.json` (embedded copy). Rebuild after syncing. +The target copies the spec into both `api/openapi.json` (source of truth) and `cmd/omni/openapi.json` (embedded copy). Rebuild after syncing. -### Claude Code: `/update-from-api-spec` +### Claude Code: `/update-api` -If you use Claude Code, the project ships a `/update-from-api-spec` slash command that drives the whole spec-bump flow end to end: previewing the diff, syncing, building, validating that the new commands and flags appear in `--help`, then committing and opening a PR. Pass an issue reference and/or a spec path: +If you use Claude Code, the project ships a `/update-api` slash command that drives the whole spec-bump flow end to end: syncing, inspecting the diff, building, validating that the new commands and flags appear in `--help`, then committing and opening a PR. With no arguments it pulls `main` over `gh`; pass an issue reference, a ref name, or a local spec path to override: ``` -/update-from-api-spec #47 -/update-from-api-spec /path/to/openapi.json -/update-from-api-spec #47 add fullyResolved param +/update-api #47 +/update-api --ref my-branch +/update-api /path/to/openapi.json +/update-api #47 add fullyResolved param ``` -It resolves the source spec from the argument or `$OMNI_OPENAPI_SPEC`, and asks if neither is set. The skill is defined at [.claude/commands/update-from-api-spec.md](.claude/commands/update-from-api-spec.md) — edit it there. +The skill is defined at [.claude/commands/update-api.md](.claude/commands/update-api.md) — edit it there. ## Publishing a New Version diff --git a/Makefile b/Makefile index 11bb68e..83283d2 100644 --- a/Makefile +++ b/Makefile @@ -16,10 +16,28 @@ clean: test: go test ./... -# Update the OpenAPI spec from an external source +# Update the OpenAPI spec. +# +# By default, fetches the spec from exploreomni/omni@main via `gh api` +# (requires `gh auth` with read access to the monorepo). +# +# To sync from a local checkout instead — useful when testing an unmerged +# spec change — set OMNI_OPENAPI_SPEC to a local path: +# OMNI_OPENAPI_SPEC=/path/to/openapi.json make sync-spec +# +# To pin to a non-default branch or commit: +# OMNI_OPENAPI_REF=my-branch make sync-spec +OMNI_OPENAPI_REPO ?= exploreomni/omni +OMNI_OPENAPI_PATH ?= packages/bi-app/app/types/api/openapi/openapi.json +OMNI_OPENAPI_REF ?= main + sync-spec: -ifndef OMNI_OPENAPI_SPEC - $(error OMNI_OPENAPI_SPEC is not set — point it at the path to your openapi.json) +ifdef OMNI_OPENAPI_SPEC + @echo "Syncing spec from local file: $(OMNI_OPENAPI_SPEC)" + cp "$(OMNI_OPENAPI_SPEC)" api/openapi.json +else + @command -v gh >/dev/null 2>&1 || { echo "gh CLI is required (https://cli.github.com). Or set OMNI_OPENAPI_SPEC to a local path." >&2; exit 1; } + @echo "Fetching spec from $(OMNI_OPENAPI_REPO)@$(OMNI_OPENAPI_REF)" + gh api "repos/$(OMNI_OPENAPI_REPO)/contents/$(OMNI_OPENAPI_PATH)?ref=$(OMNI_OPENAPI_REF)" -H "Accept: application/vnd.github.raw" > api/openapi.json endif - cp $(OMNI_OPENAPI_SPEC) api/openapi.json cp api/openapi.json cmd/omni/openapi.json