Skip to content
Open
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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Auto-resolve `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` from `~/.secrets` when not in environment (fixes doctor + judge in non-shell contexts)
- Multi-path example dataset resolution in `evals doctor` (works globally installed)
- `--module`, `--export`, `--command`, `--mcp-command`, `--tool` options on `evals run` and `evals ci run`
- `evals sync push/pull/status` commands via `@hasna/cloud` SDK
- `evals sync push/pull/status` commands via the legacy shared sync SDK
- Shell completion: `evals completion bash` / `evals completion zsh`
- React SPA dashboard served by `evals-serve`
- Pass^k metric (`repeat: N`, `passThreshold`) on eval cases
Expand All @@ -51,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- OpenAI v6 `tool_calls` type change (`function` property access)

### Changed
- Upgraded all dependencies to latest: `@anthropic-ai/sdk@0.82`, `openai@6`, `zod@4`, `commander@14`, `typescript@6`, `@modelcontextprotocol/sdk@1.29`, `@hasna/cloud@1.30`
- Upgraded all dependencies to latest at the time, including the legacy shared sync package that has since been removed from active runtime use

## [0.1.0] - 2026-03-27

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ evals mcp register --gemini # Gemini (~/.gemini/settings.json)
evals mcp register --all # all three at once
```

## Storage Sync

Runs and baselines are stored locally in SQLite. Set a remote PostgreSQL storage URL to sync them:

```bash
export HASNA_EVALS_DATABASE_URL="postgres://..."

evals sync status
evals sync push
evals sync pull
evals sync sync
```

---

## CI / GitHub Actions
Expand Down
Binary file added hasna-attachments-1.0.24.tgz
Binary file not shown.
14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hasna/evals",
"version": "0.1.21",
"version": "0.1.25",
"description": "Open source AI evaluation framework — LLM-as-judge + assertion-based evals for any AI app. CLI + MCP server.",
"type": "module",
"main": "dist/index.js",
Expand All @@ -14,6 +14,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./storage": {
"types": "./dist/storage.d.ts",
"import": "./dist/storage.js"
}
},
"files": [
Expand All @@ -24,7 +28,7 @@
"README.md"
],
"scripts": {
"build": "cd dashboard && bun run build && cd .. && bun build src/cli/index.ts --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/index.ts --outdir dist --target bun && tsc --emitDeclarationOnly --outDir dist",
"build": "rm -rf dist && cd dashboard && bun run build && cd .. && bun build src/cli/index.ts --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/index.ts src/storage.ts --outdir dist --target bun && tsc --emitDeclarationOnly --outDir dist",
"build:dashboard": "cd dashboard && bun run build",
"typecheck": "tsc --noEmit",
"test": "bun test",
Expand Down Expand Up @@ -65,16 +69,18 @@
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.82.0",
"@hasna/cloud": "^0.1.30",
"@modelcontextprotocol/sdk": "^1.29.0",
"ajv": "^8.18.0",
"chalk": "^5.4.1",
"commander": "^14.0.3",
"openai": "^6.33.0",
"zod": "^4.3.6"
"pg": "^8.13.3",
"zod": "^4.3.6",
"@hasna/events": "^0.1.6"
},
"devDependencies": {
"@types/bun": "^1.2.4",
"@types/pg": "^8.20.0",
"typescript": "^6.0.2"
}
}
9 changes: 3 additions & 6 deletions src/adapters/anthropic-openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,10 @@ describe("OpenAI adapter", () => {
});
});

// ─── resolveKey in judge (via env injection) ─────────────────────────────────
// ─── judge import smoke ──────────────────────────────────────────────────────

describe("judge.ts resolveKey — secrets fallback", () => {
test("ANTHROPIC_API_KEY is injected from secrets on module load", async () => {
// The judge module runs resolveKey eagerly on load.
// In CI or clean envs it reads from ~/.secrets if available.
// We just verify the module imports without throwing.
describe("judge.ts", () => {
test("imports without resolving secrets on module load", async () => {
const { runJudge } = await import("../core/judge.js");
expect(typeof runJudge).toBe("function");
});
Expand Down
30 changes: 30 additions & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,33 @@ describe("evals completion", () => {
expect(zsh.stdout).toContain("sync:");
});
});

describe("evals sync", () => {
test("reports remote storage status with canonical env names", async () => {
const { stdout, exitCode } = await runCli(["sync", "status", "--json"]);
expect(exitCode).toBe(0);

const status = JSON.parse(stdout) as {
configured: boolean;
mode: string;
env: string[];
activeEnv: string | null;
service: string;
tables: string[];
};

expect(status.configured).toBe(false);
expect(status.mode).toBe("local");
expect(status.env).toEqual(["HASNA_EVALS_DATABASE_URL", "EVALS_DATABASE_URL"]);
expect(status.activeEnv).toBe(null);
expect(status.service).toBe("evals");
expect(status.tables).toEqual(["runs", "baselines"]);
});

test("describes sync using storage terminology", async () => {
const { stdout, exitCode } = await runCli(["sync", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("remote PostgreSQL storage");
expect(stdout).not.toContain("cloud");
});
});
2 changes: 1 addition & 1 deletion src/cli/commands/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ _evals() {
'doctor:Health check'
'mcp:MCP server management'
'completion:Print shell completion script'
'sync:Sync eval runs and datasets with cloud'
'sync:Sync eval runs and datasets with remote storage'
)

_arguments -C \\
Expand Down
53 changes: 5 additions & 48 deletions src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,4 @@
import { Command } from "commander";
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";

/**
* Try to resolve an API key from process.env first, then from the
* ~/.secrets/hasnaxyz/<service>/live.env file as a fallback.
* This handles cases where the CLI is invoked outside a shell session
* that sources the secrets (e.g. cron, agent spawns, MCP calls).
*/
function resolveApiKey(envVar: string, secretsPath: string, secretsKey: string): string | undefined {
// 1. Direct env var
if (process.env[envVar]) return process.env[envVar];

// 2. ~/.secrets fallback
const fullPath = join(homedir(), ".secrets", secretsPath);
if (existsSync(fullPath)) {
try {
const content = readFileSync(fullPath, "utf8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith(secretsKey + "=")) {
const value = trimmed.slice(secretsKey.length + 1).replace(/^["']|["']$/g, "");
if (value) {
// Auto-inject for this process so downstream calls work too
process.env[envVar] = value;
return value;
}
}
}
} catch { /* ignore read errors */ }
}

return undefined;
}

export function doctorCommand(): Command {
return new Command("doctor")
Expand All @@ -42,24 +7,16 @@ export function doctorCommand(): Command {
.action(async (opts: { json?: boolean }) => {
const checks: Array<{ name: string; ok: boolean; hint?: string }> = [];

// Check Anthropic API key — env var or ~/.secrets fallback
const anthropicKey = resolveApiKey(
"ANTHROPIC_API_KEY",
"hasnaxyz/anthropic/live.env",
"HASNAXYZ_ANTHROPIC_LIVE_API_KEY"
);
// Check Anthropic API key.
const anthropicKey = process.env["ANTHROPIC_API_KEY"];
checks.push({
name: "ANTHROPIC_API_KEY",
ok: !!anthropicKey,
hint: "export ANTHROPIC_API_KEY=<your-key> (or add to ~/.secrets/hasnaxyz/anthropic/live.env)",
hint: "export ANTHROPIC_API_KEY=<your-key>",
});

// Check OpenAI API key (optional) — env var or ~/.secrets fallback
const openaiKey = resolveApiKey(
"OPENAI_API_KEY",
"hasnaxyz/openai/live.env",
"HASNAXYZ_OPENAI_LIVE_API_KEY"
);
// Check OpenAI API key (optional).
const openaiKey = process.env["OPENAI_API_KEY"];
checks.push({
name: "OPENAI_API_KEY (optional)",
ok: !!openaiKey,
Expand Down
144 changes: 90 additions & 54 deletions src/cli/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,128 @@
import { Command } from "commander";
import {
STORAGE_TABLES,
getStoragePg,
getStorageStatus,
storagePull,
storagePush,
storageSync,
getSyncMetaAll,
runStorageMigrations,
type SyncResult,
} from "../../db/storage-sync.js";
import { PG_MIGRATIONS } from "../../db/pg-migrations.js";

function parseTables(value?: string): string[] | undefined {
if (!value) return undefined;
return value.split(",").map((table) => table.trim()).filter(Boolean);
}

function printResults(results: SyncResult[], label: string): void {
const total = results.reduce((sum, result) => sum + result.rowsWritten, 0);
for (const result of results) {
const errors = result.errors.length > 0 ? ` (${result.errors.join("; ")})` : "";
console.log(` ${result.table}: ${result.rowsWritten}/${result.rowsRead} rows ${label}${errors}`);
}
console.log(`\x1b[32m✓ ${total} rows ${label}\x1b[0m`);
}

export function syncCommand(): Command {
const cmd = new Command("sync")
.description("Sync eval runs and datasets with cloud");
.description("Sync eval runs and baselines with remote PostgreSQL storage");

cmd
.command("push")
.description("Push local runs and datasets to cloud")
.description("Push local runs and baselines to remote storage")
.option("--dry-run", "Show what would be pushed without doing it")
.action(async (opts: { dryRun?: boolean }) => {
.option("--tables <tables>", "Comma-separated table names")
.action(async (opts: { dryRun?: boolean; tables?: string }) => {
try {
const { syncPush } = await import("@hasna/cloud");
void await import("../../db/store.js"); // ensure DB initialized

if (opts.dryRun) {
console.log("Dry run — would push evals database to cloud.");
console.log(`Dry run — would push tables: ${(parseTables(opts.tables) ?? [...STORAGE_TABLES]).join(", ")}`);
return;
}

console.log("Pushing to cloud...");
const { SqliteAdapter, PgAdapterAsync, getConnectionString, getDbPath } = await import("@hasna/cloud");
const connStr = getConnectionString("evals");
const dbPath = getDbPath("evals");
const local = new SqliteAdapter(dbPath);
const remote = new PgAdapterAsync(connStr);
const results = await syncPush(local, remote, { tables: ["runs", "baselines"] });
const total = results.reduce((s, r) => s + r.rowsWritten, 0);
console.log(`\x1b[32m✓ Pushed ${total} rows\x1b[0m`);
const results = await storagePush({ tables: parseTables(opts.tables) });
printResults(results, "pushed");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ERR_MODULE_NOT_FOUND" ||
String(err).includes("not found")) {
console.error("Cloud sync requires @hasna/cloud. Run: bun install");
} else {
console.error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
}
console.error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});

cmd
.command("pull")
.description("Pull runs and datasets from cloud")
.description("Pull runs and baselines from remote storage")
.option("--dry-run", "Show what would be pulled without doing it")
.action(async (opts: { dryRun?: boolean }) => {
.option("--tables <tables>", "Comma-separated table names")
.action(async (opts: { dryRun?: boolean; tables?: string }) => {
try {
const { syncPull } = await import("@hasna/cloud");
void await import("../../db/store.js"); // ensure DB initialized

if (opts.dryRun) {
console.log("Dry run — would pull evals data from cloud.");
console.log(`Dry run — would pull tables: ${(parseTables(opts.tables) ?? [...STORAGE_TABLES]).join(", ")}`);
return;
}

console.log("Pulling from cloud...");
const { SqliteAdapter, PgAdapterAsync, getConnectionString, getDbPath } = await import("@hasna/cloud");
const connStr = getConnectionString("evals");
const dbPath = getDbPath("evals");
const local = new SqliteAdapter(dbPath);
const remote = new PgAdapterAsync(connStr);
const results = await syncPull(remote, local, { tables: ["runs", "baselines"] });
const total = results.reduce((s, r) => s + r.rowsWritten, 0);
console.log(`\x1b[32m✓ Pulled ${total} rows\x1b[0m`);
const results = await storagePull({ tables: parseTables(opts.tables) });
printResults(results, "pulled");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ERR_MODULE_NOT_FOUND" ||
String(err).includes("not found")) {
console.error("Cloud sync requires @hasna/cloud. Run: bun install");
} else {
console.error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
}
console.error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});

cmd
.command("status")
.description("Show cloud sync status")
.action(async () => {
.command("sync")
.description("Bidirectional sync: pull then push")
.option("--tables <tables>", "Comma-separated table names")
.action(async (opts: { tables?: string }) => {
try {
const result = await storageSync({ tables: parseTables(opts.tables) });
printResults(result.pull, "pulled");
printResults(result.push, "pushed");
} catch (err) {
console.error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});

cmd
.command("migrate")
.description("Apply PostgreSQL migrations for evals")
.option("--dry-run", "Print SQL without executing")
.action(async (opts: { dryRun?: boolean }) => {
try {
const { getCloudConfig } = await import("@hasna/cloud");
const config = await getCloudConfig();
if (!config) {
console.log("Cloud sync not configured. Run: evals sync push");
if (opts.dryRun) {
for (const sql of PG_MIGRATIONS) console.log(sql);
return;
}
console.log(`\x1b[32m✓ Cloud sync configured\x1b[0m`);
console.log(` Service: evals`);
} catch {
console.log("Cloud sync not configured.");
const pg = await getStoragePg();
await runStorageMigrations(pg);
await pg.close();
console.log("\x1b[32m✓ Migrations applied\x1b[0m");
} catch (err) {
console.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});

cmd
.command("status")
.description("Show storage sync status")
.option("--json", "Output as JSON")
.action((opts: { json?: boolean }) => {
const status = getStorageStatus();
if (opts.json) {
console.log(JSON.stringify(status, null, 2));
return;
}
console.log(`Storage configured: ${status.configured ? "yes" : "no"}`);
console.log(`Mode: ${status.mode}`);
console.log(`Env: ${status.env.join(", ")}`);
console.log(`Tables: ${status.tables.join(", ")}`);
const sync = getSyncMetaAll();
if (sync.length === 0) console.log("Sync: no local sync history");
for (const entry of sync) {
console.log(` ${entry.table_name} ${entry.direction}: ${entry.last_synced_at ?? "never"}`);
}
});

Expand Down
Loading