@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
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
|
+
})
|