Summon
A code generator framework where generators are pure functions that return data, not side effects.
Write a generator once. Run it for real. Preview with --dry-run. Test without mocks. Same code, different interpreters.
generate: (answers) => writeFile(`src/${answers.name}.ts`, code)
Installation
bun add @canonical/summon
Using Generators
Summon discovers generators from installed packages automatically.
summon
summon component react
summon component react src/components/Button
summon component react --component-path=src/components/Button
summon component react src/components/Button --dry-run
summon component react src/components/Button --verbose
Every prompt becomes a CLI flag. Boolean prompts with default: true use the --no- prefix to disable. Generators may also define a positional argument for their primary input (like a path), allowing cleaner command invocations.
CLI Options
-d, --dry-run | Preview without writing files |
-y, --yes | Skip confirmation prompts and preview |
-v, --verbose | Show debug output |
--show-files | Show file contents in dry-run |
-l, --llm | LLM mode: dry-run with markdown output, no prompts, no stamps |
--format <type> | Output format: json (implies dry-run, no prompts, no stamps) |
--no-preview | Skip the file preview |
--no-generated-stamp | Disable generated file stamp comments |
LLM Usage: Summon treats LLMs as first-class CLI users. Use --llm for structured markdown output or --format json for machine-parseable JSON. Both imply --dry-run --yes --show-files --no-generated-stamp:
summon component react src/components/Button --llm
summon component react src/components/Button --format json
export SUMMON_LLM=1
summon component react src/components/Button
summon component react --help --llm
Shell Autocompletion
Summon supports TAB completion for Bash, Zsh, and Fish shells. Completions are dynamic - they automatically detect newly installed generator packages without needing to re-run setup.
summon --setup-completion
echo '. <(summon --completion)' >> ~/.zshrc
summon --completion >> ~/.summon-completion.sh
echo 'source ~/.summon-completion.sh' >> ~/.bash_profile
echo 'summon --completion-fish | source' >> ~/.config/fish/config.fish
After setup, restart your shell or source the config file. Then:
summon <TAB>
summon component <TAB>
summon component react <TAB>
summon component react --comp<TAB>
Completion features:
- Generator names - Navigate the command tree with TAB
- Generator flags - All prompts become completable flags
- Confirm prompts - Shows
--no-X for prompts with default: true
- Select/multiselect - Completes with available choices
- Path prompts - Filesystem path completion for prompts containing "path", "dir", "file", etc.
To remove autocompletion:
summon --cleanup-completion
Positional Arguments
Generators can define one prompt as a positional argument, allowing users to provide the primary input without a flag:
summon component react src/components/Button
summon component react --component-path=src/components/Button
Positional arguments also get filesystem path completion when using TAB:
summon component react src/comp<TAB>
Installing Generator Packages
Generator packages follow the naming convention summon-* or @scope/summon-*:
bun add @canonical/summon-component
summon component react
summon component svelte
Creating Generators
A generator is a pure function that takes answers and returns a Taskβa data structure describing what to do.
interface ModuleAnswers {
name: string;
withTests: boolean;
}
import type { GeneratorDefinition } from "@canonical/summon";
import { debug, info, writeFile, mkdir, sequence_, when } from "@canonical/summon";
import type { ModuleAnswers } from "./types.js";
export const generator = {
meta: {
name: "module",
description: "Creates a new module",
version: "0.1.0",
},
prompts: [
{ name: "name", type: "text", message: "Module name:", positional: true },
{ name: "withTests", type: "confirm", message: "Include tests?", default: true },
],
generate: (answers) => sequence_([
info(`Creating module: ${answers.name}`),
debug("Creating module directory"),
mkdir(`src/${answers.name}`),
debug("Creating index file"),
writeFile(`src/${answers.name}/index.ts`, `export const ${answers.name} = {};\n`),
when(answers.withTests, debug("Creating test file")),
when(answers.withTests,
writeFile(`src/${answers.name}/index.test.ts`, `test("works", () => {});\n`)
),
info(`Created module at src/${answers.name}`),
]),
} as const satisfies GeneratorDefinition<ModuleAnswers>;
export { generator } from "./generator.js";
export type { ModuleAnswers } from "./types.js";
import type { AnyGenerator } from "@canonical/summon";
import { generator as moduleGenerator } from "./module/index.js";
export const generators = {
"module": moduleGenerator,
} as const satisfies Record<string, AnyGenerator>;
Package Structure
Each generator should be split into three files for maintainability:
my-summon-package/
βββ package.json
βββ src/
β βββ index.ts # Package barrel - exports generators record
β βββ module/
β β βββ index.ts # Generator barrel
β β βββ types.ts # Answer types
β β βββ generator.ts # Generator definition
β βββ templates/ # EJS templates (optional)
βββ README.md
{
"name": "@myorg/summon-module",
"main": "src/index.ts",
"peerDependencies": {
"@canonical/summon": "workspace:*"
}
}
Local Development
For developing generators locally, link them to make them globally available:
cd /path/to/my-summon-package
bun link
npm link
summon module src/utils
Project-local packages (in ./node_modules) take precedence over globally linked ones, so you can override with project-specific versions.
Testing Generators
Because generators return data, testing is straightforwardβno mocks needed:
import { dryRun, getAffectedFiles } from "@canonical/summon";
import { generators } from "./index";
const generator = generators["module"];
test("creates expected files", () => {
const task = generator.generate({ name: "utils", withTests: true });
const { effects } = dryRun(task);
expect(getAffectedFiles(effects)).toEqual([
"src/utils/index.ts",
"src/utils/index.test.ts",
]);
});
test("skips test file when disabled", () => {
const task = generator.generate({ name: "utils", withTests: false });
const { effects } = dryRun(task);
expect(getAffectedFiles(effects)).not.toContain("src/utils/index.test.ts");
});
The dry-run interpreter maintains a virtual filesystem, so conditional logic based on exists() works correctly even without touching the disk.
The Monadic Pattern
Summon uses a monad to compose tasks. If you've used Promises, you already understand the core idea.
What's a Monad?
A monad is a design pattern for chaining operations that have some context (like "might fail" or "has effects"). Think of it as a pipeline where each step can:
- Transform values (
map) β Apply a function to the result
- Chain operations (
flatMap) β Use the result to create the next step
- Short-circuit on failure β Errors propagate automatically
fetchUser(id)
.then(user => fetchOrders(user.id))
.then(orders => orders.length)
.catch(handleError);
task(readFile("config.json"))
.map(content => JSON.parse(content))
.flatMap(config => writeFile(
config.outputPath,
generateCode(config)
))
.recover(err => pure(defaultConfig));
The Task Type
Task<A> represents a computation that:
- Describes effects (file I/O, shell commands, etc.)
- Eventually produces a value of type
A
- May fail with an error
type Task<A> =
| { _tag: "Pure"; value: A }
| { _tag: "Fail"; error: TaskError }
| { _tag: "Effect"; effect: Effect; cont: ... }
Composing Tasks
The TaskBuilder provides a fluent API for composition:
import { task, pure } from "@canonical/summon";
const pipeline = task(readFile("input.txt"))
.map(content => content.toUpperCase())
.flatMap(upper => writeFile("output.txt", upper))
.andThen(info("Done!"))
.recover(err => pure(void 0));
Key Operations
pure(value) | Wrap a value in a Task | pure(42) |
map(fn) | Transform the result | .map(x => x * 2) |
flatMap(fn) | Chain with another Task | .flatMap(x => writeFile(...)) |
andThen(task) | Sequence, discard previous | .andThen(info("next")) |
recover(fn) | Handle errors | .recover(e => pure(default)) |
tap(fn) | Side effect, keep value | .tap(x => debug(x)) |
Why Monads for Generators?
- Composable β Small tasks combine into complex workflows
- Predictable β Errors propagate without explicit handling at each step
- Testable β The pipeline is data; inspect it without running effects
- Declarative β Describe what to do, not how to do it
const scaffoldFeature = (name: string) =>
task(mkdir(`src/features/${name}`))
.andThen(template({
source: "templates/feature.ts.ejs",
dest: `src/features/${name}/index.ts`,
vars: { name },
}))
.andThen(when(config.withTests,
template({
source: "templates/test.ts.ejs",
dest: `src/features/${name}/index.test.ts`,
vars: { name },
})
))
.andThen(info(`Created feature: ${name}`))
.unwrap();
For a deeper dive into the "effects as data" philosophy, see Explanation.
Templating Engine
Summon uses EJS by default for template rendering, but supports custom templating engines via the TemplatingEngine interface.
Default (EJS)
import { template, templateDir } from "@canonical/summon";
template({
source: "templates/component.tsx.ejs",
dest: "src/components/<%= name %>.tsx",
vars: { name: "Button" },
});
Custom Engines
Implement TemplatingEngine to use Handlebars, Mustache, Nunjucks, or any other engine:
import type { TemplatingEngine } from "@canonical/summon";
import Handlebars from "handlebars";
import * as fs from "node:fs/promises";
const handlebarsEngine: TemplatingEngine = {
render(template, vars) {
return Handlebars.compile(template)(vars);
},
async renderAsync(template, vars) {
return Handlebars.compile(template)(vars);
},
async renderFile(templatePath, vars) {
const content = await fs.readFile(templatePath, "utf-8");
return Handlebars.compile(content)(vars);
},
};
template({
source: "templates/component.hbs",
dest: "src/components/{{name}}.tsx",
vars: { name: "Button" },
engine: handlebarsEngine,
});
Interface
interface TemplatingEngine {
render(template: string, vars: Record<string, unknown>): string;
renderAsync(template: string, vars: Record<string, unknown>): Promise<string>;
renderFile(templatePath: string, vars: Record<string, unknown>): Promise<string>;
}
The engine option is available on template(), templateDir(), renderString(), renderStringAsync(), and renderFile().
Documentation
License
GPL-3.0