Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/expose-metric-outputs.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<!-- reference:end -->

Expand Down
16 changes: 16 additions & 0 deletions __tests__/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
])
);
Expand Down
16 changes: 16 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<platform> package. It has to be
# installed as a real dependency tree, which a `node24` action cannot do.
Expand Down
33 changes: 27 additions & 6 deletions dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions docs/content/reference.mdx
Original file line number Diff line number Diff line change
@@ -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 */}

Expand All @@ -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 */}

Expand All @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
);
}

Expand Down
59 changes: 50 additions & 9 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,51 @@ const parseSummary = (stdout: string): string => {
return clean;
};

const writeJobSummary = async (
text: string,
result: AgentResult
): Promise<void> => {
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<void> => {
const tableRows = buildSummaryTableRows(result);

await summary
.addHeading("Cursor Agent Run", 2)
.addTable([
Expand Down Expand Up @@ -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<ActionOutputs> => {
Expand All @@ -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,
};
};

Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface ActionOutputs {
summary: string;
exitCode: number;
status: string;
durationMs?: number;
totalTokens?: number;
inputTokens?: number;
outputTokens?: number;
}

export interface TokenUsageStats {
Expand Down
Loading