@llm4ts/shell 0.2.0

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.
Files changed (46) hide show
  1. package/dist/Cli.d.ts +33 -0
  2. package/dist/Cli.d.ts.map +1 -0
  3. package/dist/Cli.js +148 -0
  4. package/dist/Cli.js.map +1 -0
  5. package/dist/CoderChoice.d.ts +18 -0
  6. package/dist/CoderChoice.d.ts.map +1 -0
  7. package/dist/CoderChoice.js +41 -0
  8. package/dist/CoderChoice.js.map +1 -0
  9. package/dist/FlowCatalog.d.ts +37 -0
  10. package/dist/FlowCatalog.d.ts.map +1 -0
  11. package/dist/FlowCatalog.js +86 -0
  12. package/dist/FlowCatalog.js.map +1 -0
  13. package/dist/FlowLaunch.d.ts +36 -0
  14. package/dist/FlowLaunch.d.ts.map +1 -0
  15. package/dist/FlowLaunch.js +86 -0
  16. package/dist/FlowLaunch.js.map +1 -0
  17. package/dist/Menu.d.ts +9 -0
  18. package/dist/Menu.d.ts.map +1 -0
  19. package/dist/Menu.js +101 -0
  20. package/dist/Menu.js.map +1 -0
  21. package/dist/Package.d.ts +3 -0
  22. package/dist/Package.d.ts.map +1 -0
  23. package/dist/Package.js +7 -0
  24. package/dist/Package.js.map +1 -0
  25. package/dist/ResolveFallback.d.ts +2 -0
  26. package/dist/ResolveFallback.d.ts.map +1 -0
  27. package/dist/ResolveFallback.js +29 -0
  28. package/dist/ResolveFallback.js.map +1 -0
  29. package/dist/cli-main.d.ts +3 -0
  30. package/dist/cli-main.d.ts.map +1 -0
  31. package/dist/cli-main.js +39 -0
  32. package/dist/cli-main.js.map +1 -0
  33. package/flows/implement.ts +38 -0
  34. package/flows/issue-pr.ts +108 -0
  35. package/flows/judge-suite.ts +63 -0
  36. package/flows/local.ts +59 -0
  37. package/flows/sdd.ts +181 -0
  38. package/package.json +66 -0
  39. package/src/Cli.ts +230 -0
  40. package/src/CoderChoice.ts +58 -0
  41. package/src/FlowCatalog.ts +119 -0
  42. package/src/FlowLaunch.ts +99 -0
  43. package/src/Menu.ts +123 -0
  44. package/src/Package.ts +11 -0
  45. package/src/ResolveFallback.ts +31 -0
  46. package/src/cli-main.ts +48 -0
@@ -0,0 +1,99 @@
1
+ import { spawn } from "node:child_process"
2
+ import { existsSync } from "node:fs"
3
+ import { constants } from "node:os"
4
+ import { dirname, join, resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Schema from "effect/Schema"
8
+
9
+ export class FlowLaunchError extends Schema.TaggedErrorClass<FlowLaunchError>()("FlowLaunch", {
10
+ message: Schema.String
11
+ }) {}
12
+
13
+ /**
14
+ * True when the flow script cannot resolve `@llm4ts/runner` from its own
15
+ * location — a global flow, a built-in copy, or a bare repository. Project
16
+ * flows inside a repository that installs `@llm4ts/*` resolve their own pin
17
+ * and need no help (the project-wins policy of ADR 0006). Implemented as the
18
+ * same `node_modules` directory walk ESM resolution performs, so the check
19
+ * stays honest under test runners that patch the module system.
20
+ */
21
+ export const needsResolveFallback = (flowPath: string): boolean => {
22
+ let directory = dirname(resolve(flowPath))
23
+ while (true) {
24
+ if (existsSync(join(directory, "node_modules", "@llm4ts", "runner", "package.json"))) {
25
+ return false
26
+ }
27
+ const parent = dirname(directory)
28
+ if (parent === directory) {
29
+ return true
30
+ }
31
+ directory = parent
32
+ }
33
+ }
34
+
35
+ // In the published package the hook is the compiled sibling; when running
36
+ // from src (tests), it is the TypeScript source, which the child can load
37
+ // because it always runs with type stripping enabled.
38
+ const resolveFallbackUrl = (() => {
39
+ const compiled = new URL("./ResolveFallback.js", import.meta.url)
40
+ return existsSync(fileURLToPath(compiled))
41
+ ? compiled.href
42
+ : new URL("./ResolveFallback.ts", import.meta.url).href
43
+ })()
44
+
45
+ export interface LaunchFlowOptions {
46
+ readonly flowPath: string
47
+ readonly taskArgs: ReadonlyArray<string>
48
+ readonly environment?: Readonly<Record<string, string | undefined>>
49
+ readonly cwd?: string
50
+ /** Overrides `LLM4TS_CODER` in the child for this run only. */
51
+ readonly coder?: string
52
+ /** `inherit` shares the terminal (the default); tests use `ignore`. */
53
+ readonly stdio?: "inherit" | "ignore"
54
+ }
55
+
56
+ /**
57
+ * Runs a flow script as a child `node` process with type stripping, the
58
+ * terminal inherited, and the task text appended to argv. While the child
59
+ * runs, SIGINT is ignored in the parent so Ctrl-C reaches the whole
60
+ * foreground process group and the shell survives to resume. Resolves with
61
+ * the child's exit code (`128 + signal` when terminated by a signal).
62
+ */
63
+ export const launchFlow = (options: LaunchFlowOptions): Effect.Effect<number, FlowLaunchError> =>
64
+ Effect.callback<number, FlowLaunchError>((resume) => {
65
+ const environment = { ...(options.environment ?? process.env) }
66
+ if (options.coder !== undefined) {
67
+ environment.LLM4TS_CODER = options.coder
68
+ }
69
+ const nodeArgs = ["--experimental-strip-types"]
70
+ if (needsResolveFallback(options.flowPath)) {
71
+ nodeArgs.push("--import", resolveFallbackUrl)
72
+ }
73
+ const child = spawn(process.execPath, [...nodeArgs, options.flowPath, ...options.taskArgs], {
74
+ stdio: options.stdio ?? "inherit",
75
+ env: environment,
76
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd })
77
+ })
78
+ const ignoreSigint = (): void => {}
79
+ process.on("SIGINT", ignoreSigint)
80
+ const restoreSigint = (): void => {
81
+ process.removeListener("SIGINT", ignoreSigint)
82
+ }
83
+ child.on("error", (error) => {
84
+ restoreSigint()
85
+ resume(Effect.fail(new FlowLaunchError({ message: error.message })))
86
+ })
87
+ child.on("exit", (code, signal) => {
88
+ restoreSigint()
89
+ if (signal !== null) {
90
+ resume(Effect.succeed(128 + (constants.signals[signal] ?? 15)))
91
+ } else {
92
+ resume(Effect.succeed(code ?? 1))
93
+ }
94
+ })
95
+ return Effect.sync(() => {
96
+ restoreSigint()
97
+ child.kill("SIGINT")
98
+ })
99
+ })
package/src/Menu.ts ADDED
@@ -0,0 +1,123 @@
1
+ import * as Console from "effect/Console"
2
+ import * as Effect from "effect/Effect"
3
+ import { FileSystem } from "effect/FileSystem"
4
+ import * as Prompt from "effect/unstable/cli/Prompt"
5
+ import { coderChoices, findOnPath, resolvedCoderToken } from "./CoderChoice.ts"
6
+ import { discoverFlows, type DiscoveredFlow, type FlowTierPaths } from "./FlowCatalog.ts"
7
+ import { launchFlow } from "./FlowLaunch.ts"
8
+
9
+ type MenuAction = "run" | "view" | "exit"
10
+
11
+ const flowChoice = (flow: DiscoveredFlow): Prompt.SelectChoice<DiscoveredFlow> => {
12
+ const shadows = flow.shadows.length === 0 ? "" : ` (shadows ${flow.shadows.join(", ")})`
13
+ return {
14
+ title: `${flow.name} [${flow.tier}]${shadows}`,
15
+ value: flow,
16
+ ...(flow.description === undefined ? {} : { description: flow.description })
17
+ }
18
+ }
19
+
20
+ const selectFlow = Effect.fn("@llm4ts/shell/Menu.selectFlow")(function* (tiers: FlowTierPaths) {
21
+ const flows = yield* discoverFlows(tiers)
22
+ if (flows.length === 0) {
23
+ yield* Console.error("no flows discovered — add scripts under .llm4ts/flows/")
24
+ return undefined
25
+ }
26
+ return yield* Prompt.run(
27
+ Prompt.select({
28
+ message: "Which flow?",
29
+ choices: flows.map(flowChoice)
30
+ })
31
+ )
32
+ })
33
+
34
+ const selectCoder = Effect.fn("@llm4ts/shell/Menu.selectCoder")(function* (
35
+ environment: Readonly<Record<string, string | undefined>>
36
+ ) {
37
+ const resolved = resolvedCoderToken(environment)
38
+ const choices: Array<Prompt.SelectChoice<string | undefined>> = [
39
+ {
40
+ title: `environment default (${resolved})`,
41
+ value: undefined,
42
+ description: "Use LLM4TS_CODER as-is"
43
+ },
44
+ ...coderChoices.map(
45
+ (choice): Prompt.SelectChoice<string | undefined> => ({
46
+ title:
47
+ findOnPath(choice.executable, environment) === undefined
48
+ ? choice.token
49
+ : `${choice.token} ✓ found`,
50
+ value: choice.token
51
+ })
52
+ )
53
+ ]
54
+ return yield* Prompt.run(Prompt.select({ message: "Which coding agent?", choices }))
55
+ })
56
+
57
+ const runFlowInteractive = Effect.fn("@llm4ts/shell/Menu.runFlowInteractive")(function* (
58
+ tiers: FlowTierPaths
59
+ ) {
60
+ const flow = yield* selectFlow(tiers)
61
+ if (flow === undefined) {
62
+ return
63
+ }
64
+ if (flow.description !== undefined) {
65
+ yield* Console.log(`${flow.name}: ${flow.description}`)
66
+ }
67
+ const task = yield* Prompt.run(
68
+ Prompt.text({ message: "Task text (empty uses the flow's default)" })
69
+ )
70
+ const coder = yield* selectCoder(process.env)
71
+ const exitCode = yield* launchFlow({
72
+ flowPath: flow.path,
73
+ taskArgs: task.trim().length === 0 ? [] : [task],
74
+ ...(coder === undefined ? {} : { coder })
75
+ })
76
+ yield* exitCode === 0
77
+ ? Console.log(`${flow.name} completed`)
78
+ : Console.error(`${flow.name} exited with code ${exitCode}`)
79
+ })
80
+
81
+ const viewFlowInteractive = Effect.fn("@llm4ts/shell/Menu.viewFlowInteractive")(function* (
82
+ tiers: FlowTierPaths
83
+ ) {
84
+ const flow = yield* selectFlow(tiers)
85
+ if (flow === undefined) {
86
+ return
87
+ }
88
+ const fs = yield* FileSystem
89
+ const source = yield* fs.readFileString(flow.path).pipe(Effect.orElseSucceed(() => ""))
90
+ yield* Console.log(source.trimEnd())
91
+ })
92
+
93
+ /**
94
+ * The interactive main menu: run a flow, view a flow, exit. Ctrl-C inside a
95
+ * prompt quits the menu cleanly rather than crashing the shell.
96
+ */
97
+ export const mainMenu = Effect.fn("@llm4ts/shell/Menu.mainMenu")(function* (tiers: FlowTierPaths) {
98
+ while (true) {
99
+ const action = yield* Prompt.run(
100
+ Prompt.select<MenuAction>({
101
+ message: "llm4ts shell",
102
+ choices: [
103
+ { title: "Run a flow", value: "run" },
104
+ { title: "View a flow", value: "view" },
105
+ { title: "Exit", value: "exit" }
106
+ ]
107
+ })
108
+ ).pipe(Effect.catchTag("QuitError", () => Effect.succeed<MenuAction>("exit")))
109
+ switch (action) {
110
+ case "run": {
111
+ yield* runFlowInteractive(tiers).pipe(Effect.catchTag("QuitError", () => Effect.void))
112
+ break
113
+ }
114
+ case "view": {
115
+ yield* viewFlowInteractive(tiers).pipe(Effect.catchTag("QuitError", () => Effect.void))
116
+ break
117
+ }
118
+ case "exit": {
119
+ return
120
+ }
121
+ }
122
+ }
123
+ })
package/src/Package.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { createRequire } from "node:module"
2
+ import * as Schema from "effect/Schema"
3
+
4
+ const Manifest = Schema.Struct({ version: Schema.String })
5
+
6
+ const manifest = Schema.decodeUnknownSync(Manifest)(
7
+ createRequire(import.meta.url)("../package.json")
8
+ )
9
+
10
+ export const packageName = "@llm4ts/shell"
11
+ export const packageVersion: string = manifest.version
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Module-resolution fallback registered in flow child processes via
3
+ * `--import`. Resolution is tried normally first, so a project-local
4
+ * `node_modules` with its own `@llm4ts/*` pin always wins; only when a bare
5
+ * specifier fails to resolve from the flow's own location is it retried
6
+ * anchored at this file — the shell's installation, whose dependencies
7
+ * include `@llm4ts/*` and `effect`. See ADR 0006.
8
+ */
9
+ import { registerHooks } from "node:module"
10
+
11
+ const anchor = import.meta.url
12
+
13
+ const isBareSpecifier = (specifier: string): boolean =>
14
+ !specifier.startsWith(".") &&
15
+ !specifier.startsWith("/") &&
16
+ !specifier.startsWith("file:") &&
17
+ !specifier.startsWith("node:") &&
18
+ !specifier.startsWith("data:")
19
+
20
+ registerHooks({
21
+ resolve(specifier, context, nextResolve) {
22
+ try {
23
+ return nextResolve(specifier, context)
24
+ } catch (error) {
25
+ if (isBareSpecifier(specifier) && context.parentURL !== anchor) {
26
+ return nextResolve(specifier, { ...context, parentURL: anchor })
27
+ }
28
+ throw error
29
+ }
30
+ }
31
+ })
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ import * as NodeServices from "@effect/platform-node/NodeServices"
3
+ import * as Effect from "effect/Effect"
4
+ import { runShellCommand } from "./Cli.ts"
5
+
6
+ const exitCodeFor = (error: unknown): number => {
7
+ if (typeof error === "object" && error !== null && "_tag" in error) {
8
+ switch (error._tag) {
9
+ case "ShellUsage":
10
+ case "ShowHelp":
11
+ return 2
12
+ default:
13
+ return 1
14
+ }
15
+ }
16
+ return 1
17
+ }
18
+
19
+ const errorMessage = (error: unknown): string | undefined => {
20
+ if (typeof error === "object" && error !== null && "_tag" in error) {
21
+ if (error._tag === "ShowHelp") {
22
+ // Help (with any parse errors) was already rendered by the command runner.
23
+ return undefined
24
+ }
25
+ if ("message" in error && typeof error.message === "string") {
26
+ return error.message
27
+ }
28
+ }
29
+ return String(error)
30
+ }
31
+
32
+ Effect.runFork(
33
+ runShellCommand(process.argv.slice(2)).pipe(
34
+ Effect.match({
35
+ onFailure: (error) =>
36
+ Effect.sync(() => {
37
+ const message = errorMessage(error)
38
+ if (message !== undefined) {
39
+ process.stderr.write(`llm4ts: ${message}\n`)
40
+ }
41
+ process.exitCode = exitCodeFor(error)
42
+ }),
43
+ onSuccess: () => Effect.void
44
+ }),
45
+ Effect.flatten,
46
+ Effect.provide(NodeServices.layer)
47
+ )
48
+ )