diff --git a/README.md b/README.md index 675b6e2..82be42a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ amp flags list --project --limit 5 amp flags create --project --key my-flag --name "My Flag" amp flags get --project --flag amp flags archive --project --flag --dry-run +amp heatmaps click-map --project --page-url https://example.com/pricing +amp zoning zone-metrics --project --page-url https://example.com/pricing --metric click_rate_pageview ``` ## Smoke test diff --git a/docs/cli.md b/docs/cli.md index 692f580..8e47a04 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -73,12 +73,12 @@ granular `read:`/`write:` scopes below plus the legacy `mcp:read`/`mcp:write` org scopes), so all commands work immediately after login. The per-command scopes below document what each command needs. -| Command family | Scopes | -| -------------------------- | --------------------------------- | -| `context`, `projects list` | `projects:read` | -| `events *` | `taxonomy:read`, `taxonomy:write` | -| `flags *` | `flags:read`, `flags:write` | -| `charts *` | `analytics:read` | +| Command family | Scopes | +| ------------------------------------ | --------------------------------- | +| `context`, `projects list` | `projects:read` | +| `events *` | `taxonomy:read`, `taxonomy:write` | +| `flags *` | `flags:read`, `flags:write` | +| `charts *`, `heatmaps *`, `zoning *` | `analytics:read` | Route-level scopes are defined on each OpenAPI operation (`x-required-scopes`). diff --git a/openapi/bundled/openapi.bundled.json b/openapi/bundled/openapi.bundled.json index 172e574..a3e976c 100644 --- a/openapi/bundled/openapi.bundled.json +++ b/openapi/bundled/openapi.bundled.json @@ -51,6 +51,14 @@ "name": "Analytics", "description": "Saved chart discovery and query operations." }, + { + "name": "Heatmaps", + "description": "Click map and scroll map queries for a page URL." + }, + { + "name": "Zoning", + "description": "Zone metric queries for a page URL." + }, { "name": "Feature Flags", "description": "Feature flag configuration and rollout operations." @@ -65,7 +73,7 @@ }, { "name": "Auth", - "description": "OAuth device-flow and token endpoints (RFC 8628 / RFC 6749)." + "description": "Signup, OAuth device-flow, and token endpoints (RFC 8628 / RFC 6749)." } ], "paths": { @@ -270,6 +278,81 @@ } } }, + "/v1/auth/signup": { + "post": { + "tags": ["Auth"], + "operationId": "signup", + "summary": "Sign up for an Amplitude account", + "description": "Sign up for an Amplitude account. The response either provides credentials for the new account or starts an OAuth flow to complete signup.\n", + "x-required-scopes": [], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignupRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Signup result with credentials, an OAuth flow, or additional signup information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignupResponse" + } + } + } + }, + "400": { + "description": "Invalid signup request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignupError" + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "500": { + "description": "Signup could not be completed due to a server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignupError" + } + } + } + }, + "502": { + "description": "Signup upstream service error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignupError" + } + } + } + }, + "503": { + "description": "Signup authentication service is temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignupError" + } + } + } + } + } + } + }, "/v1/auth/token": { "post": { "tags": ["Auth"], @@ -2181,6 +2264,222 @@ } } }, + "/v1/projects/{project_id}/heatmaps/click-map": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "post": { + "tags": ["Heatmaps"], + "operationId": "queryClickMap", + "summary": "Query click map", + "description": "Returns click counts for one page URL, grouped by the CSS selector of the\nclicked element or by page coordinate, ranked by count. Filter by device\nviewport band and date range. Results reflect the authenticated caller's\nproject access and data access controls.\n\nThis POST computes a result and does not mutate state. It is therefore a\nread operation and does not require an `Idempotency-Key`.\n\nOmitting `time_range` uses the last 30 days in the project timezone. The\nspan of `time_range` cannot exceed 90 days.\n", + "x-required-scopes": ["analytics:read"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClickMapQueryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Click map computed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ClickMap" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/HeatmapForbiddenProblem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/NotConfiguredProblem" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "500": { + "$ref": "#/components/responses/Problem" + }, + "502": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/AvailabilityUnavailableProblem" + }, + "504": { + "$ref": "#/components/responses/UpstreamTimeoutProblem" + } + } + } + }, + "/v1/projects/{project_id}/heatmaps/scroll-map": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "post": { + "tags": ["Heatmaps"], + "operationId": "queryScrollMap", + "summary": "Query scroll map", + "description": "Returns the scroll-depth distribution for one page URL as a cumulative\nhistogram from the top of the page, plus the average fold and the deepest\nscroll observed. Filter by device viewport band and date range. Results\nreflect the authenticated caller's project access and data access\ncontrols.\n\nThis POST computes a result and does not mutate state. It is therefore a\nread operation and does not require an `Idempotency-Key`.\n\nOmitting `time_range` uses the last 30 days in the project timezone. The\nspan of `time_range` cannot exceed 90 days.\n", + "x-required-scopes": ["analytics:read"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScrollMapQueryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Scroll map computed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ScrollMap" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/HeatmapForbiddenProblem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/NotConfiguredProblem" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "500": { + "$ref": "#/components/responses/Problem" + }, + "502": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/AvailabilityUnavailableProblem" + }, + "504": { + "$ref": "#/components/responses/UpstreamTimeoutProblem" + } + } + } + }, + "/v1/projects/{project_id}/zoning/zone-metrics": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "post": { + "tags": ["Zoning"], + "operationId": "queryZoneMetrics", + "summary": "Query zone metrics", + "description": "Returns one metric for every zone on a page URL, ranked by value. A zone\nis identified by the CSS selector of its element. Results reflect the\nauthenticated caller's project access and data access controls.\n\nThis POST computes a result and does not mutate state. It is therefore a\nread operation and does not require an `Idempotency-Key`.\n\nOmitting `time_range` uses the last 30 days in the project timezone. The\nspan of `time_range` cannot exceed 90 days.\n", + "x-required-scopes": ["analytics:read"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoneMetricsQueryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Zone metrics computed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ZoneMetrics" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/ZoningForbiddenProblem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/NotConfiguredProblem" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "500": { + "$ref": "#/components/responses/Problem" + }, + "502": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/AvailabilityUnavailableProblem" + }, + "504": { + "$ref": "#/components/responses/UpstreamTimeoutProblem" + } + } + } + }, "/v1/projects/{project_id}/flags": { "get": { "tags": ["Feature Flags"], @@ -3264,18 +3563,184 @@ } } } - } - }, - "schemas": { - "ObjectMeta": { - "type": "object", - "required": ["id", "object", "created_at", "updated_at"], - "properties": { - "id": { - "type": "string" - }, - "object": { - "type": "string", + }, + "HeatmapForbiddenProblem": { + "description": "The caller cannot use this operation. `operation_not_enabled` means the\noperation is not enabled for the caller's organization;\n`insufficient_scope` means the token lacks `analytics:read`; `forbidden`\nmeans the caller lacks permission to view heatmaps in the project;\n`data_access_restricted` means data access controls exclude the requested\ndata.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "examples": { + "operationNotEnabled": { + "summary": "Endpoint unavailable", + "value": { + "type": "https://developer-api.amplitude.com/problems/operation-not-enabled", + "title": "Endpoint unavailable", + "status": 403, + "detail": "This endpoint is unavailable for your organization.", + "error_code": "operation_not_enabled", + "retryable": false + } + }, + "insufficientScope": { + "summary": "Missing required scope", + "value": { + "type": "https://developer-api.amplitude.com/problems/forbidden", + "title": "Insufficient scope", + "status": 403, + "detail": "Token missing required scopes.", + "error_code": "insufficient_scope", + "retryable": false + } + }, + "forbidden": { + "summary": "Missing project permission", + "value": { + "type": "https://developer-api.amplitude.com/problems/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "You do not have permission to view heatmaps in this project.", + "error_code": "forbidden", + "retryable": false + } + }, + "dataAccessRestricted": { + "summary": "Data access restricted", + "value": { + "type": "https://developer-api.amplitude.com/problems/data-access-restricted", + "title": "Data access restricted", + "status": 403, + "detail": "Data access controls exclude the requested data.", + "error_code": "data_access_restricted", + "retryable": false + } + } + } + } + } + }, + "NotConfiguredProblem": { + "description": "The project does not capture the events or properties this query needs.\nReturned with `error_code: session_replay_not_configured`; `detail` names\nwhat is missing.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "https://developer-api.amplitude.com/problems/session-replay-not-configured", + "title": "Session replay not configured", + "status": 422, + "detail": "The project is missing required events: [Amplitude] Element Clicked.", + "error_code": "session_replay_not_configured", + "retryable": false + } + } + } + }, + "AvailabilityUnavailableProblem": { + "description": "Endpoint availability could not be determined. Retry the request.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "https://developer-api.amplitude.com/problems/operation-availability-unavailable", + "title": "Operation availability unavailable", + "status": 503, + "detail": "Operation availability could not be determined. Retry the request.", + "error_code": "operation_availability_unavailable", + "retryable": true + } + } + } + }, + "UpstreamTimeoutProblem": { + "description": "The query exceeded the synchronous timeout. Narrow the date range and retry.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "https://developer-api.amplitude.com/problems/upstream-timeout", + "title": "Upstream timeout", + "status": 504, + "detail": "The query did not complete in time. Narrow the date range and retry.", + "error_code": "upstream_timeout", + "retryable": true + } + } + } + }, + "ZoningForbiddenProblem": { + "description": "The caller cannot use this operation. `operation_not_enabled` means the\noperation is not enabled for the caller's organization;\n`insufficient_scope` means the token lacks `analytics:read`; `forbidden`\nmeans the caller lacks access to the project; `data_access_restricted`\nmeans data access controls exclude the requested data.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "examples": { + "operationNotEnabled": { + "summary": "Endpoint unavailable", + "value": { + "type": "https://developer-api.amplitude.com/problems/operation-not-enabled", + "title": "Endpoint unavailable", + "status": 403, + "detail": "This endpoint is unavailable for your organization.", + "error_code": "operation_not_enabled", + "retryable": false + } + }, + "insufficientScope": { + "summary": "Missing required scope", + "value": { + "type": "https://developer-api.amplitude.com/problems/forbidden", + "title": "Insufficient scope", + "status": 403, + "detail": "Token missing required scopes.", + "error_code": "insufficient_scope", + "retryable": false + } + }, + "forbidden": { + "summary": "Missing project permission", + "value": { + "type": "https://developer-api.amplitude.com/problems/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "You do not have permission to view zoning in this project.", + "error_code": "forbidden", + "retryable": false + } + }, + "dataAccessRestricted": { + "summary": "Data access restricted", + "value": { + "type": "https://developer-api.amplitude.com/problems/data-access-restricted", + "title": "Data access restricted", + "status": 403, + "detail": "Data access controls exclude the requested data.", + "error_code": "data_access_restricted", + "retryable": false + } + } + } + } + } + } + }, + "schemas": { + "ObjectMeta": { + "type": "object", + "required": ["id", "object", "created_at", "updated_at"], + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", "description": "Stable resource-type discriminator. Each resource sets a fixed value\n(declared via `const` on the concrete schema, e.g. `project`, `event`).\nClients can use this field to safely narrow polymorphic responses.\n" }, "created_at": { @@ -3846,6 +4311,383 @@ }, "additionalProperties": false }, + "TimeRange": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "string", + "format": "date", + "description": "Inclusive start date (project timezone unless `timezone` is set on query)." + }, + "end": { + "type": "string", + "format": "date", + "description": "Inclusive end date." + } + }, + "additionalProperties": false + }, + "ClickMapQueryRequest": { + "type": "object", + "description": "Click map query. When `time_range` is omitted the last 30 days in the\nproject timezone are used. The span of `time_range` cannot exceed 90\ndays.\n", + "required": ["page_url"], + "properties": { + "page_url": { + "$ref": "#/components/schemas/PageUrl" + }, + "url_match": { + "$ref": "#/components/schemas/HeatmapUrlMatch" + }, + "device_type": { + "$ref": "#/components/schemas/HeatmapDeviceType" + }, + "viewport_width": { + "$ref": "#/components/schemas/ViewportWidth" + }, + "time_range": { + "$ref": "#/components/schemas/TimeRange" + }, + "group_by": { + "type": "string", + "description": "`selector` groups clicks by the CSS selector of the clicked element;\n`pixel` groups them by page coordinate.\n", + "enum": ["selector", "pixel"], + "default": "selector" + }, + "metric": { + "type": "string", + "description": "`totals` counts every click; `uniques` counts distinct users who\nclicked.\n", + "enum": ["totals", "uniques"], + "default": "totals" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50, + "description": "Maximum number of rows returned after ranking by `count`." + } + }, + "additionalProperties": false + }, + "ScrollMapQueryRequest": { + "type": "object", + "description": "Scroll map query. When `time_range` is omitted the last 30 days in the\nproject timezone are used. The span of `time_range` cannot exceed 90\ndays.\n", + "required": ["page_url"], + "properties": { + "page_url": { + "$ref": "#/components/schemas/PageUrl" + }, + "url_match": { + "$ref": "#/components/schemas/HeatmapUrlMatch" + }, + "device_type": { + "$ref": "#/components/schemas/HeatmapDeviceType" + }, + "viewport_width": { + "$ref": "#/components/schemas/ViewportWidth" + }, + "time_range": { + "$ref": "#/components/schemas/TimeRange" + } + }, + "additionalProperties": false + }, + "ClickMap": { + "type": "object", + "description": "Ranked click counts for one page. Exactly one of `selectors` and `pixels`\nis populated, chosen by `group_by`; the other is `null`. Rows are sorted\nby `count` descending. Results reflect the caller's data access.\n", + "required": [ + "id", + "object", + "project_id", + "page_url", + "url_match", + "device_type", + "viewport_width", + "group_by", + "metric", + "time_range", + "timezone", + "result_count", + "truncated", + "selectors", + "pixels", + "warnings" + ], + "properties": { + "id": { + "type": "string", + "description": "Identifier for this computation." + }, + "object": { + "type": "string", + "const": "click_map", + "description": "Resource-type discriminator. Always `click_map`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "page_url": { + "type": "string", + "description": "The page URL after normalization." + }, + "url_match": { + "$ref": "#/components/schemas/HeatmapUrlMatch" + }, + "device_type": { + "$ref": "#/components/schemas/HeatmapDeviceType" + }, + "viewport_width": { + "type": ["integer", "null"], + "description": "Exact viewport width used when `device_type` is `custom`; otherwise `null`." + }, + "group_by": { + "type": "string", + "enum": ["selector", "pixel"] + }, + "metric": { + "type": "string", + "enum": ["totals", "uniques"] + }, + "time_range": { + "$ref": "#/components/schemas/TimeRange" + }, + "timezone": { + "type": "string", + "description": "IANA timezone used to resolve `time_range` day boundaries (the project timezone)." + }, + "result_count": { + "type": "integer", + "minimum": 0, + "description": "Number of rows returned." + }, + "truncated": { + "type": "boolean", + "description": "`true` when more rows were available than `limit` allowed. The\nunderlying query is itself bounded, so `false` does not guarantee\nthat every clicked element on the page is listed.\n" + }, + "selectors": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/ClickMapSelectorRow" + } + }, + "pixels": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/ClickMapPixelRow" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal conditions, such as an empty result for the range." + } + } + }, + "ScrollMap": { + "type": "object", + "description": "Scroll-depth distribution for one page, as a cumulative histogram from the\ntop of the page. All buckets are returned in page order. Results reflect\nthe caller's data access.\n", + "required": [ + "id", + "object", + "project_id", + "page_url", + "url_match", + "device_type", + "viewport_width", + "time_range", + "timezone", + "total_sessions", + "average_fold_px", + "max_scroll_depth_px", + "bucket_count", + "buckets", + "warnings" + ], + "properties": { + "id": { + "type": "string", + "description": "Identifier for this computation." + }, + "object": { + "type": "string", + "const": "scroll_map", + "description": "Resource-type discriminator. Always `scroll_map`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "page_url": { + "type": "string", + "description": "The page URL after normalization." + }, + "url_match": { + "$ref": "#/components/schemas/HeatmapUrlMatch" + }, + "device_type": { + "$ref": "#/components/schemas/HeatmapDeviceType" + }, + "viewport_width": { + "type": ["integer", "null"], + "description": "Exact viewport width used when `device_type` is `custom`; otherwise `null`." + }, + "time_range": { + "$ref": "#/components/schemas/TimeRange" + }, + "timezone": { + "type": "string", + "description": "IANA timezone used to resolve `time_range` day boundaries (the project timezone)." + }, + "total_sessions": { + "type": "integer", + "minimum": 0, + "description": "Sessions with a scroll event on the page in the range (the first bucket's count)." + }, + "average_fold_px": { + "type": ["integer", "null"], + "description": "Average viewport height in pixels, or `null` when unavailable." + }, + "max_scroll_depth_px": { + "type": ["integer", "null"], + "description": "Deepest scroll position observed in page pixels, or `null` when unavailable." + }, + "bucket_count": { + "type": "integer", + "minimum": 0 + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScrollMapBucket" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal conditions, such as an empty result for the range." + } + } + }, + "ZoneMetricsQueryRequest": { + "type": "object", + "required": ["page_url"], + "properties": { + "page_url": { + "$ref": "#/components/schemas/PageUrl" + }, + "url_match": { + "$ref": "#/components/schemas/ZoningUrlMatch" + }, + "time_range": { + "$ref": "#/components/schemas/TimeRange" + }, + "metric": { + "$ref": "#/components/schemas/ZoneMetric" + }, + "zone_ids": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Restrict the result to these zones. A zone id is the CSS selector of\nthe element, as returned in `zones[].zone_id`. Omit to rank every zone\non the page.\n" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20, + "description": "Maximum number of zones returned after ranking by `value`." + } + }, + "additionalProperties": false, + "description": "Zone metrics query. When `time_range` is omitted the last 30 days in the\nproject timezone are used. The span of `time_range` cannot exceed 90\ndays. One metric is computed per request.\n" + }, + "ZoneMetrics": { + "type": "object", + "description": "One metric for every zone on a page, ranked by `value` descending.\nResults reflect the caller's data access.\n", + "required": [ + "id", + "object", + "project_id", + "page_url", + "url_match", + "metric", + "time_range", + "timezone", + "zone_count", + "total_zone_count", + "truncated", + "zones", + "warnings" + ], + "properties": { + "id": { + "type": "string", + "description": "Identifier for this computation." + }, + "object": { + "type": "string", + "const": "zone_metrics", + "description": "Resource-type discriminator. Always `zone_metrics`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "page_url": { + "type": "string", + "description": "The page URL after normalization." + }, + "url_match": { + "$ref": "#/components/schemas/ZoningUrlMatch" + }, + "metric": { + "$ref": "#/components/schemas/ZoneMetric" + }, + "time_range": { + "$ref": "#/components/schemas/TimeRange" + }, + "timezone": { + "type": "string", + "description": "IANA timezone used to resolve `time_range` day boundaries (the project timezone)." + }, + "zone_count": { + "type": "integer", + "minimum": 0, + "description": "Number of zones returned." + }, + "total_zone_count": { + "type": "integer", + "minimum": 0, + "description": "Number of zones with data before `limit` was applied." + }, + "truncated": { + "type": "boolean", + "description": "`true` when `total_zone_count` exceeds `zone_count`." + }, + "zones": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ZoneMetricRow" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal conditions. Includes a warning when the numbers behind a\nrate were computed from differently aged cache snapshots, which can\npush a rate above its natural bound.\n" + } + } + }, "FeatureFlag": { "allOf": [ { @@ -4187,6 +5029,82 @@ } } }, + "SignupRequest": { + "type": "object", + "additionalProperties": false, + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email address of the user signing up for the Amplitude account." + }, + "full_name": { + "type": "string", + "minLength": 1, + "description": "Full name of the user signing up for the Amplitude account." + }, + "scope": { + "type": "string", + "minLength": 1, + "description": "Space-delimited OAuth scopes to request." + }, + "terms_acceptance": { + "type": "object", + "properties": { + "terms_of_service": { + "$ref": "#/components/schemas/SignupDocumentAcceptance" + }, + "privacy_policy": { + "$ref": "#/components/schemas/SignupDocumentAcceptance" + } + } + } + } + }, + "SignupResponse": { + "description": "Signup result. The response provides credentials, triggers an OAuth flow, or requests additional signup information.\n", + "oneOf": [ + { + "$ref": "#/components/schemas/SignupCredentialsResponse" + }, + { + "$ref": "#/components/schemas/SignupRequiresAuthResponse" + }, + { + "$ref": "#/components/schemas/SignupNeedsInformationResponse" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "credentials": "#/components/schemas/SignupCredentialsResponse", + "requires_auth": "#/components/schemas/SignupRequiresAuthResponse", + "needs_information": "#/components/schemas/SignupNeedsInformationResponse" + } + } + }, + "SignupError": { + "type": "object", + "required": ["type", "error"], + "properties": { + "type": { + "const": "error" + }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, "OAuthError": { "type": "object", "description": "RFC 6749 §5.2 OAuth error response.", @@ -4376,6 +5294,71 @@ } } }, + "SignupDocumentAcceptance": { + "type": "object", + "required": ["url", "accepted"], + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "accepted": { + "const": true + } + } + }, + "SignupCredentialsResponse": { + "type": "object", + "required": ["type", "credentials"], + "properties": { + "type": { + "const": "credentials" + }, + "credentials": { + "$ref": "#/components/schemas/TokenResponse" + } + } + }, + "SignupRequiresAuthResponse": { + "type": "object", + "required": ["type", "requires_auth"], + "properties": { + "type": { + "const": "requires_auth" + }, + "requires_auth": { + "type": "object", + "required": ["type", "device_authorization"], + "properties": { + "type": { + "const": "device_authorization" + }, + "device_authorization": { + "$ref": "#/components/schemas/DeviceAuthorizationResponse" + } + } + } + } + }, + "SignupNeedsInformationResponse": { + "type": "object", + "required": ["type", "needs_information"], + "properties": { + "type": { + "const": "needs_information" + }, + "needs_information": { + "type": "object", + "required": ["schema"], + "properties": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "DeviceCodeTokenRequest": { "type": "object", "required": ["grant_type", "device_code"], @@ -4438,23 +5421,6 @@ "ads_account" ] }, - "TimeRange": { - "type": "object", - "required": ["start", "end"], - "properties": { - "start": { - "type": "string", - "format": "date", - "description": "Inclusive start date (project timezone unless `timezone` is set on query)." - }, - "end": { - "type": "string", - "format": "date", - "description": "Inclusive end date." - } - }, - "additionalProperties": false - }, "ResultKind": { "type": "string", "description": "High-level shape of the normalized result payload. Adapters set this\nexplicitly per supported chart type; `unknown` means the server could not\nclassify the result shape safely.\n", @@ -4651,6 +5617,138 @@ } } }, + "PageUrl": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Page URL to analyze. Accepts `http` and `https` URLs; a URL without a\nscheme is treated as `https`. The query string and fragment are removed\nbefore matching, so `https://example.com/pricing?utm=x` and\n`https://example.com/pricing` are the same page.\n", + "example": "https://example.com/pricing" + }, + "HeatmapUrlMatch": { + "type": "string", + "description": "How `page_url` is matched against the page URL recorded on each event.\n`is` matches the exact URL, `contains` matches any URL containing it,\n`has_prefix` matches URLs starting with it, and `glob` treats `*` as a\nwildcard.\n", + "enum": ["is", "contains", "has_prefix", "glob"], + "default": "is" + }, + "HeatmapDeviceType": { + "type": "string", + "description": "Viewport band to include, matching the device selector in the product:\n`desktop` is at least 1440 px wide, `laptop` is 900 to 1440 px, `tablet`\nis 500 to 900 px, `mobile` is at most 500 px. Use `custom` with\n`viewport_width` to match one exact width.\n", + "enum": ["desktop", "laptop", "tablet", "mobile", "custom"], + "default": "desktop" + }, + "ViewportWidth": { + "type": "integer", + "minimum": 1, + "maximum": 20000, + "description": "Exact viewport width in pixels. Required when `device_type` is `custom`\nand not accepted otherwise.\n" + }, + "ClickMapSelectorRow": { + "type": "object", + "required": ["selector", "count"], + "properties": { + "selector": { + "type": "string", + "description": "CSS selector of the clicked element." + }, + "count": { + "type": "integer", + "minimum": 0, + "description": "Clicks (`metric: totals`) or unique users (`metric: uniques`)." + } + }, + "additionalProperties": false + }, + "ClickMapPixelRow": { + "type": "object", + "required": ["x", "y", "count"], + "properties": { + "x": { + "type": "integer", + "description": "Horizontal page coordinate in pixels." + }, + "y": { + "type": "integer", + "description": "Vertical page coordinate in pixels." + }, + "count": { + "type": "integer", + "minimum": 0, + "description": "Clicks (`metric: totals`) or unique users (`metric: uniques`)." + } + }, + "additionalProperties": false + }, + "ScrollMapBucket": { + "type": "object", + "required": [ + "depth_from_px", + "depth_to_px", + "sessions_reached", + "percent_reached" + ], + "properties": { + "depth_from_px": { + "type": "integer", + "minimum": 0, + "description": "Lower bound of the scroll-depth bucket in page pixels." + }, + "depth_to_px": { + "type": "integer", + "minimum": 0, + "description": "Upper bound of the scroll-depth bucket in page pixels." + }, + "sessions_reached": { + "type": "integer", + "minimum": 0, + "description": "Sessions that scrolled at least to this bucket." + }, + "percent_reached": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "`sessions_reached` divided by the sessions counted in the first\nbucket, as a fraction. The first bucket is always `1`.\n" + } + }, + "additionalProperties": false + }, + "ZoningUrlMatch": { + "type": "string", + "description": "How `page_url` is matched against the page URL recorded on each event.\n`is` matches the exact URL; `contains` matches any URL containing it.\n", + "enum": ["is", "contains"], + "default": "is" + }, + "ZoneMetric": { + "type": "string", + "description": "Metric computed per zone. Rates are fractions (`0.18` is 18%).\n\n- `click_rate_pageview`: page views with a click in the zone, divided by page views.\n- `click_rate_session`: sessions with a click in the zone, divided by sessions.\n- `rage_click_rate_pageview`: page views with a rage click in the zone, divided by page views.\n- `rage_click_rate_session`: sessions with a rage click in the zone, divided by sessions.\n- `click_distribution`: clicks in the zone, divided by all clicks on the page.\n- `click_recurrence`: clicks in the zone, divided by page views. Can exceed 1.\n- `rage_click_recurrence`: rage clicks in the zone, divided by page views. Can exceed 1.\n- `exposure_rate`: page views where the zone was visible, divided by page views.\n- `attractiveness_rate`: page views where the zone was visible and clicked, divided by page views where it was visible. Can exceed 1.\n- `revenue_per_click`: revenue attributed to the zone, divided by sessions that clicked it, in the project's revenue unit.\n", + "enum": [ + "click_rate_pageview", + "click_rate_session", + "rage_click_rate_pageview", + "rage_click_rate_session", + "click_distribution", + "click_recurrence", + "rage_click_recurrence", + "exposure_rate", + "attractiveness_rate", + "revenue_per_click" + ], + "default": "click_rate_pageview" + }, + "ZoneMetricRow": { + "type": "object", + "required": ["zone_id", "value"], + "properties": { + "zone_id": { + "type": "string", + "description": "CSS selector identifying the zone." + }, + "value": { + "type": "number", + "description": "The metric value for this zone. Rates are fractions and are returned uncapped." + } + }, + "additionalProperties": false + }, "RolloutWeights": { "type": "object", "additionalProperties": { diff --git a/openapi/bundled/openapi.bundled.yaml b/openapi/bundled/openapi.bundled.yaml index 5928c73..45a946c 100644 --- a/openapi/bundled/openapi.bundled.yaml +++ b/openapi/bundled/openapi.bundled.yaml @@ -31,6 +31,10 @@ tags: description: User-property taxonomy operations (scoped to a project). - name: Analytics description: Saved chart discovery and query operations. + - name: Heatmaps + description: Click map and scroll map queries for a page URL. + - name: Zoning + description: Zone metric queries for a page URL. - name: Feature Flags description: Feature flag configuration and rollout operations. - name: Destinations @@ -38,7 +42,7 @@ tags: - name: Skills description: Skill documents for implementing Amplitude, intended for AI agents. - name: Auth - description: OAuth device-flow and token endpoints (RFC 8628 / RFC 6749). + description: Signup, OAuth device-flow, and token endpoints (RFC 8628 / RFC 6749). paths: /v1/projects/{project_id}/agent-analytics/insights: get: @@ -180,6 +184,55 @@ paths: application/json: schema: $ref: '#/components/schemas/OAuthError' + /v1/auth/signup: + post: + tags: + - Auth + operationId: signup + summary: Sign up for an Amplitude account + description: | + Sign up for an Amplitude account. The response either provides credentials for the new account or starts an OAuth flow to complete signup. + x-required-scopes: [] + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SignupRequest' + responses: + '200': + description: Signup result with credentials, an OAuth flow, or additional signup information. + content: + application/json: + schema: + $ref: '#/components/schemas/SignupResponse' + '400': + description: Invalid signup request. + content: + application/json: + schema: + $ref: '#/components/schemas/SignupError' + '429': + $ref: '#/components/responses/RateLimitProblem' + '500': + description: Signup could not be completed due to a server error. + content: + application/json: + schema: + $ref: '#/components/schemas/SignupError' + '502': + description: Signup upstream service error. + content: + application/json: + schema: + $ref: '#/components/schemas/SignupError' + '503': + description: Signup authentication service is temporarily unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/SignupError' /v1/auth/token: post: tags: @@ -1632,6 +1685,183 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/ProblemDetails' + /v1/projects/{project_id}/heatmaps/click-map: + parameters: + - $ref: '#/components/parameters/ProjectId' + post: + tags: + - Heatmaps + operationId: queryClickMap + summary: Query click map + description: | + Returns click counts for one page URL, grouped by the CSS selector of the + clicked element or by page coordinate, ranked by count. Filter by device + viewport band and date range. Results reflect the authenticated caller's + project access and data access controls. + + This POST computes a result and does not mutate state. It is therefore a + read operation and does not require an `Idempotency-Key`. + + Omitting `time_range` uses the last 30 days in the project timezone. The + span of `time_range` cannot exceed 90 days. + x-required-scopes: + - analytics:read + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ClickMapQueryRequest' + responses: + '200': + description: Click map computed successfully. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ClickMap' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/HeatmapForbiddenProblem' + '404': + $ref: '#/components/responses/Problem' + '422': + $ref: '#/components/responses/NotConfiguredProblem' + '429': + $ref: '#/components/responses/RateLimitProblem' + '500': + $ref: '#/components/responses/Problem' + '502': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/AvailabilityUnavailableProblem' + '504': + $ref: '#/components/responses/UpstreamTimeoutProblem' + /v1/projects/{project_id}/heatmaps/scroll-map: + parameters: + - $ref: '#/components/parameters/ProjectId' + post: + tags: + - Heatmaps + operationId: queryScrollMap + summary: Query scroll map + description: | + Returns the scroll-depth distribution for one page URL as a cumulative + histogram from the top of the page, plus the average fold and the deepest + scroll observed. Filter by device viewport band and date range. Results + reflect the authenticated caller's project access and data access + controls. + + This POST computes a result and does not mutate state. It is therefore a + read operation and does not require an `Idempotency-Key`. + + Omitting `time_range` uses the last 30 days in the project timezone. The + span of `time_range` cannot exceed 90 days. + x-required-scopes: + - analytics:read + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScrollMapQueryRequest' + responses: + '200': + description: Scroll map computed successfully. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ScrollMap' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/HeatmapForbiddenProblem' + '404': + $ref: '#/components/responses/Problem' + '422': + $ref: '#/components/responses/NotConfiguredProblem' + '429': + $ref: '#/components/responses/RateLimitProblem' + '500': + $ref: '#/components/responses/Problem' + '502': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/AvailabilityUnavailableProblem' + '504': + $ref: '#/components/responses/UpstreamTimeoutProblem' + /v1/projects/{project_id}/zoning/zone-metrics: + parameters: + - $ref: '#/components/parameters/ProjectId' + post: + tags: + - Zoning + operationId: queryZoneMetrics + summary: Query zone metrics + description: | + Returns one metric for every zone on a page URL, ranked by value. A zone + is identified by the CSS selector of its element. Results reflect the + authenticated caller's project access and data access controls. + + This POST computes a result and does not mutate state. It is therefore a + read operation and does not require an `Idempotency-Key`. + + Omitting `time_range` uses the last 30 days in the project timezone. The + span of `time_range` cannot exceed 90 days. + x-required-scopes: + - analytics:read + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ZoneMetricsQueryRequest' + responses: + '200': + description: Zone metrics computed successfully. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ZoneMetrics' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/ZoningForbiddenProblem' + '404': + $ref: '#/components/responses/Problem' + '422': + $ref: '#/components/responses/NotConfiguredProblem' + '429': + $ref: '#/components/responses/RateLimitProblem' + '500': + $ref: '#/components/responses/Problem' + '502': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/AvailabilityUnavailableProblem' + '504': + $ref: '#/components/responses/UpstreamTimeoutProblem' /v1/projects/{project_id}/flags: get: tags: @@ -2438,6 +2668,145 @@ components: detail: Too many requests. error_code: rate_limit_exceeded retryable: true + HeatmapForbiddenProblem: + description: | + The caller cannot use this operation. `operation_not_enabled` means the + operation is not enabled for the caller's organization; + `insufficient_scope` means the token lacks `analytics:read`; `forbidden` + means the caller lacks permission to view heatmaps in the project; + `data_access_restricted` means data access controls exclude the requested + data. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + examples: + operationNotEnabled: + summary: Endpoint unavailable + value: + type: https://developer-api.amplitude.com/problems/operation-not-enabled + title: Endpoint unavailable + status: 403 + detail: This endpoint is unavailable for your organization. + error_code: operation_not_enabled + retryable: false + insufficientScope: + summary: Missing required scope + value: + type: https://developer-api.amplitude.com/problems/forbidden + title: Insufficient scope + status: 403 + detail: Token missing required scopes. + error_code: insufficient_scope + retryable: false + forbidden: + summary: Missing project permission + value: + type: https://developer-api.amplitude.com/problems/forbidden + title: Forbidden + status: 403 + detail: You do not have permission to view heatmaps in this project. + error_code: forbidden + retryable: false + dataAccessRestricted: + summary: Data access restricted + value: + type: https://developer-api.amplitude.com/problems/data-access-restricted + title: Data access restricted + status: 403 + detail: Data access controls exclude the requested data. + error_code: data_access_restricted + retryable: false + NotConfiguredProblem: + description: | + The project does not capture the events or properties this query needs. + Returned with `error_code: session_replay_not_configured`; `detail` names + what is missing. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + example: + type: https://developer-api.amplitude.com/problems/session-replay-not-configured + title: Session replay not configured + status: 422 + detail: 'The project is missing required events: [Amplitude] Element Clicked.' + error_code: session_replay_not_configured + retryable: false + AvailabilityUnavailableProblem: + description: Endpoint availability could not be determined. Retry the request. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + example: + type: https://developer-api.amplitude.com/problems/operation-availability-unavailable + title: Operation availability unavailable + status: 503 + detail: Operation availability could not be determined. Retry the request. + error_code: operation_availability_unavailable + retryable: true + UpstreamTimeoutProblem: + description: The query exceeded the synchronous timeout. Narrow the date range and retry. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + example: + type: https://developer-api.amplitude.com/problems/upstream-timeout + title: Upstream timeout + status: 504 + detail: The query did not complete in time. Narrow the date range and retry. + error_code: upstream_timeout + retryable: true + ZoningForbiddenProblem: + description: | + The caller cannot use this operation. `operation_not_enabled` means the + operation is not enabled for the caller's organization; + `insufficient_scope` means the token lacks `analytics:read`; `forbidden` + means the caller lacks access to the project; `data_access_restricted` + means data access controls exclude the requested data. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + examples: + operationNotEnabled: + summary: Endpoint unavailable + value: + type: https://developer-api.amplitude.com/problems/operation-not-enabled + title: Endpoint unavailable + status: 403 + detail: This endpoint is unavailable for your organization. + error_code: operation_not_enabled + retryable: false + insufficientScope: + summary: Missing required scope + value: + type: https://developer-api.amplitude.com/problems/forbidden + title: Insufficient scope + status: 403 + detail: Token missing required scopes. + error_code: insufficient_scope + retryable: false + forbidden: + summary: Missing project permission + value: + type: https://developer-api.amplitude.com/problems/forbidden + title: Forbidden + status: 403 + detail: You do not have permission to view zoning in this project. + error_code: forbidden + retryable: false + dataAccessRestricted: + summary: Data access restricted + value: + type: https://developer-api.amplitude.com/problems/data-access-restricted + title: Data access restricted + status: 403 + detail: Data access controls exclude the requested data. + error_code: data_access_restricted + retryable: false schemas: ObjectMeta: type: object @@ -2985,37 +3354,379 @@ components: event reached Amplitude. additionalProperties: false additionalProperties: false - FeatureFlag: - allOf: - - $ref: '#/components/schemas/FeatureFlagBase' - - type: object - required: - - object - properties: - object: - type: string - const: feature_flag - description: Resource-type discriminator. Always `feature_flag`. - Variant: + TimeRange: type: object required: - - key + - start + - end properties: - key: + start: type: string - pattern: ^[A-Za-z0-9_-]+$ - description: Stable variant key. The value `off` is reserved. - name: - type: - - string - - 'null' - description: - type: - - string - - 'null' - payload: - description: Optional JSON payload returned by evaluation. - TargetSegment: + format: date + description: Inclusive start date (project timezone unless `timezone` is set on query). + end: + type: string + format: date + description: Inclusive end date. + additionalProperties: false + ClickMapQueryRequest: + type: object + description: | + Click map query. When `time_range` is omitted the last 30 days in the + project timezone are used. The span of `time_range` cannot exceed 90 + days. + required: + - page_url + properties: + page_url: + $ref: '#/components/schemas/PageUrl' + url_match: + $ref: '#/components/schemas/HeatmapUrlMatch' + device_type: + $ref: '#/components/schemas/HeatmapDeviceType' + viewport_width: + $ref: '#/components/schemas/ViewportWidth' + time_range: + $ref: '#/components/schemas/TimeRange' + group_by: + type: string + description: | + `selector` groups clicks by the CSS selector of the clicked element; + `pixel` groups them by page coordinate. + enum: + - selector + - pixel + default: selector + metric: + type: string + description: | + `totals` counts every click; `uniques` counts distinct users who + clicked. + enum: + - totals + - uniques + default: totals + limit: + type: integer + minimum: 1 + maximum: 200 + default: 50 + description: Maximum number of rows returned after ranking by `count`. + additionalProperties: false + ScrollMapQueryRequest: + type: object + description: | + Scroll map query. When `time_range` is omitted the last 30 days in the + project timezone are used. The span of `time_range` cannot exceed 90 + days. + required: + - page_url + properties: + page_url: + $ref: '#/components/schemas/PageUrl' + url_match: + $ref: '#/components/schemas/HeatmapUrlMatch' + device_type: + $ref: '#/components/schemas/HeatmapDeviceType' + viewport_width: + $ref: '#/components/schemas/ViewportWidth' + time_range: + $ref: '#/components/schemas/TimeRange' + additionalProperties: false + ClickMap: + type: object + description: | + Ranked click counts for one page. Exactly one of `selectors` and `pixels` + is populated, chosen by `group_by`; the other is `null`. Rows are sorted + by `count` descending. Results reflect the caller's data access. + required: + - id + - object + - project_id + - page_url + - url_match + - device_type + - viewport_width + - group_by + - metric + - time_range + - timezone + - result_count + - truncated + - selectors + - pixels + - warnings + properties: + id: + type: string + description: Identifier for this computation. + object: + type: string + const: click_map + description: Resource-type discriminator. Always `click_map`. + project_id: + type: string + pattern: ^[0-9]+$ + page_url: + type: string + description: The page URL after normalization. + url_match: + $ref: '#/components/schemas/HeatmapUrlMatch' + device_type: + $ref: '#/components/schemas/HeatmapDeviceType' + viewport_width: + type: + - integer + - 'null' + description: Exact viewport width used when `device_type` is `custom`; otherwise `null`. + group_by: + type: string + enum: + - selector + - pixel + metric: + type: string + enum: + - totals + - uniques + time_range: + $ref: '#/components/schemas/TimeRange' + timezone: + type: string + description: IANA timezone used to resolve `time_range` day boundaries (the project timezone). + result_count: + type: integer + minimum: 0 + description: Number of rows returned. + truncated: + type: boolean + description: | + `true` when more rows were available than `limit` allowed. The + underlying query is itself bounded, so `false` does not guarantee + that every clicked element on the page is listed. + selectors: + type: + - array + - 'null' + items: + $ref: '#/components/schemas/ClickMapSelectorRow' + pixels: + type: + - array + - 'null' + items: + $ref: '#/components/schemas/ClickMapPixelRow' + warnings: + type: array + items: + type: string + description: Non-fatal conditions, such as an empty result for the range. + ScrollMap: + type: object + description: | + Scroll-depth distribution for one page, as a cumulative histogram from the + top of the page. All buckets are returned in page order. Results reflect + the caller's data access. + required: + - id + - object + - project_id + - page_url + - url_match + - device_type + - viewport_width + - time_range + - timezone + - total_sessions + - average_fold_px + - max_scroll_depth_px + - bucket_count + - buckets + - warnings + properties: + id: + type: string + description: Identifier for this computation. + object: + type: string + const: scroll_map + description: Resource-type discriminator. Always `scroll_map`. + project_id: + type: string + pattern: ^[0-9]+$ + page_url: + type: string + description: The page URL after normalization. + url_match: + $ref: '#/components/schemas/HeatmapUrlMatch' + device_type: + $ref: '#/components/schemas/HeatmapDeviceType' + viewport_width: + type: + - integer + - 'null' + description: Exact viewport width used when `device_type` is `custom`; otherwise `null`. + time_range: + $ref: '#/components/schemas/TimeRange' + timezone: + type: string + description: IANA timezone used to resolve `time_range` day boundaries (the project timezone). + total_sessions: + type: integer + minimum: 0 + description: Sessions with a scroll event on the page in the range (the first bucket's count). + average_fold_px: + type: + - integer + - 'null' + description: Average viewport height in pixels, or `null` when unavailable. + max_scroll_depth_px: + type: + - integer + - 'null' + description: Deepest scroll position observed in page pixels, or `null` when unavailable. + bucket_count: + type: integer + minimum: 0 + buckets: + type: array + items: + $ref: '#/components/schemas/ScrollMapBucket' + warnings: + type: array + items: + type: string + description: Non-fatal conditions, such as an empty result for the range. + ZoneMetricsQueryRequest: + type: object + required: + - page_url + properties: + page_url: + $ref: '#/components/schemas/PageUrl' + url_match: + $ref: '#/components/schemas/ZoningUrlMatch' + time_range: + $ref: '#/components/schemas/TimeRange' + metric: + $ref: '#/components/schemas/ZoneMetric' + zone_ids: + type: array + maxItems: 200 + items: + type: string + minLength: 1 + description: | + Restrict the result to these zones. A zone id is the CSS selector of + the element, as returned in `zones[].zone_id`. Omit to rank every zone + on the page. + limit: + type: integer + minimum: 1 + maximum: 200 + default: 20 + description: Maximum number of zones returned after ranking by `value`. + additionalProperties: false + description: | + Zone metrics query. When `time_range` is omitted the last 30 days in the + project timezone are used. The span of `time_range` cannot exceed 90 + days. One metric is computed per request. + ZoneMetrics: + type: object + description: | + One metric for every zone on a page, ranked by `value` descending. + Results reflect the caller's data access. + required: + - id + - object + - project_id + - page_url + - url_match + - metric + - time_range + - timezone + - zone_count + - total_zone_count + - truncated + - zones + - warnings + properties: + id: + type: string + description: Identifier for this computation. + object: + type: string + const: zone_metrics + description: Resource-type discriminator. Always `zone_metrics`. + project_id: + type: string + pattern: ^[0-9]+$ + page_url: + type: string + description: The page URL after normalization. + url_match: + $ref: '#/components/schemas/ZoningUrlMatch' + metric: + $ref: '#/components/schemas/ZoneMetric' + time_range: + $ref: '#/components/schemas/TimeRange' + timezone: + type: string + description: IANA timezone used to resolve `time_range` day boundaries (the project timezone). + zone_count: + type: integer + minimum: 0 + description: Number of zones returned. + total_zone_count: + type: integer + minimum: 0 + description: Number of zones with data before `limit` was applied. + truncated: + type: boolean + description: '`true` when `total_zone_count` exceeds `zone_count`.' + zones: + type: array + items: + $ref: '#/components/schemas/ZoneMetricRow' + warnings: + type: array + items: + type: string + description: | + Non-fatal conditions. Includes a warning when the numbers behind a + rate were computed from differently aged cache snapshots, which can + push a rate above its natural bound. + FeatureFlag: + allOf: + - $ref: '#/components/schemas/FeatureFlagBase' + - type: object + required: + - object + properties: + object: + type: string + const: feature_flag + description: Resource-type discriminator. Always `feature_flag`. + Variant: + type: object + required: + - key + properties: + key: + type: string + pattern: ^[A-Za-z0-9_-]+$ + description: Stable variant key. The value `off` is reserved. + name: + type: + - string + - 'null' + description: + type: + - string + - 'null' + payload: + description: Optional JSON payload returned by evaluation. + TargetSegment: type: object required: - conditions @@ -3253,6 +3964,62 @@ components: type: integer interval: type: integer + SignupRequest: + type: object + additionalProperties: false + required: + - email + properties: + email: + type: string + format: email + description: Email address of the user signing up for the Amplitude account. + full_name: + type: string + minLength: 1 + description: Full name of the user signing up for the Amplitude account. + scope: + type: string + minLength: 1 + description: Space-delimited OAuth scopes to request. + terms_acceptance: + type: object + properties: + terms_of_service: + $ref: '#/components/schemas/SignupDocumentAcceptance' + privacy_policy: + $ref: '#/components/schemas/SignupDocumentAcceptance' + SignupResponse: + description: | + Signup result. The response provides credentials, triggers an OAuth flow, or requests additional signup information. + oneOf: + - $ref: '#/components/schemas/SignupCredentialsResponse' + - $ref: '#/components/schemas/SignupRequiresAuthResponse' + - $ref: '#/components/schemas/SignupNeedsInformationResponse' + discriminator: + propertyName: type + mapping: + credentials: '#/components/schemas/SignupCredentialsResponse' + requires_auth: '#/components/schemas/SignupRequiresAuthResponse' + needs_information: '#/components/schemas/SignupNeedsInformationResponse' + SignupError: + type: object + required: + - type + - error + properties: + type: + const: error + error: + type: object + required: + - code + - message + properties: + code: + type: string + message: + type: string OAuthError: type: object description: RFC 6749 §5.2 OAuth error response. @@ -3410,6 +4177,61 @@ components: evaluated_at: type: string format: date-time + SignupDocumentAcceptance: + type: object + required: + - url + - accepted + properties: + url: + type: string + format: uri + accepted: + const: true + SignupCredentialsResponse: + type: object + required: + - type + - credentials + properties: + type: + const: credentials + credentials: + $ref: '#/components/schemas/TokenResponse' + SignupRequiresAuthResponse: + type: object + required: + - type + - requires_auth + properties: + type: + const: requires_auth + requires_auth: + type: object + required: + - type + - device_authorization + properties: + type: + const: device_authorization + device_authorization: + $ref: '#/components/schemas/DeviceAuthorizationResponse' + SignupNeedsInformationResponse: + type: object + required: + - type + - needs_information + properties: + type: + const: needs_information + needs_information: + type: object + required: + - schema + properties: + schema: + type: object + additionalProperties: true DeviceCodeTokenRequest: type: object required: @@ -3473,21 +4295,6 @@ components: - event_mapping - transformation - ads_account - TimeRange: - type: object - required: - - start - - end - properties: - start: - type: string - format: date - description: Inclusive start date (project timezone unless `timezone` is set on query). - end: - type: string - format: date - description: Inclusive end date. - additionalProperties: false ResultKind: type: string description: | @@ -3660,6 +4467,159 @@ components: reason: type: string description: Human-readable explanation of truncation. + PageUrl: + type: string + minLength: 1 + maxLength: 2048 + description: | + Page URL to analyze. Accepts `http` and `https` URLs; a URL without a + scheme is treated as `https`. The query string and fragment are removed + before matching, so `https://example.com/pricing?utm=x` and + `https://example.com/pricing` are the same page. + example: https://example.com/pricing + HeatmapUrlMatch: + type: string + description: | + How `page_url` is matched against the page URL recorded on each event. + `is` matches the exact URL, `contains` matches any URL containing it, + `has_prefix` matches URLs starting with it, and `glob` treats `*` as a + wildcard. + enum: + - is + - contains + - has_prefix + - glob + default: is + HeatmapDeviceType: + type: string + description: | + Viewport band to include, matching the device selector in the product: + `desktop` is at least 1440 px wide, `laptop` is 900 to 1440 px, `tablet` + is 500 to 900 px, `mobile` is at most 500 px. Use `custom` with + `viewport_width` to match one exact width. + enum: + - desktop + - laptop + - tablet + - mobile + - custom + default: desktop + ViewportWidth: + type: integer + minimum: 1 + maximum: 20000 + description: | + Exact viewport width in pixels. Required when `device_type` is `custom` + and not accepted otherwise. + ClickMapSelectorRow: + type: object + required: + - selector + - count + properties: + selector: + type: string + description: CSS selector of the clicked element. + count: + type: integer + minimum: 0 + description: 'Clicks (`metric: totals`) or unique users (`metric: uniques`).' + additionalProperties: false + ClickMapPixelRow: + type: object + required: + - x + - 'y' + - count + properties: + x: + type: integer + description: Horizontal page coordinate in pixels. + 'y': + type: integer + description: Vertical page coordinate in pixels. + count: + type: integer + minimum: 0 + description: 'Clicks (`metric: totals`) or unique users (`metric: uniques`).' + additionalProperties: false + ScrollMapBucket: + type: object + required: + - depth_from_px + - depth_to_px + - sessions_reached + - percent_reached + properties: + depth_from_px: + type: integer + minimum: 0 + description: Lower bound of the scroll-depth bucket in page pixels. + depth_to_px: + type: integer + minimum: 0 + description: Upper bound of the scroll-depth bucket in page pixels. + sessions_reached: + type: integer + minimum: 0 + description: Sessions that scrolled at least to this bucket. + percent_reached: + type: number + minimum: 0 + maximum: 1 + description: | + `sessions_reached` divided by the sessions counted in the first + bucket, as a fraction. The first bucket is always `1`. + additionalProperties: false + ZoningUrlMatch: + type: string + description: | + How `page_url` is matched against the page URL recorded on each event. + `is` matches the exact URL; `contains` matches any URL containing it. + enum: + - is + - contains + default: is + ZoneMetric: + type: string + description: | + Metric computed per zone. Rates are fractions (`0.18` is 18%). + + - `click_rate_pageview`: page views with a click in the zone, divided by page views. + - `click_rate_session`: sessions with a click in the zone, divided by sessions. + - `rage_click_rate_pageview`: page views with a rage click in the zone, divided by page views. + - `rage_click_rate_session`: sessions with a rage click in the zone, divided by sessions. + - `click_distribution`: clicks in the zone, divided by all clicks on the page. + - `click_recurrence`: clicks in the zone, divided by page views. Can exceed 1. + - `rage_click_recurrence`: rage clicks in the zone, divided by page views. Can exceed 1. + - `exposure_rate`: page views where the zone was visible, divided by page views. + - `attractiveness_rate`: page views where the zone was visible and clicked, divided by page views where it was visible. Can exceed 1. + - `revenue_per_click`: revenue attributed to the zone, divided by sessions that clicked it, in the project's revenue unit. + enum: + - click_rate_pageview + - click_rate_session + - rage_click_rate_pageview + - rage_click_rate_session + - click_distribution + - click_recurrence + - rage_click_recurrence + - exposure_rate + - attractiveness_rate + - revenue_per_click + default: click_rate_pageview + ZoneMetricRow: + type: object + required: + - zone_id + - value + properties: + zone_id: + type: string + description: CSS selector identifying the zone. + value: + type: number + description: The metric value for this zone. Rates are fractions and are returned uncapped. + additionalProperties: false RolloutWeights: type: object additionalProperties: diff --git a/package.json b/package.json index 1187967..15952ff 100644 --- a/package.json +++ b/package.json @@ -45,12 +45,15 @@ "@inquirer/prompts": "^8.5.2", "chalk": "^4.1.2", "commander": "^15.0.0", + "env-paths": "^3.0.0", "fastest-levenshtein": "^1.0.16", + "filename-reserved-regex": "^4.0.1", "open": "^11.0.0", "proper-lockfile": "^4.1.2", "zod": "^4.3.6" }, "devDependencies": { + "@types/filename-reserved-regex": "^3.0.0", "@types/node": "26.5.1", "@types/proper-lockfile": "^4.1.4", "@typescript/native-preview": "7.0.0-dev.20260601.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce45342..5ef2099 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 + env-paths: + specifier: ^3.0.0 + version: 3.0.0 fastest-levenshtein: specifier: ^1.0.16 version: 1.0.16 + filename-reserved-regex: + specifier: ^4.0.1 + version: 4.0.1 open: specifier: ^11.0.0 version: 11.0.0 @@ -30,6 +36,9 @@ importers: specifier: ^4.3.6 version: 4.4.3 devDependencies: + '@types/filename-reserved-regex': + specifier: ^3.0.0 + version: 3.0.0 '@types/node': specifier: 26.5.1 version: 26.5.1 @@ -50,7 +59,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.4 - version: 4.1.9(@types/node@26.5.1)(vite@8.1.0) + version: 4.1.9(@types/node@26.5.1)(vite@8.1.0(@types/node@26.5.1)(tsx@4.7.2)) packages: @@ -460,6 +469,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/filename-reserved-regex@3.0.0': + resolution: {integrity: sha512-DusFb+cKDTVxXZ5ZwqUY9n7McLR9Y5T1CbG21r61Kk0zPuSkWMdeXm9rubxJrynQP9x8hXJphdCJiMJACG2cSw==} + '@types/node@26.5.1': resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} @@ -611,6 +623,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -648,6 +664,10 @@ packages: picomatch: optional: true + filename-reserved-regex@4.0.1: + resolution: {integrity: sha512-qUet2faQFKvtvVUsEf7wCrTURwxBOIZpspsLHGifw9QCWk55ITE2FrG8XhfQqG/uxMA1xEGFVQbL+Yfm0O94+Q==} + engines: {node: '>=20'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1286,6 +1306,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/filename-reserved-regex@3.0.0': {} + '@types/node@26.5.1': dependencies: undici-types: 8.9.0 @@ -1336,7 +1358,7 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0)': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.5.1)(tsx@4.7.2))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 @@ -1419,6 +1441,8 @@ snapshots: detect-libc@2.1.2: {} + env-paths@3.0.0: {} + es-module-lexer@2.1.0: {} esbuild@0.19.12: @@ -1469,6 +1493,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + filename-reserved-regex@4.0.1: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -1698,10 +1724,10 @@ snapshots: fsevents: 2.3.3 tsx: 4.7.2 - vitest@4.1.9(@types/node@26.5.1)(vite@8.1.0): + vitest@4.1.9(@types/node@26.5.1)(vite@8.1.0(@types/node@26.5.1)(tsx@4.7.2)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.5.1)(tsx@4.7.2)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 diff --git a/src/amplitude-data-path.test.ts b/src/amplitude-data-path.test.ts deleted file mode 100644 index 152df39..0000000 --- a/src/amplitude-data-path.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-data-path'; - -describe('amplitudeDataPath', () => { - it('locates CLI state files in the shared Amplitude data directory', () => { - expect(amplitudeDataPath(amplitudeDataFiles.clientIdentity)).toBe( - join(homedir(), '.amplitude', 'amp', 'state.json'), - ); - }); - - it('preserves an explicit state-file override', () => { - expect( - amplitudeDataPath( - amplitudeDataFiles.clientIdentity, - '/tmp/amp-state.json', - ), - ).toBe('/tmp/amp-state.json'); - }); -}); diff --git a/src/amplitude-paths.test.ts b/src/amplitude-paths.test.ts new file mode 100644 index 0000000..5a0a6da --- /dev/null +++ b/src/amplitude-paths.test.ts @@ -0,0 +1,43 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + amplitudeDataFiles, + amplitudeDataPath, + materializedSkillsRoot, +} from './amplitude-paths'; + +describe('amplitudeDataPath', () => { + it('locates CLI state files in the shared Amplitude data directory', () => { + expect(amplitudeDataPath(amplitudeDataFiles.clientIdentity)).toBe( + join(homedir(), '.amplitude', 'amp', 'state.json'), + ); + }); + + it('preserves an explicit state-file override', () => { + expect( + amplitudeDataPath( + amplitudeDataFiles.clientIdentity, + '/tmp/amp-state.json', + ), + ).toBe('/tmp/amp-state.json'); + }); +}); + +const envPathsCalls = vi.hoisted((): string[] => []); + +vi.mock('env-paths', () => ({ + default: (name: string) => { + envPathsCalls.push(name); + return { cache: '/user-cache/amp-cli-nodejs' }; + }, +})); + +it('places materialized skills in the CLI cache directory', () => { + expect(envPathsCalls).toEqual(['amp-cli']); + expect(materializedSkillsRoot()).toBe( + join('/user-cache/amp-cli-nodejs', 'skills'), + ); +}); diff --git a/src/amplitude-data-path.ts b/src/amplitude-paths.ts similarity index 63% rename from src/amplitude-data-path.ts rename to src/amplitude-paths.ts index 8c98f14..9bb212a 100644 --- a/src/amplitude-data-path.ts +++ b/src/amplitude-paths.ts @@ -1,12 +1,20 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; +import envPaths from 'env-paths'; + +const amplitudeCliPaths = envPaths('amp-cli'); + export const amplitudeDataFiles = Object.freeze({ clientIdentity: 'state.json', credentials: 'credentials.json', pendingLogins: 'pending-logins.json', }); +export const amplitudeCacheDirectories = Object.freeze({ + skills: 'skills', +}); + export type AmplitudeDataFile = (typeof amplitudeDataFiles)[keyof typeof amplitudeDataFiles]; @@ -16,3 +24,7 @@ export function amplitudeDataPath( ): string { return override ?? join(homedir(), '.amplitude', 'amp', fileName); } + +export function materializedSkillsRoot(): string { + return join(amplitudeCliPaths.cache, amplitudeCacheDirectories.skills); +} diff --git a/src/args.test.ts b/src/args.test.ts index 1d2845b..d88dba9 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isFlagEnabled, parseArgs } from './args'; +import { authGlobalOptionAliases, isFlagEnabled, parseArgs } from './args'; describe('parseArgs', () => { it('separates command tokens from flags', () => { @@ -17,12 +17,23 @@ describe('parseArgs', () => { expect(parseArgs(['--dry-run']).flags).toEqual({ 'dry-run': true }); }); + it.each([ + [['skills', 'get', 'integrating-amplitude', '--save'], true], + [['skills', 'get', 'integrating-amplitude', '--save=true'], 'true'], + [['skills', 'get', 'integrating-amplitude', '--save=false'], 'false'], + ])('parses --save as an optional boolean value', (argv, expectedSave) => { + const { command, flags } = parseArgs(argv); + + expect(command).toEqual(['skills', 'get', 'integrating-amplitude']); + expect(flags.save).toBe(expectedSave); + }); + it('parses short switches -h and -v as booleans', () => { expect(parseArgs(['-h']).flags).toEqual({ h: true }); expect(parseArgs(['-v']).flags).toEqual({ v: true }); }); - it.each(['json', 'help', 'version'])( + it.each(['json', 'help', 'version', 'save'])( 'keeps the skills name positional when --%s precedes it', (alias) => { const { command, flags } = parseArgs([ @@ -40,7 +51,7 @@ describe('parseArgs', () => { }, ); - it.each(['json', 'help', 'version'])( + it.each(['json', 'help', 'version', 'save'])( 'keeps the skills name positional when --%s follows it', (alias) => { const { command, flags } = parseArgs([ @@ -65,6 +76,17 @@ describe('parseArgs', () => { expect(flags.json).toBe('false'); }); + it('does not suggest the hidden --save flag for a typo', () => { + expect(() => parseArgs(['skills', 'get', 'example', '--svae'])).toThrow( + "unknown option '--svae'", + ); + try { + parseArgs(['skills', 'get', 'example', '--svae']); + } catch (error) { + expect(String(error)).not.toContain('Did you mean --save'); + } + }); + it('does not suggest the hidden --env flag for a typo', () => { expect(() => parseArgs(['skills', 'get', '--enx'])).toThrowError( /^unknown option '--enx'$/, @@ -132,6 +154,10 @@ describe('parseArgs', () => { expect(command).toEqual(['auth', 'token']); expect(flags).toMatchObject({ flow: 'device', scope: 'openid email' }); }); + + it('keeps --save out of auth command globals', () => { + expect(authGlobalOptionAliases()).not.toContain('save'); + }); }); describe('isFlagEnabled', () => { diff --git a/src/args.ts b/src/args.ts index a98c9bc..5445158 100644 --- a/src/args.ts +++ b/src/args.ts @@ -27,6 +27,8 @@ interface CliOptionDefinition extends ParseableOption { // there would silently drop the flag and mislead the caller. See // apiGlobalOptionAliases(). onApiCommands: boolean; + /** Whether this global is meaningful on bespoke auth/logout commands. */ + onAuthCommands?: boolean; requiresAuthentication?: true; visible: boolean; } @@ -63,6 +65,13 @@ const GLOBAL_OPTIONS = defineGlobalOptions([ onApiCommands: true, visible: true, }, + { + aliases: ['save'], + valueRequirement: 'optional', + onApiCommands: false, + onAuthCommands: false, + visible: false, + }, { aliases: ['yes'], valueRequirement: 'optional', @@ -184,6 +193,18 @@ export function apiGlobalOptionAliases( ]; } +/** Global aliases meaningful on bespoke auth/logout commands. */ +export function authGlobalOptionAliases(): GlobalOptionAlias[] { + return [ + ...new Set( + GLOBAL_OPTIONS.filter( + (option) => + !('onAuthCommands' in option) || option.onAuthCommands !== false, + ).flatMap((option) => option.aliases), + ), + ]; +} + function attributeName(alias: string): string { return alias.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase(), diff --git a/src/auth-flag-coverage.test.ts b/src/auth-flag-coverage.test.ts index 9d45eb7..08ce6fa 100644 --- a/src/auth-flag-coverage.test.ts +++ b/src/auth-flag-coverage.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { globalOptionAliases } from './args'; +import { authGlobalOptionAliases } from './args'; import { buildCatalog } from './catalog'; /** @@ -72,7 +72,7 @@ function allowedAuthFlagAliases(): Set { const catalogAuthAliases = buildCatalog() .filter((entry) => entry.group === 'auth') .flatMap((entry) => entry.flags.flatMap((flag) => flag.aliases)); - return new Set([...globalOptionAliases(), ...catalogAuthAliases]); + return new Set([...authGlobalOptionAliases(), ...catalogAuthAliases]); } describe('auth handler flag-read coverage', () => { diff --git a/src/catalog.test.ts b/src/catalog.test.ts index 646547a..6a8ae1a 100644 --- a/src/catalog.test.ts +++ b/src/catalog.test.ts @@ -177,6 +177,7 @@ describe('buildCatalog — auth/meta commands', () => { ]); expect(get?.globalFlags).toEqual([ 'json', + 'save', 'env', 'region', 'help', @@ -185,4 +186,13 @@ describe('buildCatalog — auth/meta commands', () => { 'v', ]); }); + + it('describes document output without revealing the internal save flag', () => { + const get = buildCatalog().find( + (command) => command.command.join(' ') === 'skills get', + ); + + expect(get?.description).toContain('data.document'); + expect(get?.description).not.toContain('--save'); + }); }); diff --git a/src/catalog.ts b/src/catalog.ts index 6d92dd2..af3e902 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,6 +1,7 @@ import { type GlobalOptionAlias, apiGlobalOptionAliases, + authGlobalOptionAliases, globalOptionAliases, } from './args'; import { DEFAULT_POLL_TIMEOUT_SECONDS } from './config'; @@ -92,9 +93,11 @@ const GROUP_ORDER: Partial> = { 'user-properties': 4, flags: 5, charts: 6, - 'destination-types': 7, - destinations: 8, - skills: 10, + heatmaps: 7, + zoning: 8, + 'destination-types': 9, + destinations: 10, + skills: 11, auth: 20, }; @@ -106,6 +109,8 @@ const GROUP_DESCRIPTIONS: Partial> = { 'user-properties': 'User properties', flags: 'Feature flags', charts: 'Saved and ad-hoc charts', + heatmaps: 'Click map and scroll map data for a page URL', + zoning: 'Zone metrics for a page URL', 'destination-types': 'Available destination partners and their schemas', destinations: 'Configured destinations in a project', skills: @@ -130,6 +135,12 @@ const EXAMPLES: Partial> = { 'flags get': 'amp flags get --project --flag ', 'flags archive': 'amp flags archive --project --flag --dry-run', + 'heatmaps click-map': + 'amp heatmaps click-map --project --page-url https://example.com/pricing --limit 25', + 'heatmaps scroll-map': + 'amp heatmaps scroll-map --project --page-url https://example.com/pricing --device mobile', + 'zoning zone-metrics': + 'amp zoning zone-metrics --project --page-url https://example.com/pricing --metric rage_click_rate_session', }; const API_COMMAND_DESCRIPTIONS: Partial> = { @@ -409,9 +420,27 @@ export type SkillsVerb = 'list' | 'get'; export const SKILLS_GLOBAL_FLAGS: Record = { list: ['json', 'env', ...SKILLS_COMMON_FLAGS], - get: ['json', 'env', 'region', ...SKILLS_COMMON_FLAGS], + get: ['json', 'save', 'env', 'region', ...SKILLS_COMMON_FLAGS], }; +const SKILLS_GET_COMMAND: CatalogCommand = { + command: ['skills', 'get'], + summary: 'Print one skill document', + group: 'skills', + order: GROUP_ORDER.skills, + description: + 'Prints a named implementation skill as raw markdown, including frontmatter. Use amp skills list to find names; add --json to return the document in data.document.', + flags: [], + positional: { name: 'name', required: true }, + globalFlags: SKILLS_GLOBAL_FLAGS.get, + example: 'amp skills get integrating-amplitude', + requiredScopes: [], +}; + +export function isSkillsGetCommand(command: string[]): boolean { + return findCatalogCommand(command) === SKILLS_GET_COMMAND; +} + const SKILLS_COMMANDS: CatalogCommand[] = [ { command: ['skills', 'list'], @@ -425,25 +454,13 @@ const SKILLS_COMMANDS: CatalogCommand[] = [ example: 'amp skills list --json', requiredScopes: [], }, - { - command: ['skills', 'get'], - summary: 'Print one skill document', - group: 'skills', - order: GROUP_ORDER.skills, - description: - 'Prints a named implementation skill as markdown, including frontmatter. Use amp skills list to find names; add --json to return the document in data.document.', - flags: [], - positional: { name: 'name', required: true }, - globalFlags: SKILLS_GLOBAL_FLAGS.get, - example: 'amp skills get integrating-amplitude', - requiredScopes: [], - }, + SKILLS_GET_COMMAND, ]; export function buildCatalog(): CatalogCommand[] { return [ ...apiCommands(), - ...withGlobalFlags(AUTH_COMMANDS, globalOptionAliases()), + ...withGlobalFlags(AUTH_COMMANDS, authGlobalOptionAliases()), ...SKILLS_COMMANDS, ]; } diff --git a/src/cli.test.ts b/src/cli.test.ts index a0391b4..8136cd0 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -111,6 +111,35 @@ describe('main routing', () => { expect(logSpy).toHaveBeenCalledTimes(2); }); + it.each([ + ['version --save', ['version', '--save']], + ['--version --save', ['--version', '--save']], + ['--save', ['--save']], + ['help --save', ['help', '--save']], + ['skills list --save --help', ['skills', 'list', '--save', '--help']], + ])( + 'rejects %s before early routing can bypass flag validation', + async (_, argv) => { + await runWith(argv); + + expect(process.exitCode).toBe(2); + const parsed = JSON.parse(String(errSpy.mock.calls[0]?.[0])); + expect(parsed.error.error_code).toBe('usage_error'); + expect(parsed.message).toContain('--save'); + expect(logSpy).not.toHaveBeenCalled(); + }, + ); + + it('keeps normal help routing when --save is absent', async () => { + await runWith(['help']); + + expect(process.exitCode).toBeUndefined(); + expect(errSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toHaveProperty( + 'commands', + ); + }); + it('renders a helpful error for an unknown command as a JSON usage error on stderr', async () => { await runWith(['frobnicate']); @@ -240,6 +269,18 @@ describe('main routing', () => { expect(logSpy).not.toHaveBeenCalled(); }); + it('rejects --save outside skills get', async () => { + await runWith(['auth', 'pat', '--save']); + + expect(process.exitCode).toBe(2); + const parsed = JSON.parse(String(errSpy.mock.calls[0]?.[0])); + expect(parsed.error.error_code).toBe('usage_error'); + expect(parsed.message).toBe( + 'The --save flag is only supported by `amp skills get `.', + ); + expect(authCommands.runAuthPat).not.toHaveBeenCalled(); + }); + it('validates flags before the delete-confirmation gate: an unknown flag on a DELETE with no --yes surfaces usage_error, not the "Pass --yes" gate error', async () => { await runWith([ 'events', diff --git a/src/cli.ts b/src/cli.ts index 52f41ec..f5030b7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ /* eslint-disable no-console */ import { type FlagValue, - globalOptionAliases, + authGlobalOptionAliases, isFlagEnabled, parseArgs, } from './args'; @@ -17,7 +17,7 @@ import { runAuthUse, runLogout, } from './auth-commands'; -import { buildCatalog } from './catalog'; +import { buildCatalog, isSkillsGetCommand } from './catalog'; import { CliError, formatErrorEnvelope, @@ -62,7 +62,7 @@ function assertKnownAuthFlags( candidate.command.every((part, index) => part === command[index]), ); const catalogAliases = entry?.flags.flatMap((flag) => flag.aliases) ?? []; - const allowed = new Set([...globalOptionAliases(), ...catalogAliases]); + const allowed = new Set([...authGlobalOptionAliases(), ...catalogAliases]); assertFlagsAllowed(allowed, `amp ${command.join(' ')}`, flags); } @@ -86,6 +86,17 @@ export function isVersionRequested( ); } +function assertSaveFlagAllowed( + command: string[], + flags: Record, +): void { + if (flags.save !== undefined && !isSkillsGetCommand(command)) { + throw usageError( + 'The --save flag is only supported by `amp skills get `.', + ); + } +} + export async function main(): Promise { const isTTY = Boolean(process.stdout.isTTY); let flags: Record | undefined; @@ -93,6 +104,7 @@ export async function main(): Promise { const parsed = parseArgs(process.argv.slice(2)); flags = parsed.flags; const { command } = parsed; + assertSaveFlagAllowed(command, flags); if (isVersionRequested(command, flags)) { console.log(formatVersion()); @@ -126,7 +138,7 @@ export async function main(): Promise { return; } - if (command[1] === 'get' && command.length <= 3) { + if (isSkillsGetCommand(command)) { assertSkillsFlags('get', flags); await runSkillsGet(command[2], flags); return; diff --git a/src/client-identity.ts b/src/client-identity.ts index b39ac55..44b24b5 100644 --- a/src/client-identity.ts +++ b/src/client-identity.ts @@ -11,7 +11,7 @@ import { dirname, join } from 'node:path'; import { z } from 'zod'; -import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-data-path'; +import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-paths'; const CURRENT_VERSION = 1; export const AMP_DEVICE_ID_HEADER = 'Amp-Device-Id'; diff --git a/src/credential-store.ts b/src/credential-store.ts index b669baa..16cf755 100644 --- a/src/credential-store.ts +++ b/src/credential-store.ts @@ -11,7 +11,7 @@ import { debuglog } from 'node:util'; import { lock } from 'proper-lockfile'; import { z } from 'zod'; -import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-data-path'; +import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-paths'; import { transportError, usageError } from './cli-error'; /** diff --git a/src/generated/cli-manifest.ts b/src/generated/cli-manifest.ts index 3d537d8..b6d2d33 100644 --- a/src/generated/cli-manifest.ts +++ b/src/generated/cli-manifest.ts @@ -1181,6 +1181,218 @@ export const CLI_OPERATIONS = [ }, ], }, + { + command: ['heatmaps', 'click-map'], + method: 'POST', + operationId: 'queryClickMap', + path: '/v1/projects/{project_id}/heatmaps/click-map', + requiredScopes: ['analytics:read'], + summary: 'Query click map', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + ], + body: [ + { + name: 'page_url', + required: true, + aliases: ['page-url', 'url'], + type: 'string', + nullable: false, + }, + { + name: 'url_match', + required: false, + aliases: ['url-match', 'match'], + type: 'string', + nullable: false, + enum: ['is', 'contains', 'has_prefix', 'glob'], + }, + { + name: 'device_type', + required: false, + aliases: ['device-type', 'device'], + type: 'string', + nullable: false, + enum: ['desktop', 'laptop', 'tablet', 'mobile', 'custom'], + }, + { + name: 'viewport_width', + required: false, + aliases: ['viewport-width'], + type: 'integer', + nullable: false, + }, + { + name: 'time_range', + required: false, + aliases: ['time-range'], + type: 'object', + nullable: false, + }, + { + name: 'group_by', + required: false, + aliases: ['group-by'], + type: 'string', + nullable: false, + enum: ['selector', 'pixel'], + }, + { + name: 'metric', + required: false, + aliases: ['metric'], + type: 'string', + nullable: false, + enum: ['totals', 'uniques'], + }, + { + name: 'limit', + required: false, + aliases: ['limit'], + type: 'integer', + nullable: false, + }, + ], + }, + { + command: ['heatmaps', 'scroll-map'], + method: 'POST', + operationId: 'queryScrollMap', + path: '/v1/projects/{project_id}/heatmaps/scroll-map', + requiredScopes: ['analytics:read'], + summary: 'Query scroll map', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + ], + body: [ + { + name: 'page_url', + required: true, + aliases: ['page-url', 'url'], + type: 'string', + nullable: false, + }, + { + name: 'url_match', + required: false, + aliases: ['url-match', 'match'], + type: 'string', + nullable: false, + enum: ['is', 'contains', 'has_prefix', 'glob'], + }, + { + name: 'device_type', + required: false, + aliases: ['device-type', 'device'], + type: 'string', + nullable: false, + enum: ['desktop', 'laptop', 'tablet', 'mobile', 'custom'], + }, + { + name: 'viewport_width', + required: false, + aliases: ['viewport-width'], + type: 'integer', + nullable: false, + }, + { + name: 'time_range', + required: false, + aliases: ['time-range'], + type: 'object', + nullable: false, + }, + ], + }, + { + command: ['zoning', 'zone-metrics'], + method: 'POST', + operationId: 'queryZoneMetrics', + path: '/v1/projects/{project_id}/zoning/zone-metrics', + requiredScopes: ['analytics:read'], + summary: 'Query zone metrics', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + ], + body: [ + { + name: 'page_url', + required: true, + aliases: ['page-url', 'url'], + type: 'string', + nullable: false, + }, + { + name: 'url_match', + required: false, + aliases: ['url-match', 'match'], + type: 'string', + nullable: false, + enum: ['is', 'contains'], + }, + { + name: 'time_range', + required: false, + aliases: ['time-range'], + type: 'object', + nullable: false, + }, + { + name: 'metric', + required: false, + aliases: ['metric'], + type: 'string', + nullable: false, + enum: [ + 'click_rate_pageview', + 'click_rate_session', + 'rage_click_rate_pageview', + 'rage_click_rate_session', + 'click_distribution', + 'click_recurrence', + 'rage_click_recurrence', + 'exposure_rate', + 'attractiveness_rate', + 'revenue_per_click', + ], + }, + { + name: 'zone_ids', + required: false, + aliases: ['zone-ids', 'zones'], + type: 'array', + nullable: false, + }, + { + name: 'limit', + required: false, + aliases: ['limit'], + type: 'integer', + nullable: false, + }, + ], + }, { command: ['flags', 'list'], method: 'GET', diff --git a/src/help-nested-topic.test.ts b/src/help-nested-topic.test.ts new file mode 100644 index 0000000..b9b6639 --- /dev/null +++ b/src/help-nested-topic.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { CatalogCommand } from './catalog'; +import { printCommandHelp } from './help'; + +// A synthetic three-level command family, added on top of the real catalog so +// multi-token help stays covered regardless of which command families the +// current spec happens to expose. +const NESTED_FAMILY: CatalogCommand[] = ['list', 'get', 'create'].map( + (verb) => ({ + command: ['widgets', 'schedules', verb], + summary: `${verb} widget schedules`, + group: 'widgets', + flags: [], + globalFlags: [], + requiredScopes: ['widgets:read'], + }), +); + +vi.mock('./catalog', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildCatalog: () => [...actual.buildCatalog(), ...NESTED_FAMILY], + }; +}); + +function capture(fn: () => void): string { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + fn(); + return log.mock.calls.map((call) => String(call[0])).join('\n'); + } finally { + log.mockRestore(); + } +} + +describe('help for a multi-token command prefix', () => { + it('json → compact index of the commands under the prefix, and only those', () => { + const parsed = JSON.parse( + capture(() => + printCommandHelp(['widgets', 'schedules'], { + json: true, + isTTY: false, + }), + ), + ); + expect(parsed.command).toBe('widgets schedules'); + expect(typeof parsed.detail).toBe('string'); + expect( + parsed.commands.map((c: { command: string }) => c.command).sort(), + ).toEqual([ + 'widgets schedules create', + 'widgets schedules get', + 'widgets schedules list', + ]); + for (const entry of parsed.commands) { + expect(entry).not.toHaveProperty('flags'); + } + }); + + it('json → the one-token group still expands to every command beneath it', () => { + const parsed = JSON.parse( + capture(() => + printCommandHelp(['widgets'], { json: true, isTTY: false }), + ), + ); + expect(parsed.command).toBe('widgets'); + expect(parsed.commands).toHaveLength(NESTED_FAMILY.length); + }); + + it('prose → lists the commands under the prefix', () => { + const output = capture(() => printCommandHelp(['widgets', 'schedules'])); + expect(output).toContain('amp widgets schedules — available commands:'); + expect(output).toContain('widgets schedules list'); + expect(output).toContain('widgets schedules create'); + }); + + it('an unknown nested prefix still throws a usage error', () => { + expect(() => + printCommandHelp(['widgets', 'nope'], { json: true, isTTY: false }), + ).toThrow(/Unknown command: widgets nope/); + }); +}); diff --git a/src/help.test.ts b/src/help.test.ts index 63440a1..5cd6491 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -116,11 +116,25 @@ describe('help', () => { const output = log.mock.calls.map((c) => String(c[0])).join('\n'); expect(output).toContain('--json'); expect(output).toContain('data.document'); + expect(output).not.toContain('--save'); } finally { log.mockRestore(); } }); + it.each([false, true])( + 'hides --save in skills get help (JSON: %s)', + (json) => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + printCommandHelp(['skills', 'get'], { json, isTTY: false }); + expect(log.mock.calls.flat().join('\n')).not.toContain('--save'); + } finally { + log.mockRestore(); + } + }, + ); + it.each([ ['skills', 'list'], ['skills', 'get'], @@ -236,6 +250,8 @@ describe('help', () => { 'user-properties', 'flags', 'charts', + 'heatmaps', + 'zoning', 'destination-types', 'destinations', 'skills', @@ -418,6 +434,18 @@ describe('help JSON', () => { } }); + it('one-token topic + json keeps group members that are not prefixed by the group (auth → logout)', () => { + const parsed = JSON.parse( + capture(() => printCommandHelp(['auth'], { json: true, isTTY: false })), + ); + const commands = parsed.commands.map((c: { command: string }) => c.command); + expect(commands).toContain('logout'); + expect(commands).toContain('auth login'); + expect( + parsed.commands.every((c: { group: string }) => c.group === 'auth'), + ).toBe(true); + }); + it('exact command wins over group filter for `context` (both a command and a group)', () => { const parsed = JSON.parse( capture(() => diff --git a/src/help.ts b/src/help.ts index b925a73..cec5fa0 100644 --- a/src/help.ts +++ b/src/help.ts @@ -91,6 +91,30 @@ export function serializeCatalog(): CatalogDump { }; } +/** + * Catalog commands listed under a help topic: `['flags']` returns every + * `flags *` command, and a longer prefix returns the commands nested under it. + * A prefix can be any depth, so a three-level command family expands like a + * one-token group. A one-token topic also includes the catalog `group`'s + * members whose command does not start with the group name (`logout` lives in + * `auth`), so `amp help auth --json` keeps listing it. Exact matches are + * excluded — callers resolve those with `findCatalogCommand` first. + */ +function catalogCommandsUnderTopic(command: string[]): CatalogCommand[] { + const isExact = (c: CatalogCommand): boolean => + c.command.length === command.length && + c.command.every((part, index) => part === command[index]); + const hasPrefix = (c: CatalogCommand): boolean => + c.command.length > command.length && + command.every((part, index) => c.command[index] === part); + const inGroup = (c: CatalogCommand): boolean => + command.length === 1 && c.group === command[0]; + + return buildCatalog().filter( + (c) => !isExact(c) && (hasPrefix(c) || inGroup(c)), + ); +} + function helpAsJson(command: string[], isTTY: boolean): string { if (command.length === 0) { return formatJsonOutput(serializeCatalog(), isTTY); @@ -99,18 +123,16 @@ function helpAsJson(command: string[], isTTY: boolean): string { if (entry) { return formatJsonOutput(toDetail(entry), isTTY); } - if (command.length === 1) { - const commands = buildCatalog().filter((c) => c.group === command[0]); - if (commands.length > 0) { - return formatJsonOutput( - { - command: command.join(' '), - detail: JSON_DRILLDOWN_HINT, - commands: commands.map(toIndexEntry), - }, - isTTY, - ); - } + const commands = catalogCommandsUnderTopic(command); + if (commands.length > 0) { + return formatJsonOutput( + { + command: command.join(' '), + detail: JSON_DRILLDOWN_HINT, + commands: commands.map(toIndexEntry), + }, + isTTY, + ); } throw usageError( `Unknown command: ${command.join(' ')}. Run \`amp help\` to list commands.`, @@ -277,12 +299,10 @@ export function printCommandHelp( // tag, so `operationsMatchingPrefix` finds nothing for them. The catalog knows // every group, generated or not — so fall back to it rather than teaching this // function each bespoke group by name. - if (command.length === 1) { - const grouped = buildCatalog().filter((c) => c.group === command[0]); - if (grouped.length > 0) { - printGroupHelp(command, catalogGroupRows(grouped)); - return; - } + const grouped = catalogCommandsUnderTopic(command); + if (grouped.length > 0) { + printGroupHelp(command, catalogGroupRows(grouped)); + return; } throw usageError( diff --git a/src/output.test.ts b/src/output.test.ts index 25adcea..5915c00 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -28,6 +28,16 @@ const listEvents: CliOperation = { body: [], }; +const listScheduledEventActions: CliOperation = { + command: ['events', 'scheduled-actions', 'list'], + method: 'GET', + operationId: 'listScheduledEventActions', + path: '/v1/projects/{project_id}/scheduled-event-actions', + requiredScopes: ['read:taxonomy'], + parameters: [], + body: [], +}; + const getContext: CliOperation = { command: ['context'], method: 'GET', @@ -149,6 +159,40 @@ describe('output formatting', () => { expect(header).not.toContain('DISPLAY_NAME'); }); + it('shows action on scheduled event action lists', () => { + const output = formatSuccessOutput( + { + data: [ + { + id: 'action-1', + object: 'scheduled_event_action', + event_type: 'A Brand New Event', + display_name: 'A Brand New Event', + action: 'delete', + status: 'pending', + target_date: '2026-09-28T00:00:00.000Z', + }, + { + id: 'action-2', + object: 'scheduled_event_action', + event_type: 'Test_Event_2', + display_name: 'Test_Event_2', + action: 'block', + status: 'pending', + target_date: '2026-09-24T00:00:00.000Z', + }, + ], + }, + listScheduledEventActions, + ); + + const header = output.split('\n')[0]; + expect(header).toContain('ID'); + expect(header).toContain('ACTION'); + expect(output).toContain('delete'); + expect(output).toContain('block'); + }); + it('emits no trailing whitespace on any table row', () => { const output = formatSuccessOutput( { diff --git a/src/output.ts b/src/output.ts index 353159a..02849c2 100644 --- a/src/output.ts +++ b/src/output.ts @@ -84,6 +84,7 @@ function pickColumns(rows: Row[]): string[] { 'name', 'display_name', 'event_type', + 'action', 'enabled', 'archived', 'is_active', diff --git a/src/pending-store.ts b/src/pending-store.ts index 755ce49..6d3ffb5 100644 --- a/src/pending-store.ts +++ b/src/pending-store.ts @@ -9,7 +9,7 @@ import { dirname, join } from 'node:path'; import { z } from 'zod'; -import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-data-path'; +import { amplitudeDataFiles, amplitudeDataPath } from './amplitude-paths'; export const CURRENT_PENDING_VERSION = 1; diff --git a/src/skill-materializer.test.ts b/src/skill-materializer.test.ts new file mode 100644 index 0000000..28b2f12 --- /dev/null +++ b/src/skill-materializer.test.ts @@ -0,0 +1,653 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CliError } from './cli-error'; +import { materializeSkill } from './skill-materializer'; + +type RenameAction = (() => void) | undefined; + +const filesystemControl = vi.hoisted( + (): { + failRename: boolean; + failCleanup: boolean; + onRename: RenameAction; + } => ({ + failRename: false, + failCleanup: false, + onRename: undefined, + }), +); + +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + rmSync: (...args: Parameters) => { + if (filesystemControl.failCleanup) { + throw new Error('simulated cleanup failure'); + } + return original.rmSync(...args); + }, + renameSync: (...args: Parameters) => { + const onRename = filesystemControl.onRename; + if (onRename !== undefined) { + filesystemControl.onRename = undefined; + onRename(); + throw new Error('simulated rename race'); + } + if (filesystemControl.failRename) { + throw new Error('simulated rename failure'); + } + return original.renameSync(...args); + }, + }; +}); + +const temporaryDirectories: string[] = []; +const fixedNow = new Date('2026-09-18T12:34:56.789Z'); +const laterNow = new Date('2026-09-19T12:34:56.789Z'); +const latestNow = new Date('2026-09-20T12:34:56.789Z'); +const name = 'first-event-node'; +const document = `---\r\nname: ${name}\r\ndescription: Test skill\r\n---\r\n\r\n# Test skill\r\n`; +const canonicalDocument = document; + +function temporaryRoot(): string { + const directory = mkdtempSync(join(tmpdir(), 'amp-skill-materializer-')); + temporaryDirectories.push(directory); + return directory; +} + +function materialize( + rootDirectory = temporaryRoot(), + sourceDocument = document, + now = fixedNow, +) { + return materializeSkill(name, sourceDocument, { + rootDirectory, + now: () => now, + }); +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function versionDirectory( + rootDirectory: string, + digest: string, + now = fixedNow, + prefixLength = 12, +): string { + const timestamp = now + .toISOString() + .replace(/[-:.]/g, '') + .slice(0, 15) + .concat('Z'); + return join( + rootDirectory, + name, + `${timestamp}-${digest.slice(0, prefixLength)}`, + ); +} + +function versionDirectories(rootDirectory: string): string[] { + return readdirSync(join(rootDirectory, name)).filter((entry) => + /^\d{8}T\d{6}Z-[a-f0-9]{12}(?:[a-f0-9]{4})*$/.test(entry), + ); +} + +function stagingDirectories(rootDirectory: string): string[] { + const skillDirectory = join(rootDirectory, name); + try { + return readdirSync(skillDirectory).filter((entry) => + entry.startsWith('.staging-'), + ); + } catch { + return []; + } +} + +function materializerUrl(): string { + return pathToFileURL(join(process.cwd(), 'src/skill-materializer.ts')).href; +} + +// Namespace import on purpose. How tsx exposes the module depends on the +// nearest package.json: with no "type" field the file loads as CommonJS and +// Node's interop puts module.exports on `default` with no named exports; with +// "type": "commonjs" it loads as ESM with the named export and no `default`. +// Reading whichever is present keeps the subprocess working under both. +function materializeInSubprocess(rootDirectory: string) { + return spawnSync( + process.execPath, + [ + '--import', + 'tsx', + '--input-type=module', + '--eval', + `import * as loaded from ${JSON.stringify(materializerUrl())}; const materializer = loaded.materializeSkill ? loaded : loaded.default; if (process.platform !== 'win32') process.umask(0); materializer.materializeSkill(${JSON.stringify(name)}, ${JSON.stringify(document)}, { rootDirectory: ${JSON.stringify(rootDirectory)}, now: () => new Date(${JSON.stringify(fixedNow.toISOString())}) });`, + ], + { encoding: 'utf8' }, + ); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('materializeSkill', () => { + it('preserves CR-only document bytes', () => { + const rootDirectory = temporaryRoot(); + const crOnlyDocument = document.replace(/\r\n/g, '\r'); + + const result = materializeSkill(name, crOnlyDocument, { + rootDirectory, + now: () => fixedNow, + }); + + expect(readFileSync(result.path, 'utf8')).toBe(crOnlyDocument); + }); + + it('preserves trailing whitespace-only lines', () => { + const rootDirectory = temporaryRoot(); + const documentWithTrailingBlankLines = `${document}\t \r\n \r\n`; + + const result = materializeSkill(name, documentWithTrailingBlankLines, { + rootDirectory, + now: () => fixedNow, + }); + + expect(readFileSync(result.path, 'utf8')).toBe( + documentWithTrailingBlankLines, + ); + }); + + it('publishes fetched skill bytes in an immutable version directory', () => { + const rootDirectory = temporaryRoot(); + + const result = materialize(rootDirectory); + const digest = sha256(canonicalDocument); + const expectedPath = join( + versionDirectory(rootDirectory, digest), + 'SKILL.md', + ); + + expect(result).toEqual({ + name, + path: expectedPath, + sha256: digest, + bytes: Buffer.byteLength(canonicalDocument), + lines: 6, + instruction: `Read and follow the complete \`${name}\` skill at \`${expectedPath}\` for the current task.`, + }); + expect(readFileSync(result.path, 'utf8')).toBe(canonicalDocument); + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); + + it.skipIf(process.platform === 'win32')( + 'creates new root and skill directories with private POSIX modes', + () => { + const rootDirectory = join(temporaryRoot(), 'materialized-skills'); + const result = materializeInSubprocess(rootDirectory); + + expect(result.status).toBe(0); + + expect(statSync(rootDirectory).mode.toString(8).slice(-3)).toBe('700'); + expect( + statSync(join(rootDirectory, name)).mode.toString(8).slice(-3), + ).toBe('700'); + }, + ); + + it('allows a later same-user process to read a materialized skill', () => { + const rootDirectory = join(temporaryRoot(), 'materialized-skills'); + const path = join( + versionDirectory(rootDirectory, sha256(canonicalDocument)), + 'SKILL.md', + ); + const creator = materializeInSubprocess(rootDirectory); + const reader = spawnSync(process.execPath, [ + '--eval', + `process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(path)}));`, + ]); + + expect(creator.status).toBe(0); + expect(reader.stderr.toString()).toBe(''); + expect(reader.status).toBe(0); + expect(reader.stdout).toEqual(Buffer.from(canonicalDocument)); + }); + + it.skipIf(process.platform === 'win32')( + 'does not reuse an exact-content symlink artifact', + () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const symlinkDirectory = versionDirectory(rootDirectory, digest); + const sourcePath = join(rootDirectory, 'source.md'); + const symlinkPath = join(symlinkDirectory, 'SKILL.md'); + writeFileSync(sourcePath, canonicalDocument); + mkdirSync(symlinkDirectory, { recursive: true }); + symlinkSync(sourcePath, symlinkPath); + + const result = materialize(rootDirectory, document, laterNow); + + expect(result.path).toBe( + join(versionDirectory(rootDirectory, digest, laterNow), 'SKILL.md'), + ); + expect(readFileSync(result.path, 'utf8')).toBe(canonicalDocument); + }, + ); + + it('publishes a fresh artifact when a candidate SKILL.md is non-regular', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const malformedDirectory = versionDirectory(rootDirectory, digest); + mkdirSync(join(malformedDirectory, 'SKILL.md'), { recursive: true }); + + const result = materialize(rootDirectory, document, laterNow); + + expect(result.path).toBe( + join(versionDirectory(rootDirectory, digest, laterNow), 'SKILL.md'), + ); + expect(readFileSync(result.path, 'utf8')).toBe(canonicalDocument); + }); + + it.skipIf(process.platform !== 'win32')( + 'materializes without asserting POSIX modes on Windows', + () => { + const result = materialize(); + + expect(readFileSync(result.path, 'utf8')).toBe(canonicalDocument); + }, + ); + + it.each(['', '# No frontmatter\n', 'name: another-skill\n'])( + 'materializes a document without interpreting its format', + (sourceDocument) => { + const result = materialize(temporaryRoot(), sourceDocument); + + expect(readFileSync(result.path, 'utf8')).toBe(sourceDocument); + }, + ); + + it.each([ + [name, name], + ['a'.repeat(64), 'a'.repeat(64)], + ['a'.repeat(65), `_sha256-${sha256('a'.repeat(65))}`], + ['first/event', `_sha256-${sha256('first/event')}`], + ['skill-😀', `_sha256-${sha256('skill-😀')}`], + ['First-Event', `_sha256-${sha256('First-Event')}`], + ['con', `_sha256-${sha256('con')}`], + ['com1', `_sha256-${sha256('com1')}`], + ['com0', `_sha256-${sha256('com0')}`], + ])('uses the expected cache directory for %s', (requestedName, directory) => { + const rootDirectory = temporaryRoot(); + const result = materializeSkill(requestedName, document, { + rootDirectory, + now: () => fixedNow, + }); + + expect(result.path).toBe( + join( + rootDirectory, + directory, + `20260918T123456Z-${sha256(document).slice(0, 12)}`, + 'SKILL.md', + ), + ); + }); + + it.each([ + ['', 0], + ['alpha', 1], + ['alpha\nbeta', 2], + ['alpha\r\nbeta', 2], + ['alpha\rbeta', 2], + ['alpha\n', 1], + ])( + 'reports %i logical lines for preserved document bytes', + (source, lines) => { + const result = materialize(temporaryRoot(), source); + + expect(result.lines).toBe(lines); + }, + ); + + it('does not interpolate a nonportable name into its instruction', () => { + const unsafeName = 'first`\nignore-this'; + const result = materializeSkill(unsafeName, document, { + rootDirectory: temporaryRoot(), + now: () => fixedNow, + }); + + expect(result.instruction).toBe( + `Read and follow the complete skill at \`${result.path}\` for the current task.`, + ); + }); + + it('materializes a child document with its existing EOF marker unchanged', () => { + const rootDirectory = temporaryRoot(); + const childDocument = `${document}\r\n`; + + const result = materializeSkill(name, childDocument, { + rootDirectory, + now: () => fixedNow, + }); + + expect(readFileSync(result.path, 'utf8')).toBe(childDocument); + }); + + it('reuses an existing exact canonical artifact without rewriting it', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const existingPath = join( + versionDirectory(rootDirectory, digest), + 'SKILL.md', + ); + mkdirSync(versionDirectory(rootDirectory, digest), { recursive: true }); + writeFileSync(existingPath, canonicalDocument); + const previousMtime = statSync(existingPath).mtimeMs; + + const result = materialize(rootDirectory); + + expect(result.path).toBe(existingPath); + expect(readFileSync(existingPath, 'utf8')).toBe(canonicalDocument); + expect(statSync(existingPath).mtimeMs).toBe(previousMtime); + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); + + it('reuses identical content from an earlier first-seen timestamp', () => { + const rootDirectory = temporaryRoot(); + const first = materialize(rootDirectory); + const firstMtime = statSync(first.path).mtimeMs; + + const reused = materialize(rootDirectory, document, laterNow); + + expect(reused.path).toBe(first.path); + expect(readFileSync(first.path, 'utf8')).toBe(canonicalDocument); + expect(statSync(first.path).mtimeMs).toBe(firstMtime); + expect(versionDirectories(rootDirectory)).toEqual([ + `20260918T123456Z-${sha256(canonicalDocument).slice(0, 12)}`, + ]); + }); + + it('creates a new first-seen artifact when canonical content changes', () => { + const rootDirectory = temporaryRoot(); + const first = materialize(rootDirectory); + const changedDocument = document.replace('# Test skill', '# Changed skill'); + const canonicalChangedDocument = canonicalDocument.replace( + '# Test skill', + '# Changed skill', + ); + + const changed = materialize(rootDirectory, changedDocument, laterNow); + + expect(readFileSync(first.path, 'utf8')).toBe(canonicalDocument); + expect(changed.path).toBe( + join( + versionDirectory( + rootDirectory, + sha256(canonicalChangedDocument), + laterNow, + ), + 'SKILL.md', + ), + ); + expect(readFileSync(changed.path, 'utf8')).toBe(canonicalChangedDocument); + }); + + it('reuses the original artifact after content changes and returns', () => { + const rootDirectory = temporaryRoot(); + const first = materialize(rootDirectory); + const changedDocument = document.replace('# Test skill', '# Changed skill'); + + materialize(rootDirectory, changedDocument, laterNow); + const reused = materialize(rootDirectory, document, latestNow); + + expect(reused.path).toBe(first.path); + expect(versionDirectories(rootDirectory)).toHaveLength(2); + }); + + it('uses candidate filenames only as a shortlist for byte comparison', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const misleadingDirectory = versionDirectory(rootDirectory, digest); + const misleadingPath = join(misleadingDirectory, 'SKILL.md'); + mkdirSync(misleadingDirectory, { recursive: true }); + writeFileSync(misleadingPath, 'different bytes\n'); + + const result = materialize(rootDirectory, document, laterNow); + + expect(result.path).toBe( + join(versionDirectory(rootDirectory, digest, laterNow), 'SKILL.md'), + ); + expect(readFileSync(misleadingPath, 'utf8')).toBe('different bytes\n'); + }); + + it('reuses an exact artifact with an extended hash prefix', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const extendedDirectory = versionDirectory( + rootDirectory, + digest, + fixedNow, + 16, + ); + const extendedPath = join(extendedDirectory, 'SKILL.md'); + mkdirSync(extendedDirectory, { recursive: true }); + writeFileSync(extendedPath, canonicalDocument); + + const result = materialize(rootDirectory, document, laterNow); + + expect(result.path).toBe(extendedPath); + }); + + it('reuses the earliest exact artifact when legacy duplicates exist', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const earliestDirectory = versionDirectory(rootDirectory, digest); + const laterDirectory = versionDirectory(rootDirectory, digest, laterNow); + const earliestPath = join(earliestDirectory, 'SKILL.md'); + mkdirSync(earliestDirectory, { recursive: true }); + mkdirSync(laterDirectory, { recursive: true }); + writeFileSync(earliestPath, canonicalDocument); + writeFileSync(join(laterDirectory, 'SKILL.md'), canonicalDocument); + + const result = materialize(rootDirectory, document, latestNow); + + expect(result.path).toBe(earliestPath); + }); + + it('extends the digest prefix when its candidate contains different bytes', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const collisionDirectory = versionDirectory(rootDirectory, digest); + const collisionPath = join(collisionDirectory, 'SKILL.md'); + mkdirSync(collisionDirectory, { recursive: true }); + writeFileSync(collisionPath, 'different bytes\n'); + + const result = materialize(rootDirectory); + + expect(readFileSync(collisionPath, 'utf8')).toBe('different bytes\n'); + expect(result.path).toBe( + join( + rootDirectory, + name, + `20260918T123456Z-${digest.slice(0, 16)}`, + 'SKILL.md', + ), + ); + expect(readFileSync(result.path, 'utf8')).toBe(canonicalDocument); + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); + + it('does not reuse a differently encoded artifact with the same UTF-8 text', () => { + const rootDirectory = temporaryRoot(); + const documentWithReplacement = document.replace( + '# Test skill', + '# Test skill \uFFFD', + ); + const canonicalWithReplacement = canonicalDocument.replace( + '# Test skill', + '# Test skill \uFFFD', + ); + const digest = sha256(canonicalWithReplacement); + const collisionDirectory = versionDirectory(rootDirectory, digest); + const collisionPath = join(collisionDirectory, 'SKILL.md'); + const canonicalBytes = Buffer.from(canonicalWithReplacement); + const replacementCharacter = Buffer.from('\uFFFD'); + const replacementOffset = canonicalBytes.indexOf(replacementCharacter); + const invalidUtf8Bytes = Buffer.concat([ + canonicalBytes.subarray(0, replacementOffset), + Buffer.from([0xff]), + canonicalBytes.subarray(replacementOffset + replacementCharacter.length), + ]); + mkdirSync(collisionDirectory, { recursive: true }); + writeFileSync(collisionPath, invalidUtf8Bytes); + + const result = materializeSkill(name, documentWithReplacement, { + rootDirectory, + now: () => fixedNow, + }); + + expect(readFileSync(collisionPath)).toEqual(invalidUtf8Bytes); + expect(result.path).toBe( + join( + rootDirectory, + name, + `20260918T123456Z-${digest.slice(0, 16)}`, + 'SKILL.md', + ), + ); + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); + + it('removes its staging directory when publication fails', () => { + const rootDirectory = temporaryRoot(); + filesystemControl.failRename = true; + let caught: unknown; + + try { + materialize(rootDirectory); + } catch (error) { + caught = error; + } finally { + filesystemControl.failRename = false; + } + + expect(caught).toBeInstanceOf(CliError); + if (caught instanceof CliError) { + expect(caught.errorCode).toBe('transport_error'); + expect(caught.hint).toContain('permissions for the skill cache'); + expect(caught.hint).not.toContain('temporary directory'); + } + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); + + it('preserves the publication error when staging cleanup also fails', () => { + const rootDirectory = temporaryRoot(); + filesystemControl.failRename = true; + filesystemControl.failCleanup = true; + try { + expect(() => materialize(rootDirectory)).toThrow( + 'simulated rename failure', + ); + } finally { + filesystemControl.failRename = false; + filesystemControl.failCleanup = false; + } + }); + + it('reuses an artifact published by a rename race when its bytes match', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const racedDirectory = versionDirectory(rootDirectory, digest); + const racedPath = join(racedDirectory, 'SKILL.md'); + filesystemControl.onRename = () => { + mkdirSync(racedDirectory, { recursive: true }); + writeFileSync(racedPath, canonicalDocument); + }; + + const result = materialize(rootDirectory); + + expect(result.path).toBe(racedPath); + expect(readFileSync(racedPath, 'utf8')).toBe(canonicalDocument); + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); + + it('extends the digest prefix after a rename race publishes different bytes', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const racedDirectory = versionDirectory(rootDirectory, digest); + const racedPath = join(racedDirectory, 'SKILL.md'); + filesystemControl.onRename = () => { + mkdirSync(racedDirectory, { recursive: true }); + writeFileSync(racedPath, 'raced bytes\n'); + }; + + const result = materialize(rootDirectory); + + expect(readFileSync(racedPath, 'utf8')).toBe('raced bytes\n'); + expect(result.path).toBe( + join( + rootDirectory, + name, + `20260918T123456Z-${digest.slice(0, 16)}`, + 'SKILL.md', + ), + ); + expect(readFileSync(result.path, 'utf8')).toBe(canonicalDocument); + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); + + it('fails without altering artifacts when all digest prefixes are occupied', () => { + const rootDirectory = temporaryRoot(); + const digest = sha256(canonicalDocument); + const paths: string[] = []; + for (let prefixLength = 12; prefixLength <= 64; prefixLength += 4) { + const directory = join( + rootDirectory, + name, + `20260918T123456Z-${digest.slice(0, prefixLength)}`, + ); + const path = join(directory, 'SKILL.md'); + mkdirSync(directory, { recursive: true }); + writeFileSync(path, `occupied ${prefixLength}\n`); + paths.push(path); + } + + let caught: unknown; + try { + materialize(rootDirectory); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(CliError); + if (caught instanceof CliError) { + expect(caught.errorCode).toBe('transport_error'); + expect(caught.hint).toContain('conflicting cache entries'); + expect(caught.hint).not.toContain('temporary root'); + } + for (const path of paths) { + expect(readFileSync(path, 'utf8')).toMatch(/^occupied /); + } + expect(stagingDirectories(rootDirectory)).toEqual([]); + }); +}); diff --git a/src/skill-materializer.ts b/src/skill-materializer.ts new file mode 100644 index 0000000..b10141d --- /dev/null +++ b/src/skill-materializer.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +import { windowsReservedNameRegex } from 'filename-reserved-regex'; + +import { materializedSkillsRoot } from './amplitude-paths'; +import { CliError, transportError } from './cli-error'; + +const portableSkillName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const versionDirectoryPattern = + /^\d{8}T\d{6}Z-([a-f0-9]{12})(?:[a-f0-9]{4}){0,13}$/; +const windowsReservedName = windowsReservedNameRegex(); + +export interface MaterializedSkill { + name: string; + path: string; + sha256: string; + bytes: number; + lines: number; + instruction: string; +} + +export interface SkillMaterializerDeps { + rootDirectory?: string; + now?: () => Date; +} + +interface SkillDirectoryIdentity { + directoryName: string; + includesNameInInstruction: boolean; +} + +function cacheError(action: string, error: unknown): CliError { + const reason = error instanceof Error ? error.message : 'an unknown error'; + return transportError( + `Could not ${action}: ${reason}.`, + 'Check permissions for the skill cache and available disk space, then retry.', + ); +} + +function skillDirectoryIdentity(name: string): SkillDirectoryIdentity { + if ( + name.length <= 64 && + portableSkillName.test(name) && + !windowsReservedName.test(name) + ) { + return { directoryName: name, includesNameInInstruction: true }; + } + return { + directoryName: `_sha256-${createHash('sha256').update(name, 'utf8').digest('hex')}`, + includesNameInInstruction: false, + }; +} + +function lineCount(document: string): number { + if (document.length === 0) { + return 0; + } + const endings = document.match(/\r\n|\r|\n/g)?.length ?? 0; + const endsWithLineEnding = /(?:\r\n|\r|\n)$/.test(document); + return endings + 1 - (endsWithLineEnding ? 1 : 0); +} + +function timestamp(now: Date): string { + return now.toISOString().replace(/[-:.]/g, '').slice(0, 15).concat('Z'); +} + +function candidateDirectory( + skillDirectory: string, + publishedAt: string, + sha256: string, + prefixLength: number, +): string { + return join( + skillDirectory, + `${publishedAt}-${sha256.slice(0, prefixLength)}`, + ); +} + +function candidateBytes(path: string): Buffer | undefined { + try { + if (!lstatSync(path).isFile()) { + return undefined; + } + return readFileSync(path); + } catch { + return undefined; + } +} + +function findExistingArtifact( + skillDirectory: string, + sha256: string, + canonicalBytes: Buffer, +): string | undefined { + const candidates = readdirSync(skillDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((entry) => versionDirectoryPattern.test(entry)) + .sort(); + + for (const candidate of candidates) { + const separator = candidate.indexOf('-'); + const hashPrefix = candidate.slice(separator + 1); + if (!sha256.startsWith(hashPrefix)) { + continue; + } + + const path = join(skillDirectory, candidate, 'SKILL.md'); + if (candidateBytes(path)?.equals(canonicalBytes)) { + return path; + } + } + + return undefined; +} + +function removeStagingDirectory(path: string | undefined): void { + if (path !== undefined) { + try { + rmSync(path, { recursive: true, force: true }); + } catch { + // Cleanup must not replace the original publication error. + } + } +} + +function ensureDirectories( + rootDirectory: string, + skillDirectory: string, +): void { + mkdirSync(rootDirectory, { mode: 0o700, recursive: true }); + mkdirSync(skillDirectory, { mode: 0o700, recursive: true }); +} + +export function materializeSkill( + name: string, + document: string, + deps: SkillMaterializerDeps = {}, +): MaterializedSkill { + const documentBytes = Buffer.from(document); + const sha256 = createHash('sha256').update(documentBytes).digest('hex'); + const rootDirectory = deps.rootDirectory ?? materializedSkillsRoot(); + const directoryIdentity = skillDirectoryIdentity(name); + const skillDirectory = join(rootDirectory, directoryIdentity.directoryName); + const publishedAt = timestamp((deps.now ?? (() => new Date()))()); + + try { + ensureDirectories(rootDirectory, skillDirectory); + + const existingPath = findExistingArtifact( + skillDirectory, + sha256, + documentBytes, + ); + if (existingPath !== undefined) { + return materializedSkill( + name, + existingPath, + sha256, + document, + directoryIdentity.includesNameInInstruction, + ); + } + + for (let prefixLength = 12; prefixLength <= 64; prefixLength += 4) { + const directory = candidateDirectory( + skillDirectory, + publishedAt, + sha256, + prefixLength, + ); + const path = join(directory, 'SKILL.md'); + const existing = candidateBytes(path); + + if (existing?.equals(documentBytes)) { + return materializedSkill( + name, + path, + sha256, + document, + directoryIdentity.includesNameInInstruction, + ); + } + if (existsSync(directory)) { + continue; + } + + let stagingDirectory: string | undefined; + try { + stagingDirectory = mkdtempSync(join(skillDirectory, '.staging-')); + writeFileSync(join(stagingDirectory, 'SKILL.md'), document, 'utf8'); + renameSync(stagingDirectory, directory); + stagingDirectory = undefined; + return materializedSkill( + name, + path, + sha256, + document, + directoryIdentity.includesNameInInstruction, + ); + } catch (error) { + removeStagingDirectory(stagingDirectory); + + if (!existsSync(directory)) { + throw error; + } + + const racedBytes = candidateBytes(path); + if (racedBytes?.equals(documentBytes)) { + return materializedSkill( + name, + path, + sha256, + document, + directoryIdentity.includesNameInInstruction, + ); + } + } + } + } catch (error) { + if (error instanceof CliError) { + throw error; + } + throw cacheError(`save skill "${name}"`, error); + } + + throw transportError( + `Could not save skill "${name}" because every SHA-256 path is already occupied by different content.`, + 'Remove conflicting cache entries, then retry.', + ); +} + +function materializedSkill( + name: string, + path: string, + sha256: string, + document: string, + includesNameInInstruction: boolean, +): MaterializedSkill { + return { + name, + path, + sha256, + bytes: Buffer.byteLength(document), + lines: lineCount(document), + instruction: includesNameInInstruction + ? `Read and follow the complete \`${name}\` skill at \`${path}\` for the current task.` + : `Read and follow the complete skill at \`${path}\` for the current task.`, + }; +} diff --git a/src/skills-commands.test.ts b/src/skills-commands.test.ts index 72da8bc..f8f7c45 100644 --- a/src/skills-commands.test.ts +++ b/src/skills-commands.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { CliError, transportError } from './cli-error'; import { DEFAULT_API_BASE_URL } from './config'; import { assertSkillsFlags, @@ -51,6 +52,7 @@ describe('assertSkillsFlags', () => { expect(() => assertSkillsFlags('get', { json: true, + save: true, env: 'local', region: 'us', help: true, @@ -72,6 +74,12 @@ describe('assertSkillsFlags', () => { /unknown|unrecognized|not supported/i, ); }); + + it('rejects --save on list', () => { + expect(() => assertSkillsFlags('list', { save: true })).toThrowError( + /unknown|unrecognized|not supported/i, + ); + }); }); describe('runSkillsList', () => { @@ -258,6 +266,16 @@ version: 1 # Using Amplitude `; +const MATERIALIZED_SKILL = { + name: 'integrating-amplitude', + path: '/tmp/amp/skills/integrating-amplitude/20260918T123456Z-123456789abc/SKILL.md', + sha256: '123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + bytes: 123, + lines: 8, + instruction: + 'Read and follow the complete `integrating-amplitude` skill at `/tmp/amp/skills/integrating-amplitude/20260918T123456Z-123456789abc/SKILL.md` for the current task.', +}; + describe('runSkillsGet', () => { it('writes a compact JSON envelope when requested without a TTY', async () => { const chunks: string[] = []; @@ -351,6 +369,75 @@ describe('runSkillsGet', () => { expect(chunks.join('')).toBe(DOCUMENT); }); + it.each([true, false])( + 'writes only the saved-skill instruction and materializes once when isTTY=%s', + async (isTTY) => { + const chunks: string[] = []; + const materialize = vi.fn(() => MATERIALIZED_SKILL); + + await runSkillsGet( + 'integrating-amplitude', + { region: 'us', save: true }, + { + write: (chunk) => chunks.push(chunk), + isTTY, + fetchDocument: async () => DOCUMENT, + materialize, + }, + ); + + expect(chunks.join('')).toBe(`${MATERIALIZED_SKILL.instruction}\n`); + expect(materialize).toHaveBeenCalledTimes(1); + expect(materialize).toHaveBeenCalledWith( + 'integrating-amplitude', + DOCUMENT, + ); + }, + ); + + it.each([true, false])( + 'returns saved-skill metadata without the document as JSON when isTTY=%s', + async (isTTY) => { + const chunks: string[] = []; + const materialize = vi.fn(() => MATERIALIZED_SKILL); + + await runSkillsGet( + 'integrating-amplitude', + { region: 'us', save: true, json: true }, + { + write: (chunk) => chunks.push(chunk), + isTTY, + fetchDocument: async () => DOCUMENT, + materialize, + }, + ); + + const output = JSON.parse(chunks.join('')); + expect(output).toEqual({ data: MATERIALIZED_SKILL }); + expect(output.data).not.toHaveProperty('document'); + expect(materialize).toHaveBeenCalledTimes(1); + }, + ); + + it('keeps raw output and skips materialization when --save=false', async () => { + const chunks: string[] = []; + const materialize = vi.fn(() => MATERIALIZED_SKILL); + + await runSkillsGet( + 'integrating-amplitude', + { region: 'us', save: 'false' }, + { + write: (chunk) => chunks.push(chunk), + isTTY: false, + fetchDocument: async () => DOCUMENT, + materialize, + }, + ); + + expect(chunks.join('')).toBe(DOCUMENT); + expect(materialize).not.toHaveBeenCalled(); + }); + // Exercises the default stdout rather than an injected one: `console.log` // appends a newline to a document that already ends in one, so `amp skills get // X > f` would not be the bytes the server served. @@ -376,6 +463,56 @@ describe('runSkillsGet', () => { } }); + it.each([ + transportError('Permission denied', 'Check cache permissions.'), + new Error('Unexpected write failure'), + ])('recommends direct output when saving fails: %s', async (failure) => { + const write = vi.fn(); + const operation = runSkillsGet( + 'integrating-amplitude', + { save: true, region: 'us' }, + { + write, + fetchDocument: async () => DOCUMENT, + materialize: () => { + throw failure; + }, + }, + ); + await expect(operation).rejects.toMatchObject({ + message: expect.stringContaining(failure.message), + errorCode: 'transport_error', + exitCode: 5, + hint: expect.stringContaining( + 'Alternatively, omit `--save` to output the skill document directly', + ), + }); + if (failure instanceof CliError) { + await expect(operation).rejects.toMatchObject({ + hint: expect.stringContaining('Check cache permissions.'), + }); + } + expect(write).not.toHaveBeenCalled(); + }); + + it('preserves retrieval errors without suggesting that omitting save fixes them', async () => { + const failure = transportError( + 'Registry unavailable', + 'Check connectivity.', + ); + await expect( + runSkillsGet( + 'example', + { save: true, region: 'us' }, + { + fetchDocument: async () => { + throw failure; + }, + }, + ), + ).rejects.toMatchObject({ hint: 'Check connectivity.' }); + }); + it('requires a skill name', async () => { await expect( runSkillsGet( diff --git a/src/skills-commands.ts b/src/skills-commands.ts index 17a9a84..5ef6678 100644 --- a/src/skills-commands.ts +++ b/src/skills-commands.ts @@ -1,12 +1,13 @@ import { type FlagValue, isFlagEnabled } from './args'; import { SKILLS_GLOBAL_FLAGS, type SkillsVerb } from './catalog'; -import { usageError } from './cli-error'; +import { CliError, transportError, usageError } from './cli-error'; import { formatJsonOutput, normalizeWhitespace, shouldUseJsonOutput, } from './output'; import { assertFlagsAllowed } from './request'; +import { materializeSkill, type MaterializedSkill } from './skill-materializer'; import { type SkillIndexEntry, fetchSkillDocument, @@ -43,6 +44,7 @@ export interface SkillsCommandDeps { path?: string; fetchIndex?: (baseUrl: string) => Promise; fetchDocument?: (baseUrl: string, name: string) => Promise; + materialize?: (name: string, document: string) => MaterializedSkill; } function writeStdout(chunk: string): void { @@ -115,6 +117,37 @@ export async function runSkillsGet( ); const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY); + if (isFlagEnabled(flags.save)) { + const materialize = deps.materialize ?? materializeSkill; + let saved: MaterializedSkill; + try { + saved = materialize(name, document); + } catch (error) { + const failure = + error instanceof CliError + ? error + : transportError( + `Could not save skill: ${error instanceof Error ? error.message : String(error)}.`, + 'Check permissions for the skill cache and available disk space, then retry.', + ); + failure.hint = [ + failure.hint, + 'Alternatively, omit `--save` to output the skill document directly', + ] + .filter((hint) => hint !== undefined && hint.length > 0) + .join('\n\n'); + throw failure; + } + + if (isFlagEnabled(flags.json)) { + write(`${formatJsonOutput({ data: saved }, isTTY)}\n`); + return; + } + + write(`${saved.instruction}\n`); + return; + } + if (isFlagEnabled(flags.json)) { write(`${formatJsonOutput({ data: { name, document } }, isTTY)}\n`); return;