@elench/testkit 0.1.93 → 0.1.96

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
@@ -78,11 +78,23 @@ npx @elench/testkit db snapshot capture --service api --output scripts/testkit/s
78
78
  ```
79
79
 
80
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.
81
+ opens with a repo-aware landing panel: provider/model, current directory,
82
+ latest run result, focused file/service, regression counts, and suggested next
83
+ prompts. The bottom composer is the primary interaction surface, and the status
84
+ line shows approximate context remaining when the active provider/model window
85
+ is known, for example `[~96% remaining]`.
86
+
87
+ Natural-language turns still go through Codex or Claude, but `testkit` owns the
88
+ transcript, command execution surface, context files under `.testkit/assistant/`,
89
+ and rendering around `testkit`, `npm`, and `npx` commands. When the provider
90
+ runs testkit-managed commands, the assistant records structured tool lifecycle
91
+ blocks and refreshes the latest run artifact so follow-up questions can use the
92
+ new result immediately.
93
+
94
+ Assistant provider coverage is tested against the real `codex` and `claude`
95
+ CLIs. The test suite assumes both are installed and authenticated; provider
96
+ adapter, assistant shell, `shell_exec`, and real testkit-run coverage do not use
97
+ provider stand-in binaries or simulated provider sessions.
86
98
 
87
99
  Assistant runtime settings are repo-local. Use `/provider`, `/model`,
88
100
  `/effort`, and `/settings` inside the assistant to inspect or change the active
@@ -90,7 +102,8 @@ provider runtime; changes are persisted to `.testkit/assistant/settings.json`.
90
102
  CLI flags such as `--provider`, `--model`, `--effort`, and repeatable
91
103
  `--provider-arg` override those settings for the current launch. The composer
92
104
  has an always-visible cursor and supports arrow keys, Home/End, Ctrl+A/Ctrl+E,
93
- Backspace, Delete, and Ctrl+D.
105
+ Backspace, Delete, Ctrl+D, and Ctrl+L to clear the visible transcript. Ctrl+C
106
+ quits the assistant.
94
107
 
95
108
  The non-interactive `assistant --message ...` mode uses the same provider/tool
96
109
  engine for one hosted turn at a time. It is useful in scripts and tests, but
@@ -268,7 +281,7 @@ File-local execution metadata now lives next to the test when possible:
268
281
  import { defineFile } from "@elench/testkit/config";
269
282
 
270
283
  export const testkit = defineFile({
271
- skip: "Billing is still stubbed locally",
284
+ skip: "Billing is currently unavailable locally",
272
285
  locks: ["global-worker-loop"],
273
286
  });
274
287
  ```
@@ -4,7 +4,6 @@ import {
4
4
  buildStatusEvent,
5
5
  buildToolEvent,
6
6
  createHostedSessionRunner,
7
- extractTextFragments,
8
7
  } from "./shared.mjs";
9
8
 
10
9
  export function startClaudeHostedSession({
@@ -21,6 +20,7 @@ export function startClaudeHostedSession({
21
20
  "-p",
22
21
  "--output-format",
23
22
  "stream-json",
23
+ "--verbose",
24
24
  "--include-partial-messages",
25
25
  ];
26
26
 
@@ -73,6 +73,25 @@ function parseClaudePayload(payload) {
73
73
  return events;
74
74
  }
75
75
 
76
+ if (type === "stream_event") {
77
+ const streamEvent = payload.event || {};
78
+ if (streamEvent.type === "content_block_delta" && streamEvent.delta?.type === "text_delta") {
79
+ const text = String(streamEvent.delta.text || "");
80
+ if (text) events.push({ type: "delta", text });
81
+ return events;
82
+ }
83
+ if (streamEvent.type === "tool_use" || streamEvent.content_block?.type === "tool_use") {
84
+ const tool = streamEvent.content_block || streamEvent;
85
+ const event = buildToolEvent(
86
+ tool.name || tool.tool_name || streamEvent.type,
87
+ tool.input ? JSON.stringify(tool.input) : null
88
+ );
89
+ if (event) events.push(event);
90
+ return events;
91
+ }
92
+ return events;
93
+ }
94
+
76
95
  if (type && /tool/i.test(type)) {
77
96
  const event = buildToolEvent(
78
97
  payload.name || payload.tool_name || payload.tool || type,
@@ -82,10 +101,14 @@ function parseClaudePayload(payload) {
82
101
  return events;
83
102
  }
84
103
 
85
- const fragments = [...new Set(extractTextFragments(payload, []))];
86
- if (fragments.length > 0) {
87
- for (const fragment of fragments) {
88
- events.push({ type: "delta", text: fragment });
104
+ if (type === "assistant") {
105
+ return events;
106
+ }
107
+
108
+ if (type === "result") {
109
+ if (payload.is_error || payload.subtype === "error") {
110
+ const event = buildErrorEvent(payload.result || payload.error || "Claude command failed");
111
+ if (event) events.push(event);
89
112
  }
90
113
  return events;
91
114
  }
@@ -36,6 +36,11 @@ export function createHostedSessionRunner({ provider, child, onEvent, parsePaylo
36
36
  const completion = (async () => {
37
37
  const result = await child;
38
38
  const finalText = (readFinalText ? readFinalText(result) : null) || assistantText.trim() || null;
39
+ if ((result.exitCode ?? 0) !== 0 && !finalText) {
40
+ const message = result.stderr || result.stdout || `${provider} exited with code ${result.exitCode ?? 1}`;
41
+ emit({ type: "error", message });
42
+ throw new Error(message);
43
+ }
39
44
  if (finalText) emit({ type: "final", text: finalText });
40
45
  emit({ type: "exit", code: result.exitCode ?? 0 });
41
46
  settled = true;
@@ -1,9 +1,10 @@
1
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";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import { bold, cyan, dim, green, red, yellow } from "../presentation/colors.mjs";
4
4
  import { getComposerRenderParts } from "./composer.mjs";
5
+ import { buildAssistantViewModel } from "./view-model.mjs";
5
6
 
6
- const MAX_VISIBLE_MESSAGES = 22;
7
+ const MAX_BLOCK_LINES = 18;
7
8
 
8
9
  export function AssistantApp({
9
10
  assistantState,
@@ -13,6 +14,7 @@ export function AssistantApp({
13
14
  onRequestClose,
14
15
  } = {}) {
15
16
  const { exit } = useApp();
17
+ const { stdout } = useStdout();
16
18
  const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
17
19
  const [initialPromptStarted, setInitialPromptStarted] = useState(false);
18
20
  const [initialPromptFinished, setInitialPromptFinished] = useState(false);
@@ -30,7 +32,7 @@ export function AssistantApp({
30
32
  Promise.resolve(assistantState.submitInput(initialPrompt)).finally(() => {
31
33
  setInitialPromptFinished(true);
32
34
  });
33
- }, [assistantState, exit, exitAfterInitialPrompt, initialPrompt, initialPromptStarted, onRequestClose]);
35
+ }, [assistantState, initialPrompt, initialPromptStarted]);
34
36
 
35
37
  useEffect(() => {
36
38
  if (!exitAfterInitialPrompt || !initialPromptFinished || snapshot.busy) return;
@@ -40,9 +42,12 @@ export function AssistantApp({
40
42
  return () => clearTimeout(timer);
41
43
  }, [exit, exitAfterInitialPrompt, initialPromptFinished, onRequestClose, snapshot.busy]);
42
44
 
43
- const visibleMessages = useMemo(
44
- () => snapshot.messages.slice(-MAX_VISIBLE_MESSAGES),
45
- [snapshot.messages]
45
+ const view = useMemo(
46
+ () => buildAssistantViewModel(snapshot, {
47
+ cwd: snapshot.productDir || process.cwd(),
48
+ terminalWidth: stdout?.columns || process.stdout?.columns || 100,
49
+ }),
50
+ [snapshot, stdout?.columns]
46
51
  );
47
52
 
48
53
  return createElement(
@@ -55,28 +60,15 @@ export function AssistantApp({
55
60
  onRequestClose,
56
61
  })
57
62
  : null,
58
- createElement(Text, null, dim(buildHeader(snapshot))),
59
- snapshot.notice ? createElement(Text, null, yellow(snapshot.notice)) : null,
63
+ view.blocks.length === 0
64
+ ? createElement(WelcomePanel, { view })
65
+ : createElement(Transcript, { view }),
60
66
  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)))
67
+ createElement(ComposerBar, { view, busy: snapshot.busy }),
68
+ createElement(Text, null, dim(view.statusLine)),
69
+ exitAfterInitialPrompt && initialPromptFinished && !snapshot.busy
70
+ ? createElement(Text, null, dim("initial prompt complete"))
71
+ : null
80
72
  );
81
73
  }
82
74
 
@@ -88,8 +80,8 @@ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
88
80
  (onRequestClose || exit)();
89
81
  return;
90
82
  }
91
- if (input === "q" && !snapshot.busy && snapshot.composer.length === 0) {
92
- (onRequestClose || exit)();
83
+ if (key.ctrl && input === "l" && !snapshot.busy) {
84
+ assistantState.clearMessages();
93
85
  return;
94
86
  }
95
87
  if (key.return) {
@@ -130,76 +122,113 @@ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
130
122
  return null;
131
123
  }
132
124
 
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
- }
125
+ function WelcomePanel({ view }) {
126
+ return createElement(
127
+ Box,
128
+ {
129
+ borderStyle: "round",
130
+ flexDirection: "column",
131
+ paddingLeft: 1,
132
+ paddingRight: 1,
133
+ },
134
+ createElement(Text, null, bold(view.title)),
135
+ createElement(Text, null, dim(view.welcome.subtitle)),
136
+ createElement(Text, null, ""),
137
+ ...view.welcome.rows.map(([label, value]) => (
138
+ createElement(Text, { key: label }, `${padLabel(label)} ${colorWelcomeValue(label, value)}`)
139
+ )),
140
+ createElement(Text, null, ""),
141
+ createElement(Text, null, bold("Try")),
142
+ ...view.welcome.suggestions.map((suggestion) => (
143
+ createElement(Text, { key: suggestion }, ` ${dim("›")} ${suggestion}`)
144
+ ))
145
+ );
146
+ }
142
147
 
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
- );
148
+ function Transcript({ view }) {
149
+ return createElement(
150
+ Box,
151
+ { flexDirection: "column" },
152
+ createElement(Text, null, bold(view.title)),
153
+ createElement(Text, null, dim(view.welcome.rows.find(([label]) => label === "Provider")?.[1] || "")),
154
+ createElement(Text, null, ""),
155
+ view.notice ? createElement(Text, null, yellow(view.notice)) : null,
156
+ ...view.blocks.flatMap((block) => renderBlock(block))
157
+ );
158
+ }
159
+
160
+ function renderBlock(block) {
161
+ const lines = String(block.text || "").split(/\r?\n/);
162
+ const visibleLines = lines.length > MAX_BLOCK_LINES
163
+ ? [...lines.slice(0, MAX_BLOCK_LINES - 1), `… ${lines.length - MAX_BLOCK_LINES + 1} more lines omitted`]
164
+ : lines;
165
+ const marker = colorMarker(block);
166
+ const title = block.title ? ` ${bold(block.title)}` : "";
167
+ const first = visibleLines[0] || "";
168
+ const rendered = [
169
+ createElement(Text, { key: `${block.id}-first` }, `${marker}${title}${title && first ? " " : ""}${colorBlockText(block, first)}`),
170
+ ];
171
+
172
+ for (let index = 1; index < visibleLines.length; index += 1) {
173
+ rendered.push(createElement(Text, { key: `${block.id}-${index}` }, ` ${visibleLines[index]}`));
152
174
  }
153
- rendered.push(createElement(Text, { key: `${message.id}-gap` }, ""));
175
+ rendered.push(createElement(Text, { key: `${block.id}-gap` }, ""));
154
176
  return rendered;
155
177
  }
156
178
 
157
- function renderComposer(snapshot) {
179
+ function ComposerBar({ view, busy }) {
158
180
  const { before, current, after, empty } = getComposerRenderParts({
159
- text: snapshot.composer || "",
160
- cursor: snapshot.composerCursor ?? 0,
181
+ text: view.composer.text,
182
+ cursor: view.composer.cursor,
161
183
  });
184
+ const prompt = cyan("❯");
185
+ const promptText = empty ? dim(`${view.composer.placeholder} `) : before;
162
186
  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
187
+ Box,
188
+ {
189
+ borderStyle: "single",
190
+ borderLeft: false,
191
+ borderRight: false,
192
+ paddingTop: 0,
193
+ paddingBottom: 0,
194
+ },
195
+ createElement(
196
+ Text,
197
+ null,
198
+ `${prompt} `,
199
+ promptText,
200
+ createElement(Text, { inverse: true }, current),
201
+ after,
202
+ busy ? dim(" provider responding") : ""
203
+ )
168
204
  );
169
205
  }
170
206
 
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}`;
207
+ function padLabel(label) {
208
+ return `${dim(String(label).padEnd(10, " "))}`;
179
209
  }
180
210
 
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";
211
+ function colorWelcomeValue(label, value) {
212
+ if (label === "Latest" && /^FAILED\b/.test(String(value))) return red(value);
213
+ if (label === "Latest" && /^PASSED\b/.test(String(value))) return green(value);
214
+ if (label === "Issues" && value !== "None detected") return yellow(value);
215
+ if (label === "Provider") return cyan(value);
216
+ return value;
189
217
  }
190
218
 
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>");
219
+ function colorMarker(block) {
220
+ if (block.kind === "user") return cyan(block.marker);
221
+ if (block.kind === "system") return red(block.marker);
222
+ if (block.kind === "tool-running") return yellow(block.marker);
223
+ if (block.kind === "testkit-run") return green(block.marker);
224
+ return block.marker;
196
225
  }
197
226
 
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;
227
+ function colorBlockText(block, text) {
228
+ if (block.kind === "user") return text;
229
+ if (block.kind === "system") return red(text);
230
+ if (block.kind === "tool-running") return yellow(text);
231
+ return text;
203
232
  }
204
233
 
205
234
  function isPrintableInput(input, key) {
@@ -0,0 +1,227 @@
1
+ const TESTKIT_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
2
+ const TESTKIT_DIR_COMMANDS = new Set(["run", "discover", "status", "doctor", "destroy", "cleanup", "typecheck"]);
3
+ const PACKAGE_RUNNERS = new Set(["npx", "pnpm", "npm", "yarn", "bun"]);
4
+
5
+ export function extractShellCommand(args = {}) {
6
+ if (!args || typeof args !== "object") return "";
7
+ const value =
8
+ args.command ??
9
+ args.cmd ??
10
+ args.commandString ??
11
+ args.shellCommand ??
12
+ args.input ??
13
+ args.script ??
14
+ "";
15
+ if (Array.isArray(value)) return value.map((part) => shellEscapeArg(part)).join(" ");
16
+ return String(value || "");
17
+ }
18
+
19
+ export function planShellCommand(rawCommand) {
20
+ const raw = String(rawCommand || "").trim();
21
+ if (!raw) {
22
+ return {
23
+ executableCommand: "",
24
+ rawCommand: raw,
25
+ displayCommand: raw,
26
+ command: "",
27
+ title: "Shell command",
28
+ testkitRelated: false,
29
+ normalized: false,
30
+ };
31
+ }
32
+
33
+ const testkit = planTestkitCommand(raw);
34
+ if (testkit) return testkit;
35
+
36
+ const testkitScript = planTestkitPackageScript(raw);
37
+ if (testkitScript) return testkitScript;
38
+
39
+ return {
40
+ executableCommand: raw,
41
+ rawCommand: raw,
42
+ displayCommand: raw,
43
+ command: firstCommandToken(raw),
44
+ title: "Shell command",
45
+ testkitRelated: false,
46
+ normalized: false,
47
+ };
48
+ }
49
+
50
+ function planTestkitPackageScript(raw) {
51
+ if (containsShellControl(raw)) return null;
52
+ const tokens = tokenizeShellWords(raw);
53
+ if (!tokens || tokens.length < 3) return null;
54
+ if (tokens[0] !== "npm" || tokens[1] !== "run") return null;
55
+ if (tokens[2] !== "testkit" && !tokens[2].startsWith("testkit:")) return null;
56
+ return {
57
+ executableCommand: raw,
58
+ rawCommand: raw,
59
+ displayCommand: raw,
60
+ command: "npm run testkit",
61
+ title: "npm testkit script",
62
+ testkitRelated: true,
63
+ normalized: false,
64
+ };
65
+ }
66
+
67
+ function planTestkitCommand(raw) {
68
+ if (containsShellControl(raw)) return null;
69
+ const tokens = tokenizeShellWords(raw);
70
+ if (!tokens || tokens.length === 0) return null;
71
+
72
+ const extracted = extractTestkitInvocation(tokens);
73
+ if (!extracted) return null;
74
+
75
+ const canonicalArgs = canonicalizeTestkitArgs(extracted.args);
76
+ const executableCommand = ["testkit", ...canonicalArgs].map(shellEscapeArg).join(" ");
77
+ const wasNormalized = executableCommand !== raw;
78
+ return {
79
+ executableCommand,
80
+ rawCommand: raw,
81
+ displayCommand: executableCommand,
82
+ command: "testkit",
83
+ title: "testkit command",
84
+ testkitRelated: true,
85
+ normalized: wasNormalized,
86
+ normalizationReason: wasNormalized ? extracted.reason : null,
87
+ };
88
+ }
89
+
90
+ function extractTestkitInvocation(tokens) {
91
+ if (tokens[0] === "testkit") {
92
+ return {
93
+ args: tokens.slice(1),
94
+ reason: "canonicalized local testkit invocation",
95
+ };
96
+ }
97
+
98
+ if (!PACKAGE_RUNNERS.has(tokens[0])) return null;
99
+
100
+ if (tokens[0] === "npm" && ["exec", "x"].includes(tokens[1])) {
101
+ const index = findPackageTarget(tokens, 2);
102
+ if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced npm exec testkit with local testkit" };
103
+ }
104
+
105
+ if (tokens[0] === "npx") {
106
+ const index = findPackageTarget(tokens, 1);
107
+ if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced npx testkit with local testkit" };
108
+ }
109
+
110
+ if (tokens[0] === "pnpm" && ["exec", "dlx"].includes(tokens[1])) {
111
+ const index = findPackageTarget(tokens, 2);
112
+ if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced pnpm testkit launcher with local testkit" };
113
+ }
114
+
115
+ if (tokens[0] === "yarn" && tokens[1] === "testkit") {
116
+ return { args: tokens.slice(2), reason: "replaced yarn testkit launcher with local testkit" };
117
+ }
118
+
119
+ if (tokens[0] === "bun" && ["x", "run"].includes(tokens[1])) {
120
+ const index = findPackageTarget(tokens, 2);
121
+ if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced bun testkit launcher with local testkit" };
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ function canonicalizeTestkitArgs(inputArgs) {
128
+ const args = [...inputArgs];
129
+ if (args.length === 0) return [];
130
+
131
+ if (TESTKIT_TYPES.has(args[0])) {
132
+ return withDir(["run", "--type", args[0], ...args.slice(1)]);
133
+ }
134
+
135
+ if (!TESTKIT_DIR_COMMANDS.has(args[0])) {
136
+ return args;
137
+ }
138
+
139
+ if (args[0] === "run") {
140
+ const runArgs = [...args];
141
+ if (TESTKIT_TYPES.has(runArgs[1])) {
142
+ const type = runArgs.splice(1, 1)[0];
143
+ if (!hasFlag(runArgs, "--type", "-t")) runArgs.splice(1, 0, "--type", type);
144
+ }
145
+ return withDir(runArgs);
146
+ }
147
+
148
+ return withDir(args);
149
+ }
150
+
151
+ function withDir(args) {
152
+ if (hasFlag(args, "--dir", "-d") || args.includes("--help") || args.includes("-h")) return args;
153
+ const [command, ...rest] = args;
154
+ return [command, "--dir", ".", ...rest];
155
+ }
156
+
157
+ function hasFlag(args, longFlag, shortFlag) {
158
+ return args.some((arg) => arg === longFlag || arg.startsWith(`${longFlag}=`) || arg === shortFlag);
159
+ }
160
+
161
+ function findPackageTarget(tokens, startIndex) {
162
+ for (let index = startIndex; index < tokens.length; index += 1) {
163
+ const token = tokens[index];
164
+ if (token === "--") continue;
165
+ if (token === "testkit" || token === "@elench/testkit") return index;
166
+ if (!token.startsWith("-")) return -1;
167
+ }
168
+ return -1;
169
+ }
170
+
171
+ function firstCommandToken(command) {
172
+ const tokens = tokenizeShellWords(command);
173
+ return tokens?.[0] || command.split(/\s+/)[0] || "command";
174
+ }
175
+
176
+ function containsShellControl(command) {
177
+ return /[\n;&|<>`]/.test(command);
178
+ }
179
+
180
+ function tokenizeShellWords(command) {
181
+ const words = [];
182
+ let current = "";
183
+ let quote = null;
184
+ let escaping = false;
185
+
186
+ for (const char of String(command)) {
187
+ if (escaping) {
188
+ current += char;
189
+ escaping = false;
190
+ continue;
191
+ }
192
+ if (char === "\\") {
193
+ escaping = true;
194
+ continue;
195
+ }
196
+ if (quote) {
197
+ if (char === quote) {
198
+ quote = null;
199
+ } else {
200
+ current += char;
201
+ }
202
+ continue;
203
+ }
204
+ if (char === "'" || char === '"') {
205
+ quote = char;
206
+ continue;
207
+ }
208
+ if (/\s/.test(char)) {
209
+ if (current) {
210
+ words.push(current);
211
+ current = "";
212
+ }
213
+ continue;
214
+ }
215
+ current += char;
216
+ }
217
+
218
+ if (escaping || quote) return null;
219
+ if (current) words.push(current);
220
+ return words;
221
+ }
222
+
223
+ function shellEscapeArg(value) {
224
+ const stringValue = String(value);
225
+ if (/^[a-zA-Z0-9._:@/%+=,-]+$/.test(stringValue)) return stringValue;
226
+ return `'${stringValue.replace(/'/g, `'\\''`)}'`;
227
+ }
@@ -160,7 +160,9 @@ function buildContextMarkdown(productDir, snapshot, paths) {
160
160
  lines.push(
161
161
  "",
162
162
  "## Guidance",
163
- "- Use shell commands like `npm run testkit`, `npx testkit`, or `testkit run --dir . --type <type>` when you need to execute tests.",
163
+ "- Use the local `testkit` command directly when you need to execute or inspect tests.",
164
+ "- Preferred commands: `testkit run --dir . --type <type>`, `testkit discover --dir .`, `testkit status --dir .`, and `testkit doctor --dir .`.",
165
+ "- Do not launch testkit through pnpm, npm, yarn, bun, or npx unless the user explicitly asks for that exact package-manager command.",
164
166
  "- Use the command log and focused context files before rereading artifacts manually.",
165
167
  "- Prefer repo-local commands over guessing project-specific wrappers.",
166
168
  ""
@@ -180,8 +182,6 @@ function buildCommandsMarkdown() {
180
182
  "- `testkit status --dir .`",
181
183
  "- `testkit doctor --dir .`",
182
184
  "- `testkit destroy --dir .`",
183
- "- `npm run testkit`",
184
- "- `npx testkit --dir . --type e2e`",
185
185
  "",
186
186
  ].join("\n");
187
187
  }