mirror of
https://github.com/getcargohq/cargo-skills.git
synced 2026-09-14 19:17:15 +08:00
6683645b00
* CI: run the HOL AI plugin scanner hashgraph-online/awesome-ai-plugins requires every listed plugin's source repo to invoke `ai-plugin-scanner-action` on push/pull_request before the listing PR can merge (getcargohq/cargo-skills#83 → their PR #72). Pinned to a full commit SHA instead of the floating `v1` tag, with a read-only token and no secrets in scope, so a third-party action in our workflow surface can't reach anything it shouldn't. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * CI: publish the scanner report when the scan fails The action's built-in summary step is `if: success()`, so a failing scan prints counts and no findings. Render markdown to a file and cat it into the step summary unconditionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * CI: tee the scanner report to the job log as well Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * CI: clear the plugin scanner's 80-point / no-high gate The scanner job this PR added failed on its first run: 71/100 against a `min_score` of 80, one critical finding and one high. The awesome-ai-plugins contribution gate re-scans this repo with the same two thresholds, so the findings had to be fixed rather than configured around. Now 92/100 (A), zero critical, zero high. - Pin every `uses:` in .github/workflows/ to a full commit SHA, and add .github/dependabot.yml so the pins move weekly instead of never. - Add SECURITY.md: private reporting via the Security tab, and an explicit boundary between this bundle and the cargo-ai CLI / Cargo platform. - Stop inlining `curl … install.sh` in cargo/SKILL.md. The command now lives only in README.md, where a human runs it; the router keeps the guidance (never run it on the user's behalf) and points there. - Use execFileSync, not execSync, for `npm root -g` in the QA script library — no shell to interpret a command string. Windows gets the npm.cmd shim. - Fill in repository/license/keywords in .codex-plugin/plugin.json, a category on the .agents marketplace entry, and add .codexignore. - Set write_step_summary: false on the scanner action and publish the report ourselves, so it lands once whether the gate passes or fails. Two medium findings are left standing and documented in CHANGELOG.md: the `anthropic` provider slug in cargo-gtm's description (correct, and CI-synced with the CLI) and `fetch(` in the run-output downloader (the point of the function). Neither is a gate failure. Co-Authored-By: Auree Aubert <16732193+aureeaubert@users.noreply.github.com> Co-Authored-By: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com> Co-authored-by: Auree Aubert <16732193+aureeaubert@users.noreply.github.com>
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
// Shared helpers for the cargo-gtm QA scripts.
|
|
//
|
|
// Runtime contract: Node >= 22.18 runs these files directly (`node <script>.ts`,
|
|
// native type-stripping). Use erasable TypeScript syntax only — no enums,
|
|
// namespaces, or parameter properties. Core helpers are dependency-free;
|
|
// `@cargo-ai/api` is loaded lazily and only for API mode.
|
|
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
|
|
export type Row = Record<string, string>;
|
|
|
|
export function fail(message: string): never {
|
|
process.stderr.write(`Error: ${message}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI arguments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type ArgSpec = {
|
|
/** Flags that take a value, e.g. ["input", "output", "workflow-uuid"]. */
|
|
value?: string[];
|
|
/** Boolean flags, e.g. ["fixtures", "json"]. */
|
|
boolean?: string[];
|
|
};
|
|
|
|
export type Args = { values: Map<string, string>; flags: Set<string> };
|
|
|
|
export function parseArgs(argv: string[], spec: ArgSpec): Args {
|
|
const values = new Map<string, string>();
|
|
const flags = new Set<string>();
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const raw = argv[i];
|
|
if (!raw.startsWith("--")) fail(`unexpected argument: ${raw}`);
|
|
const name = raw.slice(2);
|
|
if (spec.boolean?.includes(name)) {
|
|
flags.add(name);
|
|
} else if (spec.value?.includes(name)) {
|
|
const value = argv[++i];
|
|
if (value === undefined || value.startsWith("--")) {
|
|
fail(`flag --${name} requires a value`);
|
|
}
|
|
values.set(name, value);
|
|
} else {
|
|
fail(`unknown flag: --${name}`);
|
|
}
|
|
}
|
|
return { values, flags };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSV (RFC 4180: quoted fields, embedded commas/quotes/newlines, CRLF)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function parseCsv(text: string): Row[] {
|
|
const rows: string[][] = [];
|
|
let field = "";
|
|
let record: string[] = [];
|
|
let inQuotes = false;
|
|
const pushField = () => {
|
|
record.push(field);
|
|
field = "";
|
|
};
|
|
const pushRecord = () => {
|
|
// Skip blank lines (a record that is a single empty field).
|
|
if (record.length > 1 || record[0] !== "") rows.push(record);
|
|
record = [];
|
|
};
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (inQuotes) {
|
|
if (ch === '"') {
|
|
if (text[i + 1] === '"') {
|
|
field += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = false;
|
|
}
|
|
} else {
|
|
field += ch;
|
|
}
|
|
} else if (ch === '"') {
|
|
inQuotes = true;
|
|
} else if (ch === ",") {
|
|
pushField();
|
|
} else if (ch === "\n" || ch === "\r") {
|
|
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
pushField();
|
|
pushRecord();
|
|
} else {
|
|
field += ch;
|
|
}
|
|
}
|
|
if (field !== "" || record.length > 0) {
|
|
pushField();
|
|
pushRecord();
|
|
}
|
|
if (rows.length === 0) return [];
|
|
const header = rows[0];
|
|
return rows.slice(1).map((cells) => {
|
|
const row: Row = {};
|
|
header.forEach((name, i) => {
|
|
row[name] = cells[i] ?? "";
|
|
});
|
|
return row;
|
|
});
|
|
}
|
|
|
|
function csvEscape(value: string): string {
|
|
return /[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
|
}
|
|
|
|
export function toCsv(rows: Row[], headers?: string[]): string {
|
|
const cols = headers ?? [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
const lines = [cols.map(csvEscape).join(",")];
|
|
for (const row of rows) {
|
|
lines.push(cols.map((c) => csvEscape(row[c] ?? "")).join(","));
|
|
}
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input loading: --input file (.csv / .json) or Cargo API mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function readJson<T>(path: string): T {
|
|
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
}
|
|
|
|
function rowsFromJson(data: unknown): Row[] {
|
|
// Accept `orchestration action execute-batch` output directly: rows live
|
|
// under a top-level "results" (or "records") key rather than at the root.
|
|
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
|
|
const wrapped = data as { results?: unknown; records?: unknown };
|
|
if (Array.isArray(wrapped.results)) data = wrapped.results;
|
|
else if (Array.isArray(wrapped.records)) data = wrapped.records;
|
|
}
|
|
if (!Array.isArray(data)) fail("expected a JSON array of objects (or {\"results\": [...]} batch output)");
|
|
return data.map((item) => {
|
|
const row: Row = {};
|
|
for (const [key, value] of Object.entries(item as Record<string, unknown>)) {
|
|
row[key] =
|
|
value === null || value === undefined
|
|
? ""
|
|
: typeof value === "string"
|
|
? value
|
|
: JSON.stringify(value);
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
export function readInputFile(path: string): Row[] {
|
|
if (!existsSync(path)) fail(`input file not found: ${path}`);
|
|
const text = readFileSync(path, "utf8");
|
|
return path.endsWith(".json") ? rowsFromJson(JSON.parse(text)) : parseCsv(text);
|
|
}
|
|
|
|
function resolveAccessToken(): string {
|
|
const fromEnv = process.env.CARGO_API_TOKEN;
|
|
if (fromEnv) return fromEnv;
|
|
const credentialsPath = join(homedir(), ".config", "cargo-ai", "credentials.json");
|
|
if (existsSync(credentialsPath)) {
|
|
const credentials = readJson<{ accessToken?: string }>(credentialsPath);
|
|
if (credentials.accessToken) return credentials.accessToken;
|
|
}
|
|
return fail(
|
|
"no Cargo credentials found — set CARGO_API_TOKEN or run `cargo-ai login`",
|
|
);
|
|
}
|
|
|
|
async function loadCargoApi(): Promise<{ buildApi: (deps: object) => any }> {
|
|
try {
|
|
return (await import("@cargo-ai/api")) as any;
|
|
} catch {
|
|
// Not resolvable from here — try the global npm root (the CLI itself is
|
|
// installed globally, so this is the common case for a global install).
|
|
try {
|
|
// execFileSync, not execSync: the argument vector goes to npm directly
|
|
// instead of through a shell, so there is no command string to interpret.
|
|
// Windows needs the .cmd shim by name, since there is no shell to find it.
|
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
const globalRoot = execFileSync(npm, ["root", "-g"], {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
const requireFromGlobal = createRequire(join(globalRoot, "noop.js"));
|
|
const entry = requireFromGlobal.resolve("@cargo-ai/api");
|
|
return (await import(entry)) as any;
|
|
} catch {
|
|
return fail(
|
|
"API mode needs the @cargo-ai/api package — `npm install -g @cargo-ai/api`, " +
|
|
"or pass --input <file.csv|file.json> instead",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export type ApiModeOptions = {
|
|
workflowUuid: string;
|
|
batchUuid?: string;
|
|
outputNodeSlug?: string;
|
|
workspaceUuid?: string;
|
|
};
|
|
|
|
/**
|
|
* Fetch a workflow's output rows via the Cargo API — the programmatic
|
|
* equivalent of `cargo-ai orchestration run download-outputs`.
|
|
*/
|
|
export async function fetchRunOutputRows(options: ApiModeOptions): Promise<Row[]> {
|
|
const { buildApi } = await loadCargoApi();
|
|
const api = buildApi({
|
|
accessToken: resolveAccessToken(),
|
|
workspaceUuid: options.workspaceUuid ?? process.env.CARGO_WORKSPACE_UUID,
|
|
});
|
|
const { url } = await api.orchestration.run.downloadOutputs({
|
|
workflowUuid: options.workflowUuid,
|
|
batchUuid: options.batchUuid,
|
|
outputNodeSlug: options.outputNodeSlug,
|
|
format: "json",
|
|
});
|
|
const response = await fetch(url);
|
|
if (!response.ok) fail(`failed to download outputs (${response.status})`);
|
|
return rowsFromJson(await response.json());
|
|
}
|
|
|
|
/**
|
|
* Standard input resolution shared by every QA script:
|
|
* --input <file> CSV or JSON rows
|
|
* --workflow-uuid <uuid> API mode (+ optional --batch-uuid,
|
|
* --output-node-slug, --workspace-uuid)
|
|
*/
|
|
export async function readRows(args: Args): Promise<Row[]> {
|
|
const input = args.values.get("input");
|
|
if (input) return readInputFile(input);
|
|
const workflowUuid = args.values.get("workflow-uuid");
|
|
if (workflowUuid) {
|
|
return fetchRunOutputRows({
|
|
workflowUuid,
|
|
batchUuid: args.values.get("batch-uuid"),
|
|
outputNodeSlug: args.values.get("output-node-slug"),
|
|
workspaceUuid: args.values.get("workspace-uuid"),
|
|
});
|
|
}
|
|
return fail("pass --input <file.csv|file.json> or --workflow-uuid <uuid>");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture metrics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Metrics = { precision: number; recall: number; f1: number };
|
|
|
|
export function metrics(tp: number, fp: number, fn: number): Metrics {
|
|
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
|
|
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
|
|
const f1 =
|
|
precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
|
|
return { precision, recall, f1 };
|
|
}
|
|
|
|
export type Thresholds = { precision?: number; recall?: number };
|
|
|
|
/**
|
|
* Print fixture metrics and exit non-zero when below thresholds. Call at the
|
|
* end of every script's --fixtures mode: CI relies on the exit code.
|
|
*/
|
|
export function reportFixtureRun(
|
|
scriptName: string,
|
|
results: { total: number; failures: string[] },
|
|
measured?: { metrics: Metrics; thresholds: Thresholds },
|
|
): void {
|
|
const lines = [`${scriptName}: ${results.total} fixture cases`];
|
|
if (measured) {
|
|
const { precision, recall, f1 } = measured.metrics;
|
|
lines.push(
|
|
`precision=${precision.toFixed(3)} recall=${recall.toFixed(3)} f1=${f1.toFixed(3)}`,
|
|
);
|
|
}
|
|
for (const failure of results.failures) lines.push(` FAIL ${failure}`);
|
|
process.stdout.write(lines.join("\n") + "\n");
|
|
|
|
let ok = results.failures.length === 0;
|
|
if (measured) {
|
|
const { precision = 0, recall = 0 } = measured.thresholds;
|
|
if (measured.metrics.precision < precision) {
|
|
process.stdout.write(`FAIL precision ${measured.metrics.precision.toFixed(3)} < required ${precision}\n`);
|
|
ok = false;
|
|
}
|
|
if (measured.metrics.recall < recall) {
|
|
process.stdout.write(`FAIL recall ${measured.metrics.recall.toFixed(3)} < required ${recall}\n`);
|
|
ok = false;
|
|
}
|
|
}
|
|
process.stdout.write(ok ? "PASS\n" : "FAIL\n");
|
|
process.exit(ok ? 0 : 1);
|
|
}
|