Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bc45478
Initial plan
Copilot Apr 8, 2026
56b48ff
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 8, 2026
965dc4e
feat: implement .apply REPL command (Approach 1: Minimalist Function …
Copilot Apr 8, 2026
9c431c1
fix: address code review feedback - rename test params, add path trav…
Copilot Apr 8, 2026
ad25c0c
docs: add .apply command documentation to usage.md
Copilot Apr 8, 2026
3bdc527
feat: update ApplyContext to import Context type; generate default sc…
Copilot Apr 8, 2026
18a7690
fix: use generic example code in default scenarios/index.ts scaffold
Copilot Apr 8, 2026
dacc7d6
feat: scan for _.context.ts files to generate narrowed loadContext ov…
Copilot Apr 9, 2026
8571e86
fix: improve parameter name sanitization and strengthen ordering test…
Copilot Apr 9, 2026
ba514bb
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 9, 2026
b08a259
feat: add tab completion for .apply command in the REPL
Copilot Apr 9, 2026
6ed61e5
fix: document export regex limitation and strengthen tab completion t…
Copilot Apr 9, 2026
0f9bf16
fix: restrict .apply tab completion to exported functions only
Copilot Apr 9, 2026
fb4b293
feat: add ScenarioRegistry; move scenario loading from REPL to Module…
Copilot Apr 9, 2026
46856c2
refactor: fix async test and add JSDoc to createCompleter
Copilot Apr 9, 2026
87415e3
fix: resolve build errors and address code review feedback
Copilot Apr 9, 2026
10937d0
refactor: rename method and extract regex constant per code review
Copilot Apr 9, 2026
726a06f
feat: update scenarios/index.ts scaffold with JSDoc comments and help…
Copilot Apr 10, 2026
4cdebd5
feat: rename apply-context.ts to scenario-context.ts, add Scenario ty…
Copilot Apr 10, 2026
85db3c2
feat: regenerate scenario-context.ts when _.context.ts files change a…
Copilot Apr 10, 2026
5505dcf
refactor: extract isContextFile helper in ModuleLoader
Copilot Apr 10, 2026
e8dd300
refactor: use EventTarget event instead of callback for context-file-…
Copilot Apr 10, 2026
ac90080
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 10, 2026
e1f0e25
refactor: make ContextRegistry an EventTarget, dispatch context-chang…
Copilot Apr 10, 2026
785ca5b
fix: use correct path key when removing context from ContextRegistry …
Copilot Apr 10, 2026
795a039
docs: add JSDoc to ContextRegistry.remove(), simplify context path no…
Copilot Apr 10, 2026
6e7eca0
Merge branch 'main' into copilot/implement-apply-command-design
pmcelhaney Apr 10, 2026
fcb0d05
fix code formatting
pmcelhaney Apr 10, 2026
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
17 changes: 17 additions & 0 deletions .changeset/apply-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"counterfact": minor
---

Add `.apply` REPL dot-command (Approach 1: Minimalist Function Injection).

The `.apply` command lets you run scenario scripts from the REPL prompt without leaving the terminal. A scenario is a plain TypeScript (or JavaScript) file that exports named functions. Each function receives an `ApplyContext` object with `{ context, loadContext, routes, route }` and can freely read or mutate state.

**Path resolution:**

| Command | File | Function |
|---|---|---|
| `.apply foo` | `scenarios/index.ts` | `foo` |
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |

The `ApplyContext` type is written to `types/apply-context.ts` during code generation.
44 changes: 44 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,50 @@ await req.send()

See the [Route Builder guide](./route-builder.md) for full documentation.

### Scenario scripts with `.apply`

For more complex setups you can automate REPL interactions by writing _scenario scripts_ — plain TypeScript files that export named functions. Run them with `.apply`:

```
⬣> .apply soldPets
```

**Path resolution:** the argument to `.apply` is a slash-separated path. The last segment is the function name; everything before it is the file path, resolved relative to `<basePath>/scenarios/` (with `index.ts` as the default file).

| Command | File | Function |
|---|---|---|
| `.apply foo` | `scenarios/index.ts` | `foo` |
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |

A scenario function receives a single argument with `{ context, loadContext, routes, route }`:

```ts
// scenarios/index.ts
import type { Scenario } from "../types/scenario-context.js";

export const soldPets: Scenario = ($) => {
// Mutate context directly — same as typing in the REPL
$.context.petService.reset();
$.context.petService.addPet({ id: 1, status: "sold" });
$.context.petService.addPet({ id: 2, status: "available" });

// Store a pre-configured route builder for later use in the REPL
$.routes.findSold = $
.route("/pet/findByStatus")
.method("get")
.query({ status: "sold" });
}
```

After the command runs you can immediately use anything stored in `$.routes`:

```js
⬣> routes.findSold.send()
```

The `Scenario` type and `ApplyContext` interface are generated automatically into `types/scenario-context.ts` when you run Counterfact with type generation enabled.

---

## Proxy 🔀
Expand Down
11 changes: 11 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import { loadOpenApiDocument } from "./server/load-openapi-document.js";
import { ModuleLoader } from "./server/module-loader.js";
import { OpenApiWatcher } from "./server/openapi-watcher.js";
import { Registry } from "./server/registry.js";
import { ScenarioRegistry } from "./server/scenario-registry.js";
import { Transpiler } from "./server/transpiler.js";
import { CodeGenerator } from "./typescript-generator/code-generator.js";
import { writeApplyContextType } from "./typescript-generator/generate.js";
import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";

export { loadOpenApiDocument } from "./server/load-openapi-document.js";
Expand Down Expand Up @@ -114,6 +116,8 @@ export async function counterfact(config: Config) {

const contextRegistry = new ContextRegistry();

const scenarioRegistry = new ScenarioRegistry();

const codeGenerator = new CodeGenerator(
config.openApiPath,
config.basePath,
Expand Down Expand Up @@ -142,8 +146,14 @@ export async function counterfact(config: Config) {
compiledPathsDirectory,
registry,
contextRegistry,
nodePath.join(modulesPath, "scenarios").replaceAll("\\", "/"),
scenarioRegistry,
);

contextRegistry.addEventListener("context-changed", () => {
void writeApplyContextType(modulesPath);
});

const middleware = koaMiddleware(dispatcher, config);

const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
Expand Down Expand Up @@ -209,6 +219,7 @@ export async function counterfact(config: Config) {
config,
undefined, // use the default print function (stdout)
openApiDocument,
scenarioRegistry,
),
};
}
128 changes: 127 additions & 1 deletion src/repl/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Config } from "../server/config.js";
import type { ContextRegistry } from "../server/context-registry.js";
import type { OpenApiDocument } from "../server/dispatcher.js";
import type { Registry } from "../server/registry.js";
import type { ScenarioRegistry } from "../server/scenario-registry.js";

import { RawHttpClient } from "./raw-http-client.js";
import { createRouteFunction } from "./route-builder.js";
Expand All @@ -29,11 +30,61 @@ const ROUTE_BUILDER_METHODS = [
"send(",
];

/**
* Creates a tab-completion function for the REPL.
*
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
* @param scenarioRegistry - When provided, enables tab completion for `.apply` commands by enumerating
* exported function names and file-key prefixes from the loaded scenario modules.
*/
export function createCompleter(
registry: Registry,
fallback?: (line: string, callback: CompleterCallback) => void,
scenarioRegistry?: ScenarioRegistry,
) {
return (line: string, callback: CompleterCallback): void => {
// Check for .apply completion: .apply <partial>
const applyMatch = line.match(/^\.apply\s+(?<partial>\S*)$/u);

if (applyMatch) {
const partial = applyMatch.groups?.["partial"] ?? "";

if (scenarioRegistry !== undefined) {
const slashIdx = partial.lastIndexOf("/");

if (slashIdx === -1) {
// No slash: complete exports from "index" key + top-level file prefixes
const indexFunctions =
scenarioRegistry.getExportedFunctionNames("index");
const fileKeys = scenarioRegistry
.getFileKeys()
.filter((k) => k !== "index");
const topLevelPrefixes = [
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
];
const allOptions = [...indexFunctions, ...topLevelPrefixes];
const matches = allOptions.filter((c) => c.startsWith(partial));

callback(null, [matches, partial]);
} else {
// Has slash: complete exports from the named file key
const fileKey = partial.slice(0, slashIdx);
const funcPartial = partial.slice(slashIdx + 1);
const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
const matches = functions
.filter((e) => e.startsWith(funcPartial))
.map((e) => `${fileKey}/${e}`);

callback(null, [matches, partial]);
}
} else {
callback(null, [[], partial]);
}

Comment on lines +47 to +84
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When scenarioRegistry is not provided, the .apply completer calls callback(null, [[], line]). In Node’s REPL completer API the second tuple element should be the word to replace (here the .apply argument partial), not the full input line; returning line can cause completion to behave incorrectly. Compute partial from the regex match unconditionally and return [[], partial] in this branch.

Copilot uses AI. Check for mistakes.
return;
}

// Check for RouteBuilder method completion: route("..."). or chained calls
const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);

Expand Down Expand Up @@ -76,6 +127,7 @@ export function startRepl(
config: Config,
print = printToStdout,
openApiDocument?: OpenApiDocument,
scenarioRegistry?: ScenarioRegistry,
) {
function printProxyStatus() {
if (config.proxyUrl === "") {
Expand Down Expand Up @@ -148,7 +200,11 @@ export function startRepl(

// completer is typed as readonly in @types/node but is writable at runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(replServer as any).completer = createCompleter(registry, builtinCompleter);
(replServer as any).completer = createCompleter(
registry,
builtinCompleter,
scenarioRegistry,
);

replServer.defineCommand("counterfact", {
action() {
Expand Down Expand Up @@ -214,5 +270,75 @@ export function startRepl(
openApiDocument,
);

replServer.context.routes = {};

replServer.defineCommand("apply", {
async action(text: string) {
const parts = text.trim().split("/").filter(Boolean);

if (parts.length === 0) {
print("usage: .apply <path>");
this.clearBufferedCommand();
this.displayPrompt();
return;
}

if (parts.some((part) => part === ".." || part === ".")) {
print("Error: Path must not contain '.' or '..' segments");
this.clearBufferedCommand();
this.displayPrompt();
return;
}

const functionName = parts[parts.length - 1] ?? "";
const fileKey =
parts.length === 1 ? "index" : parts.slice(0, -1).join("/");

const module = scenarioRegistry?.getModule(fileKey);

if (module === undefined) {
print(`Error: Could not find scenario file "${fileKey}"`);
this.clearBufferedCommand();
this.displayPrompt();
return;
}

const fn = module[functionName];

if (typeof fn !== "function") {
print(
`Error: "${functionName}" is not a function exported from "${fileKey}"`,
);
this.clearBufferedCommand();
this.displayPrompt();
return;
}

try {
const applyContext = {
context: replServer.context["context"] as Record<string, unknown>,
loadContext: replServer.context["loadContext"] as (
path: string,
) => Record<string, unknown>,
route: replServer.context["route"] as (path: string) => unknown,
routes: replServer.context["routes"] as Record<string, unknown>,
};

await (fn as (ctx: typeof applyContext) => Promise<void> | void)(
applyContext,
);

print(`Applied ${text.trim()}`);
} catch (error) {
print(`Error: ${String(error)}`);
}

this.clearBufferedCommand();
this.displayPrompt();
},

help: 'apply a scenario script (".apply <path>" calls the named export from scenarios/)',
});

return replServer;
}
23 changes: 22 additions & 1 deletion src/server/context-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@ function cloneForCache(value: unknown): unknown {
return clone;
}

export class ContextRegistry {
export class ContextRegistry extends EventTarget {
private readonly entries = new Map<string, Context>();

private readonly cache = new Map<string, Context>();

private readonly seen = new Set<string>();

public constructor() {
super();
this.add("/", {});
}

Expand All @@ -67,6 +68,26 @@ export class ContextRegistry {
this.entries.set(path, context);

this.cache.set(path, cloneForCache(context) as Context);

this.dispatchEvent(new Event("context-changed"));
}

/**
* Removes the context entry for the given path and dispatches a
* "context-changed" event so that listeners (e.g. the scenario-context type
* generator) can regenerate type files in response to the removal.
*
* @param path - The route path whose context entry should be deleted
* (e.g. "/pets").
*/
public remove(path: string): void {
this.entries.delete(path);

this.cache.delete(path);

this.seen.delete(path);

this.dispatchEvent(new Event("context-changed"));
}

public find(path: string): Context {
Expand Down
Loading
Loading