Bladeren bron

feat(cli): add shared interact command

Ma 7 maanden geleden
bovenliggende
commit
810e76898d

+ 113 - 0
packages/cli/src/__tests__/interact-command.test.ts

@@ -0,0 +1,113 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createProgram } from "../program.js";
+
+describe("interact command", () => {
+  const originalArgv = process.argv;
+  let projectRoot: string;
+  let stdoutSpy: ReturnType<typeof vi.spyOn> | {
+    mockClear: () => void;
+    mock: { calls: Array<ReadonlyArray<unknown>> };
+  };
+
+  beforeEach(async () => {
+    stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-interact-cli-"));
+    await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
+    stdoutSpy.mockClear();
+    process.argv = originalArgv;
+  });
+
+  afterEach(() => {
+    process.argv = originalArgv;
+    vi.restoreAllMocks();
+  });
+
+  it("routes natural language through the shared executor and prints plain text by default", async () => {
+    const runInteraction = vi.fn(async () => ({
+      request: { intent: "write_next" },
+      responseText: "Continuing harbor.",
+      session: {
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [{ role: "assistant", content: "Continuing harbor.", timestamp: 1 }],
+        events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
+      },
+    }));
+
+    const program = createProgram({
+      runInteraction,
+      readInteractionInput: async () => "",
+    });
+
+    await program.parseAsync(["interact", "continue", "--book", "harbor"], {
+      from: "user",
+    });
+
+    expect(runInteraction).toHaveBeenCalledWith(
+      expect.objectContaining({
+        projectRoot: process.cwd(),
+        input: "continue",
+        activeBookId: "harbor",
+      }),
+    );
+    expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining("Continuing harbor."));
+  });
+
+  it("emits structured JSON when --json is used", async () => {
+    const runInteraction = vi.fn(async () => ({
+      request: { intent: "switch_mode", mode: "auto" },
+      responseText: "Switched to auto.",
+      session: {
+        activeBookId: "harbor",
+        automationMode: "auto",
+        messages: [],
+        events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
+      },
+    }));
+
+    const program = createProgram({
+      runInteraction,
+      readInteractionInput: async () => "",
+    });
+
+    await program.parseAsync(["interact", "切换到全自动", "--book", "harbor", "--json"], {
+      from: "user",
+    });
+
+    const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join("");
+    const parsed = JSON.parse(output);
+    expect(parsed.request.intent).toBe("switch_mode");
+    expect(parsed.responseText).toBe("Switched to auto.");
+    expect(parsed.session.automationMode).toBe("auto");
+  });
+
+  it("reads the message from stdin when no args are provided", async () => {
+    const runInteraction = vi.fn(async () => ({
+      request: { intent: "explain_status" },
+      responseText: "Explaining status.",
+      session: {
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      },
+    }));
+
+    const program = createProgram({
+      runInteraction,
+      readInteractionInput: async () => "why did it stop?",
+    });
+
+    await program.parseAsync(["interact"], { from: "user" });
+
+    expect(runInteraction).toHaveBeenCalledWith(
+      expect.objectContaining({
+        input: "why did it stop?",
+      }),
+    );
+  });
+});

+ 102 - 0
packages/cli/src/commands/interact.ts

@@ -0,0 +1,102 @@
+import { Command } from "commander";
+import {
+  processProjectInteractionInput,
+  type InteractionRuntimeTools,
+} from "@actalk/inkos-core";
+import { createInteractionTools } from "../tui/tools.js";
+
+export interface InteractCommandHooks {
+  readonly runInteraction?: (params: {
+    readonly projectRoot: string;
+    readonly input: string;
+    readonly activeBookId?: string;
+    readonly tools: InteractionRuntimeTools;
+  }) => Promise<{
+    readonly request: unknown;
+    readonly responseText?: string;
+    readonly session: {
+      readonly automationMode: string;
+      readonly activeBookId?: string;
+      readonly currentExecution?: unknown;
+      readonly pendingDecision?: unknown;
+      readonly messages: ReadonlyArray<unknown>;
+      readonly events: ReadonlyArray<unknown>;
+    };
+  }>;
+  readonly createTools?: (projectRoot: string) => Promise<InteractionRuntimeTools>;
+  readonly readInput?: () => Promise<string>;
+}
+
+async function readInteractionInput(
+  args: ReadonlyArray<string>,
+  readInput?: () => Promise<string>,
+): Promise<string> {
+  const inline = args.join(" ").trim();
+  if (inline) {
+    return inline;
+  }
+
+  if (readInput) {
+    const provided = (await readInput()).trim();
+    if (provided) {
+      return provided;
+    }
+  }
+
+  if (!process.stdin.isTTY) {
+    const chunks: Buffer[] = [];
+    for await (const chunk of process.stdin) {
+      chunks.push(chunk as Buffer);
+    }
+    const piped = Buffer.concat(chunks).toString("utf-8").trim();
+    if (piped) {
+      return piped;
+    }
+  }
+
+  throw new Error("Interaction message is required. Pass text arguments or pipe input via stdin.");
+}
+
+export function createInteractCommand(hooks: InteractCommandHooks = {}): Command {
+  return new Command("interact")
+    .description("Run a shared natural-language interaction against the current project")
+    .argument("[message...]", "Natural-language message")
+    .option("--book <bookId>", "Bind a specific active book for this interaction")
+    .option("--json", "Emit structured JSON for external agents")
+    .action(async (messageArgs: ReadonlyArray<string>, opts) => {
+      const input = await readInteractionInput(messageArgs, hooks.readInput);
+      const projectRoot = process.cwd();
+      const tools = hooks.createTools
+        ? await hooks.createTools(projectRoot)
+        : hooks.runInteraction
+          ? ({} as InteractionRuntimeTools)
+          : await createInteractionTools(projectRoot);
+      const runInteraction = hooks.runInteraction ?? processProjectInteractionInput;
+      const result = await runInteraction({
+        projectRoot,
+        input,
+        activeBookId: opts.book,
+        tools,
+      });
+
+      if (opts.json) {
+        process.stdout.write(`${JSON.stringify({
+          request: result.request,
+          responseText: result.responseText,
+          session: result.session,
+          currentExecution: result.session.currentExecution ?? null,
+          pendingDecision: result.session.pendingDecision ?? null,
+          events: result.session.events,
+        }, null, 2)}\n`);
+        return;
+      }
+
+      const text = result.responseText
+        ?? (result.session.messages.at(-1) && "content" in (result.session.messages.at(-1) as Record<string, unknown>)
+          ? String((result.session.messages.at(-1) as Record<string, unknown>).content)
+          : "");
+      if (text) {
+        process.stdout.write(`${text}\n`);
+      }
+    });
+}

+ 7 - 0
packages/cli/src/program.ts

@@ -26,6 +26,7 @@ import { importCommand } from "./commands/import.js";
 import { fanficCommand } from "./commands/fanfic.js";
 import { fanficCommand } from "./commands/fanfic.js";
 import { studioCommand } from "./commands/studio.js";
 import { studioCommand } from "./commands/studio.js";
 import { consolidateCommand } from "./commands/consolidate.js";
 import { consolidateCommand } from "./commands/consolidate.js";
+import { createInteractCommand, type InteractCommandHooks } from "./commands/interact.js";
 import { createTuiCommand } from "./commands/tui.js";
 import { createTuiCommand } from "./commands/tui.js";
 import { launchTui } from "./tui/app.js";
 import { launchTui } from "./tui/app.js";
 
 
@@ -34,6 +35,8 @@ const { version } = require("../package.json") as { version: string };
 
 
 export interface ProgramHooks {
 export interface ProgramHooks {
   readonly launchTui?: (projectRoot: string) => Promise<void> | void;
   readonly launchTui?: (projectRoot: string) => Promise<void> | void;
+  readonly runInteraction?: InteractCommandHooks["runInteraction"];
+  readonly readInteractionInput?: InteractCommandHooks["readInput"];
 }
 }
 
 
 export function createProgram(hooks: ProgramHooks = {}): Command {
 export function createProgram(hooks: ProgramHooks = {}): Command {
@@ -78,6 +81,10 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
   program.addCommand(fanficCommand);
   program.addCommand(fanficCommand);
   program.addCommand(studioCommand);
   program.addCommand(studioCommand);
   program.addCommand(consolidateCommand);
   program.addCommand(consolidateCommand);
+  program.addCommand(createInteractCommand({
+    runInteraction: hooks.runInteraction,
+    readInput: hooks.readInteractionInput,
+  }));
   program.addCommand(createTuiCommand({ launchTui: hooks.launchTui }));
   program.addCommand(createTuiCommand({ launchTui: hooks.launchTui }));
 
 
   return program;
   return program;