Skip to content
Merged
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
18 changes: 12 additions & 6 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,9 @@ const REPL_DESCRIPTION = "Run and reconstruct one XMD entry in an interactive te
*
* The entry a person types may run Agent work, so this command configures the
* Agent the same way `xmd run` does: the five shared fields, with the same
* descriptions, defaults and environment precedence. Everything else a run
* configures is absent — this command renders no file, takes no document
* reference and writes no journal of its own, so an exec deadline, a props file
* and a trace each configure work it never performs.
* descriptions, defaults and environment precedence. Component directories use
* the same ordered include option. The command takes no document reference or
* props file and exposes no trace or execution deadline.
*
* The one option of its own is `--sidekick-agent`, because this command has a
* Sidekick and a run has none. It is a separate selection from
Expand All @@ -326,6 +325,7 @@ const replConfig = object({
"that retained history and the exact view it names",
...field(z.string().optional(), cli.argument()),
},
include: executionFields.include,
...agentFields,
sidekickAgent: {
description: "agent name for the Sidekick's own conversations",
Expand Down Expand Up @@ -597,6 +597,10 @@ const REPL_HELP = [
" Each one starts from the bindings the entries before it published, so a",
" later entry can read and revise what an earlier one left behind.",
"",
" --include <directory> and --include=<directory> select component directories.",
" Repeat them in search order; explicit values replace the default list [components, .].",
" Relative directories resolve from the invocation working directory.",
"",
" The screen always shows a location of the form",
" xmd://repl/<execution>/<surface>..., and when the command ends it prints the",
" whole xmd repl <location> that reopens the one it ended at. Running it",
Expand Down Expand Up @@ -652,14 +656,15 @@ export interface ReplHostAssembly {
* silence would let them believe they got it.
*/
/**
* The three REPL options that carry a value, by the spelling a caller writes.
* The four REPL options that carry a value, by the spelling a caller writes.
*
* Exported because the documentation check reads this command's own examples,
* and a token after one of these is that option's value rather than a location.
* A second roster beside this one would be a reader that disagreed with the
* command about what it had been handed.
*/
export const REPL_VALUES: readonly string[] = [
"--include",
"--agent-provider",
"--default-agent",
"--sidekick-agent",
Expand Down Expand Up @@ -716,7 +721,7 @@ export function replGrammarError(
return (
`unrecognized option for xmd repl: ${name} — xmd repl takes one optional location, ` +
"the agent options --agent-provider, --default-agent, --approve-all, --approve-reads " +
"and --deny-all, and --sidekick-agent for the Sidekick's own conversations"
"and --deny-all, --include for component directories, and --sidekick-agent for the Sidekick's own conversations"
);
}
if (positional.length > 1) {
Expand Down Expand Up @@ -2978,6 +2983,7 @@ function* dispatch(
// whether anybody answers to that name is the first explicit message's
// question rather than this command's.
const profile = yield* assembleReplProfile(replStack, plugins, {
includes: command.config.include,
...(command.config.sidekickAgent === undefined
? {}
: { sidekickAgent: command.config.sidekickAgent }),
Expand Down
15 changes: 5 additions & 10 deletions packages/cli/src/repl-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,6 @@ import { ReplEntryHost } from "./repl-entry-installation.ts";
import type { ReplEntryInstallation } from "./repl-entry-installation.ts";
import { renderSyntaxDocumentation, renderSyntaxMarkdown, syntaxSymbols } from "./syntax.ts";

/**
* Where a REPL entry looks for components.
*
* Fixed, because this command takes no include option: an entry is typed rather
* than named, so there is no document beside which a caller could have meant
* something else.
*/
const REPL_INCLUDES: readonly string[] = Object.freeze(["components", "."]);

/**
Expand All @@ -65,6 +58,7 @@ export interface ReplProfileSeams {
* parameter: a caller states what it knows and leaves out the rest.
*/
export interface ReplProfileFacts extends ReplProfileSeams {
readonly includes?: readonly string[];
/**
* The name `--sidekick-agent` carried, or none when the line named none.
*
Expand Down Expand Up @@ -201,6 +195,7 @@ export function* assembleReplProfile(
plugins: CommandPlugins,
facts: ReplProfileFacts = {},
): Operation<ReplExecutionProfile> {
const includes = Object.freeze([...(facts.includes ?? REPL_INCLUDES)]);
yield* installAgentProviderStack(stack, facts.acp);
// Settled here, with the stack and the Plugins, because it is a fact about
// the command a person invoked rather than about any conversation they later
Expand All @@ -212,7 +207,7 @@ export function* assembleReplProfile(
const plan = yield* planComponentDeclaration({
surface: "component",
...(facts.planWriterRoot === undefined ? {} : { planWriterRoot: facts.planWriterRoot }),
includes: REPL_INCLUDES,
includes,
// The command's own Plugin assembly, not a second installation of it: two
// would be two catalogs claiming the same names.
plugins,
Expand All @@ -234,8 +229,8 @@ export function* assembleReplProfile(
const entryInstallation = yield* ReplEntryHost.get();

return Object.freeze({
includes: REPL_INCLUDES,
vocabulary: entryVocabulary(REPL_INCLUDES, plugins),
includes,
vocabulary: entryVocabulary(includes, plugins),
// Read off the admitted assembly here, once, while it is in hand. Copied
// rather than referenced so nothing downstream can reach a Plugin value
// through the profile, and frozen because a profile is read and never
Expand Down
68 changes: 66 additions & 2 deletions packages/cli/tests/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import { runCli } from "@executablemd/test-support/launch";
import { cliRuntime, runCli, runShell, shellQuote } from "@executablemd/test-support/launch";
import { ensureDir, rm, writeTextFile } from "@effectionx/fs";
import { ensure, scoped } from "effection";
import type { Operation } from "effection";
import { randomUUID } from "node:crypto";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { useTempDirectory } from "@executablemd/test-support/temp";

function* useFixture<T>(
files: Record<string, string>,
Expand Down Expand Up @@ -248,6 +250,8 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false
// claim this row makes is unchanged — the command's own help describes what
// an execution does with the entries it is given — and it is now the
// behavior the mounted journeys actually prove.
expect(own.stdout).toContain("--include");
expect(own.stdout).toContain("explicit values replace the default list [components, .]");
expect(own.stdout).toContain("ENTRIES");
expect(own.stdout).toContain("An execution admits one entry at a time");
expect(own.stdout).toContain("the bindings the entries before it published");
Expand Down Expand Up @@ -293,7 +297,7 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false
expect(options).toEqual(described(run.stdout));
// Approve-reads is what an unstated line means, and the help says so.
expect(repl.stdout).toContain("ask for the rest (default)");
// The five are all of what it shares: this command adds no data directory,
// The Agent fields are shared alongside the include field: this command adds no data directory,
// and the one option it adds of its own is covered by C2 below.
expect(repl.stdout).not.toContain("--data-dir");
});
Expand All @@ -308,6 +312,11 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false
["repl", "--deny-all"],
["repl", "--agent-provider", "acpx"],
["repl", "--default-agent", "claude"],
["repl", "--include", "../first"],
["repl", "--include=../first"],
["repl", "--include", "../first", "--include=/absolute/second"],
["repl", "xmd://repl/kf39sla2/entries", "--include", "../first"],
["repl", "--include=../first", "xmd://repl/kf39sla2/entries"],
]) {
const ran = yield* runCli(accepted).join();
expect([accepted, ran.code]).toEqual([accepted, 1]);
Expand Down Expand Up @@ -341,6 +350,61 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false
expect(withLocation.stderr).toContain("at most one location");
});

it("CH13: missing include values refuse before terminal acquisition", function* () {
for (const args of [
["repl", "--include"],
["repl", "--include="],
["repl", "--include", ""],
["repl", "--include", "--deny-all"],
]) {
const ran = yield* runCli(args).join();
expect([args, ran.code, ran.stderr]).toEqual([
args,
1,
"xmd repl: --include requires a value\n",
]);
}
});

if (cliRuntime() === "deno") {
it("A7: the repository task accepts selected directories through the source CLI", function* () {
const root = yield* useTempDirectory("xmd-include-task-");
const first = path.join(root, "first");
yield* ensureDir(first);
const cacheInfo: unknown = JSON.parse((yield* runShell("deno info --json").expect()).stdout);
if (
typeof cacheInfo !== "object" ||
cacheInfo === null ||
!("denoDir" in cacheInfo) ||
typeof cacheInfo.denoDir !== "string"
) {
throw new Error("deno info supplied no prepared cache path");
}
const repository = fileURLToPath(new URL("../../../", import.meta.url));
for (const args of [
["--include", first],
[`--include=${first}`, "--include", first],
]) {
const ran = yield* runShell(
["deno", "task", "xmd", "repl", ...args].map(shellQuote).join(" "),
{
cwd: repository,
env: {
HOME: root,
XDG_DATA_HOME: path.join(root, "share"),
DENO_DIR: cacheInfo.denoDir,
},
},
).join();
expect(ran.code).toBe(1);
expect(ran.stderr).toContain(
"xmd repl: the REPL runs where a terminal is: it is not available over a pipe.",
);
expect(ran.stderr).not.toContain("unrecognized option");
}
});
}

it("C2: repl states its own Sidekick agent option, and names a value or refuses", function* () {
const repl = yield* runCli(["repl", "--help"]).expect();
const run = yield* runCli(["run", "--help"]).expect();
Expand Down
Loading
Loading