diff --git a/.changeset/expose-metric-outputs.md b/.changeset/expose-metric-outputs.md new file mode 100644 index 0000000..de75f96 --- /dev/null +++ b/.changeset/expose-metric-outputs.md @@ -0,0 +1,11 @@ +--- +"@pungrumpy/cursor-action": patch +--- + +Expose execution duration and granular token usage outputs: + +- Add `duration-ms` output reporting run duration in milliseconds. +- Add `total-tokens`, `input-tokens`, and `output-tokens` outputs for agent usage tracking. +- Display input, output, cached, and total tokens in the GitHub Actions job summary table. +- Remove em dash in timeout warning message. +- Synchronize package description and reference documentation. diff --git a/README.md b/README.md index 034d438..4355b1b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ The action runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`. | `summary` | Text summary returned by the cursor-agent | | `exit-code` | Exit code from the cursor-agent process | | `status` | Run status returned by the cursor-agent (finished, error, or cancelled) | +| `duration-ms` | Execution duration in milliseconds | +| `total-tokens` | Total tokens consumed by the agent run | +| `input-tokens` | Input tokens consumed by the agent run | +| `output-tokens` | Output tokens generated by the agent run | diff --git a/__tests__/output.test.ts b/__tests__/output.test.ts index 14cc539..1abe630 100644 --- a/__tests__/output.test.ts +++ b/__tests__/output.test.ts @@ -137,16 +137,32 @@ describe("setOutputs", () => { stderr: "", stdout: "Done", usage: { + cacheReadTokens: 50, + inputTokens: 100, + outputTokens: 150, totalTokens: 250, }, }; const outputs = await setOutputs(result); expect(outputs.status).toBe("cancelled"); + expect(outputs.durationMs).toBe(3500); + expect(outputs.inputTokens).toBe(100); + expect(outputs.outputTokens).toBe(150); + expect(outputs.totalTokens).toBe(250); + expect(mockSetOutput).toHaveBeenCalledWith("status", "cancelled"); + expect(mockSetOutput).toHaveBeenCalledWith("duration-ms", "3500"); + expect(mockSetOutput).toHaveBeenCalledWith("input-tokens", "100"); + expect(mockSetOutput).toHaveBeenCalledWith("output-tokens", "150"); + expect(mockSetOutput).toHaveBeenCalledWith("total-tokens", "250"); + expect(mockSummaryChain.addTable).toHaveBeenCalledWith( expect.arrayContaining([ ["Agent Status", "cancelled"], ["Duration", "3.5s"], + ["Input Tokens", "100"], + ["Output Tokens", "150"], + ["Cache Read Tokens", "50"], ["Total Tokens", "250"], ]) ); diff --git a/action.yml b/action.yml index 6666d94..322478a 100644 --- a/action.yml +++ b/action.yml @@ -53,6 +53,22 @@ outputs: description: "Run status returned by the cursor-agent (finished, error, or cancelled)" value: ${{ steps.agent.outputs.status }} + duration-ms: + description: "Execution duration in milliseconds" + value: ${{ steps.agent.outputs.duration-ms }} + + total-tokens: + description: "Total tokens consumed by the agent run" + value: ${{ steps.agent.outputs.total-tokens }} + + input-tokens: + description: "Input tokens consumed by the agent run" + value: ${{ steps.agent.outputs.input-tokens }} + + output-tokens: + description: "Output tokens generated by the agent run" + value: ${{ steps.agent.outputs.output-tokens }} + # @cursor/sdk cannot be bundled: it dynamically imports its own webpack chunks # at runtime and resolves a native @cursor/sdk- package. It has to be # installed as a real dependency tree, which a `node24` action cannot do. diff --git a/dist/index.mjs b/dist/index.mjs index f132642..3ca3737 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -20,7 +20,7 @@ const getInputs = () => { if (!VALID_PERMISSIONS.includes(permissionsRaw)) throw new Error(`Invalid 'permissions' value: '${permissionsRaw}'. Must be one of: ${VALID_PERMISSIONS.join(", ")}`); const timeout = Math.trunc(Number(timeoutRaw)); if (Number.isNaN(timeout) || timeout <= 0) throw new Error(`Invalid 'timeout' value: '${timeoutRaw}'. Must be a positive integer (seconds).`); - if (timeout > 3600) warning(`Timeout is set to ${timeout}s (${Math.round(timeout / 60)}min). This is unusually long — consider if your prompt can be shortened.`); + if (timeout > 3600) warning(`Timeout is set to ${timeout}s (${Math.round(timeout / 60)}min). This is unusually long. Consider if your prompt can be shortened.`); if (!prompt.trim()) throw new Error("The 'prompt' input cannot be empty."); return { apiKey, @@ -49,11 +49,19 @@ const parseSummary = (stdout) => { } catch {} return trimmed.replaceAll(/\u001B\[[0-9;]*[mGKHF]/gu, ""); }; +const buildSummaryTableRows = (result) => { + const rows = [["Status", result.exitCode === 0 ? "✅ Success" : `❌ Failed (exit ${result.exitCode})`], ["Exit Code", String(result.exitCode)]]; + if (result.status) rows.push(["Agent Status", result.status]); + if (result.durationMs !== void 0) rows.push(["Duration", `${(result.durationMs / 1e3).toFixed(1)}s`]); + const { usage } = result; + if (usage?.inputTokens !== void 0) rows.push(["Input Tokens", String(usage.inputTokens)]); + if (usage?.outputTokens !== void 0) rows.push(["Output Tokens", String(usage.outputTokens)]); + if (usage?.cacheReadTokens !== void 0 && usage.cacheReadTokens > 0) rows.push(["Cache Read Tokens", String(usage.cacheReadTokens)]); + if (usage?.totalTokens !== void 0) rows.push(["Total Tokens", String(usage.totalTokens)]); + return rows; +}; const writeJobSummary = async (text, result) => { - const tableRows = [["Status", result.exitCode === 0 ? "✅ Success" : `❌ Failed (exit ${result.exitCode})`], ["Exit Code", String(result.exitCode)]]; - if (result.status) tableRows.push(["Agent Status", result.status]); - if (result.durationMs !== void 0) tableRows.push(["Duration", `${(result.durationMs / 1e3).toFixed(1)}s`]); - if (result.usage?.totalTokens !== void 0) tableRows.push(["Total Tokens", String(result.usage.totalTokens)]); + const tableRows = buildSummaryTableRows(result); await summary.addHeading("Cursor Agent Run", 2).addTable([[{ data: "Field", header: true @@ -67,17 +75,30 @@ const writeJobSummary = async (text, result) => { if (diag && diag !== errText) await summary.addHeading("Diagnostics", 3).addRaw(`\n\`\`\`\n${diag.slice(0, 2e4)}${diag.length > 2e4 ? "\n… (truncated)" : ""}\n\`\`\`\n`); await summary.write(); }; +const setMetricOutputs = (result) => { + if (result.durationMs !== void 0) setOutput("duration-ms", String(result.durationMs)); + const { usage } = result; + if (usage?.totalTokens !== void 0) setOutput("total-tokens", String(usage.totalTokens)); + if (usage?.inputTokens !== void 0) setOutput("input-tokens", String(usage.inputTokens)); + if (usage?.outputTokens !== void 0) setOutput("output-tokens", String(usage.outputTokens)); +}; const setOutputs = async (result) => { const text = parseSummary(result.stdout); const status = result.status ?? (result.exitCode === 0 ? "finished" : "error"); setOutput("summary", text); setOutput("exit-code", String(result.exitCode)); setOutput("status", status); + setMetricOutputs(result); await writeJobSummary(text, result); + const { usage } = result; return { + durationMs: result.durationMs, exitCode: result.exitCode, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, status, - summary: text + summary: text, + totalTokens: usage?.totalTokens }; }; const maskSecret = (apiKey) => setSecret(apiKey); diff --git a/docs/content/reference.mdx b/docs/content/reference.mdx index 7cc07fb..ce3661e 100644 --- a/docs/content/reference.mdx +++ b/docs/content/reference.mdx @@ -1,9 +1,9 @@ --- title: Reference -description: Every input and output, generated from action.yml. +description: Complete input and output reference for cursor-action. --- -The tables below are generated from `action.yml` by `bun run docs:reference`, and CI fails when they drift from it. They cannot describe an input the action does not have, or a default it does not use. +The tables below are generated from `action.yml` by `bun run docs:reference`, and are checked for drift in CI. {/* reference:start */} @@ -26,6 +26,10 @@ The tables below are generated from `action.yml` by `bun run docs:reference`, an | `summary` | Text summary returned by the cursor-agent | | `exit-code` | Exit code from the cursor-agent process | | `status` | Run status returned by the cursor-agent (finished, error, or cancelled) | +| `duration-ms` | Execution duration in milliseconds | +| `total-tokens` | Total tokens consumed by the agent run | +| `input-tokens` | Input tokens consumed by the agent run | +| `output-tokens` | Output tokens generated by the agent run | {/* reference:end */} @@ -48,3 +52,7 @@ Ignored since v1.0.0. The SDK resolves the agent version itself, and pinning a C `exit-code` is `0` when the agent run finishes successfully. It is `1` when the run times out, cancels, or encounters an error. `status` reports the terminal state of the agent run: `finished`, `error`, or `cancelled`. + +`duration-ms` reports the run duration in milliseconds when reported by the SDK. + +`total-tokens`, `input-tokens`, and `output-tokens` report token counts from the run when available. diff --git a/package.json b/package.json index 9736793..ce4f439 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@pungrumpy/cursor-action", "version": "1.0.2", - "description": "GitHub Action to install Cursor CLI and run cursor-agent in CI", + "description": "Run cursor-agent in CI pipelines using the official @cursor/sdk", "keywords": [ "ai", "cli", diff --git a/src/input.ts b/src/input.ts index 3f2c9a8..3c67f19 100644 --- a/src/input.ts +++ b/src/input.ts @@ -41,7 +41,7 @@ export const getInputs = (): ActionInputs => { if (timeout > 3600) { warning( `Timeout is set to ${timeout}s (${Math.round(timeout / 60)}min). ` + - `This is unusually long — consider if your prompt can be shortened.` + "This is unusually long. Consider if your prompt can be shortened." ); } diff --git a/src/output.ts b/src/output.ts index 3089834..f2160c6 100644 --- a/src/output.ts +++ b/src/output.ts @@ -40,32 +40,51 @@ const parseSummary = (stdout: string): string => { return clean; }; -const writeJobSummary = async ( - text: string, - result: AgentResult -): Promise => { +const buildSummaryTableRows = (result: AgentResult): string[][] => { const status = result.exitCode === 0 ? "✅ Success" : `❌ Failed (exit ${result.exitCode})`; - const tableRows: string[][] = [ + const rows: string[][] = [ ["Status", status], ["Exit Code", String(result.exitCode)], ]; if (result.status) { - tableRows.push(["Agent Status", result.status]); + rows.push(["Agent Status", result.status]); } if (result.durationMs !== undefined) { - tableRows.push(["Duration", `${(result.durationMs / 1000).toFixed(1)}s`]); + rows.push(["Duration", `${(result.durationMs / 1000).toFixed(1)}s`]); + } + + const { usage } = result; + if (usage?.inputTokens !== undefined) { + rows.push(["Input Tokens", String(usage.inputTokens)]); } - if (result.usage?.totalTokens !== undefined) { - tableRows.push(["Total Tokens", String(result.usage.totalTokens)]); + if (usage?.outputTokens !== undefined) { + rows.push(["Output Tokens", String(usage.outputTokens)]); } + if (usage?.cacheReadTokens !== undefined && usage.cacheReadTokens > 0) { + rows.push(["Cache Read Tokens", String(usage.cacheReadTokens)]); + } + + if (usage?.totalTokens !== undefined) { + rows.push(["Total Tokens", String(usage.totalTokens)]); + } + + return rows; +}; + +const writeJobSummary = async ( + text: string, + result: AgentResult +): Promise => { + const tableRows = buildSummaryTableRows(result); + await summary .addHeading("Cursor Agent Run", 2) .addTable([ @@ -99,6 +118,22 @@ const writeJobSummary = async ( await summary.write(); }; +const setMetricOutputs = (result: AgentResult): void => { + if (result.durationMs !== undefined) { + setOutput("duration-ms", String(result.durationMs)); + } + const { usage } = result; + if (usage?.totalTokens !== undefined) { + setOutput("total-tokens", String(usage.totalTokens)); + } + if (usage?.inputTokens !== undefined) { + setOutput("input-tokens", String(usage.inputTokens)); + } + if (usage?.outputTokens !== undefined) { + setOutput("output-tokens", String(usage.outputTokens)); + } +}; + export const setOutputs = async ( result: AgentResult ): Promise => { @@ -109,13 +144,19 @@ export const setOutputs = async ( setOutput("summary", text); setOutput("exit-code", String(result.exitCode)); setOutput("status", status); + setMetricOutputs(result); await writeJobSummary(text, result); + const { usage } = result; return { + durationMs: result.durationMs, exitCode: result.exitCode, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, status, summary: text, + totalTokens: usage?.totalTokens, }; }; diff --git a/src/types.ts b/src/types.ts index 9f6686b..a913392 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,10 @@ export interface ActionOutputs { summary: string; exitCode: number; status: string; + durationMs?: number; + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; } export interface TokenUsageStats {