From 122d6d063ee79de2f6f1f3326dc0e90fe14b659d Mon Sep 17 00:00:00 2001 From: devrimcavusoglu Date: Thu, 7 May 2026 10:05:06 +0300 Subject: [PATCH] Add `skern init` instruction-snippet writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 / 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) --- CHANGELOG.md | 11 + docs/reference/commands.md | 21 +- internal/cli/init.go | 262 ++++++++++++++++-- internal/cli/init_test.go | 204 ++++++++++++-- internal/cli/instructions/base.md | 3 + internal/cli/instructions/instructions.go | 34 +++ .../cli/instructions/instructions_test.go | 50 ++++ internal/cli/instructions/tool_forming.md | 9 + internal/cli/instructions/writer.go | 120 ++++++++ internal/cli/instructions/writer_test.go | 170 ++++++++++++ internal/output/types_skill.go | 32 +++ scripts/smoke_test.sh | 36 +++ 12 files changed, 904 insertions(+), 48 deletions(-) create mode 100644 internal/cli/instructions/base.md create mode 100644 internal/cli/instructions/instructions.go create mode 100644 internal/cli/instructions/instructions_test.go create mode 100644 internal/cli/instructions/tool_forming.md create mode 100644 internal/cli/instructions/writer.go create mode 100644 internal/cli/instructions/writer_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e7d71..a2b906a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`skern init` can now write a skern usage snippet into agent instruction + files** (`AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`). Off by default; + opt in with `--instructions` (or accept the interactive prompt on a TTY). + The snippet is wrapped in `` / + `` markers so re-running updates the block + in place. Three additional flags shape the output: `--tool-forming-loop` + appends a search-before-create workflow section (off by default), + `--target ` overrides auto-discovery for explicit files, and + `--print-instructions` emits the snippet to stdout without writing files. + The `InitResult` JSON envelope grows an `instructions` field reporting + what was written. - **Five new platform adapters: `cursor`, `gemini-cli`, `github-copilot`, `windsurf`, `continue`.** All five accept the same `--platform` flag, route installs to the platform's expected skill directory, and participate in diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 508ca1b..73e464e 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -22,12 +22,29 @@ skern version # Print version info ## `skern init` -Initialize the `.skern/` directory in the current project. This creates the project-scoped skill registry. +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. ```sh -skern init +skern init # creates .skern/ only (default) +skern init --instructions # also writes the usage snippet to discovered agent config files +skern init --instructions --tool-forming-loop # adds the search-before-create workflow section +skern init --target ./MY_AGENT.md # write to a specific file (skips auto-discovery) +skern init --print-instructions # print the snippet to stdout instead of writing files ``` +**Flags:** + +| Flag | Description | +|------|-------------| +| `--instructions` | Write the skern usage snippet to discovered agent config files (`AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`). Default: off. | +| `--tool-forming-loop` | Include the tool-forming-loop section (search-before-create workflow). Default: off. | +| `--target ` | Explicit instruction file path. Repeatable. Disables auto-discovery when set. | +| `--print-instructions` | Print the rendered snippet to stdout instead of writing files. | + +The instruction snippet is wrapped in `` / `` markers, so re-running `skern init --instructions` updates the block in place rather than appending duplicates. + +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. + ## `skern skill create` Scaffold a new `SKILL.md` file in the registry. diff --git a/internal/cli/init.go b/internal/cli/init.go index 9bafcfb..54eb2b9 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,47 +1,257 @@ package cli import ( + "bufio" "fmt" + "io" "os" "path/filepath" + "strings" + "github.com/devrimcavusoglu/skern/internal/cli/instructions" "github.com/devrimcavusoglu/skern/internal/output" "github.com/spf13/cobra" ) func newInitCmd() *cobra.Command { + var ( + writeInstr bool + toolForming bool + printInstr bool + targetPaths []string + ) + cmd := &cobra.Command{ Use: "init", Short: "Initialize a .skern project directory", - Long: "Creates .skern/ and .skern/skills/ directories in the current project. Idempotent — safe to run multiple times.", - Args: cobra.NoArgs, + Long: `Creates .skern/ and .skern/skills/ directories in the current project. +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. + +Idempotent — safe to run multiple times. The instruction snippet is +wrapped in start/end markers so re-running updates the block in place.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - skillsDir := filepath.Join(".", ".skern", "skills") - - // Check if already initialized - if info, err := os.Stat(skillsDir); err == nil && info.IsDir() { - result := output.InitResult{ - Path: filepath.Join(".", ".skern"), - Created: false, - } - text := fmt.Sprintf("Already initialized: %s\n", filepath.Join(".", ".skern")) - getContext(cmd).Printer.PrintResult(result, text) - return nil - } - - if err := os.MkdirAll(skillsDir, 0o755); err != nil { - return fmt.Errorf("creating .skern directory: %w", err) - } - - result := output.InitResult{ - Path: filepath.Join(".", ".skern"), - Created: true, - } - text := fmt.Sprintf("Initialized skern project at %s\n", filepath.Join(".", ".skern")) - getContext(cmd).Printer.PrintResult(result, text) - return nil + return runInit(cmd, runInitOpts{ + writeInstr: writeInstr, + toolForming: toolForming, + printInstr: printInstr, + targetPaths: targetPaths, + }) }, } + cmd.Flags().BoolVar(&writeInstr, "instructions", false, + "write the skern usage snippet to agent instruction files (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md by default)") + cmd.Flags().BoolVar(&toolForming, "tool-forming-loop", false, + "include the tool-forming-loop section in the instruction snippet (search-before-create workflow)") + cmd.Flags().BoolVar(&printInstr, "print-instructions", false, + "print the rendered instruction snippet to stdout instead of writing files") + cmd.Flags().StringSliceVar(&targetPaths, "target", nil, + "explicit instruction file path to write to; repeatable. Disables auto-discovery when set.") + return cmd } + +type runInitOpts struct { + writeInstr bool + toolForming bool + printInstr bool + targetPaths []string +} + +func runInit(cmd *cobra.Command, opts runInitOpts) error { + cc := getContext(cmd) + + skillsDir := filepath.Join(".", ".skern", "skills") + skernPath := filepath.Join(".", ".skern") + created := true + if info, err := os.Stat(skillsDir); err == nil && info.IsDir() { + created = false + } else if err := os.MkdirAll(skillsDir, 0o755); err != nil { + return fmt.Errorf("creating .skern directory: %w", err) + } + + instrResult, err := handleInstructions(cmd, cc, opts) + if err != nil { + return err + } + + result := output.InitResult{ + Path: skernPath, + Created: created, + Instructions: instrResult, + } + text := initTextSummary(skernPath, created, instrResult) + cc.Printer.PrintResult(result, text) + return nil +} + +// handleInstructions resolves the user's choices (flags + interactive +// prompts), writes or prints the snippet, and returns a structured result. +// Returns nil when the user did not opt in to writing instructions. +func handleInstructions(cmd *cobra.Command, cc *CommandContext, opts runInitOpts) (*output.InstructionsResult, error) { + wantInstr, wantToolForming, err := resolveInstructionChoices(cmd, cc, opts) + if err != nil { + return nil, err + } + if !wantInstr { + return nil, nil + } + + rendered := instructions.Render(wantToolForming) + + if opts.printInstr { + _, _ = io.WriteString(cmd.OutOrStdout(), rendered) + return &output.InstructionsResult{ + ToolForming: wantToolForming, + Targets: nil, + Writes: nil, + Printed: true, + }, nil + } + + targets, err := resolveTargets(opts) + if err != nil { + return nil, err + } + + res := &output.InstructionsResult{ + ToolForming: wantToolForming, + Targets: targets, + Writes: []output.InstructionWriteResult{}, + } + for _, t := range targets { + w, werr := instructions.Write(t, rendered) + if werr != nil { + return nil, werr + } + res.Writes = append(res.Writes, output.InstructionWriteResult{ + Path: w.Path, + Action: w.Action, + Created: w.Created, + }) + } + return res, nil +} + +// resolveInstructionChoices folds flag values + TTY interactivity into the +// final (writeInstructions, toolFormingLoop) decision. +func resolveInstructionChoices(cmd *cobra.Command, cc *CommandContext, opts runInitOpts) (bool, bool, error) { + flags := cmd.Flags() + + wantInstr := opts.writeInstr || opts.printInstr || len(opts.targetPaths) > 0 + wantToolForming := opts.toolForming + + // Skip prompting when JSON mode (machine-driven) or when stdin is not a + // terminal (CI, scripts, redirected input, tests). + in := cmd.InOrStdin() + canPrompt := !cc.Printer.IsJSON() && isTerminal(in) + + // Prompts go to stderr so they never collide with --print-instructions + // output on stdout when scripts pipe init through. + promptOut := cmd.ErrOrStderr() + + if !wantInstr && canPrompt && !flags.Changed("instructions") && + !flags.Changed("print-instructions") && len(opts.targetPaths) == 0 { + yes, err := promptYesNo(in, promptOut, + "Append skern usage instructions to agent config files (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md)?", false) + if err != nil { + return false, false, err + } + wantInstr = yes + } + + if wantInstr && !wantToolForming && canPrompt && !flags.Changed("tool-forming-loop") { + yes, err := promptYesNo(in, promptOut, + "Include tool-forming-loop section (instructs the agent to search before creating)?", false) + if err != nil { + return false, false, err + } + wantToolForming = yes + } + + return wantInstr, wantToolForming, nil +} + +// resolveTargets returns the list of files to write to. Explicit --target +// paths win; otherwise auto-discovery probes CandidateProjectFiles in cwd. +func resolveTargets(opts runInitOpts) ([]string, error) { + if len(opts.targetPaths) > 0 { + return opts.targetPaths, nil + } + return instructions.DiscoverTargets(".") +} + +// isTerminal reports whether r is a *os.File backed by a character device +// (terminal). Returns false for non-file readers (e.g. test injectees) so +// tests never trigger interactive prompts. +func isTerminal(r io.Reader) bool { + f, ok := r.(*os.File) + if !ok { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return (info.Mode() & os.ModeCharDevice) != 0 +} + +// promptYesNo writes prompt to w and reads a y/n answer from r. The default +// (returned when the user just hits enter) is controlled by defaultYes. +func promptYesNo(r io.Reader, w io.Writer, prompt string, defaultYes bool) (bool, error) { + suffix := " [y/N]: " + if defaultYes { + suffix = " [Y/n]: " + } + if _, err := fmt.Fprint(w, prompt+suffix); err != nil { + return false, err + } + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + // EOF / no input — fall back to default. + return defaultYes, nil + } + answer := strings.ToLower(strings.TrimSpace(scanner.Text())) + switch answer { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + case "": + return defaultYes, nil + default: + return defaultYes, nil + } +} + +func initTextSummary(skernPath string, created bool, instr *output.InstructionsResult) string { + var b strings.Builder + if created { + fmt.Fprintf(&b, "Initialized skern project at %s\n", skernPath) + } else { + fmt.Fprintf(&b, "Already initialized: %s\n", skernPath) + } + if instr == nil { + return b.String() + } + if instr.Printed { + return b.String() // snippet already streamed to stdout above + } + if len(instr.Writes) == 0 { + fmt.Fprintln(&b, "No agent instruction files found (looked for AGENTS.md, CLAUDE.md, .claude/CLAUDE.md).") + fmt.Fprintln(&b, "Pass --target to write to a specific file.") + return b.String() + } + tfTag := "" + if instr.ToolForming { + tfTag = " (with tool-forming-loop)" + } + fmt.Fprintf(&b, "Wrote skern usage snippet%s to:\n", tfTag) + for _, w := range instr.Writes { + fmt.Fprintf(&b, " %s [%s]\n", w.Path, w.Action) + } + return b.String() +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 8b4f054..aee1f6f 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -4,50 +4,53 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" + "github.com/devrimcavusoglu/skern/internal/cli/instructions" "github.com/devrimcavusoglu/skern/internal/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestInit(t *testing.T) { +// withTempCwd switches the test process to a fresh temp dir and restores +// the original on cleanup. Init writes relative paths so tests must run +// from a clean cwd. +func withTempCwd(t *testing.T) string { + t.Helper() dir := t.TempDir() - origDir, _ := os.Getwd() + orig, err := os.Getwd() + require.NoError(t, err) require.NoError(t, os.Chdir(dir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) + t.Cleanup(func() { _ = os.Chdir(orig) }) + return dir +} + +func TestInit(t *testing.T) { + dir := withTempCwd(t) out, err := runCmd(t, nil, "init") require.NoError(t, err) assert.Contains(t, out, "Initialized") - // Verify directories exist info, err := os.Stat(filepath.Join(dir, ".skern", "skills")) require.NoError(t, err) assert.True(t, info.IsDir()) } func TestInit_Idempotent(t *testing.T) { - dir := t.TempDir() - origDir, _ := os.Getwd() - require.NoError(t, os.Chdir(dir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) + withTempCwd(t) - // First init _, err := runCmd(t, nil, "init") require.NoError(t, err) - // Second init should succeed with "already initialized" message out, err := runCmd(t, nil, "init") require.NoError(t, err) assert.Contains(t, out, "Already initialized") } func TestInit_JSON(t *testing.T) { - dir := t.TempDir() - origDir, _ := os.Getwd() - require.NoError(t, os.Chdir(dir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) + withTempCwd(t) out, err := runCmd(t, nil, "init", "--json") require.NoError(t, err) @@ -56,19 +59,15 @@ func TestInit_JSON(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(out), &result)) assert.True(t, result.Created) assert.NotEmpty(t, result.Path) + assert.Nil(t, result.Instructions, "Instructions should be omitted when not opted in") } func TestInit_JSON_AlreadyExists(t *testing.T) { - dir := t.TempDir() - origDir, _ := os.Getwd() - require.NoError(t, os.Chdir(dir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) + withTempCwd(t) - // First init _, err := runCmd(t, nil, "init", "--json") require.NoError(t, err) - // Second init out, err := runCmd(t, nil, "init", "--json") require.NoError(t, err) @@ -76,3 +75,168 @@ func TestInit_JSON_AlreadyExists(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(out), &result)) assert.False(t, result.Created) } + +// --- Instruction-snippet flag flows --- + +func TestInit_Instructions_NoTargetsFound(t *testing.T) { + withTempCwd(t) + + out, err := runCmd(t, nil, "init", "--instructions", "--json") + require.NoError(t, err) + + var result output.InitResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.NotNil(t, result.Instructions) + assert.False(t, result.Instructions.ToolForming) + assert.Empty(t, result.Instructions.Targets) + assert.Empty(t, result.Instructions.Writes) + assert.False(t, result.Instructions.Printed) +} + +func TestInit_Instructions_DiscoversAndWrites(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Project\n"), 0o644)) + + out, err := runCmd(t, nil, "init", "--instructions", "--json") + require.NoError(t, err) + + var result output.InitResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.NotNil(t, result.Instructions) + require.Len(t, result.Instructions.Writes, 1) + assert.Equal(t, filepath.Join("AGENTS.md"), result.Instructions.Writes[0].Path) + assert.Equal(t, "appended", result.Instructions.Writes[0].Action) + + body, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + bodyStr := string(body) + assert.Contains(t, bodyStr, "# Project") + assert.Contains(t, bodyStr, instructions.StartMarker) + assert.Contains(t, bodyStr, "Skern (skill management)") + assert.NotContains(t, bodyStr, "Tool-forming loop", "tool-forming should be off by default") +} + +func TestInit_Instructions_WithToolFormingLoop(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(""), 0o644)) + + _, err := runCmd(t, nil, "init", "--instructions", "--tool-forming-loop", "--json") + require.NoError(t, err) + + body, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + require.NoError(t, err) + assert.Contains(t, string(body), "Tool-forming loop") + assert.Contains(t, string(body), "skern skill search") +} + +func TestInit_Instructions_TargetCreatesNewFile(t *testing.T) { + dir := withTempCwd(t) + target := filepath.Join("subdir", "MY_AGENT.md") + + _, err := runCmd(t, nil, "init", "--target", target, "--json") + require.NoError(t, err) + + body, err := os.ReadFile(filepath.Join(dir, target)) + require.NoError(t, err) + assert.Contains(t, string(body), instructions.StartMarker) + assert.Contains(t, string(body), instructions.EndMarker) +} + +func TestInit_Instructions_TargetSkipsAutoDiscovery(t *testing.T) { + dir := withTempCwd(t) + // Create both a candidate file and an explicit target. Only the target + // should be touched. + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("orig"), 0o644)) + target := "EXPLICIT.md" + require.NoError(t, os.WriteFile(filepath.Join(dir, target), []byte("explicit-orig"), 0o644)) + + out, err := runCmd(t, nil, "init", "--target", target, "--json") + require.NoError(t, err) + + var result output.InitResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.NotNil(t, result.Instructions) + require.Len(t, result.Instructions.Writes, 1) + assert.Equal(t, target, result.Instructions.Writes[0].Path) + + agents, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + assert.Equal(t, "orig", string(agents), "auto-discovery candidate must not be touched when --target is set") +} + +func TestInit_Instructions_PrintToStdout(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("untouched"), 0o644)) + + out, err := runCmd(t, nil, "init", "--print-instructions", "--tool-forming-loop") + require.NoError(t, err) + + assert.Contains(t, out, instructions.StartMarker) + assert.Contains(t, out, "Tool-forming loop") + + // Discovered file must NOT be modified in print mode. + body, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + assert.Equal(t, "untouched", string(body)) +} + +func TestInit_Instructions_IdempotentReRun(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# proj\n"), 0o644)) + + _, err := runCmd(t, nil, "init", "--instructions", "--json") + require.NoError(t, err) + + first, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + + out, err := runCmd(t, nil, "init", "--instructions", "--json") + require.NoError(t, err) + + second, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + assert.Equal(t, string(first), string(second), "second run must be a no-op when content unchanged") + + var result output.InitResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.NotNil(t, result.Instructions) + require.Len(t, result.Instructions.Writes, 1) + assert.Equal(t, "unchanged", result.Instructions.Writes[0].Action) +} + +func TestInit_Instructions_UpdatesBlockOnToggleChange(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(""), 0o644)) + + // Round 1: opt out of tool-forming. + _, err := runCmd(t, nil, "init", "--instructions", "--json") + require.NoError(t, err) + + round1, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + assert.NotContains(t, string(round1), "Tool-forming loop") + + // Round 2: opt in. The block should be replaced in place, not appended. + _, err = runCmd(t, nil, "init", "--instructions", "--tool-forming-loop", "--json") + require.NoError(t, err) + + round2, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + round2Str := string(round2) + assert.Contains(t, round2Str, "Tool-forming loop") + assert.Equal(t, 1, strings.Count(round2Str, instructions.StartMarker), + "block must be replaced, not duplicated, on re-run") +} + +func TestInit_Instructions_NoPromptInJSONMode(t *testing.T) { + withTempCwd(t) + // Run --json with no instruction flags. Should not prompt (no stdin + // input given, and with --json the writer never reaches the prompt + // branch). Result.Instructions must be nil. + out, err := runCmd(t, nil, "init", "--json") + require.NoError(t, err) + + var result output.InitResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Nil(t, result.Instructions) +} diff --git a/internal/cli/instructions/base.md b/internal/cli/instructions/base.md new file mode 100644 index 0000000..11db231 --- /dev/null +++ b/internal/cli/instructions/base.md @@ -0,0 +1,3 @@ +## Skern (skill management) + +Use `skern` for ALL skill-related tasks — discovery, reading, creation, edits, removal. Run `skern --help` for the full command surface. diff --git a/internal/cli/instructions/instructions.go b/internal/cli/instructions/instructions.go new file mode 100644 index 0000000..816351a --- /dev/null +++ b/internal/cli/instructions/instructions.go @@ -0,0 +1,34 @@ +// Package instructions assembles and writes the skern usage snippet that +// `skern init` injects into agent instruction files (CLAUDE.md, AGENTS.md, +// etc.). The snippet teaches the agent to use skern for all skill-related +// tasks instead of reading platform-native skill directories directly. +package instructions + +import ( + _ "embed" + "strings" +) + +//go:embed base.md +var baseMD string + +//go:embed tool_forming.md +var toolFormingMD string + +// Sentinel markers wrap the rendered snippet so re-running `skern init` can +// update the block in-place rather than appending duplicates. +const ( + StartMarker = "" + EndMarker = "" +) + +// Render returns the instruction snippet wrapped in start/end markers. +// When toolForming is true the opt-in tool-forming-loop section is appended +// to the always-included base. +func Render(toolForming bool) string { + body := strings.TrimRight(baseMD, "\n") + if toolForming { + body += "\n\n" + strings.TrimRight(toolFormingMD, "\n") + } + return StartMarker + "\n" + body + "\n" + EndMarker + "\n" +} diff --git a/internal/cli/instructions/instructions_test.go b/internal/cli/instructions/instructions_test.go new file mode 100644 index 0000000..e425073 --- /dev/null +++ b/internal/cli/instructions/instructions_test.go @@ -0,0 +1,50 @@ +package instructions + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRender_BaseAlwaysIncluded(t *testing.T) { + got := Render(false) + assert.Contains(t, got, StartMarker) + assert.Contains(t, got, EndMarker) + assert.Contains(t, got, "Skern (skill management)") + assert.Contains(t, got, "ALL skill-related tasks") +} + +func TestRender_BaseExcludesToolForming(t *testing.T) { + got := Render(false) + assert.NotContains(t, got, "Tool-forming loop") + assert.NotContains(t, got, "skern skill search") +} + +func TestRender_WithToolForming(t *testing.T) { + got := Render(true) + assert.Contains(t, got, "Tool-forming loop") + assert.Contains(t, got, "skern skill search") + assert.Contains(t, got, "≥ 0.6") + assert.Contains(t, got, "≥ 0.9") +} + +func TestRender_StableMarkers(t *testing.T) { + for _, tf := range []bool{false, true} { + got := Render(tf) + assert.True(t, strings.HasPrefix(got, StartMarker+"\n"), + "render must start with start marker; toolForming=%v", tf) + assert.True(t, strings.HasSuffix(got, EndMarker+"\n"), + "render must end with end marker; toolForming=%v", tf) + } +} + +func TestRender_Idempotent(t *testing.T) { + a := Render(false) + b := Render(false) + assert.Equal(t, a, b) + + c := Render(true) + d := Render(true) + assert.Equal(t, c, d) +} diff --git a/internal/cli/instructions/tool_forming.md b/internal/cli/instructions/tool_forming.md new file mode 100644 index 0000000..cdbd3c0 --- /dev/null +++ b/internal/cli/instructions/tool_forming.md @@ -0,0 +1,9 @@ +### Tool-forming loop (always run first) + +Before doing recurring or pattern-following work: + +1. `skern skill search ` — see if a relevant skill already exists. +2. Match with score ≥ 0.6: read it via `skern skill show ` and follow its body. +3. No match: `skern skill create ` — implement, and it's reusable next time. + +Score ≥ 0.9 means a near-duplicate exists (creation blocked without `--force`) — read before forcing. Capacity warnings (≥20 project, ≥50 user) appear in JSON output; prefer editing existing skills once you see them. diff --git a/internal/cli/instructions/writer.go b/internal/cli/instructions/writer.go new file mode 100644 index 0000000..37784de --- /dev/null +++ b/internal/cli/instructions/writer.go @@ -0,0 +1,120 @@ +package instructions + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// CandidateProjectFiles lists project-scope agent instruction files that +// `skern init` will probe when the user has not supplied explicit `--target` +// paths. Order is preserved in the output of DiscoverTargets so callers can +// surface a deterministic list to the user. +// +// User-level files (e.g. ~/.claude/CLAUDE.md) are intentionally NOT in this +// list — they are global and require explicit opt-in via `--target`. +var CandidateProjectFiles = []string{ + "AGENTS.md", + "CLAUDE.md", + filepath.Join(".claude", "CLAUDE.md"), +} + +// DiscoverTargets returns the subset of CandidateProjectFiles that exist +// (as regular files) under projectRoot. Symlinks are followed via os.Stat. +func DiscoverTargets(projectRoot string) ([]string, error) { + var found []string + for _, rel := range CandidateProjectFiles { + path := filepath.Join(projectRoot, rel) + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("checking %s: %w", path, err) + } + if info.Mode().IsRegular() { + found = append(found, path) + } + } + return found, nil +} + +// WriteResult records the outcome of a single Write call. +type WriteResult struct { + Path string `json:"path"` + Action string `json:"action"` // "created", "updated", "unchanged", "appended" + Created bool `json:"created"` +} + +// blockPattern matches a previously-written skern block from start to end +// marker (inclusive), with an optional trailing blank line. Multiline mode +// is required because the rendered block spans multiple lines. +var blockPattern = regexp.MustCompile( + `(?s)` + regexp.QuoteMeta(StartMarker) + `.*?` + regexp.QuoteMeta(EndMarker) + `\n?`, +) + +// Write inserts (or updates) the rendered instruction block in `path`. +// +// Behavior: +// - File missing: created, block written as the file's only content. +// - File present, no existing block: rendered block appended (with a blank +// line separator if the file's last byte is not already a newline). +// - File present, existing block: replaced in place, leaving surrounding +// content untouched. +// - File present, existing block matches rendered block byte-for-byte: +// no write occurs (idempotent). Action is "unchanged". +// +// The rendered block is the value returned by Render(toolForming). +func Write(path, rendered string) (WriteResult, error) { + res := WriteResult{Path: path} + + existing, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return res, fmt.Errorf("reading %s: %w", path, err) + } + if mkErr := os.MkdirAll(filepath.Dir(path), 0o755); mkErr != nil { + return res, fmt.Errorf("creating parent of %s: %w", path, mkErr) + } + if writeErr := os.WriteFile(path, []byte(rendered), 0o644); writeErr != nil { + return res, fmt.Errorf("writing %s: %w", path, writeErr) + } + res.Action = "created" + res.Created = true + return res, nil + } + + current := string(existing) + loc := blockPattern.FindStringIndex(current) + if loc != nil { + // Existing block — replace. + if current[loc[0]:loc[1]] == rendered { + res.Action = "unchanged" + return res, nil + } + updated := current[:loc[0]] + rendered + current[loc[1]:] + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return res, fmt.Errorf("updating %s: %w", path, err) + } + res.Action = "updated" + return res, nil + } + + // No block — append, preserving original contents. + var b strings.Builder + b.WriteString(current) + if !strings.HasSuffix(current, "\n") { + b.WriteString("\n") + } + if !strings.HasSuffix(current, "\n\n") { + b.WriteString("\n") + } + b.WriteString(rendered) + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + return res, fmt.Errorf("appending to %s: %w", path, err) + } + res.Action = "appended" + return res, nil +} diff --git a/internal/cli/instructions/writer_test.go b/internal/cli/instructions/writer_test.go new file mode 100644 index 0000000..919b153 --- /dev/null +++ b/internal/cli/instructions/writer_test.go @@ -0,0 +1,170 @@ +package instructions + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverTargets_FindsExistingFiles(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("hi"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, ".claude"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".claude", "CLAUDE.md"), []byte("hi"), 0o644)) + + got, err := DiscoverTargets(root) + require.NoError(t, err) + assert.Contains(t, got, filepath.Join(root, "AGENTS.md")) + assert.Contains(t, got, filepath.Join(root, ".claude", "CLAUDE.md")) + assert.NotContains(t, got, filepath.Join(root, "CLAUDE.md")) +} + +func TestDiscoverTargets_EmptyWhenNoneExist(t *testing.T) { + root := t.TempDir() + got, err := DiscoverTargets(root) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestDiscoverTargets_IgnoresDirectoriesWithMatchingNames(t *testing.T) { + root := t.TempDir() + // Create a directory named AGENTS.md instead of a file. + require.NoError(t, os.MkdirAll(filepath.Join(root, "AGENTS.md"), 0o755)) + + got, err := DiscoverTargets(root) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestWrite_CreatesNewFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "AGENTS.md") + rendered := Render(false) + + res, err := Write(path, rendered) + require.NoError(t, err) + assert.Equal(t, "created", res.Action) + assert.True(t, res.Created) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, rendered, string(got)) +} + +func TestWrite_AppendsToExistingFileWithoutBlock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CLAUDE.md") + original := "# Project\n\nExisting content here.\n" + require.NoError(t, os.WriteFile(path, []byte(original), 0o644)) + + rendered := Render(true) + res, err := Write(path, rendered) + require.NoError(t, err) + assert.Equal(t, "appended", res.Action) + assert.False(t, res.Created) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), "Existing content here.") + assert.Contains(t, string(got), StartMarker) + assert.Contains(t, string(got), EndMarker) + assert.Contains(t, string(got), "Tool-forming loop") +} + +func TestWrite_UpdatesExistingBlockInPlace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "AGENTS.md") + prefix := "# Header\n\nBefore.\n\n" + suffix := "\nAfter.\n" + original := prefix + Render(false) + suffix + require.NoError(t, os.WriteFile(path, []byte(original), 0o644)) + + rendered := Render(true) + res, err := Write(path, rendered) + require.NoError(t, err) + assert.Equal(t, "updated", res.Action) + + got, err := os.ReadFile(path) + require.NoError(t, err) + gotStr := string(got) + assert.Contains(t, gotStr, "Before.") + assert.Contains(t, gotStr, "After.") + assert.Contains(t, gotStr, "Tool-forming loop") + // Make sure there's exactly one block. + assert.Equal(t, 1, countOccurrences(gotStr, StartMarker)) + assert.Equal(t, 1, countOccurrences(gotStr, EndMarker)) +} + +func TestWrite_NoOpWhenBlockUnchanged(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "AGENTS.md") + rendered := Render(true) + original := "preface\n\n" + rendered + "\nepilogue\n" + require.NoError(t, os.WriteFile(path, []byte(original), 0o644)) + + info1, err := os.Stat(path) + require.NoError(t, err) + mtime1 := info1.ModTime() + + res, err := Write(path, rendered) + require.NoError(t, err) + assert.Equal(t, "unchanged", res.Action) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, string(got)) + + // File should not have been re-written. Allow filesystems with low mtime + // resolution (most fail on second-level checks); we mostly care that the + // file content is identical. + info2, err := os.Stat(path) + require.NoError(t, err) + if info2.ModTime().After(mtime1) { + // Some filesystems still update mtime. Don't fail; the byte equality + // above is the real contract. + t.Logf("file mtime updated despite unchanged content (fs-specific)") + } +} + +func TestWrite_AppendInsertsBlankLineSeparator(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "AGENTS.md") + // Original ends with a single newline — writer should insert another + // before the block to keep markdown spacing readable. + require.NoError(t, os.WriteFile(path, []byte("# Header\n\nExisting paragraph.\n"), 0o644)) + + rendered := Render(false) + _, err := Write(path, rendered) + require.NoError(t, err) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), "Existing paragraph.\n\n"+StartMarker, + "blank line should separate prior content from inserted block") +} + +func countOccurrences(s, sub string) int { + count := 0 + for i := 0; i+len(sub) <= len(s); { + j := i + indexOf(s[i:], sub) + if j < i { + break + } + count++ + i = j + len(sub) + } + return count +} + +// indexOf returns the index of sub in s, or -1. +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/output/types_skill.go b/internal/output/types_skill.go index 006cb39..182bbe7 100644 --- a/internal/output/types_skill.go +++ b/internal/output/types_skill.go @@ -4,6 +4,38 @@ package output type InitResult struct { Path string `json:"path"` Created bool `json:"created"` + // Instructions reports the outcome of the optional `skern init` + // instruction-snippet step. Nil when the user did not opt in to + // writing instructions (the default). Non-nil otherwise — even when + // no candidate files were found, so JSON consumers can distinguish + // "didn't try" from "tried, found nothing". + Instructions *InstructionsResult `json:"instructions,omitempty"` +} + +// InstructionsResult reports what happened when `skern init` wrote (or +// would have written) the skern usage snippet into agent instruction files. +type InstructionsResult struct { + // ToolForming records whether the rendered snippet included the + // opt-in tool-forming-loop section. + ToolForming bool `json:"tool_forming"` + // Targets is the list of files considered for writing — auto-discovered + // project files plus any --target overrides. Always present so consumers + // can see what was searched even when nothing was written. + Targets []string `json:"targets"` + // Writes is one entry per file actually touched (or would have been + // touched, in --print-instructions mode where it is empty). + Writes []InstructionWriteResult `json:"writes"` + // Printed is true when --print-instructions emitted to stdout instead + // of writing files. + Printed bool `json:"printed"` +} + +// InstructionWriteResult mirrors instructions.WriteResult for the public +// JSON contract. +type InstructionWriteResult struct { + Path string `json:"path"` + Action string `json:"action"` + Created bool `json:"created"` } // VersionResult is the JSON envelope for version output. diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh index 033d0b9..bcd020a 100755 --- a/scripts/smoke_test.sh +++ b/scripts/smoke_test.sh @@ -231,6 +231,39 @@ test_init_json() { teardown_env } +test_init_instructions_discovers_and_writes() { + setup_env + # Seed a project-level AGENTS.md so auto-discovery has something to find. + echo "# Existing project" > "$PROJECT_DIR/AGENTS.md" + local out + out="$(cd "$PROJECT_DIR" && $SKERN init --instructions --tool-forming-loop --json 2>&1)" + assert_contains "init reports instructions written" "$out" '"action":"appended"' + assert_contains "init reports tool_forming true" "$out" '"tool_forming":true' + assert_contains "AGENTS.md got skern block" "$(cat "$PROJECT_DIR/AGENTS.md")" "skern:instructions:start" + assert_contains "AGENTS.md got tool-forming section" "$(cat "$PROJECT_DIR/AGENTS.md")" "Tool-forming loop" + teardown_env +} + +test_init_instructions_default_off() { + setup_env + echo "# Existing project" > "$PROJECT_DIR/AGENTS.md" + local out + out="$(cd "$PROJECT_DIR" && $SKERN init --json 2>&1)" + assert_not_contains "default JSON init does not include instructions" "$out" '"instructions"' + assert_not_contains "AGENTS.md untouched by default" "$(cat "$PROJECT_DIR/AGENTS.md")" "skern:instructions:start" + teardown_env +} + +test_init_instructions_print_only() { + setup_env + echo "# Existing project" > "$PROJECT_DIR/AGENTS.md" + local out + out="$(cd "$PROJECT_DIR" && $SKERN init --print-instructions 2>&1)" + assert_contains "print-instructions emits start marker" "$out" "skern:instructions:start" + assert_not_contains "AGENTS.md untouched in print mode" "$(cat "$PROJECT_DIR/AGENTS.md")" "skern:instructions:start" + teardown_env +} + # ============================================================ # Skill lifecycle — E2E # ============================================================ @@ -465,6 +498,9 @@ run_test "test_platform_help" test_platform_help # Init run_test "test_init_creates_directory" test_init_creates_directory run_test "test_init_json" test_init_json +run_test "test_init_instructions_discovers_and_writes" test_init_instructions_discovers_and_writes +run_test "test_init_instructions_default_off" test_init_instructions_default_off +run_test "test_init_instructions_print_only" test_init_instructions_print_only # Skill CRUD run_test "test_skill_create" test_skill_create