Skip to content

LOU-H: DX — zero-config, CLI scaffold & playground - #9

Merged
LinuxDevil merged 16 commits into
mainfrom
loushy/lou-h
Sep 28, 2026
Merged

LinuxDevil merged 16 commits into
mainfrom
loushy/lou-h

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

Summary

Implements all 12 tickets from LOU-H (Phase 2-3), collapsing Loushy's DX from three hand-wired objects to a single CLI command:

  • LOU-H1: createAgent({prompt, provider, tools?}) one-liner, additive to the existing AgentBuilder/AgentExecutor path.
  • LOU-H2/H3/H4/H5: a new create-loushy-agent CLI package — interactive prompts (name/provider/tools), non-interactive flag shortcuts, real project scaffolding (package.json/tsconfig.json/agent entry file, Loushy pinned via a packed tarball since the SDK isn't published), and provider-specific .env.example generation.
  • LOU-H6/H7/H8: loushy dev — a local HTTP server wrapping AgentExecutor with /health and /chat, a minimal no-reload chat UI, and hot-reload on config-file changes without restarting the server or dropping connections.
  • LOU-H9: a declarative YAML/JSON AgentSpec format (zod-validated) as an alternative to the TypeScript builder chain — loushy dev now loads configs through this format.
  • LOU-H10/H11: 5 forkable starter templates (support-bot, research-assistant, workflow-router, doc-qa, slack-notifier), each defaulting to a mock provider so they run with zero API keys, plus a gallery README covering every examples/ directory (including pre-existing ones from LOU-B6/LOU-E6).
  • LOU-H12: createAgent()/AgentExecutor.execute()/AgentBuilder now throw actionable errors naming the exact missing field and a corrective code snippet instead of generic "Cannot read property of undefined" failures.

A real gap found and fixed

QA didn't stop at reading the source — it ran the actual loushy dev binary against a mock-provider config and hit Provider 'mock' not found, even though every unit test passed (because each test file registered its own mock provider locally). Root cause went deeper than expected: package.json's sideEffects: false plus nothing in the loushy dev code path importing the providers module as a value (only as TypeScript types) meant tsup/esbuild tree-shook the registration entirely out of the shipped CLI bundle — and naively forcing the whole barrel to survive bundling would have pulled in OpenAIProvider/OllamaProvider, which statically import their optional peer SDKs and crash for anyone who hasn't installed them, defeating the zero-API-key goal.

Fixed by making src/providers/mock.ts (which has zero external dependencies) self-register at module load, scoping sideEffects to just that file, and adding a targeted side-effect import in specToAgent.ts — so the mock provider survives bundling without dragging in the real, dependency-heavy providers. Verified with a real CLI invocation: node bin/loushy.js dev <mock-config> --port 3941 now genuinely serves /health and returns real AgentExecutor output from /chat.

Validation results

  • Root: npx vitest run → 641 passing, 2 skipped. 3 known pre-existing provider-suite failures (missing optional peer deps) — unrelated to this epic. npx tsc --noEmit: clean.
  • packages/create-loushy-agent: 8/8 tests passing (including a real ~15s npm install && npm run build end-to-end integration test on a freshly scaffolded project in a disposable temp directory), tsc --noEmit clean.
  • QA independently verified hot-reload genuinely doesn't restart the server (same PID/port before and after a config edit) and that scaffolding with different --provider flags produces the correct .env.example var name for each (including OLLAMA_BASE_URL, corrected from the ticket's literal OLLAMA_HOST assumption to match LOU-F8's real convention).

🤖 Generated with Claude Code

LinuxDevil and others added 16 commits September 28, 2026 21:40
Adds src/createAgent.ts exposing createAgent({prompt, provider, tools}) ->
{send}, a thin composition of the existing AgentBuilder + ToolRegistry +
static AgentExecutor.execute(). AgentExecutor.execute() does not require
any repositories, so none are wired up here. tools is keyed by name
(Record<string, ToolDescriptor>) rather than an array, since ToolDescriptor
carries no name of its own and AgentConfig.tools is itself name-keyed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds packages/create-loushy-agent with its own package.json (bin:
create-loushy-agent -> bin/cli.js), a #!/usr/bin/env node bin/cli.js that
requires dist/index.js's exported main(), and src/index.ts using Node's
built-in util.parseArgs (no new dependency) for --help/--name/--provider
/--tools/--yes and a clear "Unknown flag: --xyz" message + exit 1 for
anything else. Bundled via its own tsup.config.ts (CJS only, matching the
root project's tsup style). Verified: --help exits 0 with usage on stdout;
an unrecognized flag exits 1 with a message on stderr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds packages/create-loushy-agent/src/prompts.ts exporting AnswerConfig
{name, provider, tools} and collectAnswers() built on the `prompts`
package (text for name, select for provider, multiselect for tools, with
http/github as starter tool choices). Wired into main(): a --yes flag, or
both --name and --provider given together, skips the interactive prompts
entirely and builds AnswerConfig from flags via the same validateAnswers()
guard collectAnswers() itself uses, so an invalid --provider value throws
naming the bad value and the allowed set.

Tested two ways: a direct unit test using prompts' own `prompts.inject()`
dependency-injection support (no TTY needed), and a spawned-process
integration test driving the real bin/cli.js with --name/--provider/--yes
flags and asserting on stdout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds packages/create-loushy-agent/src/template.ts: generateProject(dir,
answers) writes a generated package.json, tsconfig.json (mirroring the
root project's baseline compilerOptions), src/agent.ts (calling
createAgent() from LOU-H1 with the chosen provider via resolveProvider()
and any wired tools), and a .env.example (LOU-H5, see next commit).

The SDK isn't published to the npm registry, so a plain semver dependency
spec would not be installable from a project generated outside this
worktree. generateProject() instead `npm pack`s the SDK's own current
source tree and copies the resulting tarball alongside the generated
project, wiring a `file:./<tarball>` dependency - this is exact-version-
pinned (the tarball's version is read straight from the SDK's own
package.json) and genuinely installable from anywhere.

main() gained --dir/--tools shortcuts so generateProject() is directly
testable. Verified end-to-end for real: scaffolds into a fresh
os.tmpdir() temp directory (never inside this worktree) with
--name=test-agent --provider=openai --yes, then actually runs
`npm install && npm run build` inside it (exit 0), and diffs the
generated project's installed @loushy/build-ai-agent version against the
SDK's own root package.json version, confirming an exact match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PROVIDER_ENV_VARS in template.ts (added in the LOU-H4 commit) already
reuses LOU-F8's resolveProvider() env table verbatim (OPENAI_API_KEY,
ANTHROPIC_API_KEY, OLLAMA_BASE_URL - note: OLLAMA_BASE_URL, not
OLLAMA_HOST as the ticket's illustrative snippet assumed; LOU-F8's real
table uses OLLAMA_BASE_URL) and generateProject() already writes a
.env.example naming the right var. This commit adds the dedicated test:
scaffolds three projects (one per provider) into separate temp dirs via
the real CLI and greps each .env.example for the exact expected var name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds src/cli/dev.ts exporting startDevServer(configPath, port = 3737),
built on Node's built-in http module. GET /health -> 200 'ok'; POST /chat
parses the JSON body and calls the loaded agent's real send() (createAgent
from LOU-H1 under the hood, so this goes through the genuine static
AgentExecutor.execute() path), returning the ExecutionResult as JSON. Port
availability is checked up front via the server's 'error' listener -
EADDRINUSE rejects startDevServer() with a message naming the port instead
of crashing uncaught.

Config loading (deferred decision, judgment call): configPath is a small
ad-hoc JSON file ({name, prompt, provider: {type, model}, tools?}) for now.
LOU-H9 (declarative agent spec format) is the designed-for successor;
dev.ts is retrofitted to use loadSpec()+specToAgent() once that lands
(see the LOU-H9 commit) rather than duplicating config-loading logic.

Provider resolution reuses LOU-F8's resolveProvider() for real provider
types (openai/anthropic/ollama/openrouter). A non-real type (e.g. 'mock',
which is intentionally outside resolveProvider()'s env-var whitelist)
falls through to LLMProviderRegistry.create() directly - this is what
lets tests run the dev server end-to-end against MockLLMProvider without
real API credentials.

Added a "loushy" bin entry to the ROOT package.json (bin/loushy.js ->
dist/cli/dev.js's startDevServer), since the ticket's file path
(src/cli/dev.ts under the root project) points at the root package as the
natural home, not packages/create-loushy-agent.

Verified: server started against a sample JSON config using a
LLMProviderRegistry-registered mock provider; POST /chat returns the mock
provider's exact response; GET /health returns 200; starting a second
server on an already-bound port rejects with a "port N is already in use"
error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds src/cli/dev-ui/index.html: an input#msg, button#send and div#log,
with an inline script that fetch()es POST /chat and appends the reply to
the log div with no page reload. Wired a GET / route in dev.ts serving
this file with Content-Type: text/html.

Static HTML isn't something tsup bundles, so tsup.config.ts gained an
onSuccess hook that copies src/cli/dev-ui/ to dist/cli/dev-ui/ after each
build, matching where dev.ts's path.join(__dirname, 'dev-ui', ...) looks
for it at runtime.

Verified: GET / returns HTML containing id="msg"/id="send"/id="log"; the
page's own inline script targets fetch('/chat'); driving that exact same
POST /chat call (the realistic message round trip the page performs)
returns the mock provider's reply.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dev.ts now watches configPath via Node's built-in fs.watch. The current
agent lives in a mutable top-level holder ({agent}) so /chat always reads
the latest reloaded version without restarting the HTTP server or
dropping the listening port. On a reload failure (e.g. invalid JSON, or a
valid-JSON-but-missing-required-field edit), the error is logged and the
previous working agent is kept in the holder rather than crashing.

Verified: started the dev server, sent one message and recorded the mock
response, edited the config file's prompt (and swapped the registered
mock provider's response) on disk, waited 400ms for the fs.watch event,
sent a second message and confirmed it reflects the new config while
`handle.server` and the port are unchanged (no restart) - then wrote an
invalid edit (missing 'prompt') and confirmed the server keeps serving the
last-good config instead of crashing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds src/spec/: schema.ts (AgentSpec {name, prompt, provider: {type,
model}, tools?} plus a zod runtime schema mirroring it), loadSpec.ts
(loads .yaml/.yml via the new `yaml` dependency, or .json via JSON.parse,
by extension; validates via the zod schema and throws an Error naming
each invalid/missing field, e.g. "'prompt': Required"), and
specToAgent.ts (maps a validated spec to a live agent via createAgent()
from LOU-H1, resolving `tools` names against the SDK's real built-in
tools - http/current-date/day-name resolve directly; 'github'/'jira'
throw a clear, actionable error instead of silently registering a broken
tool, since GitHubTools/JiraTools are credentialed factories with no spec
field to source token/owner/repo or baseUrl/email/apiToken from).

Retrofit (LOU-H6 integration): dev.ts now loads configs via
loadSpec()+specToAgent() instead of its original ad-hoc JSON loader - the
natural integration point the epic calls out. The old shape
({name, prompt, provider, tools}) is a valid AgentSpec as-is, so every
LOU-H6/H7/H8 test config and test still passes unmodified. Provider
resolution (real types through LOU-F8's resolveProvider(), 'mock' and any
other LLMProviderRegistry-registered type via the registry directly) moved
from dev.ts into specToAgent.ts as resolveSpecProvider(), now the single
canonical implementation.

Tested: an authored .yaml spec and an equivalent createAgent()-built agent
(same prompt, same scripted MockLLMProvider) produce identical text,
finishReason and toolCalls for the same input; a spec missing 'prompt'
throws naming that field; an unrecognized tool name and a credentialed
tool (github/jira) with no config each throw a distinct, clear error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds 5 example agents under examples/ - support-bot, research-assistant,
workflow-router, doc-qa, slack-notifier - each with index.ts (built on
createAgent() from LOU-H1), a minimal package.json, and a README.md.

Every index.ts defaults to a free MockLLMProvider (from
src/providers/mock.ts) so it runs with zero setup and zero API cost, and
only switches to a real provider (via resolveProvider(), LOU-F8) when the
relevant env var is already set (OPENAI_API_KEY for support-bot/
workflow-router/slack-notifier, ANTHROPIC_API_KEY for research-assistant,
OLLAMA_BASE_URL for doc-qa) - spreading coverage across all three real
provider types rather than defaulting every example to the same one.
research-assistant also wires up the built-in http tool.

Added root package.json scripts (example:support-bot, etc., matching the
existing example:tracing:* / tsx convention from LOU-E6) and verified each
one runs to completion with exit 0 against its default mock provider.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds examples/README.md with one `## [dirname](./dirname)` section per
example directory - the 5 new LOU-H10 examples (doc-qa, research-
assistant, slack-notifier, support-bot, workflow-router) plus the
pre-existing tracing/ (LOU-E6) - each with a one-line description.

Deviation: OpenRouterProvider.examples.ts (LOU-B6) is a FILE directly
under examples/, not a directory (the ticket's phrasing assumed it had
been "relocated" into its own directory; it wasn't). It's called out in a
short note at the bottom of the README instead of a `## [dirname]`
heading, since the verification is specifically a directory-listing diff
and a heading for a non-directory would break that invariant.

Verified via examples/README.test.ts: extracts every `## [x](./x)`
heading's path and diffs it against the actual directory listing under
examples/, asserting an exact match (no stale, no missing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AgentExecutor.execute() gained validateExecuteOptions() - since
AgentExecutor is a static, instance-free API, execute() is the one entry
point where a missing required option can be caught up front, before it
fails deep inside runAgentLoop() with a generic "Cannot read properties of
undefined" TypeError (e.g. `provider.generate(...)` when `provider` was
never checked). Guards `provider`, `agent`, and `input` (all three are
non-optional on ExecuteOptions but were previously undereferenced until
used), each throwing a message naming the missing field plus a corrective
one-line snippet.

AgentBuilder's existing validateAgentConfig() (src/agent-types/validators.ts)
already guarded name/agentType with clear messages ("Agent name is
required" / "Agent type is required") - this commit adds a corrective
one-line snippet to each, without changing the existing message text
(both LOU-H12's new test and the pre-existing AgentBuilder.test.ts
assertions use substring matching, so nothing else needed updating).

Searched both files for other required-field dereferences lacking a
guard; none found beyond the above (tools/flows/settings/etc. are all
genuinely optional with safe defaults already in place).

Tested: omitting provider/agent/input from AgentExecutor.execute() each
throws naming that field with "Example: ..." corrective text; building an
agent with AgentBuilder.create().build() (no name, no type) throws both
"Agent name is required. Example: ..." and "Agent type is required.
Example: ...".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s work outside the test suite

QA found that `node bin/loushy.js dev <config-with-mock-provider>` failed
with "Provider 'mock' not found" - LLMProviderRegistry only ever learned
about 'mock' inside test files (beforeAll/beforeEach), never in any
production code path.

- src/providers/index.ts: register 'mock' -> MockLLMProvider alongside the
  real providers, using the same LLMProviderRegistry.register() call
  signature already used for openai/ollama/openrouter/anthropic.
- src/providers/mock.ts: also self-register 'mock' at module load. This is
  the registration that actually matters at runtime: mock.ts has zero
  external dependencies, so it's always safe to bundle eagerly, unlike
  providers/index.ts's barrel, which also statically imports
  OpenAIProvider/OllamaProvider/OpenRouterProvider - each of which imports
  its optional peer-dependency SDK ('@ai-sdk/openai', 'ollama-ai-provider',
  ...) at module scope. Forcing the whole barrel to survive bundling (tried
  first) crashed `loushy dev` with "Cannot find module '@ai-sdk/openai'"
  for users who never installed it, defeating the zero-API-key DX goal.
  Re-registering 'mock' is safe either way: LLMProviderRegistry.register()
  is a plain Map.set(), so it never throws on re-registration.
- src/spec/specToAgent.ts: side-effect import '../providers/mock' (not the
  whole barrel) so CLI bundles like dist/cli/dev.js - whose dependency
  graph otherwise only ever touches provider *types*, never providers as
  values - actually run this registration.
- package.json: scope "sideEffects" to src/providers/mock.ts so esbuild
  (tsup) doesn't tree-shake the registration call out of dist builds; all
  other modules stay tree-shakeable as before.

Verified: `node bin/loushy.js dev <mock-config>` now boots for real and
correctly serves /health and /chat. npx vitest run / npx tsc --noEmit pass
with the same pre-existing baseline in both the root package (641
passed/2 skipped, 3 known pre-existing provider-suite failures) and
packages/create-loushy-agent (8/8). LOU-H10 starter templates
(examples/support-bot etc.) still run correctly via tsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server.listen(port) with no host argument bound to 0.0.0.0 (all
interfaces), making the local dev tool reachable over the network by
default. Default startDevServer's host param to 127.0.0.1 and wire a
matching --host CLI flag (following bin/loushy.js's existing manual
flag-parsing pattern) for developers who want LAN access, e.g. testing
from a phone. Adds a test asserting the server binds to 127.0.0.1 by
default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
readBody() accumulated request body chunks indefinitely with no size
limit; combined with the dev server being network-reachable, an
oversized POST could exhaust memory on a long-running dev server. Cap
accumulation at 1MB (common Node.js body-size-limit convention): once
exceeded, stop growing the buffered body (still draining the stream to
avoid stalling the connection) and respond 413 Payload Too Large.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
execFileSync's shell: true already shell-quotes each array argument on
Windows, so manually wrapping dir in literal quotes double-quoted the
path. Pass dir unwrapped and let shell: true handle quoting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant