Lisp code toolkit for structural analysis and editing.
- Parse: Parse Lisp source code to JSON AST
- Validate: Check syntax and report errors/warnings
- Lint: Stable rule ids with text and JSON reports (duplicates, redefinitions, eval hazards, reader portability)
- Find: Find form at specific line/column
- Extract: Extract forms in a range
- Top-level: List top-level forms
- Delete-form: Delete form by position or index
- Insert-form: Insert code before a form
- Append-form: Insert code after a form
- Insert: Insert text at exact cursor position
- Replace-form: Replace form at position
- Move: Move form from one position to another
- Balance: Analyze parenthesis/bracket balance
- Format: Reformat source with consistent indentation
make buildProduces build/cl-toolkit.
All commands accept input from three sources (in priority order):
--code- Inline code string--file- Path to a file- stdin - Piped input
Examples:
# From file
cl-toolkit parse --file myfile.lisp
# From inline code
cl-toolkit parse --code "(+ 1 2)"
# From stdin
echo "(+ 1 2)" | cl-toolkit parse
cat myfile.lisp | cl-toolkit validateAll --line and --col values are 0-indexed. Line 0 is the first line, column 0 is the first character.
# Line 0, Col 0 = first character of file
cl-toolkit find --file demo.lisp --line 0 --col 0
# Line 2, Col 1 = third line, second character
cl-toolkit find --file demo.lisp --line 2 --col 1# Parse
cl-toolkit parse --file myfile.lisp
cl-toolkit parse --code "(+ 1 2)"
echo "(+ 1 2)" | cl-toolkit parse
# Validate
cl-toolkit validate --file myfile.lisp
echo "(defun foo () (bar))" | cl-toolkit validate
# Find form at position
cl-toolkit find --file myfile.lisp --line 5 --col 2
cl-toolkit find --code "(defun foo () (bar))" --line 1 --col 2
# List top-level forms
cl-toolkit top-level --file myfile.lisp
cl-toolkit top-level --file myfile.lisp --names # Show names with indices
echo "(defun foo () (bar))" | cl-toolkit top-level
# Delete form
cl-toolkit delete-form --file myfile.lisp --line 5 --col 2
cl-toolkit delete-form --file myfile.lisp --index 0
cl-toolkit delete-form --file myfile.lisp --name bar # Delete by name
cl-toolkit delete-form --file myfile.lisp --name bar --preview # Show diff first
echo "(defun foo ()) (defun bar ())" | cl-toolkit delete-form --index 0
# Insert code before a form
cl-toolkit insert-form --file myfile.lisp --line 5 --col 2 --insert "(new-form)"
# Insert code after a form
cl-toolkit append-form --file myfile.lisp --line 5 --col 2 --insert "(new-form)"
cl-toolkit append-form --file myfile.lisp --end --insert "(new-form)" # At end of file
cl-toolkit append-form --file myfile.lisp --name foo --insert "(new-form)" # After named form
# Insert text at exact position
cl-toolkit insert --file myfile.lisp --line 5 --col 10 --insert " ; comment"
# Replace form
cl-toolkit replace-form --file myfile.lisp --line 5 --col 2 --replace "(replaced-form)"
cl-toolkit replace-form --file myfile.lisp --index 0 --replace "(replaced)" --pretty # Preserve indentation
cl-toolkit replace-form --file myfile.lisp --name bar --replace "(defun bar () 42)" # By name
cl-toolkit replace-form --file myfile.lisp --name bar --replace "..." --preview # Show diff
cl-toolkit replace-form --file myfile.lisp --index 0 --replace "(replaced)" --write # In-place
# Batch operations (multiple edits at once)
cl-toolkit batch-replace --file myfile.lisp --edits '[{"operation":"delete-index","index":1}]'
cl-toolkit batch-replace --file myfile.lisp --edits '[{"operation":"replace-index","index":0,"code":"(new-form)"}]' --write
# Move form
cl-toolkit move-form --file myfile.lisp --from-line 5 --from-col 2 --to-line 10 --to-col 2
# Note: Cannot move a form into itself (source inside dest or vice versa)
# Check balance
cl-toolkit balance --file myfile.lisp
echo "(+ 1 2)" | cl-toolkit balance
# Format source
cl-toolkit format --file myfile.lisp
echo "(defun foo(x) (+ x 1))" | cl-toolkit formatThese commands work with Lisp forms (s-expressions):
| Command | Description |
|---|---|
insert-form |
Insert code before a form at position |
append-form |
Insert code after a form at position |
replace-form |
Replace form at position with new code |
delete-form |
Delete form by position or index |
# insert-form: adds before the form
cl-toolkit insert-form --file demo.lisp --line 0 --col 0 --insert "(defun bar () 2)
"
# Result:
# (defun bar () 2)
# (defun foo (x) (+ x 1))
# append-form: adds after the form
cl-toolkit append-form --file demo.lisp --line 0 --col 0 --insert "
(defun baz () 3)"
# Result:
# (defun foo (x) (+ x 1))
# (defun baz () 3)| Command | Description |
|---|---|
insert |
Insert text at exact cursor position |
# insert: adds text at exact position (no form detection)
cl-toolkit insert --file demo.lisp --line 0 --col 12 --insert " ; comment"
# Result:
# (defun foo (x) ; comment) (+ x 1))| Command | Description |
|---|---|
parse |
Parse Lisp code to JSON AST |
validate |
Check syntax and report errors |
find |
Find form at position |
extract |
Extract forms in a range |
top-level |
List top-level forms |
move-form |
Move form from one position to another (preserves indentation, validates no self-reference) |
balance |
Analyze parenthesis balance |
format |
Reformat source with consistent indentation |
move-form moves a form from one position to after another position. The source form is deleted from its original location and inserted on a new line after the destination.
# Move cond clause 1 after clause 2
echo '(cond
((= x 1) "one")
((= x 2) "two"))' | cl-toolkit move-form --from-line 1 --from-col 2 --to-line 2 --to-col 2
# Result:
# (cond
# ((= x 2) "two")
# ((= x 1) "one"))Key behaviors:
- Indentation preserved — moved form keeps its original indentation relative to siblings
- Spacing matched — blank lines between top-level forms are preserved
- Dest promotion — if coordinates point inside a nested expression, the dest is auto-promoted to the nearest sibling form
- Ancestor validation — returns error if source and dest are in a parent-child relationship
Limitations:
- Cannot reorder elements within a list (e.g., let bindings). Use
replace-forminstead - Moves always insert after the destination. To move before a form, point to the preceding form
replace-form replaces the innermost form at the given position with new code.
# Replace a single form
echo '(defun foo () (bar))' | cl-toolkit replace-form --line 1 --col 14 --replace '(baz)'
# Result:
# (defun foo () (baz))Key behaviors:
- Single form matching — matches the smallest form containing the target position
- Position-based — uses line/col to find the form to replace
- Validation — validates input and result by default
Limitations:
-
Single form only —
replace-formreplaces exactly one form at the given position. If you provide replacement code containing multiple forms, only the matched form is replaced; the rest of your replacement code is inserted in its place, but the original trailing content (closing parens, etc.) remains.# Example: trying to insert new cond clauses before the default clause echo '(cond ((= x 1) "one") ((= x 2) "two") (t stack))))' | cl-toolkit replace-form --line 4 --col 5 --replace '((string= u "MEAN") ...) (t stack))))' # WRONG: The tool matches only (t stack) and replaces it, leaving extra parens
-
Use for surgical edits — replace-form works best for replacing a single, well-bounded form with another single form. For inserting multiple forms, use
insert-formorappend-forminstead. -
Large form replacement — When replacing a large function (e.g., a
defun), the tool may match a sub-form rather than the entire function if the coordinates point inside it. To replace an entire top-level form, use the--indexapproach or ensure coordinates point to the opening paren.
Recommended workflows:
- Insert new clauses before an existing one: Use
insert-formto add before the target, notreplace-form - Replace an entire function: Point to the first line/column of the
defun, or usedelete-form+insert-form - Modify a single expression: Use
replace-formon the specific form
- Read-only commands (parse, validate, balance, etc.) output JSON to stdout
- Modification commands (delete-form, insert-form, append-form, replace-form, format, move-form) output plain text to stdout when not using
--write - With
--write, results are written to the file (creates.bakbackup) and a unified diff is printed to stdout - No changes — if the operation produces no actual changes, "No changes made." is printed instead
All modification commands validate both input and result by default:
- Input validation: Checks that the new code is valid Lisp before performing the operation
- Result validation: Checks that the modified source is valid after the operation
Note: The PEG parser handles standard Lisp syntax including backquote, reader macros, and special forms. If you encounter a validation failure, check that your code has balanced parentheses and correct syntax. Use --no-validate-input or --no-validate-result only as a last resort when you're certain the code is valid but the parser rejects it.
lint reports findings with stable rule ids, severities, and spans.
The default text report goes to stderr and exits 1 on any finding;
--format json emits {"ok":bool,"diagnostics":[...]} on stdout,
where each diagnostic carries rule, severity, line, col,
start, end, message, and fix. --rules selects a subset;
with no --rules every registered rule runs.
# Human report (duplicates only, legacy output preserved)
cl-toolkit lint --file myfile.lisp
# Machine report, all rules
cl-toolkit lint --file myfile.lisp --format json
# One rule
cl-toolkit lint --file myfile.lisp --format json --rules redefined-top-levelCurrent rules and severities:
| Rule | Severity | Finds |
|---|---|---|
syntax-error |
error | input that does not parse (only finding reported) |
duplicate-top-level |
warning | byte-identical top-level forms |
redefined-top-level |
warning | same (head . name) defined twice with different bodies |
empty-operator |
warning | () in operator position (calls NIL) |
eval-hazard |
warning | #. read-time eval and (eval ...) calls |
sharp-underscore-dispatch |
portability | #_, which this SBCL build rejects |
skipped-conditional-branch |
info | absent-feature branches the reader never reads |
[ ] { } are constituent characters, not list delimiters
([1] is the symbol |1|), so bracket imbalance is never reported.
# Rename a definition (or call operator); references elsewhere are untouched
cl-toolkit rename --file myfile.lisp --name foo --to bar --write
# Wrap a form atomically (both halves land together or neither does)
cl-toolkit wrap-form --file myfile.lisp --name foo --open '(progn ' --close ')' --write
# Unwrap a single-child list; multi-child and empty lists refuse
cl-toolkit unwrap-form --file myfile.lisp --name foo --writebatch-replace additionally accepts rename-name (:name + :to),
wrap-name (:name + :open + :close), and unwrap-name (:name).
A failing batch reports Batch edit N (OPERATION) failed: cause, and
nothing is written unless the whole batch succeeds.
# CI gate: exit 0 when stable, otherwise the diff on stdout and exit 1
cl-toolkit format --file myfile.lisp --check--check never writes; combining it with --write/--preview is an error.
Client controls newlines by including them in the insert code. This is intentional — the tool inserts code as-is without adding automatic newlines, giving the caller full control over formatting:
# Add newline before
cl-toolkit insert-form --file demo.lisp --line 0 --col 0 --insert "(defun bar () 2)
"
# Add newline after
cl-toolkit append-form --file demo.lisp --line 0 --col 0 --insert "
(defun baz () 3)"Without leading/trailing newlines in --insert, the code is inserted inline:
# Inline insertion (no newlines)
cl-toolkit insert-form --file demo.lisp --line 0 --col 12 --insert " ; comment"Simple text insertion at exact position:
# Insert at column 12
cl-toolkit insert --file demo.lisp --line 0 --col 12 --insert " ; comment"# 1. Clone
git clone git@github.com:turtle-bazon/cl-toolkit.git ~/cl-toolkit
# 2. Install opencode plugin
mkdir -p ~/.config/opencode/tools
cp ~/cl-toolkit/opencode/tools/cl-toolkit.ts ~/.config/opencode/tools/
# 3. Update plugin to use repo path
sed -i "s|path.resolve(__dirname, \"../../build/cl-toolkit\")|\"$HOME/cl-toolkit/build/cl-toolkit\"|" ~/.config/opencode/tools/cl-toolkit.ts
# 4. Add to opencode config (path is expanded during creation)
mkdir -p ~/.config/opencode
cat > ~/.config/opencode/opencode.json << EOF
{
"tools": {
"cl-toolkit": {
"path": "${HOME}/.config/opencode/tools/cl-toolkit.ts"
}
}
}
EOFThe binary is not built automatically — run make build in the repo
first (requires SBCL + Quicklisp). The plugin checks for the binary at
call time and reports the exact command to run if missing. Re-run
setup.sh after updating the repo so the plugin snapshot stays current.
Linux/macOS:
cd /path/to/your/project
~/cl-toolkit/setup.sh .opencodeWindows (PowerShell):
cd C:\path\to\your\project
C:\cl-toolkit\setup.ps1 .opencodeCross-platform (Node.js):
cd /path/to/your/project
node ~/cl-toolkit/setup.js .opencodeTell the agent:
Use cl-toolkit to parse/validate/edit Lisp code. Commands: parse, validate, lint, find, extract, top-level, source-of, find-forms, delete-form, insert-form, append-form, insert, replace-form, rename, wrap-form, unwrap-form, move-form, balance, format.
Example prompts:
- "Parse this file and show me the top-level forms"
- "Find the form at line 5, column 2"
- "Validate this file for syntax errors"
- "Delete the form at line 10"
- "Insert
(new-form)before the form at line 5" - "Append
(new-form)after the form at line 5" - "Move the form from line 3 to after line 8"
- "Check parenthesis balance in this file"
- "Format this file with consistent indentation"
Never reconstruct a large function from memory. Extract exact current source first:
# 1. Read exact source
cl-toolkit source-of --file X.lisp --name dispatch-token > /tmp/form.lisp
# 2. Edit /tmp/form.lisp (any tool)
# 3. Write back by name
cl-toolkit replace-form --file X.lisp --name dispatch-token \
--replace "$(cat /tmp/form.lisp)" --writeChange one clause inside a large function without rewriting it:
# Replace smallest subform matching the snippet inside the named form
cl-toolkit replace-form --file X.lisp --name dispatch-token \
--match '(string= tok "?")' --replace '(string= tok "?" :test #'equal)'Exact trimmed matches are preferred; falls back to first contains-match. Every non-quiet edit reports which form was targeted with its resolved line/col — verify that output before trusting a write.
# Find every top-level form containing a snippet (catches duplicated bugs)
cl-toolkit find-forms --file src/*.lisp --contains "(nreverse args)" --with-sourceAll --line arguments and displayed [line N] values use 0-based,
newline-delimited counting. A value shown anywhere can be passed back
to --line verbatim.
Arguments containing #' and quoted lists are fragile in bash. Prefer
the reader-macro-free spellings — they are semantically identical:
# Fragile:
--replace "(member s '(:a) :test #'+)"
# Robust:
--replace "(member s (quote (:a)) :test (function +))"When using cl-toolkit with opencode, use the cl-toolkit plugin (not bash) for modification commands. The plugin generates unified diffs on success.
Correct (returns diff):
cl-toolkit({ command: "replace-form", filePath: "file.lisp", line: 5, col: 2, code: "(new-form)", write: true })Wrong (returns raw JSON):
cl-toolkit replace-form -f file.lisp --line 5 --col 2 --replace "(new-form)" --write- SBCL (Steel Bank Common Lisp)
- Quicklisp
- Make (for
make build) - Node.js (for cross-platform setup and opencode)
cl-toolkit/
├── cl-toolkit.asd # ASDF system definition
├── Makefile # Build targets
├── setup.sh # Setup script (Linux/macOS)
├── setup.ps1 # Setup script (Windows PowerShell)
├── setup.js # Setup script (Cross-platform Node.js)
├── README.md
├── src/ # Source files
│ ├── packages.lisp
│ ├── ast.lisp
│ ├── grammar.lisp
│ ├── parser.lisp
│ └── cli.lisp
├── test/ # Tests
│ ├── cl-toolkit-tests.asd
│ └── tests.lisp
└── opencode/ # Opencode plugin
└── tools/
└── cl-toolkit.ts
GPL-3.0