Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e77a3b7
plan 52: mark in-progress
claude Apr 20, 2026
a9dbec7
plan 52: add archetype template library
claude Apr 20, 2026
484f199
plan 52: raise patch coverage on archetype paths
claude Apr 20, 2026
5fd7d3b
plan 52: address Copilot review
claude Apr 20, 2026
808350a
plan 52: rework scope to user-supplied archetypes + CLI
claude Apr 21, 2026
cc8b3c8
plan 52: replace embedded archetypes with user-supplied + CLI
claude Apr 21, 2026
1d01cce
plan 52: raise patch coverage on archetypes paths
claude Apr 21, 2026
8b5298a
plan 52: broaden e2e coverage of archetypes CLI
claude Apr 21, 2026
f956950
plan 52: address Copilot review
claude Apr 21, 2026
eef4693
plan 52: review fixes — reserved names, unified errors, fixtures
claude Apr 21, 2026
cf7e767
plan 52: cover loadArchetype raw-os fallback path
claude Apr 21, 2026
fb2f316
plan 52: reject escaping archetype-roots + doc accuracy
claude Apr 21, 2026
6802276
plan 52: constrain CLI resolver to project root
claude Apr 21, 2026
c9afab8
fix: handle empty archetype roots and dot-root schema detection
Copilot Apr 21, 2026
1efcf8c
test(cmd/mdsmith): add unit tests for all functions in main package
Copilot Apr 22, 2026
8d535cc
test: clarify parallel-unsafe comment on capture helpers
Copilot Apr 22, 2026
02cae35
docs(cli): add --no-follow-symlinks to check/fix table, document metr…
Copilot Apr 22, 2026
39493bb
plan 52: address review — rename e2e test, add defaults to CLI flag t…
claude Apr 22, 2026
d6ec7ff
plan 52: tighten schema-source matching to one level deep
claude Apr 22, 2026
5905a99
plan 52: reuse archetypes.DefaultRoot + surface List errors
claude Apr 22, 2026
5c00f8b
plan 52: reject path-injection archetype names
claude Apr 22, 2026
9efb0dc
plan 52: validate init dir, drop stale comment, close pipe read ends
claude Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ footer: |
|-----|--------|-----------------------------------------------------------------------------------------------------------------|
| 50 | 🔲 | [Redundancy / Duplication Detection](plan/50_redundancy-duplication-detection.md) |
| 51 | ✅ | [Section-Level Size Limits](plan/51_section-level-size-limits.md) |
| 52 | 🔲 | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
| 52 | | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
| 53 | ⛔ | [Conciseness Scoring](plan/53_conciseness-scoring.md) |
| 54 | ⛔ | [Conciseness Metrics Design and Implementation](plan/54_metrics-guide-tradeoffs.md) |
| 56 | ⛔ | [Spike Ollama for Weasel Detection](plan/56_spike-ollama-weasel-detection.md) |
Expand Down
291 changes: 291 additions & 0 deletions cmd/mdsmith/archetypes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
package main

import (
"fmt"
"os"
"path/filepath"

"github.com/jeduden/mdsmith/internal/archetypes"
"github.com/jeduden/mdsmith/internal/config"
)

const archetypesUsage = `Usage: mdsmith archetypes <subcommand> [args]

Subcommands:
init [dir] Scaffold an archetype directory with an example.
Defaults to ./archetypes. Safe to re-run: existing
files are preserved.

list Print every archetype discovered under the
configured roots, one per line as
"<name>\t<path>".

show <name> Print the raw schema source of the named archetype
to stdout.

path <name> Print the resolved filesystem path of the named
archetype.

Archetype roots are configured under 'archetypes.roots' in
.mdsmith.yml. When unset, the single root './archetypes' is used.
`

// runArchetypes dispatches the archetypes subcommand.
func runArchetypes(args []string) int {
if len(args) == 0 {
fmt.Fprint(os.Stderr, archetypesUsage)
return 0
}

switch args[0] {
case "--help", "-h":
fmt.Fprint(os.Stderr, archetypesUsage)
return 0
case "init":
return runArchetypesInit(args[1:])
case "list":
return runArchetypesList(args[1:])
case "show":
return runArchetypesShow(args[1:])
case "path":
return runArchetypesPath(args[1:])
default:
fmt.Fprintf(os.Stderr,
"mdsmith: archetypes: unknown subcommand %q\n\n%s",
args[0], archetypesUsage)
return 2
}
}

// archetypesResolver builds a resolver constrained to the project
// root. When cfg carries archetypes.roots it validates that each
// entry is relative and does not escape the project root; when a
// rootDir is known it sets FS = os.DirFS(rootDir) so reads cannot
// reach outside it, matching required-structure's RootFS-backed
// schema resolution.
func archetypesResolver(configPath string) (*archetypes.Resolver, *config.Config, string, error) {
cfg, cfgPath, err := loadConfig(configPath)
if err != nil {
return nil, nil, "", err
}
rootDir := rootDirFromConfig(cfgPath)
roots := cfg.Archetypes.Roots
if err := archetypes.ValidateRoots(roots); err != nil {
return nil, nil, "", err
}
resolver := &archetypes.Resolver{
Roots: roots,
RootDir: rootDir,
}
if rootDir != "" {
resolver.FS = os.DirFS(rootDir)
}
return resolver, cfg, rootDir, nil
}

const archetypesInitExample = `---
title: 'string & != ""'
"status?": '"draft" | "review" | "approved"'
---
# ?

## Overview

## Details

## ...
`

const archetypesInitReadme = `# Archetypes

This directory holds required-structure schemas for common
document types used in this repository. Each ` + "`<name>.md`" + `
file is an archetype referenced by name via:

` + "```yaml" + `
rules:
required-structure:
archetype: <name>
` + "```" + `

Use ` + "`mdsmith archetypes list`" + ` to see what is available and
` + "`mdsmith archetypes show <name>`" + ` to print one.

## Reserved filenames

These files are **not** treated as archetypes by discovery, so
they are safe to keep in this directory as metadata:

- ` + "`README.md`" + `, ` + "`LICENSE.md`" + `,
` + "`CONTRIBUTING.md`" + `, ` + "`CODEOWNERS.md`" + `
(case-insensitive)
- Any filename beginning with ` + "`_`" + ` or ` + "`.`" + `
`

// runArchetypesInit scaffolds an archetype directory with an example
// schema and a README. Does not mutate the user's .mdsmith.yml.
func runArchetypesInit(args []string) int {
dir := "archetypes"
switch len(args) {
case 0:
case 1:
if args[0] == "--help" || args[0] == "-h" {
fmt.Fprintln(os.Stderr, "Usage: mdsmith archetypes init [dir]")
return 0
}
dir = args[0]
default:
fmt.Fprintln(os.Stderr, "mdsmith: archetypes init takes at most one argument")
return 2
}

if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}

examplePath := filepath.Join(dir, "example.md")
readmePath := filepath.Join(dir, "README.md")

wroteExample, err := writeIfAbsent(examplePath, archetypesInitExample)
if err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
wroteReadme, err := writeIfAbsent(readmePath, archetypesInitReadme)
if err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}

fmt.Fprintf(os.Stderr, "mdsmith: archetypes directory ready at %s\n", dir)
if wroteExample {
fmt.Fprintf(os.Stderr, " created %s\n", examplePath)
} else {
fmt.Fprintf(os.Stderr, " kept %s\n", examplePath)
}
if wroteReadme {
fmt.Fprintf(os.Stderr, " created %s\n", readmePath)
} else {
fmt.Fprintf(os.Stderr, " kept %s\n", readmePath)
}
fmt.Fprintln(os.Stderr, "\nAdd this to .mdsmith.yml to register the directory:")
fmt.Fprintf(os.Stderr, "\narchetypes:\n roots:\n - %s\n", dir)
return 0
Comment thread
jeduden marked this conversation as resolved.
}

// writeIfAbsent writes content to path only when path does not exist.
// Returns true when the file was created.
func writeIfAbsent(path, content string) (bool, error) {
if _, err := os.Stat(path); err == nil {
return false, nil
} else if !os.IsNotExist(err) {
return false, err
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return false, err
}
return true, nil
}

// runArchetypesList prints each archetype discovered under the
// configured roots.
func runArchetypesList(args []string) int {
if len(args) > 0 && (args[0] == "--help" || args[0] == "-h") {
fmt.Fprintln(os.Stderr, "Usage: mdsmith archetypes list")
return 0
}
if len(args) > 0 {
fmt.Fprintln(os.Stderr, "mdsmith: archetypes list takes no arguments")
return 2
}
resolver, _, _, err := archetypesResolver("")
if err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
entries, listErrs := resolver.ListWithErrors()
for _, err := range listErrs {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
}
if len(entries) == 0 {
if len(listErrs) > 0 {
return 2
}
fmt.Fprintf(os.Stderr,
"mdsmith: no archetypes found under roots %v\n",
resolver.EffectiveRoots())
return 1
}
for _, e := range entries {
path := e.Path
if resolver.RootDir != "" {
path = filepath.Join(resolver.RootDir, e.Path)
}
if _, err := fmt.Fprintf(os.Stdout, "%s\t%s\n", e.Name, path); err != nil {
return 2
}
}
return 0
}

// runArchetypesShow prints the raw archetype schema source.
func runArchetypesShow(args []string) int {
name, code := singleNameArg("show", args)
if code >= 0 {
return code
}
resolver, _, _, err := archetypesResolver("")
if err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
body, err := resolver.Content(name)
if err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
if _, err := os.Stdout.Write(body); err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
return 0
}

// runArchetypesPath prints the resolved filesystem path.
func runArchetypesPath(args []string) int {
name, code := singleNameArg("path", args)
if code >= 0 {
return code
}
resolver, _, _, err := archetypesResolver("")
if err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
p, err := resolver.AbsPath(name)
if err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
if _, err := fmt.Fprintln(os.Stdout, p); err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
return 2
}
return 0
}

// singleNameArg extracts a single positional name from args. Returns
// a negative exit code on success, otherwise the exit code to return.
func singleNameArg(verb string, args []string) (string, int) {
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
fmt.Fprintf(os.Stderr, "Usage: mdsmith archetypes %s <name>\n", verb)
return "", 0
}
if len(args) != 1 {
fmt.Fprintf(os.Stderr,
"mdsmith: archetypes %s requires exactly one archetype name\n", verb)
return "", 2
}
return args[0], -1
}
Loading
Loading