@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.
- package/dist/Cli.d.ts +33 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +148 -0
- package/dist/Cli.js.map +1 -0
- package/dist/CoderChoice.d.ts +18 -0
- package/dist/CoderChoice.d.ts.map +1 -0
- package/dist/CoderChoice.js +41 -0
- package/dist/CoderChoice.js.map +1 -0
- package/dist/FlowCatalog.d.ts +37 -0
- package/dist/FlowCatalog.d.ts.map +1 -0
- package/dist/FlowCatalog.js +86 -0
- package/dist/FlowCatalog.js.map +1 -0
- package/dist/FlowLaunch.d.ts +36 -0
- package/dist/FlowLaunch.d.ts.map +1 -0
- package/dist/FlowLaunch.js +86 -0
- package/dist/FlowLaunch.js.map +1 -0
- package/dist/Menu.d.ts +9 -0
- package/dist/Menu.d.ts.map +1 -0
- package/dist/Menu.js +101 -0
- package/dist/Menu.js.map +1 -0
- package/dist/Package.d.ts +3 -0
- package/dist/Package.d.ts.map +1 -0
- package/dist/Package.js +7 -0
- package/dist/Package.js.map +1 -0
- package/dist/ResolveFallback.d.ts +2 -0
- package/dist/ResolveFallback.d.ts.map +1 -0
- package/dist/ResolveFallback.js +29 -0
- package/dist/ResolveFallback.js.map +1 -0
- package/dist/cli-main.d.ts +3 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +39 -0
- package/dist/cli-main.js.map +1 -0
- package/flows/implement.ts +38 -0
- package/flows/issue-pr.ts +108 -0
- package/flows/judge-suite.ts +63 -0
- package/flows/local.ts +59 -0
- package/flows/sdd.ts +181 -0
- package/package.json +66 -0
- package/src/Cli.ts +230 -0
- package/src/CoderChoice.ts +58 -0
- package/src/FlowCatalog.ts +119 -0
- package/src/FlowLaunch.ts +99 -0
- package/src/Menu.ts +123 -0
- package/src/Package.ts +11 -0
- package/src/ResolveFallback.ts +31 -0
- 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
|
+
})
|
package/src/cli-main.ts
ADDED
|
@@ -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
|
+
)
|