@vitest-agent/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ import { DataReader, DataStore, PerClientSessionMapWriter, RunContextService, deriveIdempotencyKey } from "@vitest-agent/sdk";
2
+ import { Effect, Option } from "effect";
3
+
4
+ //#region src/lib/internal-register-agent.ts
5
+ const deriveDefaultClientNonce = (input) => {
6
+ const parent = input.parentAgentId ?? "__ROOT__";
7
+ return `${input.hostSessionId}|${input.agentType}|${parent}`;
8
+ };
9
+ /**
10
+ * End-to-end agent registration.
11
+ *
12
+ * Returns the canonical `agentId` + the conversation it belongs to +
13
+ * the resolved `idempotencyKey`. The `idempotencyHit` flag is `true`
14
+ * when the per-project store recovered an existing agent row instead
15
+ * of inserting a new one.
16
+ *
17
+ * @param input - registration inputs including host identifiers and agent metadata
18
+ * @returns an Effect resolving to `RegisterAgentOutput`
19
+ * @public
20
+ */
21
+ const registerAgentEffect = (input) => Effect.gen(function* () {
22
+ const sessionMap = yield* PerClientSessionMapWriter;
23
+ const reader = yield* DataReader;
24
+ const store = yield* DataStore;
25
+ const ctx = yield* RunContextService;
26
+ const conversationId = yield* sessionMap.mapConversation(input.transcriptPath);
27
+ const sessionMapping = yield* sessionMap.mapSession({
28
+ hostSessionId: input.hostSessionId,
29
+ conversationId,
30
+ projectKey: input.projectKey,
31
+ projectDir: input.cwd
32
+ });
33
+ const existingSession = yield* reader.getSessionByChatId(input.hostSessionId);
34
+ const sessionRowId = yield* Option.match(existingSession, {
35
+ onNone: () => store.writeSession({
36
+ chatId: input.hostSessionId,
37
+ project: input.projectKey,
38
+ cwd: input.cwd,
39
+ agentKind: input.parentAgentId === void 0 ? "main" : "subagent",
40
+ agentType: input.agentType,
41
+ triageWasNonEmpty: false,
42
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
43
+ }),
44
+ onSome: (s) => Effect.succeed(s.id)
45
+ });
46
+ const agentContext = yield* ctx.captureAgentContext(input.cwd);
47
+ const clientNonce = input.clientNonce ?? deriveDefaultClientNonce(input);
48
+ const idempotencyKey = deriveIdempotencyKey({
49
+ agentType: input.agentType,
50
+ parentAgentId: input.parentAgentId ?? null,
51
+ clientNonce
52
+ });
53
+ const result = yield* store.registerAgent({
54
+ sessionId: sessionRowId,
55
+ agentType: input.agentType,
56
+ parentAgentId: input.parentAgentId ?? null,
57
+ conversationId,
58
+ startedAt: Math.floor(Date.now() / 1e3),
59
+ ...agentContext.startGitBranch !== null && { startGitBranch: agentContext.startGitBranch },
60
+ ...agentContext.startGitCommitSha !== null && { startGitCommitSha: agentContext.startGitCommitSha },
61
+ ...agentContext.startWorktreeDir !== null && { startWorktreeDir: agentContext.startWorktreeDir },
62
+ idempotencyKey,
63
+ agentId: sessionMapping.mainAgentId
64
+ });
65
+ const isHit = result._tag === "IdempotencyHit";
66
+ return {
67
+ agentId: isHit ? result.existingAgentId : result.agentId,
68
+ conversationId,
69
+ mainAgentId: sessionMapping.mainAgentId,
70
+ idempotencyKey,
71
+ idempotencyHit: isHit
72
+ };
73
+ });
74
+
75
+ //#endregion
76
+ export { registerAgentEffect };
@@ -0,0 +1,33 @@
1
+ import { DataReader, DataStore } from "@vitest-agent/sdk";
2
+ import { Effect } from "effect";
3
+
4
+ //#region src/lib/record-session.ts
5
+ const recordSessionStart = (input) => Effect.gen(function* () {
6
+ const reader = yield* DataReader;
7
+ const store = yield* DataStore;
8
+ let parentSessionId;
9
+ if (input.parentChatId !== void 0) {
10
+ const parent = yield* reader.getSessionByChatId(input.parentChatId);
11
+ if (parent._tag === "Some") parentSessionId = parent.value.id;
12
+ }
13
+ return { sessionId: yield* store.upsertSession({
14
+ chatId: input.chatId,
15
+ project: input.project,
16
+ cwd: input.cwd,
17
+ agentKind: input.agentKind,
18
+ ...input.agentType !== void 0 && { agentType: input.agentType },
19
+ ...parentSessionId !== void 0 && { parentSessionId },
20
+ triageWasNonEmpty: input.triageWasNonEmpty,
21
+ startedAt: input.startedAt
22
+ }) };
23
+ });
24
+ const recordSessionEnd = (input) => Effect.gen(function* () {
25
+ const reader = yield* DataReader;
26
+ const store = yield* DataStore;
27
+ if ((yield* reader.getSessionByChatId(input.chatId))._tag === "None") return yield* Effect.fail(/* @__PURE__ */ new Error(`Unknown chatId: ${input.chatId}`));
28
+ yield* store.endSession(input.chatId, input.endedAt, input.endReason);
29
+ return { ok: true };
30
+ });
31
+
32
+ //#endregion
33
+ export { recordSessionEnd, recordSessionStart };
@@ -0,0 +1,40 @@
1
+ import { resolveSessionForRecording } from "./resolve-session-for-recording.js";
2
+ import { DataReader, DataStore } from "@vitest-agent/sdk";
3
+ import { Effect, Option } from "effect";
4
+
5
+ //#region src/lib/record-tdd-artifact.ts
6
+ const recordTddArtifactEffect = (input) => Effect.gen(function* () {
7
+ const reader = yield* DataReader;
8
+ const store = yield* DataStore;
9
+ const session = yield* resolveSessionForRecording({
10
+ chatId: input.chatId,
11
+ recordedAt: input.recordedAt,
12
+ ...input.project !== void 0 && { project: input.project },
13
+ ...input.cwd !== void 0 && { cwd: input.cwd }
14
+ });
15
+ const openTdd = (yield* reader.listTddTasksForSession(session.id, { walkParents: true })).find((t) => t.endedAt === null);
16
+ if (openTdd === void 0) return yield* Effect.fail(/* @__PURE__ */ new Error(`No open TDD task under chat_id ${input.chatId}. Call tdd_task start first.`));
17
+ const phaseOpt = yield* reader.getCurrentTddPhase(openTdd.id);
18
+ const phaseId = Option.isSome(phaseOpt) ? phaseOpt.value.id : (yield* store.writeTddPhase({
19
+ tddTaskId: openTdd.id,
20
+ phase: "spike",
21
+ startedAt: input.recordedAt,
22
+ transitionReason: "auto-opened by record tdd-artifact (no prior phase)"
23
+ })).id;
24
+ return {
25
+ id: yield* store.writeTddArtifact({
26
+ phaseId,
27
+ artifactKind: input.artifactKind,
28
+ ...input.fileId !== void 0 && { fileId: input.fileId },
29
+ ...input.testCaseId !== void 0 && { testCaseId: input.testCaseId },
30
+ ...input.testRunId !== void 0 && { testRunId: input.testRunId },
31
+ ...input.testFirstFailureRunId !== void 0 && { testFirstFailureRunId: input.testFirstFailureRunId },
32
+ ...input.diffExcerpt !== void 0 && { diffExcerpt: input.diffExcerpt },
33
+ recordedAt: input.recordedAt
34
+ }),
35
+ phaseId
36
+ };
37
+ });
38
+
39
+ //#endregion
40
+ export { recordTddArtifactEffect };
@@ -0,0 +1,44 @@
1
+ import { resolveSessionForRecording } from "./resolve-session-for-recording.js";
2
+ import { DataStore, TurnPayload } from "@vitest-agent/sdk";
3
+ import { Effect, Schema } from "effect";
4
+
5
+ //#region src/lib/record-turn.ts
6
+ const parseAndValidateTurnPayload = (raw) => {
7
+ let parsed;
8
+ try {
9
+ parsed = JSON.parse(raw);
10
+ } catch (e) {
11
+ return {
12
+ ok: false,
13
+ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}`
14
+ };
15
+ }
16
+ const decoded = Schema.decodeUnknownEither(TurnPayload)(parsed);
17
+ if (decoded._tag === "Left") return {
18
+ ok: false,
19
+ error: `Invalid TurnPayload: ${decoded.left.message}`
20
+ };
21
+ return {
22
+ ok: true,
23
+ payload: decoded.right
24
+ };
25
+ };
26
+ const recordTurnEffect = (input) => Effect.gen(function* () {
27
+ const parse = parseAndValidateTurnPayload(input.payloadJson);
28
+ if (!parse.ok) return yield* Effect.fail(new Error(parse.error));
29
+ const session = yield* resolveSessionForRecording({
30
+ chatId: input.chatId,
31
+ recordedAt: input.occurredAt,
32
+ ...input.project !== void 0 && { project: input.project },
33
+ ...input.cwd !== void 0 && { cwd: input.cwd }
34
+ });
35
+ return { turnId: yield* (yield* DataStore).writeTurn({
36
+ sessionId: session.id,
37
+ type: parse.payload.type,
38
+ payload: input.payloadJson,
39
+ occurredAt: input.occurredAt
40
+ }) };
41
+ });
42
+
43
+ //#endregion
44
+ export { recordTurnEffect };
@@ -0,0 +1,64 @@
1
+ import { DataStore } from "@vitest-agent/sdk";
2
+ import { Effect } from "effect";
3
+ import { SqlClient } from "@effect/sql/SqlClient";
4
+
5
+ //#region src/lib/record-workspace-changes.ts
6
+ /**
7
+ * Lib function for the `record run-workspace-changes` CLI subcommand.
8
+ *
9
+ * Driven by the PostToolUse hook on `git commit` / `git push`. Writes a
10
+ * commits row (idempotent on sha via ON CONFLICT DO NOTHING in
11
+ * DataStore.writeCommit) and zero or more run_changed_files rows.
12
+ *
13
+ * Optionally associates the changed files with a test_run_id; for the
14
+ * commit-side write path we don't have a run, so we associate the files
15
+ * with the most-recent test_run for the project (best-effort) or skip
16
+ * the file rows entirely if no test_run exists yet.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+ /**
21
+ * `ProjectRunSummary` doesn't expose `lastRunId`, so we query it directly
22
+ * via SqlClient. This is the run id of the most-recent test run for a
23
+ * given project (or any project, when `project` is unspecified). Used
24
+ * only as a best-effort association target for `run_changed_files`.
25
+ */
26
+ const findLatestRunId = (project) => Effect.gen(function* () {
27
+ const sql = yield* SqlClient;
28
+ const rows = project !== void 0 ? yield* sql`
29
+ SELECT id FROM test_runs
30
+ WHERE project = ${project}
31
+ ORDER BY timestamp DESC LIMIT 1
32
+ ` : yield* sql`
33
+ SELECT id FROM test_runs
34
+ ORDER BY timestamp DESC LIMIT 1
35
+ `;
36
+ return rows.length === 0 ? null : rows[0].id;
37
+ }).pipe(Effect.orElseSucceed(() => null));
38
+ const recordRunWorkspaceChangesEffect = (input) => Effect.gen(function* () {
39
+ const store = yield* DataStore;
40
+ yield* store.writeCommit({
41
+ sha: input.sha,
42
+ ...input.parentSha !== void 0 && { parentSha: input.parentSha },
43
+ ...input.message !== void 0 && { message: input.message },
44
+ ...input.author !== void 0 && { author: input.author },
45
+ ...input.committedAt !== void 0 && { committedAt: input.committedAt },
46
+ ...input.branch !== void 0 && { branch: input.branch }
47
+ });
48
+ const runId = yield* findLatestRunId(input.project);
49
+ if (runId !== null && input.files.length > 0) yield* store.writeRunChangedFiles({
50
+ runId,
51
+ files: input.files.map((f) => ({
52
+ filePath: f.filePath,
53
+ changeKind: f.changeKind,
54
+ commitSha: input.sha
55
+ }))
56
+ });
57
+ return {
58
+ sha: input.sha,
59
+ fileRowsWritten: runId !== null ? input.files.length : 0
60
+ };
61
+ });
62
+
63
+ //#endregion
64
+ export { recordRunWorkspaceChangesEffect };
@@ -0,0 +1,71 @@
1
+ import { DataReader, DataStore } from "@vitest-agent/sdk";
2
+ import { Effect, Option } from "effect";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ //#region src/lib/resolve-session-for-recording.ts
6
+ /**
7
+ * Shared session-resolution helper for hook-driven recording paths.
8
+ *
9
+ * Background: Claude Code can rotate the chat id (host `session_id`)
10
+ * mid-window — after a continuation, compaction, or `/mcp` reconnect —
11
+ * without the `SessionStart` hook firing for the new id. PostToolUse
12
+ * hooks then deliver tool calls under a `chat_id` that has no row in
13
+ * `sessions`, and the strict `getSessionByChatId` lookup fails with
14
+ * "Unknown chat_id". A second flavour of the same bug: the
15
+ * SubagentStart hook mints a synthetic per-dispatch key
16
+ * (`<parentChatId>-subagent-<ts>-<pid>`) for the subagent row, but
17
+ * subsequent PostToolUse hooks under that subagent receive the bare
18
+ * parent chat id. Exact-match lookup of the bare id misses the
19
+ * synthetic suffix.
20
+ *
21
+ * This resolver applies a three-step fallback that recovers either
22
+ * case without crashing the recording path:
23
+ *
24
+ * 1. Exact match by `chat_id`.
25
+ * 2. Prefix match `<chatId>-subagent-` to recover the synthetic
26
+ * subagent row (most recent first).
27
+ * 3. Idempotent bootstrap of a `main` session row keyed on the
28
+ * input `chat_id`, so future calls in this window resolve via
29
+ * step 1.
30
+ *
31
+ * Step 3 means recording paths can no longer fail with "Unknown
32
+ * chat_id". Bootstrapped rows carry `agent_kind = "main"` and
33
+ * `triage_was_non_empty = false` because the SessionStart triage
34
+ * pipeline never ran for them; downstream metrics consumers should
35
+ * treat triage-empty bootstrapped rows the same as any other
36
+ * SessionStart-less row.
37
+ *
38
+ * @packageDocumentation
39
+ */
40
+ const resolveSessionForRecording = (input) => Effect.gen(function* () {
41
+ const reader = yield* DataReader;
42
+ const store = yield* DataStore;
43
+ const exact = yield* reader.getSessionByChatId(input.chatId);
44
+ if (Option.isSome(exact)) return exact.value;
45
+ const synthetic = yield* reader.findSessionsByChatPrefix(`${input.chatId}-subagent-`);
46
+ if (synthetic.length > 0) return synthetic[0];
47
+ const cwd = input.cwd ?? process.cwd();
48
+ const project = input.project ?? readPackageName(cwd) ?? "unknown";
49
+ yield* store.upsertSession({
50
+ chatId: input.chatId,
51
+ project,
52
+ cwd,
53
+ agentKind: "main",
54
+ triageWasNonEmpty: false,
55
+ startedAt: input.recordedAt
56
+ });
57
+ const created = yield* reader.getSessionByChatId(input.chatId);
58
+ return Option.getOrThrowWith(created, () => /* @__PURE__ */ new Error(`upsertSession succeeded but row not found for ${input.chatId}`));
59
+ });
60
+ const readPackageName = (cwd) => {
61
+ try {
62
+ const raw = readFileSync(`${cwd}/package.json`, "utf8");
63
+ const parsed = JSON.parse(raw);
64
+ return typeof parsed.name === "string" ? parsed.name : void 0;
65
+ } catch {
66
+ return;
67
+ }
68
+ };
69
+
70
+ //#endregion
71
+ export { resolveSessionForRecording };
@@ -0,0 +1,101 @@
1
+ import { ProjectIdentityNotResolvableError } from "@vitest-agent/sdk";
2
+ import { Effect } from "effect";
3
+ import { mkdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ //#region src/lib/sidecar-paths.ts
8
+ const APP_NAMESPACE = "vitest-agent";
9
+ /**
10
+ * Filename of the per-project test-data SQLite database.
11
+ *
12
+ * @public
13
+ */
14
+ const DATA_DB_FILENAME = "data.db";
15
+ /**
16
+ * Filename of the per-client session-map SQLite database.
17
+ *
18
+ * @public
19
+ */
20
+ const SESSIONS_DB_FILENAME = "sessions.db";
21
+ /**
22
+ * Filename of the global discovery-registry SQLite database.
23
+ *
24
+ * @public
25
+ */
26
+ const REGISTRY_DB_FILENAME = "registry.db";
27
+ const resolveXdgDataHome = () => {
28
+ const xdg = process.env.XDG_DATA_HOME;
29
+ if (xdg !== void 0 && xdg.length > 0) return xdg;
30
+ return join(homedir(), ".local", "share");
31
+ };
32
+ /**
33
+ * Resolve (and create) the per-project data directory for the supplied
34
+ * normalized `projectKey`. Returns the directory; callers join
35
+ * {@link DATA_DB_FILENAME} onto it for the `data.db` path.
36
+ *
37
+ * @param projectKey - normalized project key (e.g. `@org__pkg`)
38
+ * @returns absolute path to the resolved (and created) data directory
39
+ * @public
40
+ */
41
+ const resolveProjectDataDir = (projectKey) => {
42
+ const dir = join(resolveXdgDataHome(), APP_NAMESPACE, projectKey);
43
+ mkdirSync(dir, { recursive: true });
44
+ return dir;
45
+ };
46
+ /**
47
+ * Resolve (and create) the directory holding the global `registry.db`.
48
+ * Callers join {@link REGISTRY_DB_FILENAME} onto it.
49
+ *
50
+ * @returns absolute path to the resolved (and created) registry directory
51
+ * @public
52
+ */
53
+ const resolveRegistryDir = () => {
54
+ const dir = join(resolveXdgDataHome(), APP_NAMESPACE);
55
+ mkdirSync(dir, { recursive: true });
56
+ return dir;
57
+ };
58
+ /**
59
+ * Resolve the per-client `sessions.db` path.
60
+ *
61
+ * Precedence: the `CLAUDE_PLUGIN_DATA` env var, then
62
+ * `VITEST_AGENT_SESSION_MAP_DIR`, then `~/.vitest-agent/`. Fails
63
+ * with `ProjectIdentityNotResolvableError` when no home directory
64
+ * is resolvable.
65
+ *
66
+ * @returns an Effect resolving to the absolute `sessions.db` path
67
+ * @public
68
+ */
69
+ const resolveSessionMapPath = () => Effect.sync(() => {
70
+ const claudePluginData = process.env.CLAUDE_PLUGIN_DATA;
71
+ if (claudePluginData !== void 0 && claudePluginData.length > 0) {
72
+ mkdirSync(claudePluginData, { recursive: true });
73
+ return join(claudePluginData, SESSIONS_DB_FILENAME);
74
+ }
75
+ const overrideDir = process.env.VITEST_AGENT_SESSION_MAP_DIR;
76
+ if (overrideDir !== void 0 && overrideDir.length > 0) {
77
+ mkdirSync(overrideDir, { recursive: true });
78
+ return join(overrideDir, SESSIONS_DB_FILENAME);
79
+ }
80
+ const home = process.env.HOME ?? process.env.USERPROFILE;
81
+ if (home === void 0 || home.length === 0) return null;
82
+ const fallbackDir = join(home, ".vitest-agent");
83
+ mkdirSync(fallbackDir, { recursive: true });
84
+ return join(fallbackDir, SESSIONS_DB_FILENAME);
85
+ }).pipe(Effect.flatMap((path) => path !== null ? Effect.succeed(path) : Effect.fail(new ProjectIdentityNotResolvableError({ tried: [
86
+ {
87
+ source: "CLAUDE_PLUGIN_DATA",
88
+ reason: "env var not set"
89
+ },
90
+ {
91
+ source: "VITEST_AGENT_SESSION_MAP_DIR",
92
+ reason: "env var not set"
93
+ },
94
+ {
95
+ source: "HOME",
96
+ reason: "env var not set (USERPROFILE also unset)"
97
+ }
98
+ ] }))));
99
+
100
+ //#endregion
101
+ export { DATA_DB_FILENAME, REGISTRY_DB_FILENAME, SESSIONS_DB_FILENAME, resolveProjectDataDir, resolveRegistryDir, resolveSessionMapPath };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@vitest-agent/cli",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "description": "On-demand CLI for vitest-agent. Reads cached test data and reports status, overview, coverage, history, trends, and cache health.",
6
+ "keywords": [
7
+ "vitest",
8
+ "agent",
9
+ "cli",
10
+ "tests",
11
+ "coverage"
12
+ ],
13
+ "homepage": "https://github.com/spencerbeggs/vitest-agent#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/spencerbeggs/vitest-agent/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/spencerbeggs/vitest-agent.git",
20
+ "directory": "packages/cli"
21
+ },
22
+ "license": "MIT",
23
+ "author": {
24
+ "name": "C. Spencer Beggs",
25
+ "email": "spencer@beggs.codes",
26
+ "url": "https://spencerbeg.gs"
27
+ },
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./index.d.ts",
32
+ "import": "./index.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "bin": {
37
+ "vitest-agent": "bin/vitest-agent.js"
38
+ },
39
+ "dependencies": {
40
+ "@effect/cli": "^0.75.2",
41
+ "@effect/cluster": "^0.59.0",
42
+ "@effect/platform": "^0.96.2",
43
+ "@effect/platform-node": "^0.107.0",
44
+ "@effect/rpc": "^0.75.1",
45
+ "@effect/sql": "^0.51.1",
46
+ "@effect/sql-sqlite-node": "^0.52.0",
47
+ "@vitest-agent/sdk": "1.0.0",
48
+ "@vitest-agent/sidecar": "1.0.0",
49
+ "effect": "^3.21.4"
50
+ },
51
+ "engines": {
52
+ "node": ">=24.11.0"
53
+ }
54
+ }
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.58.9"
9
+ }
10
+ ]
11
+ }