Skip to content

Commit 6554d91

Browse files
authored
Merge PR #152: Plan 52: user-supplied archetype templates with CLI
2 parents db0820e + 9efb0dc commit 6554d91

24 files changed

Lines changed: 3885 additions & 43 deletions

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ footer: |
1818
|-----|--------|-----------------------------------------------------------------------------------------------------------------|
1919
| 50 || [Redundancy / Duplication Detection](plan/50_redundancy-duplication-detection.md) |
2020
| 51 || [Section-Level Size Limits](plan/51_section-level-size-limits.md) |
21-
| 52 | 🔲 | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
21+
| 52 | | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
2222
| 53 || [Conciseness Scoring](plan/53_conciseness-scoring.md) |
2323
| 54 || [Conciseness Metrics Design and Implementation](plan/54_metrics-guide-tradeoffs.md) |
2424
| 56 || [Spike Ollama for Weasel Detection](plan/56_spike-ollama-weasel-detection.md) |

cmd/mdsmith/archetypes.go

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/jeduden/mdsmith/internal/archetypes"
9+
"github.com/jeduden/mdsmith/internal/config"
10+
)
11+
12+
const archetypesUsage = `Usage: mdsmith archetypes <subcommand> [args]
13+
14+
Subcommands:
15+
init [dir] Scaffold an archetype directory with an example.
16+
Defaults to ./archetypes. Safe to re-run: existing
17+
files are preserved.
18+
19+
list Print every archetype discovered under the
20+
configured roots, one per line as
21+
"<name>\t<path>".
22+
23+
show <name> Print the raw schema source of the named archetype
24+
to stdout.
25+
26+
path <name> Print the resolved filesystem path of the named
27+
archetype.
28+
29+
Archetype roots are configured under 'archetypes.roots' in
30+
.mdsmith.yml. When unset, the single root './archetypes' is used.
31+
`
32+
33+
// runArchetypes dispatches the archetypes subcommand.
34+
func runArchetypes(args []string) int {
35+
if len(args) == 0 {
36+
fmt.Fprint(os.Stderr, archetypesUsage)
37+
return 0
38+
}
39+
40+
switch args[0] {
41+
case "--help", "-h":
42+
fmt.Fprint(os.Stderr, archetypesUsage)
43+
return 0
44+
case "init":
45+
return runArchetypesInit(args[1:])
46+
case "list":
47+
return runArchetypesList(args[1:])
48+
case "show":
49+
return runArchetypesShow(args[1:])
50+
case "path":
51+
return runArchetypesPath(args[1:])
52+
default:
53+
fmt.Fprintf(os.Stderr,
54+
"mdsmith: archetypes: unknown subcommand %q\n\n%s",
55+
args[0], archetypesUsage)
56+
return 2
57+
}
58+
}
59+
60+
// archetypesResolver builds a resolver constrained to the project
61+
// root. When cfg carries archetypes.roots it validates that each
62+
// entry is relative and does not escape the project root; when a
63+
// rootDir is known it sets FS = os.DirFS(rootDir) so reads cannot
64+
// reach outside it, matching required-structure's RootFS-backed
65+
// schema resolution.
66+
func archetypesResolver(configPath string) (*archetypes.Resolver, *config.Config, string, error) {
67+
cfg, cfgPath, err := loadConfig(configPath)
68+
if err != nil {
69+
return nil, nil, "", err
70+
}
71+
rootDir := rootDirFromConfig(cfgPath)
72+
roots := cfg.Archetypes.Roots
73+
if err := archetypes.ValidateRoots(roots); err != nil {
74+
return nil, nil, "", err
75+
}
76+
resolver := &archetypes.Resolver{
77+
Roots: roots,
78+
RootDir: rootDir,
79+
}
80+
if rootDir != "" {
81+
resolver.FS = os.DirFS(rootDir)
82+
}
83+
return resolver, cfg, rootDir, nil
84+
}
85+
86+
const archetypesInitExample = `---
87+
title: 'string & != ""'
88+
"status?": '"draft" | "review" | "approved"'
89+
---
90+
# ?
91+
92+
## Overview
93+
94+
## Details
95+
96+
## ...
97+
`
98+
99+
const archetypesInitReadme = `# Archetypes
100+
101+
This directory holds required-structure schemas for common
102+
document types used in this repository. Each ` + "`<name>.md`" + `
103+
file is an archetype referenced by name via:
104+
105+
` + "```yaml" + `
106+
rules:
107+
required-structure:
108+
archetype: <name>
109+
` + "```" + `
110+
111+
Use ` + "`mdsmith archetypes list`" + ` to see what is available and
112+
` + "`mdsmith archetypes show <name>`" + ` to print one.
113+
114+
## Reserved filenames
115+
116+
These files are **not** treated as archetypes by discovery, so
117+
they are safe to keep in this directory as metadata:
118+
119+
- ` + "`README.md`" + `, ` + "`LICENSE.md`" + `,
120+
` + "`CONTRIBUTING.md`" + `, ` + "`CODEOWNERS.md`" + `
121+
(case-insensitive)
122+
- Any filename beginning with ` + "`_`" + ` or ` + "`.`" + `
123+
`
124+
125+
// runArchetypesInit scaffolds an archetype directory with an example
126+
// schema and a README. Does not mutate the user's .mdsmith.yml.
127+
// The dir must be a valid archetype root (relative, non-traversing)
128+
// so the printed config snippet is immediately registrable.
129+
func runArchetypesInit(args []string) int {
130+
dir := "archetypes"
131+
switch len(args) {
132+
case 0:
133+
case 1:
134+
if args[0] == "--help" || args[0] == "-h" {
135+
fmt.Fprintln(os.Stderr, "Usage: mdsmith archetypes init [dir]")
136+
return 0
137+
}
138+
dir = args[0]
139+
default:
140+
fmt.Fprintln(os.Stderr, "mdsmith: archetypes init takes at most one argument")
141+
return 2
142+
}
143+
144+
if err := archetypes.ValidateRoot(dir); err != nil {
145+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
146+
return 2
147+
}
148+
149+
if err := os.MkdirAll(dir, 0o755); err != nil {
150+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
151+
return 2
152+
}
153+
154+
examplePath := filepath.Join(dir, "example.md")
155+
readmePath := filepath.Join(dir, "README.md")
156+
157+
wroteExample, err := writeIfAbsent(examplePath, archetypesInitExample)
158+
if err != nil {
159+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
160+
return 2
161+
}
162+
wroteReadme, err := writeIfAbsent(readmePath, archetypesInitReadme)
163+
if err != nil {
164+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
165+
return 2
166+
}
167+
168+
fmt.Fprintf(os.Stderr, "mdsmith: archetypes directory ready at %s\n", dir)
169+
if wroteExample {
170+
fmt.Fprintf(os.Stderr, " created %s\n", examplePath)
171+
} else {
172+
fmt.Fprintf(os.Stderr, " kept %s\n", examplePath)
173+
}
174+
if wroteReadme {
175+
fmt.Fprintf(os.Stderr, " created %s\n", readmePath)
176+
} else {
177+
fmt.Fprintf(os.Stderr, " kept %s\n", readmePath)
178+
}
179+
fmt.Fprintln(os.Stderr, "\nAdd this to .mdsmith.yml to register the directory:")
180+
fmt.Fprintf(os.Stderr, "\narchetypes:\n roots:\n - %s\n", dir)
181+
return 0
182+
}
183+
184+
// writeIfAbsent writes content to path only when path does not exist.
185+
// Returns true when the file was created.
186+
func writeIfAbsent(path, content string) (bool, error) {
187+
if _, err := os.Stat(path); err == nil {
188+
return false, nil
189+
} else if !os.IsNotExist(err) {
190+
return false, err
191+
}
192+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
193+
return false, err
194+
}
195+
return true, nil
196+
}
197+
198+
// runArchetypesList prints each archetype discovered under the
199+
// configured roots.
200+
func runArchetypesList(args []string) int {
201+
if len(args) > 0 && (args[0] == "--help" || args[0] == "-h") {
202+
fmt.Fprintln(os.Stderr, "Usage: mdsmith archetypes list")
203+
return 0
204+
}
205+
if len(args) > 0 {
206+
fmt.Fprintln(os.Stderr, "mdsmith: archetypes list takes no arguments")
207+
return 2
208+
}
209+
resolver, _, _, err := archetypesResolver("")
210+
if err != nil {
211+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
212+
return 2
213+
}
214+
entries, listErrs := resolver.ListWithErrors()
215+
for _, err := range listErrs {
216+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
217+
}
218+
if len(entries) == 0 {
219+
if len(listErrs) > 0 {
220+
return 2
221+
}
222+
fmt.Fprintf(os.Stderr,
223+
"mdsmith: no archetypes found under roots %v\n",
224+
resolver.EffectiveRoots())
225+
return 1
226+
}
227+
for _, e := range entries {
228+
path := e.Path
229+
if resolver.RootDir != "" {
230+
path = filepath.Join(resolver.RootDir, e.Path)
231+
}
232+
if _, err := fmt.Fprintf(os.Stdout, "%s\t%s\n", e.Name, path); err != nil {
233+
return 2
234+
}
235+
}
236+
return 0
237+
}
238+
239+
// runArchetypesShow prints the raw archetype schema source.
240+
func runArchetypesShow(args []string) int {
241+
name, code := singleNameArg("show", args)
242+
if code >= 0 {
243+
return code
244+
}
245+
resolver, _, _, err := archetypesResolver("")
246+
if err != nil {
247+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
248+
return 2
249+
}
250+
body, err := resolver.Content(name)
251+
if err != nil {
252+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
253+
return 2
254+
}
255+
if _, err := os.Stdout.Write(body); err != nil {
256+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
257+
return 2
258+
}
259+
return 0
260+
}
261+
262+
// runArchetypesPath prints the resolved filesystem path.
263+
func runArchetypesPath(args []string) int {
264+
name, code := singleNameArg("path", args)
265+
if code >= 0 {
266+
return code
267+
}
268+
resolver, _, _, err := archetypesResolver("")
269+
if err != nil {
270+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
271+
return 2
272+
}
273+
p, err := resolver.AbsPath(name)
274+
if err != nil {
275+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
276+
return 2
277+
}
278+
if _, err := fmt.Fprintln(os.Stdout, p); err != nil {
279+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
280+
return 2
281+
}
282+
return 0
283+
}
284+
285+
// singleNameArg extracts a single positional name from args. Returns
286+
// a negative exit code on success, otherwise the exit code to return.
287+
func singleNameArg(verb string, args []string) (string, int) {
288+
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
289+
fmt.Fprintf(os.Stderr, "Usage: mdsmith archetypes %s <name>\n", verb)
290+
return "", 0
291+
}
292+
if len(args) != 1 {
293+
fmt.Fprintf(os.Stderr,
294+
"mdsmith: archetypes %s requires exactly one archetype name\n", verb)
295+
return "", 2
296+
}
297+
return args[0], -1
298+
}

0 commit comments

Comments
 (0)