@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
package/flows/sdd.ts ADDED
@@ -0,0 +1,181 @@
1
+ // Spec-driven development: write a specification, encode it as red tests, implement to green.
2
+ import { join } from "node:path"
3
+ import * as Effect from "effect/Effect"
4
+ import { makeChat } from "@llm4ts/flow/Chat"
5
+ import { FlowAborted } from "@llm4ts/flow/FlowError"
6
+ import { Plan, defaultPlanPath } from "@llm4ts/flow/Plan"
7
+ import { implementTaskLoop, stage } from "@llm4ts/flow/PlanExecution"
8
+ import { defaultPlanInstructions, planFrom, writeBrief } from "@llm4ts/flow/Planner"
9
+ import { makePlanStore } from "@llm4ts/flow/Persistence"
10
+ import {
11
+ lintCommand,
12
+ minimalReviewers,
13
+ reviewAndFixLoop,
14
+ type ReviewResult
15
+ } from "@llm4ts/flow/Review"
16
+ import { asReadOnly, coderFromEnv, gemini, withModel } from "@llm4ts/runner/Connectors"
17
+ import { resolveFlowInput } from "@llm4ts/runner/FlowArgs"
18
+ import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner"
19
+ import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore"
20
+ import { nodeProcessExecutor } from "@llm4ts/runner/NodeProcessExecutor"
21
+
22
+ const proModel = process.env.LLM4TS_REASONING_MODEL ?? "gemini-3-pro-preview"
23
+ const flashModel = process.env.LLM4TS_CODER_MODEL ?? "gemini-2.5-flash"
24
+
25
+ const specInstructions = [
26
+ "Turn the change request into a precise repository specification with context, goals,",
27
+ "non-goals, and a numbered list of testable Given/When/Then acceptance criteria.",
28
+ "Explore the repository as needed. Return Markdown prose, not a task list."
29
+ ].join("\n")
30
+
31
+ const planInstructions = [
32
+ defaultPlanInstructions,
33
+ "",
34
+ "The request below is a specification with numbered acceptance criteria.",
35
+ "The first task must encode those criteria as tests without changing production code.",
36
+ "Every later task implements production behavior toward making those tests pass."
37
+ ].join("\n")
38
+
39
+ const verificationFailure = (result: ReviewResult): FlowAborted =>
40
+ FlowAborted.make({
41
+ message: [
42
+ "acceptance criteria not met:",
43
+ ...result.issues.map((issue) => `${issue.title}\n${issue.description}`)
44
+ ].join("\n\n")
45
+ })
46
+
47
+ const program = Effect.gen(function* () {
48
+ const input = yield* resolveFlowInput(
49
+ "Add due dates, mark overdue items in list output, and show items due today."
50
+ )
51
+ const explicitCoder = process.env.LLM4TS_CODER?.trim()
52
+ const coder =
53
+ explicitCoder === undefined || explicitCoder.length === 0
54
+ ? withModel(gemini, flashModel)
55
+ : coderFromEnv(process.env)
56
+ const reasoning =
57
+ explicitCoder === undefined || explicitCoder.length === 0
58
+ ? asReadOnly(withModel(gemini, proModel))
59
+ : asReadOnly(coder)
60
+ const reviewer =
61
+ explicitCoder === undefined || explicitCoder.length === 0
62
+ ? asReadOnly(withModel(gemini, flashModel))
63
+ : asReadOnly(coder)
64
+ const store = makePlanStore(nodePlainFileStore)
65
+ const planPath = join(input.workDir, defaultPlanPath(input.prompt))
66
+
67
+ yield* runNode(
68
+ {
69
+ workDir: input.workDir,
70
+ workspace: input.workspace,
71
+ userPrompt: input.prompt,
72
+ coder,
73
+ reasoning,
74
+ reviewers: [reviewer],
75
+ environment: process.env
76
+ },
77
+ (context) =>
78
+ Effect.gen(function* () {
79
+ const plan = yield* stage(
80
+ context.events,
81
+ "specification and plan",
82
+ store.recoverOrCreate(
83
+ planPath,
84
+ Effect.gen(function* () {
85
+ const spec = yield* writeBrief(context.reasoning, input.prompt, specInstructions)
86
+ const planned = yield* planFrom(context.reasoning, spec, planInstructions)
87
+ return Plan.make({ ...planned, brief: spec })
88
+ })
89
+ )
90
+ )
91
+ const spec =
92
+ plan.brief === undefined || plan.brief.length === 0
93
+ ? yield* writeBrief(context.reasoning, input.prompt, specInstructions)
94
+ : plan.brief
95
+ const planWithSpec =
96
+ plan.brief === undefined || plan.brief.length === 0
97
+ ? Plan.make({ ...plan, brief: spec })
98
+ : plan
99
+ if (planWithSpec !== plan) {
100
+ yield* store.save(planPath, planWithSpec)
101
+ }
102
+
103
+ yield* stage(context.events, "branch", context.git.checkoutOrCreate(planWithSpec.epicId))
104
+ const specPath = join(input.workDir, `specs/${planWithSpec.epicId}.md`)
105
+ yield* stage(
106
+ context.events,
107
+ "commit specification",
108
+ nodePlainFileStore
109
+ .read(specPath)
110
+ .pipe(
111
+ Effect.flatMap((existing) =>
112
+ existing === undefined
113
+ ? nodePlainFileStore
114
+ .writeAtomic(specPath, `${spec}\n`)
115
+ .pipe(
116
+ Effect.andThen(
117
+ context.git.commitAll(`${planWithSpec.epicId}: specification`)
118
+ ),
119
+ Effect.asVoid
120
+ )
121
+ : Effect.void
122
+ )
123
+ )
124
+ )
125
+
126
+ const coderChat = yield* makeChat(context.coder, {
127
+ system:
128
+ "Implement one task at a time. The committed specification is the contract; do not weaken its tests."
129
+ })
130
+ const testGate = lintCommand(
131
+ nodeProcessExecutor,
132
+ context.events,
133
+ ["mvn", "-q", "test"],
134
+ input.workDir
135
+ )
136
+ const compileGate = lintCommand(
137
+ nodeProcessExecutor,
138
+ context.events,
139
+ ["mvn", "-q", "test-compile"],
140
+ input.workDir
141
+ )
142
+ const firstTitle = planWithSpec.tasks[0]?.title
143
+
144
+ yield* implementTaskLoop(store, context.events, planPath, planWithSpec, (task) =>
145
+ Effect.gen(function* () {
146
+ const testsTask = task.title === firstTitle
147
+ yield* coderChat.ask(planWithSpec.taskPrompt(task))
148
+ yield* reviewAndFixLoop({
149
+ reviewers: minimalReviewers,
150
+ reviewerService: context.reviewers[0] ?? context.reasoning,
151
+ coder: coderChat,
152
+ taskTitle: task.title,
153
+ currentDiff: context.git.diffAll,
154
+ events: context.events,
155
+ lint: testsTask ? compileGate : testGate,
156
+ parallelism: 1
157
+ })
158
+ if (testsTask) {
159
+ const red = yield* testGate
160
+ if (red.isClean) {
161
+ return yield* FlowAborted.make({
162
+ message:
163
+ "the new tests pass before implementation; the specification is not encoded by a red test"
164
+ })
165
+ }
166
+ }
167
+ yield* context.git.commitAll(`${planWithSpec.epicId}: ${task.title}`)
168
+ })
169
+ )
170
+ yield* stage(
171
+ context.events,
172
+ "verify acceptance criteria",
173
+ Effect.flatMap(testGate, (result) =>
174
+ result.isClean ? Effect.void : Effect.fail(verificationFailure(result))
175
+ )
176
+ )
177
+ })
178
+ )
179
+ })
180
+
181
+ runFlowMain(program)
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@llm4ts/shell",
3
+ "version": "0.2.0",
4
+ "description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "bin": {
12
+ "llm4ts": "./dist/cli-main.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "flows"
18
+ ],
19
+ "exports": {
20
+ "./Cli": "./dist/Cli.js",
21
+ "./CoderChoice": "./dist/CoderChoice.js",
22
+ "./FlowCatalog": "./dist/FlowCatalog.js",
23
+ "./FlowLaunch": "./dist/FlowLaunch.js",
24
+ "./Menu": "./dist/Menu.js",
25
+ "./Package": "./dist/Package.js",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "scripts": {
29
+ "clean": "tsc -b tsconfig.json --clean",
30
+ "test": "vitest run test"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/riccardomerolla/llm4ts.git",
39
+ "directory": "packages/shell"
40
+ },
41
+ "homepage": "https://github.com/riccardomerolla/llm4ts#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/riccardomerolla/llm4ts/issues"
44
+ },
45
+ "keywords": [
46
+ "llm",
47
+ "effect",
48
+ "ai",
49
+ "agents",
50
+ "workflow",
51
+ "cli",
52
+ "typescript"
53
+ ],
54
+ "dependencies": {
55
+ "@effect/platform-node": "^4.0.0-beta.102",
56
+ "@llm4ts/core": "workspace:*",
57
+ "@llm4ts/flow": "workspace:*",
58
+ "@llm4ts/runner": "workspace:*"
59
+ },
60
+ "peerDependencies": {
61
+ "effect": "^4.0.0-beta.102"
62
+ },
63
+ "devDependencies": {
64
+ "effect": "^4.0.0-beta.102"
65
+ }
66
+ }
package/src/Cli.ts ADDED
@@ -0,0 +1,230 @@
1
+ import { homedir } from "node:os"
2
+ import { fileURLToPath } from "node:url"
3
+ import * as Console from "effect/Console"
4
+ import * as Effect from "effect/Effect"
5
+ import { FileSystem } from "effect/FileSystem"
6
+ import * as Schema from "effect/Schema"
7
+ import * as Argument from "effect/unstable/cli/Argument"
8
+ import * as Command from "effect/unstable/cli/Command"
9
+ import * as Flag from "effect/unstable/cli/Flag"
10
+ import { makeCliProgram } from "@llm4ts/runner/Cli"
11
+ import { makeDoctorProgram } from "@llm4ts/runner/Doctor"
12
+ import {
13
+ defaultTierPaths,
14
+ discoverFlows,
15
+ type DiscoveredFlow,
16
+ type FlowTierPaths
17
+ } from "./FlowCatalog.ts"
18
+ import { launchFlow } from "./FlowLaunch.ts"
19
+ import { mainMenu } from "./Menu.ts"
20
+ import { packageVersion } from "./Package.ts"
21
+
22
+ export class ShellUsageError extends Schema.TaggedErrorClass<ShellUsageError>()("ShellUsage", {
23
+ message: Schema.String
24
+ }) {}
25
+
26
+ export class ShellActionError extends Schema.TaggedErrorClass<ShellActionError>()("ShellAction", {
27
+ message: Schema.String
28
+ }) {}
29
+
30
+ export const builtinFlowsDir = (): string => fileURLToPath(new URL("../flows", import.meta.url))
31
+
32
+ export const shellTierPaths = (options?: {
33
+ readonly cwd?: string
34
+ readonly environment?: Readonly<Record<string, string | undefined>>
35
+ }): FlowTierPaths =>
36
+ defaultTierPaths({
37
+ cwd: options?.cwd ?? process.cwd(),
38
+ homeDir: homedir(),
39
+ environment: options?.environment ?? process.env,
40
+ builtinDir: builtinFlowsDir()
41
+ })
42
+
43
+ const tierLabel = (flow: DiscoveredFlow): string => {
44
+ const shadows = flow.shadows.length === 0 ? "" : ` shadows ${flow.shadows.join(", ")}`
45
+ return `[${flow.tier}${shadows}]`
46
+ }
47
+
48
+ export const renderFlowList = (
49
+ flows: ReadonlyArray<DiscoveredFlow>,
50
+ options?: { readonly json?: boolean }
51
+ ): string => {
52
+ if (options?.json === true) {
53
+ return JSON.stringify(
54
+ flows.map((flow) => ({
55
+ name: flow.name,
56
+ tier: flow.tier,
57
+ path: flow.path,
58
+ ...(flow.description === undefined ? {} : { description: flow.description }),
59
+ shadows: flow.shadows
60
+ })),
61
+ undefined,
62
+ 2
63
+ )
64
+ }
65
+ const nameWidth = flows.reduce((width, flow) => Math.max(width, flow.name.length), 0)
66
+ return flows
67
+ .map((flow) =>
68
+ [
69
+ flow.name.padEnd(nameWidth),
70
+ tierLabel(flow),
71
+ ...(flow.description === undefined ? [] : [flow.description])
72
+ ].join(" ")
73
+ )
74
+ .join("\n")
75
+ }
76
+
77
+ /**
78
+ * Resolves a flow argument to a runnable script path: an explicit path (a
79
+ * value containing a separator or ending in `.ts`) is used as-is, anything
80
+ * else is looked up by name in the discovery listing.
81
+ */
82
+ export const resolveFlow = Effect.fn("@llm4ts/shell/Cli.resolveFlow")(function* (
83
+ reference: string,
84
+ tiers: FlowTierPaths
85
+ ) {
86
+ if (reference.includes("/") || reference.endsWith(".ts")) {
87
+ const fs = yield* FileSystem
88
+ const exists = yield* fs.exists(reference).pipe(Effect.orElseSucceed(() => false))
89
+ if (!exists) {
90
+ return yield* new ShellUsageError({ message: `flow script not found: ${reference}` })
91
+ }
92
+ return reference
93
+ }
94
+ const flows = yield* discoverFlows(tiers)
95
+ const found = flows.find((flow) => flow.name === reference)
96
+ if (found === undefined) {
97
+ const known = flows.map((flow) => flow.name).join(", ")
98
+ return yield* new ShellUsageError({
99
+ message:
100
+ known.length === 0
101
+ ? `unknown flow '${reference}' (no flows discovered)`
102
+ : `unknown flow '${reference}' (known flows: ${known})`
103
+ })
104
+ }
105
+ return found.path
106
+ })
107
+
108
+ const runCommand = Command.make(
109
+ "run",
110
+ {
111
+ flow: Argument.string("flow").pipe(
112
+ Argument.withDescription("Flow name from `llm4ts list`, or a path to a flow script")
113
+ ),
114
+ task: Argument.string("task").pipe(
115
+ Argument.variadic(),
116
+ Argument.withDescription("Task text passed to the flow")
117
+ ),
118
+ verbose: Flag.boolean("verbose").pipe(Flag.withDescription("Stream verbose flow output"))
119
+ },
120
+ (config) =>
121
+ Effect.gen(function* () {
122
+ const tiers = shellTierPaths()
123
+ const flowPath = yield* resolveFlow(config.flow, tiers)
124
+ const environment: Record<string, string | undefined> = { ...process.env }
125
+ if (config.verbose) {
126
+ environment.LLM4TS_VERBOSITY = "verbose"
127
+ }
128
+ const exitCode = yield* launchFlow({
129
+ flowPath,
130
+ taskArgs: config.task,
131
+ environment
132
+ })
133
+ yield* Effect.sync(() => {
134
+ process.exitCode = exitCode
135
+ })
136
+ })
137
+ ).pipe(Command.withDescription("Run a discovered flow as a child process"))
138
+
139
+ const listCommand = Command.make(
140
+ "list",
141
+ {
142
+ json: Flag.boolean("json").pipe(Flag.withDescription("Emit the listing as JSON"))
143
+ },
144
+ (config) =>
145
+ Effect.gen(function* () {
146
+ const flows = yield* discoverFlows(shellTierPaths())
147
+ if (flows.length === 0 && !config.json) {
148
+ yield* Console.error("no flows discovered")
149
+ return
150
+ }
151
+ yield* Console.log(renderFlowList(flows, { json: config.json }))
152
+ })
153
+ ).pipe(Command.withDescription("List flows across the project, global, and built-in tiers"))
154
+
155
+ const viewCommand = Command.make(
156
+ "view",
157
+ {
158
+ flow: Argument.string("flow").pipe(
159
+ Argument.withDescription("Flow name from `llm4ts list`, or a path to a flow script")
160
+ )
161
+ },
162
+ (config) =>
163
+ Effect.gen(function* () {
164
+ const flowPath = yield* resolveFlow(config.flow, shellTierPaths())
165
+ const fs = yield* FileSystem
166
+ const source = yield* fs
167
+ .readFileString(flowPath)
168
+ .pipe(Effect.mapError(() => new ShellActionError({ message: `cannot read ${flowPath}` })))
169
+ yield* Console.log(source.trimEnd())
170
+ })
171
+ ).pipe(Command.withDescription("Print a flow's source"))
172
+
173
+ const askCommand = Command.make(
174
+ "ask",
175
+ {
176
+ prompt: Argument.string("prompt").pipe(
177
+ Argument.variadic(),
178
+ Argument.withDescription("Prompt streamed once to the selected coding agent")
179
+ ),
180
+ repo: Flag.string("repo").pipe(
181
+ Flag.optional,
182
+ Flag.withDescription("Repository to run against (defaults to the current directory)")
183
+ )
184
+ },
185
+ (config) =>
186
+ Effect.gen(function* () {
187
+ if (config.prompt.length === 0) {
188
+ return yield* new ShellUsageError({ message: "ask needs a prompt" })
189
+ }
190
+ const argv = [
191
+ config.prompt.join(" "),
192
+ ...(config.repo._tag === "Some" ? ["--repo", config.repo.value] : [])
193
+ ]
194
+ yield* makeCliProgram(argv).pipe(
195
+ Effect.mapError((error) =>
196
+ error._tag === "ScriptUsage"
197
+ ? new ShellUsageError({ message: error.message })
198
+ : new ShellActionError({ message: error.message })
199
+ )
200
+ )
201
+ })
202
+ ).pipe(Command.withDescription("Stream a one-shot prompt to the selected coding agent"))
203
+
204
+ const doctorCommand = Command.make("doctor", {}, () =>
205
+ Effect.gen(function* () {
206
+ const report = yield* makeDoctorProgram()
207
+ yield* Console.log(report.trimEnd())
208
+ })
209
+ ).pipe(Command.withDescription("Report available connectors and credentials"))
210
+
211
+ export const shellCommand = Command.make("llm4ts", {}, () =>
212
+ Effect.gen(function* () {
213
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
214
+ return yield* new ShellUsageError({
215
+ message: "no command given and no interactive terminal; try `llm4ts --help`"
216
+ })
217
+ }
218
+ yield* mainMenu(shellTierPaths())
219
+ })
220
+ ).pipe(
221
+ Command.withDescription(
222
+ "Interactive shell and CLI for llm4ts flows: discover, inspect, and run them."
223
+ ),
224
+ Command.withSubcommands([runCommand, listCommand, viewCommand, askCommand, doctorCommand])
225
+ )
226
+
227
+ export const runShellCommand = (
228
+ argv: ReadonlyArray<string>
229
+ ): Effect.Effect<void, unknown, Command.Environment> =>
230
+ Command.runWith(shellCommand, { version: packageVersion })(argv)
@@ -0,0 +1,58 @@
1
+ import { accessSync, constants } from "node:fs"
2
+ import { delimiter, join } from "node:path"
3
+
4
+ /**
5
+ * The coder tokens `coderFromEnv` accepts as `LLM4TS_CODER` values, each with
6
+ * the executable name the corresponding CLI connector spawns. The tokens are
7
+ * the connector identity vocabulary of `@llm4ts/core/Models` narrowed to the
8
+ * CLI coding agents the runner exposes.
9
+ */
10
+ export interface CoderChoice {
11
+ readonly token: string
12
+ readonly executable: string
13
+ }
14
+
15
+ export const coderChoices: ReadonlyArray<CoderChoice> = [
16
+ { token: "claude", executable: "claude" },
17
+ { token: "codex", executable: "codex" },
18
+ { token: "gemini", executable: "gemini" },
19
+ { token: "pi", executable: "pi" },
20
+ { token: "agy", executable: "agy" },
21
+ { token: "grok", executable: "grok" },
22
+ { token: "cursor", executable: "cursor-agent" },
23
+ { token: "opencode", executable: "opencode" }
24
+ ]
25
+
26
+ /**
27
+ * Locates an executable on `PATH` without spawning a process. Decoration
28
+ * only: an undetected CLI stays selectable, mirroring orca's wizard probing.
29
+ */
30
+ export const findOnPath = (
31
+ executable: string,
32
+ environment: Readonly<Record<string, string | undefined>> = process.env
33
+ ): string | undefined => {
34
+ const searchPath = environment.PATH
35
+ if (searchPath === undefined || searchPath.length === 0) {
36
+ return undefined
37
+ }
38
+ for (const directory of searchPath.split(delimiter)) {
39
+ if (directory.length === 0) {
40
+ continue
41
+ }
42
+ const candidate = join(directory, executable)
43
+ try {
44
+ accessSync(candidate, constants.X_OK)
45
+ return candidate
46
+ } catch {
47
+ continue
48
+ }
49
+ }
50
+ return undefined
51
+ }
52
+
53
+ export const resolvedCoderToken = (
54
+ environment: Readonly<Record<string, string | undefined>> = process.env
55
+ ): string => {
56
+ const token = environment.LLM4TS_CODER ?? environment.LLM4ZIO_CODER ?? "claude"
57
+ return coderChoices.some((choice) => choice.token === token) ? token : "claude"
58
+ }
@@ -0,0 +1,119 @@
1
+ import { join } from "node:path"
2
+ import * as Effect from "effect/Effect"
3
+ import { FileSystem } from "effect/FileSystem"
4
+
5
+ export type FlowTier = "project" | "global" | "builtin"
6
+
7
+ export interface FlowTierPaths {
8
+ readonly project?: string
9
+ readonly global?: string
10
+ readonly builtin?: string
11
+ }
12
+
13
+ export interface DiscoveredFlow {
14
+ readonly name: string
15
+ readonly path: string
16
+ readonly tier: FlowTier
17
+ readonly description?: string
18
+ readonly shadows: ReadonlyArray<FlowTier>
19
+ }
20
+
21
+ /**
22
+ * The first non-empty `//` comment line within the file's leading block of
23
+ * blank lines and `//` comments is the flow's one-line description. A bare or
24
+ * whitespace-only `//` line is skipped, never returned as an empty
25
+ * description; the scan stops at the first line that is neither blank nor a
26
+ * `//` comment.
27
+ */
28
+ export const parseFlowDescription = (source: string): string | undefined => {
29
+ for (const line of source.split("\n")) {
30
+ const trimmed = line.trim()
31
+ if (trimmed.length === 0) {
32
+ continue
33
+ }
34
+ if (!trimmed.startsWith("//")) {
35
+ return undefined
36
+ }
37
+ const text = trimmed.slice(2).trim()
38
+ if (text.length > 0) {
39
+ return text
40
+ }
41
+ }
42
+ return undefined
43
+ }
44
+
45
+ export const defaultTierPaths = (options: {
46
+ readonly cwd: string
47
+ readonly homeDir: string
48
+ readonly environment?: Readonly<Record<string, string | undefined>>
49
+ readonly builtinDir?: string
50
+ }): FlowTierPaths => {
51
+ const environment = options.environment ?? process.env
52
+ const configHome =
53
+ environment.XDG_CONFIG_HOME !== undefined && environment.XDG_CONFIG_HOME.length > 0
54
+ ? environment.XDG_CONFIG_HOME
55
+ : join(options.homeDir, ".config")
56
+ return {
57
+ project: join(options.cwd, ".llm4ts", "flows"),
58
+ global: join(configHome, "llm4ts", "flows"),
59
+ ...(options.builtinDir === undefined ? {} : { builtin: options.builtinDir })
60
+ }
61
+ }
62
+
63
+ const tierOrder: ReadonlyArray<FlowTier> = ["project", "global", "builtin"]
64
+
65
+ const listTier = Effect.fn("@llm4ts/shell/FlowCatalog.listTier")(function* (
66
+ tier: FlowTier,
67
+ directory: string
68
+ ) {
69
+ const fs = yield* FileSystem
70
+ const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []))
71
+ const flows: Array<Omit<DiscoveredFlow, "shadows">> = []
72
+ for (const entry of [...entries].sort()) {
73
+ if (!entry.endsWith(".ts")) {
74
+ continue
75
+ }
76
+ const path = join(directory, entry)
77
+ const source = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => ""))
78
+ const description = parseFlowDescription(source)
79
+ flows.push({
80
+ name: entry.slice(0, -".ts".length),
81
+ path,
82
+ tier,
83
+ ...(description === undefined ? {} : { description })
84
+ })
85
+ }
86
+ return flows
87
+ })
88
+
89
+ /**
90
+ * Discovers flows across the project, global, and built-in tiers. Precedence
91
+ * is project > global > builtin, keyed by flow name; a shadowed tier is
92
+ * recorded on the winning flow's `shadows` list. Missing or unreadable
93
+ * directories contribute nothing.
94
+ */
95
+ export const discoverFlows = Effect.fn("@llm4ts/shell/FlowCatalog.discoverFlows")(function* (
96
+ tiers: FlowTierPaths
97
+ ) {
98
+ const byName = new Map<
99
+ string,
100
+ { flow: Omit<DiscoveredFlow, "shadows">; shadows: Array<FlowTier> }
101
+ >()
102
+ for (const tier of tierOrder) {
103
+ const directory = tiers[tier]
104
+ if (directory === undefined) {
105
+ continue
106
+ }
107
+ for (const flow of yield* listTier(tier, directory)) {
108
+ const existing = byName.get(flow.name)
109
+ if (existing === undefined) {
110
+ byName.set(flow.name, { flow, shadows: [] })
111
+ } else {
112
+ existing.shadows.push(tier)
113
+ }
114
+ }
115
+ }
116
+ return [...byName.values()]
117
+ .map(({ flow, shadows }): DiscoveredFlow => ({ ...flow, shadows }))
118
+ .sort((left, right) => left.name.localeCompare(right.name))
119
+ })