@elench/testkit 0.1.90 → 0.1.92

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.
package/README.md CHANGED
@@ -13,6 +13,8 @@ cd my-product
13
13
 
14
14
  # Launch the interactive assistant
15
15
  npx @elench/testkit
16
+ npx @elench/testkit assistant --provider codex --model gpt-5.4
17
+ npx @elench/testkit assistant --provider claude --model sonnet --effort high
16
18
 
17
19
  # Ask for one assistant turn non-interactively
18
20
  npx @elench/testkit assistant --message "/status"
@@ -75,18 +77,24 @@ npx @elench/testkit assistant --message "/logs api"
75
77
  npx @elench/testkit db snapshot capture --service api --output scripts/testkit/schema-baseline.sql
76
78
  ```
77
79
 
78
- `testkit` is now assistant-first in an interactive TTY, but the interactive
79
- assistant is provider-native. `testkit` boots a real Codex CLI or Claude CLI
80
- session, writes local context files under `.testkit/assistant/`, places a
81
- repo-local `testkit` wrapper on the provider `PATH`, and then hands the
82
- terminal to the provider. That means the cursor, transcript, approvals, and
83
- conversation UX come from the provider itself, while `testkit` contributes
84
- repo context, durable run artifacts, focused detail/log/artifact/setup text
85
- files, and a stable local command surface.
86
-
87
- The non-interactive `assistant --message ...` mode remains available for one
88
- hosted turn at a time. It is useful in scripts and tests, but it is not the
89
- primary interactive UX.
80
+ `testkit` is assistant-first in an interactive TTY. The interactive assistant
81
+ is a testkit-owned chat shell with a bottom composer, provider-backed
82
+ reasoning, repo context files under `.testkit/assistant/`, and inline tool
83
+ blocks for command execution. Natural-language turns still go through Codex or
84
+ Claude, but `testkit` owns the transcript, command execution surface, and
85
+ rendering around `testkit`, `npm`, and `npx` commands.
86
+
87
+ Assistant runtime settings are repo-local. Use `/provider`, `/model`,
88
+ `/effort`, and `/settings` inside the assistant to inspect or change the active
89
+ provider runtime; changes are persisted to `.testkit/assistant/settings.json`.
90
+ CLI flags such as `--provider`, `--model`, `--effort`, and repeatable
91
+ `--provider-arg` override those settings for the current launch. The composer
92
+ has an always-visible cursor and supports arrow keys, Home/End, Ctrl+A/Ctrl+E,
93
+ Backspace, Delete, and Ctrl+D.
94
+
95
+ The non-interactive `assistant --message ...` mode uses the same provider/tool
96
+ engine for one hosted turn at a time. It is useful in scripts and tests, but
97
+ it is not the primary interactive UX.
90
98
 
91
99
  Batch `run` output stays intentionally short: one line per completed file, a
92
100
  concise failure block, and a final summary. Service logs, captured runtime
@@ -1,6 +1,5 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { spawn } from "child_process";
4
3
  import { startClaudeHostedSession } from "./providers/claude.mjs";
5
4
  import { startCodexHostedSession } from "./providers/codex.mjs";
6
5
 
@@ -58,6 +57,9 @@ export function isProviderInstalled(provider, env = process.env) {
58
57
 
59
58
  export function startAgentSession({
60
59
  provider = "auto",
60
+ model = null,
61
+ effort = null,
62
+ providerArgs = [],
61
63
  cwd,
62
64
  prompt,
63
65
  onEvent,
@@ -67,37 +69,7 @@ export function startAgentSession({
67
69
  const resolvedProvider = resolvePreferredProvider(provider, env);
68
70
  const command = resolveProviderBinary(resolvedProvider, env);
69
71
  if (resolvedProvider === "claude") {
70
- return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose });
72
+ return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose, model, effort, providerArgs });
71
73
  }
72
- return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose });
73
- }
74
-
75
- export function startInteractiveProviderSession({
76
- provider = "auto",
77
- cwd,
78
- prompt,
79
- env = process.env,
80
- } = {}) {
81
- const resolvedProvider = resolvePreferredProvider(provider, env);
82
- const command = resolveProviderBinary(resolvedProvider, env);
83
- const args = buildInteractiveProviderArgs(resolvedProvider, prompt);
84
- const child = spawn(command, args, {
85
- cwd,
86
- env,
87
- stdio: "inherit",
88
- });
89
- return new Promise((resolve, reject) => {
90
- child.on("error", reject);
91
- child.on("close", (code) =>
92
- resolve({
93
- provider: resolvedProvider,
94
- exitCode: code ?? 0,
95
- })
96
- );
97
- });
98
- }
99
-
100
- export function buildInteractiveProviderArgs(provider, prompt = null) {
101
- if (!prompt) return [];
102
- return [prompt];
74
+ return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose, model, providerArgs });
103
75
  }
@@ -7,7 +7,16 @@ import {
7
7
  extractTextFragments,
8
8
  } from "./shared.mjs";
9
9
 
10
- export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEvent, purpose = "assistant" } = {}) {
10
+ export function startClaudeHostedSession({
11
+ command = "claude",
12
+ cwd,
13
+ prompt,
14
+ onEvent,
15
+ purpose = "assistant",
16
+ model = null,
17
+ effort = null,
18
+ providerArgs = [],
19
+ } = {}) {
11
20
  const args = [
12
21
  "-p",
13
22
  "--output-format",
@@ -18,6 +27,13 @@ export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEv
18
27
  if (purpose === "assistant") {
19
28
  args.push("--permission-mode", "plan");
20
29
  }
30
+ if (model) {
31
+ args.push("--model", String(model));
32
+ }
33
+ if (effort) {
34
+ args.push("--effort", String(effort));
35
+ }
36
+ args.push(...normalizeProviderArgs(providerArgs));
21
37
 
22
38
  args.push(prompt);
23
39
 
@@ -39,6 +55,11 @@ export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEv
39
55
  });
40
56
  }
41
57
 
58
+ function normalizeProviderArgs(providerArgs) {
59
+ if (!Array.isArray(providerArgs)) return [];
60
+ return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
61
+ }
62
+
42
63
  function parseClaudePayload(payload) {
43
64
  const events = [];
44
65
  if (!payload || typeof payload !== "object") return events;
@@ -11,7 +11,15 @@ import {
11
11
  readTextFileIfPresent,
12
12
  } from "./shared.mjs";
13
13
 
14
- export function startCodexHostedSession({ command = "codex", cwd, prompt, onEvent, purpose = "assistant" } = {}) {
14
+ export function startCodexHostedSession({
15
+ command = "codex",
16
+ cwd,
17
+ prompt,
18
+ onEvent,
19
+ purpose = "assistant",
20
+ model = null,
21
+ providerArgs = [],
22
+ } = {}) {
15
23
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-codex-"));
16
24
  const outputFile = path.join(tempDir, "final-message.txt");
17
25
  const args = ["exec", "--json", "-o", outputFile];
@@ -19,6 +27,10 @@ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEven
19
27
  if (purpose === "assistant") {
20
28
  args.push("-s", "read-only");
21
29
  }
30
+ if (model) {
31
+ args.push("--model", String(model));
32
+ }
33
+ args.push(...normalizeProviderArgs(providerArgs));
22
34
 
23
35
  args.push(prompt);
24
36
 
@@ -49,6 +61,11 @@ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEven
49
61
  };
50
62
  }
51
63
 
64
+ function normalizeProviderArgs(providerArgs) {
65
+ if (!Array.isArray(providerArgs)) return [];
66
+ return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
67
+ }
68
+
52
69
  function parseCodexPayload(payload) {
53
70
  const events = [];
54
71
  if (!payload || typeof payload !== "object") return events;
@@ -0,0 +1,209 @@
1
+ import React, { createElement, useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { bold, dim, green, red, yellow } from "../presentation/colors.mjs";
4
+ import { getComposerRenderParts } from "./composer.mjs";
5
+
6
+ const MAX_VISIBLE_MESSAGES = 22;
7
+
8
+ export function AssistantApp({
9
+ assistantState,
10
+ initialPrompt = null,
11
+ exitAfterInitialPrompt = false,
12
+ inputEnabled = true,
13
+ onRequestClose,
14
+ } = {}) {
15
+ const { exit } = useApp();
16
+ const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
17
+ const [initialPromptStarted, setInitialPromptStarted] = useState(false);
18
+ const [initialPromptFinished, setInitialPromptFinished] = useState(false);
19
+
20
+ useEffect(() => {
21
+ const unsubscribe = assistantState.subscribe(() => {
22
+ setSnapshot(assistantState.getSnapshot());
23
+ });
24
+ return unsubscribe;
25
+ }, [assistantState]);
26
+
27
+ useEffect(() => {
28
+ if (!initialPrompt || initialPromptStarted) return;
29
+ setInitialPromptStarted(true);
30
+ Promise.resolve(assistantState.submitInput(initialPrompt)).finally(() => {
31
+ setInitialPromptFinished(true);
32
+ });
33
+ }, [assistantState, exit, exitAfterInitialPrompt, initialPrompt, initialPromptStarted, onRequestClose]);
34
+
35
+ useEffect(() => {
36
+ if (!exitAfterInitialPrompt || !initialPromptFinished || snapshot.busy) return;
37
+ const timer = setTimeout(() => {
38
+ (onRequestClose || exit)();
39
+ }, 40);
40
+ return () => clearTimeout(timer);
41
+ }, [exit, exitAfterInitialPrompt, initialPromptFinished, onRequestClose, snapshot.busy]);
42
+
43
+ const visibleMessages = useMemo(
44
+ () => snapshot.messages.slice(-MAX_VISIBLE_MESSAGES),
45
+ [snapshot.messages]
46
+ );
47
+
48
+ return createElement(
49
+ Box,
50
+ { flexDirection: "column" },
51
+ inputEnabled
52
+ ? createElement(AssistantInputHandler, {
53
+ assistantState,
54
+ snapshot,
55
+ onRequestClose,
56
+ })
57
+ : null,
58
+ createElement(Text, null, dim(buildHeader(snapshot))),
59
+ snapshot.notice ? createElement(Text, null, yellow(snapshot.notice)) : null,
60
+ createElement(Text, null, ""),
61
+ createElement(
62
+ Box,
63
+ { flexDirection: "column" },
64
+ ...visibleMessages.flatMap((message) => renderMessage(message))
65
+ ),
66
+ createElement(Text, null, ""),
67
+ createElement(
68
+ Box,
69
+ {
70
+ borderStyle: "round",
71
+ flexDirection: "column",
72
+ paddingLeft: 1,
73
+ paddingRight: 1,
74
+ },
75
+ createElement(Text, null, dim("Message")),
76
+ renderComposer(snapshot)
77
+ ),
78
+ createElement(Text, null, ""),
79
+ createElement(Text, null, dim(buildFooter(snapshot, initialPromptFinished)))
80
+ );
81
+ }
82
+
83
+ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
84
+ const { exit } = useApp();
85
+
86
+ useInput((input, key) => {
87
+ if (key.ctrl && input === "c") {
88
+ (onRequestClose || exit)();
89
+ return;
90
+ }
91
+ if (input === "q" && !snapshot.busy && snapshot.composer.length === 0) {
92
+ (onRequestClose || exit)();
93
+ return;
94
+ }
95
+ if (key.return) {
96
+ if (!snapshot.busy) {
97
+ void assistantState.submitCurrentComposer();
98
+ }
99
+ return;
100
+ }
101
+ if (key.leftArrow) {
102
+ assistantState.moveComposerCursor(-1);
103
+ return;
104
+ }
105
+ if (key.rightArrow) {
106
+ assistantState.moveComposerCursor(1);
107
+ return;
108
+ }
109
+ if (key.home || (key.ctrl && input === "a")) {
110
+ assistantState.moveComposerCursorToStart();
111
+ return;
112
+ }
113
+ if (key.end || (key.ctrl && input === "e")) {
114
+ assistantState.moveComposerCursorToEnd();
115
+ return;
116
+ }
117
+ if (key.backspace) {
118
+ assistantState.backspaceComposer();
119
+ return;
120
+ }
121
+ if (key.delete || (key.ctrl && input === "d")) {
122
+ assistantState.deleteComposer();
123
+ return;
124
+ }
125
+ if (isPrintableInput(input, key)) {
126
+ assistantState.insertComposer(input);
127
+ }
128
+ });
129
+
130
+ return null;
131
+ }
132
+
133
+ function renderMessage(message) {
134
+ const prefix = rolePrefix(message);
135
+ const lines = String(message.text || "").split(/\r?\n/);
136
+ const rendered = [];
137
+ if (message.title) {
138
+ rendered.push(createElement(Text, { key: `${message.id}-title` }, `${prefix} ${bold(message.title)}`));
139
+ } else if (lines.length > 0) {
140
+ rendered.push(createElement(Text, { key: `${message.id}-first` }, `${prefix} ${colorForRole(message.role)(lines[0] || "")}`));
141
+ }
142
+
143
+ const remainingLines = message.title ? lines : lines.slice(1);
144
+ for (let index = 0; index < remainingLines.length; index += 1) {
145
+ rendered.push(
146
+ createElement(
147
+ Text,
148
+ { key: `${message.id}-line-${index}` },
149
+ `${message.title ? " " : " "}${remainingLines[index]}`
150
+ )
151
+ );
152
+ }
153
+ rendered.push(createElement(Text, { key: `${message.id}-gap` }, ""));
154
+ return rendered;
155
+ }
156
+
157
+ function renderComposer(snapshot) {
158
+ const { before, current, after, empty } = getComposerRenderParts({
159
+ text: snapshot.composer || "",
160
+ cursor: snapshot.composerCursor ?? 0,
161
+ });
162
+ return createElement(
163
+ Text,
164
+ null,
165
+ empty ? dim("Ask testkit to run or inspect something... ") : before,
166
+ createElement(Text, { inverse: true }, current),
167
+ after
168
+ );
169
+ }
170
+
171
+ function buildHeader(snapshot) {
172
+ const status = snapshot.busy ? snapshot.activeStatus || "working" : "ready";
173
+ const provider = snapshot.provider || "auto";
174
+ const resolvedProvider = snapshot.resolvedProvider && snapshot.resolvedProvider !== provider ? `→${snapshot.resolvedProvider}` : "";
175
+ const model = snapshot.model ? ` · ${snapshot.model}` : "";
176
+ const effort = snapshot.effort ? ` · ${snapshot.effort}` : "";
177
+ const context = snapshot.context?.selection?.filePath || snapshot.context?.selection?.serviceName || "no focus";
178
+ return `testkit assistant · ${provider}${resolvedProvider}${model}${effort} · ${status} · ${context}`;
179
+ }
180
+
181
+ function buildFooter(snapshot, promptFinished) {
182
+ if (promptFinished && snapshot.messages.length > 0) {
183
+ return "initial prompt complete";
184
+ }
185
+ if (snapshot.busy) {
186
+ return "Enter disabled while the provider is responding · Ctrl+C quit";
187
+ }
188
+ return "Enter send · arrows/Home/End move cursor · Backspace/Delete edit · /settings · q quit";
189
+ }
190
+
191
+ function rolePrefix(message) {
192
+ if (message.role === "user") return green("you>");
193
+ if (message.role === "assistant") return bold("ai>");
194
+ if (message.role === "tool") return yellow("tool>");
195
+ return red("sys>");
196
+ }
197
+
198
+ function colorForRole(role) {
199
+ if (role === "user") return green;
200
+ if (role === "tool") return yellow;
201
+ if (role === "system") return red;
202
+ return (value) => value;
203
+ }
204
+
205
+ function isPrintableInput(input, key) {
206
+ if (!input) return false;
207
+ if (key.ctrl || key.meta || key.escape || key.tab) return false;
208
+ return input >= " ";
209
+ }
@@ -0,0 +1,112 @@
1
+ const segmenter =
2
+ typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
3
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+ : null;
5
+
6
+ export function createComposerState(value = "") {
7
+ const text = String(value || "");
8
+ return {
9
+ text,
10
+ cursor: splitGraphemes(text).length,
11
+ };
12
+ }
13
+
14
+ export function setComposerText(state, value) {
15
+ const text = String(value || "");
16
+ const length = splitGraphemes(text).length;
17
+ return {
18
+ text,
19
+ cursor: clampCursor(state?.cursor ?? length, length),
20
+ };
21
+ }
22
+
23
+ export function insertComposerText(state, value) {
24
+ const insertText = String(value || "");
25
+ if (!insertText) return normalizeComposerState(state);
26
+ const parts = splitGraphemes(state?.text || "");
27
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
28
+ const insertParts = splitGraphemes(insertText);
29
+ const nextParts = [...parts.slice(0, cursor), ...insertParts, ...parts.slice(cursor)];
30
+ return {
31
+ text: nextParts.join(""),
32
+ cursor: cursor + insertParts.length,
33
+ };
34
+ }
35
+
36
+ export function backspaceComposerText(state) {
37
+ const parts = splitGraphemes(state?.text || "");
38
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
39
+ if (cursor === 0) return { text: parts.join(""), cursor };
40
+ const nextParts = [...parts.slice(0, cursor - 1), ...parts.slice(cursor)];
41
+ return {
42
+ text: nextParts.join(""),
43
+ cursor: cursor - 1,
44
+ };
45
+ }
46
+
47
+ export function deleteComposerText(state) {
48
+ const parts = splitGraphemes(state?.text || "");
49
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
50
+ if (cursor >= parts.length) return { text: parts.join(""), cursor };
51
+ const nextParts = [...parts.slice(0, cursor), ...parts.slice(cursor + 1)];
52
+ return {
53
+ text: nextParts.join(""),
54
+ cursor,
55
+ };
56
+ }
57
+
58
+ export function moveComposerCursor(state, delta) {
59
+ const parts = splitGraphemes(state?.text || "");
60
+ return {
61
+ text: parts.join(""),
62
+ cursor: clampCursor((state?.cursor ?? parts.length) + Number(delta || 0), parts.length),
63
+ };
64
+ }
65
+
66
+ export function moveComposerCursorToStart(state) {
67
+ return {
68
+ text: String(state?.text || ""),
69
+ cursor: 0,
70
+ };
71
+ }
72
+
73
+ export function moveComposerCursorToEnd(state) {
74
+ const text = String(state?.text || "");
75
+ return {
76
+ text,
77
+ cursor: splitGraphemes(text).length,
78
+ };
79
+ }
80
+
81
+ export function getComposerRenderParts(state) {
82
+ const parts = splitGraphemes(state?.text || "");
83
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
84
+ return {
85
+ before: parts.slice(0, cursor).join(""),
86
+ current: parts[cursor] || " ",
87
+ after: parts.slice(cursor + (parts[cursor] ? 1 : 0)).join(""),
88
+ empty: parts.length === 0,
89
+ };
90
+ }
91
+
92
+ function normalizeComposerState(state) {
93
+ const text = String(state?.text || "");
94
+ const parts = splitGraphemes(text);
95
+ return {
96
+ text,
97
+ cursor: clampCursor(state?.cursor ?? parts.length, parts.length),
98
+ };
99
+ }
100
+
101
+ function splitGraphemes(value) {
102
+ const text = String(value || "");
103
+ if (!text) return [];
104
+ if (segmenter) {
105
+ return [...segmenter.segment(text)].map((entry) => entry.segment);
106
+ }
107
+ return [...text];
108
+ }
109
+
110
+ function clampCursor(value, length) {
111
+ return Math.max(0, Math.min(Number(value || 0), length));
112
+ }
@@ -0,0 +1,191 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { readContextContent, buildContextSelection } from "../context-resources.mjs";
5
+
6
+ export function prepareAssistantContextPack({
7
+ productDir,
8
+ inspectState,
9
+ } = {}) {
10
+ const contextDir = path.join(productDir, ".testkit", "assistant");
11
+ const binDir = path.join(contextDir, "bin");
12
+ fs.mkdirSync(binDir, { recursive: true });
13
+
14
+ const commandLogPath = path.join(contextDir, "commands.jsonl");
15
+ const contextPath = path.join(contextDir, "context.md");
16
+ const summaryPath = path.join(contextDir, "latest-run-summary.json");
17
+ const selectionPath = path.join(contextDir, "current-selection.json");
18
+ const commandsPath = path.join(contextDir, "commands.md");
19
+ const focusedDetailPath = path.join(contextDir, "focused-detail.txt");
20
+ const focusedLogsPath = path.join(contextDir, "focused-logs.txt");
21
+ const focusedArtifactsPath = path.join(contextDir, "focused-artifacts.txt");
22
+ const focusedSetupPath = path.join(contextDir, "focused-setup.txt");
23
+ const wrapperPath = path.join(binDir, "testkit");
24
+
25
+ function refresh() {
26
+ const snapshot = inspectState?.getSnapshot?.() || {};
27
+ const detailContent = readContextContent({ productDir, snapshot, mode: "detail", logTail: 12 });
28
+ const logsContent = readContextContent({ productDir, snapshot, mode: "logs", logTail: 12 });
29
+ const artifactsContent = readContextContent({ productDir, snapshot, mode: "artifacts", logTail: 12 });
30
+ const setupContent = readContextContent({ productDir, snapshot, mode: "setup", logTail: 12 });
31
+
32
+ writeJson(summaryPath, {
33
+ summaryRows: snapshot.summaryData?.rows || [],
34
+ phase: snapshot.phase || null,
35
+ artifactPath: path.join(productDir, ".testkit", "results", "latest.json"),
36
+ });
37
+ writeJson(selectionPath, buildContextSelection(snapshot));
38
+ fs.writeFileSync(commandsPath, buildCommandsMarkdown(), "utf8");
39
+ fs.writeFileSync(focusedDetailPath, `${detailContent.lines.join("\n")}\n`, "utf8");
40
+ fs.writeFileSync(focusedLogsPath, `${logsContent.lines.join("\n")}\n`, "utf8");
41
+ fs.writeFileSync(focusedArtifactsPath, `${artifactsContent.lines.join("\n")}\n`, "utf8");
42
+ fs.writeFileSync(focusedSetupPath, `${setupContent.lines.join("\n")}\n`, "utf8");
43
+ fs.writeFileSync(
44
+ contextPath,
45
+ buildContextMarkdown(productDir, snapshot, {
46
+ contextPath,
47
+ summaryPath,
48
+ selectionPath,
49
+ commandsPath,
50
+ commandLogPath,
51
+ focusedDetailPath,
52
+ focusedLogsPath,
53
+ focusedArtifactsPath,
54
+ focusedSetupPath,
55
+ }),
56
+ "utf8"
57
+ );
58
+ fs.writeFileSync(wrapperPath, buildWrapperScript({ cliPath: resolveCliPath() }), {
59
+ encoding: "utf8",
60
+ mode: 0o755,
61
+ });
62
+ fs.chmodSync(wrapperPath, 0o755);
63
+ }
64
+
65
+ refresh();
66
+
67
+ return {
68
+ contextDir,
69
+ contextPath,
70
+ summaryPath,
71
+ selectionPath,
72
+ commandsPath,
73
+ commandLogPath,
74
+ focusedDetailPath,
75
+ focusedLogsPath,
76
+ focusedArtifactsPath,
77
+ focusedSetupPath,
78
+ binDir,
79
+ wrapperPath,
80
+ refresh,
81
+ appendCommandLog(event) {
82
+ if (!event || typeof event !== "object") return;
83
+ try {
84
+ fs.appendFileSync(
85
+ commandLogPath,
86
+ `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`,
87
+ "utf8"
88
+ );
89
+ } catch {
90
+ // Ignore assistant command log failures.
91
+ }
92
+ },
93
+ };
94
+ }
95
+
96
+ function resolveCliPath() {
97
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin", "testkit.mjs");
98
+ }
99
+
100
+ function buildWrapperScript({ cliPath } = {}) {
101
+ return `#!/usr/bin/env node
102
+ import { spawnSync } from "child_process";
103
+
104
+ const result = spawnSync(process.execPath, [${JSON.stringify(cliPath)}, ...process.argv.slice(2)], {
105
+ cwd: process.cwd(),
106
+ env: {
107
+ ...process.env,
108
+ TESTKIT_NO_ASSISTANT_DEFAULT: "1",
109
+ },
110
+ stdio: "inherit",
111
+ });
112
+
113
+ if (result.error) {
114
+ throw result.error;
115
+ }
116
+ process.exit(result.status ?? 0);
117
+ `;
118
+ }
119
+
120
+ function buildContextMarkdown(productDir, snapshot, paths) {
121
+ const rows = snapshot.summaryData?.rows || [];
122
+ const selection = snapshot.selectedEntry;
123
+ const lines = [
124
+ "# Testkit Assistant Context",
125
+ "",
126
+ `- Product directory: ${productDir}`,
127
+ `- Context file: ${paths.contextPath}`,
128
+ `- Run summary JSON: ${paths.summaryPath}`,
129
+ `- Current selection JSON: ${paths.selectionPath}`,
130
+ `- Command reference: ${paths.commandsPath}`,
131
+ `- Command log: ${paths.commandLogPath}`,
132
+ `- Focused detail: ${paths.focusedDetailPath}`,
133
+ `- Focused logs: ${paths.focusedLogsPath}`,
134
+ `- Focused artifacts: ${paths.focusedArtifactsPath}`,
135
+ `- Focused setup: ${paths.focusedSetupPath}`,
136
+ "",
137
+ "## Latest run summary",
138
+ ];
139
+
140
+ if (rows.length === 0) {
141
+ lines.push("- No persisted run artifact is currently loaded.");
142
+ } else {
143
+ for (const [label, value] of rows) {
144
+ lines.push(`- ${label}: ${value}`);
145
+ }
146
+ }
147
+
148
+ lines.push("", "## Current selection");
149
+ if (!selection) {
150
+ lines.push("- No focused file or service.");
151
+ } else {
152
+ lines.push(`- Kind: ${selection.kind}`);
153
+ if (selection.serviceName) lines.push(`- Service: ${selection.serviceName}`);
154
+ if (selection.type) lines.push(`- Type: ${selection.type}`);
155
+ if (selection.suiteName) lines.push(`- Suite: ${selection.suiteName}`);
156
+ if (selection.filePath) lines.push(`- File: ${selection.filePath}`);
157
+ if (selection.status) lines.push(`- Status: ${selection.status}`);
158
+ }
159
+
160
+ lines.push(
161
+ "",
162
+ "## Guidance",
163
+ "- Use shell commands like `npm run testkit`, `npx testkit`, or `testkit run --dir . --type <type>` when you need to execute tests.",
164
+ "- Use the command log and focused context files before rereading artifacts manually.",
165
+ "- Prefer repo-local commands over guessing project-specific wrappers.",
166
+ ""
167
+ );
168
+
169
+ return lines.join("\n");
170
+ }
171
+
172
+ function buildCommandsMarkdown() {
173
+ return [
174
+ "# Testkit Commands",
175
+ "",
176
+ "- `testkit run --dir . --type int`",
177
+ "- `testkit run --dir . --type e2e`",
178
+ "- `testkit run --dir . --file path/to/file.testkit.ts`",
179
+ "- `testkit discover --dir .`",
180
+ "- `testkit status --dir .`",
181
+ "- `testkit doctor --dir .`",
182
+ "- `testkit destroy --dir .`",
183
+ "- `npm run testkit`",
184
+ "- `npx testkit --dir . --type e2e`",
185
+ "",
186
+ ].join("\n");
187
+ }
188
+
189
+ function writeJson(filePath, value) {
190
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
191
+ }