Skip to content

Commit 3a2b09b

Browse files
Add skern init instruction-snippet writer (#87)
`skern init` can now write a skern usage snippet into agent instruction files (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md). The snippet teaches the agent to use skern for ALL skill-related tasks instead of reading platform-native skill directories. Off by default — opt in with `--instructions` or accept the interactive prompt on a TTY. Implementation: - internal/cli/instructions/ — new package. Two embedded markdown fragments (base.md always included, tool_forming.md opt-in), assembled by Render(). The writer wraps output in <!-- skern:instructions:start --> / <!-- skern:instructions:end --> markers so re-running updates the block in place rather than appending duplicates. Auto-discovery probes AGENTS.md, CLAUDE.md, .claude/CLAUDE.md in cwd; user-level files require explicit --target. - internal/cli/init.go — new flags: --instructions, --tool-forming-loop, --target (repeatable), --print-instructions. Interactive TTY prompts for both choices when flags don't fully resolve them; default No for both. Prompts go to stderr so they don't collide with --print-instructions on stdout. JSON mode never prompts. - internal/output/types_skill.go — InitResult grows an optional `instructions` field reporting tool_forming choice, candidate targets, and per-file write actions (created/updated/unchanged/ appended/printed). - scripts/smoke_test.sh — three new tests cover the discover-and-write, default-off, and print-only flows. - docs/reference/commands.md, CHANGELOG.md updated. Tool-forming-loop is intentionally opt-in per user preference: agents that already know to create skills as needed don't need the explicit search-before-create directive in their context budget. Verified locally: go test ./... pass, golangci-lint clean, 65/65 smoke tests pass on Windows. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 50519cc commit 3a2b09b

12 files changed

Lines changed: 904 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **`skern init` can now write a skern usage snippet into agent instruction
13+
files** (`AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`). Off by default;
14+
opt in with `--instructions` (or accept the interactive prompt on a TTY).
15+
The snippet is wrapped in `<!-- skern:instructions:start -->` /
16+
`<!-- skern:instructions:end -->` markers so re-running updates the block
17+
in place. Three additional flags shape the output: `--tool-forming-loop`
18+
appends a search-before-create workflow section (off by default),
19+
`--target <path>` overrides auto-discovery for explicit files, and
20+
`--print-instructions` emits the snippet to stdout without writing files.
21+
The `InitResult` JSON envelope grows an `instructions` field reporting
22+
what was written.
1223
- **Five new platform adapters: `cursor`, `gemini-cli`, `github-copilot`,
1324
`windsurf`, `continue`.** All five accept the same `--platform` flag, route
1425
installs to the platform's expected skill directory, and participate in

docs/reference/commands.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,29 @@ skern version # Print version info
2222

2323
## `skern init`
2424

25-
Initialize the `.skern/` directory in the current project. This creates the project-scoped skill registry.
25+
Initialize the `.skern/` directory in the current project (creates the project-scoped skill registry). Optionally writes a skern usage snippet into agent instruction files (`AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`) so the agent uses skern for all skill-related tasks instead of reading platform-native skill directories.
2626

2727
```sh
28-
skern init
28+
skern init # creates .skern/ only (default)
29+
skern init --instructions # also writes the usage snippet to discovered agent config files
30+
skern init --instructions --tool-forming-loop # adds the search-before-create workflow section
31+
skern init --target ./MY_AGENT.md # write to a specific file (skips auto-discovery)
32+
skern init --print-instructions # print the snippet to stdout instead of writing files
2933
```
3034

35+
**Flags:**
36+
37+
| Flag | Description |
38+
|------|-------------|
39+
| `--instructions` | Write the skern usage snippet to discovered agent config files (`AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`). Default: off. |
40+
| `--tool-forming-loop` | Include the tool-forming-loop section (search-before-create workflow). Default: off. |
41+
| `--target <path>` | Explicit instruction file path. Repeatable. Disables auto-discovery when set. |
42+
| `--print-instructions` | Print the rendered snippet to stdout instead of writing files. |
43+
44+
The instruction snippet is wrapped in `<!-- skern:instructions:start -->` / `<!-- skern:instructions:end -->` markers, so re-running `skern init --instructions` updates the block in place rather than appending duplicates.
45+
46+
When run on a TTY without `--json` or instruction flags, `skern init` prompts the user for both choices (write instructions? include tool-forming loop?). Default to **No** for both. Non-interactive runs (CI, scripts, `--json`) honor flag values only — no prompts.
47+
3148
## `skern skill create`
3249

3350
Scaffold a new `SKILL.md` file in the registry.

internal/cli/init.go

Lines changed: 236 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,257 @@
11
package cli
22

33
import (
4+
"bufio"
45
"fmt"
6+
"io"
57
"os"
68
"path/filepath"
9+
"strings"
710

11+
"github.com/devrimcavusoglu/skern/internal/cli/instructions"
812
"github.com/devrimcavusoglu/skern/internal/output"
913
"github.com/spf13/cobra"
1014
)
1115

1216
func newInitCmd() *cobra.Command {
17+
var (
18+
writeInstr bool
19+
toolForming bool
20+
printInstr bool
21+
targetPaths []string
22+
)
23+
1324
cmd := &cobra.Command{
1425
Use: "init",
1526
Short: "Initialize a .skern project directory",
16-
Long: "Creates .skern/ and .skern/skills/ directories in the current project. Idempotent — safe to run multiple times.",
17-
Args: cobra.NoArgs,
27+
Long: `Creates .skern/ and .skern/skills/ directories in the current project.
28+
Optionally writes a skern usage snippet into agent instruction files
29+
(AGENTS.md, CLAUDE.md, .claude/CLAUDE.md) so the agent uses skern for
30+
all skill-related tasks.
31+
32+
Idempotent — safe to run multiple times. The instruction snippet is
33+
wrapped in start/end markers so re-running updates the block in place.`,
34+
Args: cobra.NoArgs,
1835
RunE: func(cmd *cobra.Command, args []string) error {
19-
skillsDir := filepath.Join(".", ".skern", "skills")
20-
21-
// Check if already initialized
22-
if info, err := os.Stat(skillsDir); err == nil && info.IsDir() {
23-
result := output.InitResult{
24-
Path: filepath.Join(".", ".skern"),
25-
Created: false,
26-
}
27-
text := fmt.Sprintf("Already initialized: %s\n", filepath.Join(".", ".skern"))
28-
getContext(cmd).Printer.PrintResult(result, text)
29-
return nil
30-
}
31-
32-
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
33-
return fmt.Errorf("creating .skern directory: %w", err)
34-
}
35-
36-
result := output.InitResult{
37-
Path: filepath.Join(".", ".skern"),
38-
Created: true,
39-
}
40-
text := fmt.Sprintf("Initialized skern project at %s\n", filepath.Join(".", ".skern"))
41-
getContext(cmd).Printer.PrintResult(result, text)
42-
return nil
36+
return runInit(cmd, runInitOpts{
37+
writeInstr: writeInstr,
38+
toolForming: toolForming,
39+
printInstr: printInstr,
40+
targetPaths: targetPaths,
41+
})
4342
},
4443
}
4544

45+
cmd.Flags().BoolVar(&writeInstr, "instructions", false,
46+
"write the skern usage snippet to agent instruction files (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md by default)")
47+
cmd.Flags().BoolVar(&toolForming, "tool-forming-loop", false,
48+
"include the tool-forming-loop section in the instruction snippet (search-before-create workflow)")
49+
cmd.Flags().BoolVar(&printInstr, "print-instructions", false,
50+
"print the rendered instruction snippet to stdout instead of writing files")
51+
cmd.Flags().StringSliceVar(&targetPaths, "target", nil,
52+
"explicit instruction file path to write to; repeatable. Disables auto-discovery when set.")
53+
4654
return cmd
4755
}
56+
57+
type runInitOpts struct {
58+
writeInstr bool
59+
toolForming bool
60+
printInstr bool
61+
targetPaths []string
62+
}
63+
64+
func runInit(cmd *cobra.Command, opts runInitOpts) error {
65+
cc := getContext(cmd)
66+
67+
skillsDir := filepath.Join(".", ".skern", "skills")
68+
skernPath := filepath.Join(".", ".skern")
69+
created := true
70+
if info, err := os.Stat(skillsDir); err == nil && info.IsDir() {
71+
created = false
72+
} else if err := os.MkdirAll(skillsDir, 0o755); err != nil {
73+
return fmt.Errorf("creating .skern directory: %w", err)
74+
}
75+
76+
instrResult, err := handleInstructions(cmd, cc, opts)
77+
if err != nil {
78+
return err
79+
}
80+
81+
result := output.InitResult{
82+
Path: skernPath,
83+
Created: created,
84+
Instructions: instrResult,
85+
}
86+
text := initTextSummary(skernPath, created, instrResult)
87+
cc.Printer.PrintResult(result, text)
88+
return nil
89+
}
90+
91+
// handleInstructions resolves the user's choices (flags + interactive
92+
// prompts), writes or prints the snippet, and returns a structured result.
93+
// Returns nil when the user did not opt in to writing instructions.
94+
func handleInstructions(cmd *cobra.Command, cc *CommandContext, opts runInitOpts) (*output.InstructionsResult, error) {
95+
wantInstr, wantToolForming, err := resolveInstructionChoices(cmd, cc, opts)
96+
if err != nil {
97+
return nil, err
98+
}
99+
if !wantInstr {
100+
return nil, nil
101+
}
102+
103+
rendered := instructions.Render(wantToolForming)
104+
105+
if opts.printInstr {
106+
_, _ = io.WriteString(cmd.OutOrStdout(), rendered)
107+
return &output.InstructionsResult{
108+
ToolForming: wantToolForming,
109+
Targets: nil,
110+
Writes: nil,
111+
Printed: true,
112+
}, nil
113+
}
114+
115+
targets, err := resolveTargets(opts)
116+
if err != nil {
117+
return nil, err
118+
}
119+
120+
res := &output.InstructionsResult{
121+
ToolForming: wantToolForming,
122+
Targets: targets,
123+
Writes: []output.InstructionWriteResult{},
124+
}
125+
for _, t := range targets {
126+
w, werr := instructions.Write(t, rendered)
127+
if werr != nil {
128+
return nil, werr
129+
}
130+
res.Writes = append(res.Writes, output.InstructionWriteResult{
131+
Path: w.Path,
132+
Action: w.Action,
133+
Created: w.Created,
134+
})
135+
}
136+
return res, nil
137+
}
138+
139+
// resolveInstructionChoices folds flag values + TTY interactivity into the
140+
// final (writeInstructions, toolFormingLoop) decision.
141+
func resolveInstructionChoices(cmd *cobra.Command, cc *CommandContext, opts runInitOpts) (bool, bool, error) {
142+
flags := cmd.Flags()
143+
144+
wantInstr := opts.writeInstr || opts.printInstr || len(opts.targetPaths) > 0
145+
wantToolForming := opts.toolForming
146+
147+
// Skip prompting when JSON mode (machine-driven) or when stdin is not a
148+
// terminal (CI, scripts, redirected input, tests).
149+
in := cmd.InOrStdin()
150+
canPrompt := !cc.Printer.IsJSON() && isTerminal(in)
151+
152+
// Prompts go to stderr so they never collide with --print-instructions
153+
// output on stdout when scripts pipe init through.
154+
promptOut := cmd.ErrOrStderr()
155+
156+
if !wantInstr && canPrompt && !flags.Changed("instructions") &&
157+
!flags.Changed("print-instructions") && len(opts.targetPaths) == 0 {
158+
yes, err := promptYesNo(in, promptOut,
159+
"Append skern usage instructions to agent config files (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md)?", false)
160+
if err != nil {
161+
return false, false, err
162+
}
163+
wantInstr = yes
164+
}
165+
166+
if wantInstr && !wantToolForming && canPrompt && !flags.Changed("tool-forming-loop") {
167+
yes, err := promptYesNo(in, promptOut,
168+
"Include tool-forming-loop section (instructs the agent to search before creating)?", false)
169+
if err != nil {
170+
return false, false, err
171+
}
172+
wantToolForming = yes
173+
}
174+
175+
return wantInstr, wantToolForming, nil
176+
}
177+
178+
// resolveTargets returns the list of files to write to. Explicit --target
179+
// paths win; otherwise auto-discovery probes CandidateProjectFiles in cwd.
180+
func resolveTargets(opts runInitOpts) ([]string, error) {
181+
if len(opts.targetPaths) > 0 {
182+
return opts.targetPaths, nil
183+
}
184+
return instructions.DiscoverTargets(".")
185+
}
186+
187+
// isTerminal reports whether r is a *os.File backed by a character device
188+
// (terminal). Returns false for non-file readers (e.g. test injectees) so
189+
// tests never trigger interactive prompts.
190+
func isTerminal(r io.Reader) bool {
191+
f, ok := r.(*os.File)
192+
if !ok {
193+
return false
194+
}
195+
info, err := f.Stat()
196+
if err != nil {
197+
return false
198+
}
199+
return (info.Mode() & os.ModeCharDevice) != 0
200+
}
201+
202+
// promptYesNo writes prompt to w and reads a y/n answer from r. The default
203+
// (returned when the user just hits enter) is controlled by defaultYes.
204+
func promptYesNo(r io.Reader, w io.Writer, prompt string, defaultYes bool) (bool, error) {
205+
suffix := " [y/N]: "
206+
if defaultYes {
207+
suffix = " [Y/n]: "
208+
}
209+
if _, err := fmt.Fprint(w, prompt+suffix); err != nil {
210+
return false, err
211+
}
212+
scanner := bufio.NewScanner(r)
213+
if !scanner.Scan() {
214+
// EOF / no input — fall back to default.
215+
return defaultYes, nil
216+
}
217+
answer := strings.ToLower(strings.TrimSpace(scanner.Text()))
218+
switch answer {
219+
case "y", "yes":
220+
return true, nil
221+
case "n", "no":
222+
return false, nil
223+
case "":
224+
return defaultYes, nil
225+
default:
226+
return defaultYes, nil
227+
}
228+
}
229+
230+
func initTextSummary(skernPath string, created bool, instr *output.InstructionsResult) string {
231+
var b strings.Builder
232+
if created {
233+
fmt.Fprintf(&b, "Initialized skern project at %s\n", skernPath)
234+
} else {
235+
fmt.Fprintf(&b, "Already initialized: %s\n", skernPath)
236+
}
237+
if instr == nil {
238+
return b.String()
239+
}
240+
if instr.Printed {
241+
return b.String() // snippet already streamed to stdout above
242+
}
243+
if len(instr.Writes) == 0 {
244+
fmt.Fprintln(&b, "No agent instruction files found (looked for AGENTS.md, CLAUDE.md, .claude/CLAUDE.md).")
245+
fmt.Fprintln(&b, "Pass --target <path> to write to a specific file.")
246+
return b.String()
247+
}
248+
tfTag := ""
249+
if instr.ToolForming {
250+
tfTag = " (with tool-forming-loop)"
251+
}
252+
fmt.Fprintf(&b, "Wrote skern usage snippet%s to:\n", tfTag)
253+
for _, w := range instr.Writes {
254+
fmt.Fprintf(&b, " %s [%s]\n", w.Path, w.Action)
255+
}
256+
return b.String()
257+
}

0 commit comments

Comments
 (0)