diff --git a/AGENTS.md b/AGENTS.md index 7395fe5..e0c443d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,7 +328,6 @@ Everything planned is a tracked issue; this list is a map, not a commitment. - [#102] — `--tag` on `skill install` / `skill uninstall` for group installs - [#103] — exclude companion directories (eval corpora, fixtures) from install -- [#104] — explicit non-interactive opt-out for `skern init` **Adapter model** — all three need the declarative-hook mechanism from design decision 3; settle the mechanism once rather than special-casing each. diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9abde..fc1fe60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with at most one colon separating category from value. Tag *filters* remain case-insensitive, so legacy hand-edited uppercase tags still match. ([#96], [#98]) +- **`skern init --no-instructions`** — an explicit opt-out from the + instruction-snippet prompt for installers and CI. Writes nothing, never + prompts, and is rejected (exit 2, before anything is created) when + combined with `--instructions`, `--print-instructions`, `--target`, or + `--tool-forming-loop`. The non-interactive contract is now documented and + enforced: when stdin is not a TTY or `--json` is set, `init` never prompts + and both questions resolve to "no". ([#104]) ### Changed @@ -43,6 +50,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 after; YAML 1.1-only scalars such as bare dates and `yes`/`no` are canonicalized). `skill diff` reports differing pass-through keys under their own names. ([#100]) +- **`skern init` treated `/dev/null` as a terminal.** Interactivity was + detected with a character-device test, so `skern init < /dev/null` (the + installer / cron / `docker run` without `-i` case) still printed the + prompt before falling through to "no". Detection is now a real isatty + check. ([#104]) +- **`skern init --instructions` no longer stops to ask about the + tool-forming loop on a terminal.** Any instruction flag now disables both + prompts; an unasked question keeps its default. Previously a setup script + running `init --instructions` interactively would block on the second + question. ([#104]) - **Release workflow is idempotent on duplicate tag-push deliveries.** A redelivered tag push no longer fails the run or produces a partial release. ([#95]) @@ -64,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#97]: https://github.com/devrimcavusoglu/skern/pull/97 [#98]: https://github.com/devrimcavusoglu/skern/pull/98 [#100]: https://github.com/devrimcavusoglu/skern/issues/100 +[#104]: https://github.com/devrimcavusoglu/skern/issues/104 ## [v0.3.1] — 2026-05-13 diff --git a/docs/concepts/platform-adapters.md b/docs/concepts/platform-adapters.md index 6a154fc..b6e4b4f 100644 --- a/docs/concepts/platform-adapters.md +++ b/docs/concepts/platform-adapters.md @@ -48,6 +48,9 @@ Two consequences: - **Detection is per-platform**, not per-directory. The presence of `.agents/skills/` does not by itself indicate which agents are installed; skern looks at each platform's distinct user-level config dir (`~/.cursor`, `~/.gemini`, `~/.copilot`, `~/.codex`) to disambiguate. - **Capacity is per-directory.** When two platforms share a directory, both adapters see the same installed-skills count. Capacity thresholds protect the directory, not the logical agent — installing 50 skills via `cursor` will register as full capacity for `gemini-cli` too, because the agent will load all of them. +- **One body per skill name.** Because the four adapters write to the same path, you cannot install different content for the same skill name to, say, `codex-cli` and `github-copilot` — the last install wins. Per-platform variants and a per-platform destination override are tracked in [#47](https://github.com/devrimcavusoglu/skern/issues/47) and [#101](https://github.com/devrimcavusoglu/skern/issues/101). + +The shared directory is not a skern invention: GitHub Copilot accepts `.github/skills/`, `.claude/skills/`, **and** `.agents/skills/` for project scope ([GitHub docs](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills)), and Codex CLI, Cursor, and Gemini CLI follow the [vercel-labs/skills](https://github.com/vercel-labs/skills#supported-agents) layout. ## One Platform per Invocation diff --git a/docs/contributing/development.md b/docs/contributing/development.md index bbb29d3..85164ed 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -81,6 +81,7 @@ Two that shape where you'll be editing: |------------|---------| | `github.com/spf13/cobra` | CLI framework | | `gopkg.in/yaml.v3` | YAML frontmatter parsing | +| `golang.org/x/term` | Real isatty check for `skern init` prompts (`/dev/null` is a char device but not a terminal) | | `github.com/stretchr/testify` | Test assertions | ## Issue Tracking & Branching diff --git a/docs/guide/agent-setup.md b/docs/guide/agent-setup.md index 5208d18..f93901a 100644 --- a/docs/guide/agent-setup.md +++ b/docs/guide/agent-setup.md @@ -40,9 +40,10 @@ This appends a section that tells the agent to: |------|------| | Write to a specific file (skips auto-discovery) | `--target ./MY_AGENT.md` (repeatable) | | Print the snippet to stdout instead of writing | `--print-instructions` | -| Run non-interactively (CI, scripts) | Just pass the flags — prompts only fire on a TTY | +| Run non-interactively (CI, scripts) | Pass `--instructions` or `--no-instructions` — any instruction flag disables the prompts, and they never fire without a TTY anyway | +| Skip the snippet entirely, no prompt | `--no-instructions` | -When run on a TTY without `--instructions`/`--print-instructions`/`--target`, `skern init` asks whether to write the snippet and whether to include the tool-forming loop. Both default to **No**. Non-interactive runs honor flag values only. +When run on a TTY with no instruction flag at all, `skern init` asks whether to write the snippet and whether to include the tool-forming loop. Both default to **No**. Any instruction flag silences both questions (so `skern init --instructions` in a terminal writes the snippet and returns — it does not stop to ask about the tool-forming loop). When stdin is not a TTY or `--json` is set, skern never prompts — both answers resolve to No. Installers and CI that don't want the snippet should say so explicitly with `skern init --no-instructions`, which writes nothing and never prompts regardless of TTY state. ## How the Loop Works diff --git a/docs/platforms/github-copilot.md b/docs/platforms/github-copilot.md index f8f5f0b..420faee 100644 --- a/docs/platforms/github-copilot.md +++ b/docs/platforms/github-copilot.md @@ -11,6 +11,10 @@ The project-level path is shared with `codex-cli`, `cursor`, and `gemini-cli`. See [Platform Adapters › Shared project directory](/concepts/platform-adapters#shared-project-directory). +### Why `.agents/skills/` and not `.github/skills/`? + +Copilot discovers project skills from **any** of `.github/skills/`, `.claude/skills/`, and `.agents/skills/`, and personal skills from `~/.copilot/skills/` or `~/.agents/skills/` ([GitHub docs: About agent skills](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills)). All three project locations are valid; skern uses `.agents/skills/` because it is the cross-agent convention shared with Codex CLI, Cursor, and Gemini CLI, so a project-scoped install reaches every agent that reads it. If you need a Copilot-only location, install manually to `.github/skills/` — a per-platform destination override is tracked in [#101](https://github.com/devrimcavusoglu/skern/issues/101). + ## Install a Skill ```sh diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 62eed23..87f2daa 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -50,6 +50,7 @@ skern init --instructions # also writes the snippet to disco 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; repeatable) skern init --print-instructions # print the snippet to stdout instead of writing files +skern init --no-instructions # explicit opt-out: never writes, never prompts (installers, CI) ``` **Flags:** @@ -57,13 +58,19 @@ skern init --print-instructions # print the snippet to stdout inst | Flag | Description | |------|-------------| | `--instructions` | Write the skern usage snippet to discovered agent config files. Default: off. | +| `--no-instructions` | Explicit opt-out: do not write or offer the snippet, and never prompt. Mutually exclusive with `--instructions`, `--print-instructions`, `--target`, and `--tool-forming-loop` (exit 2 if combined, and nothing — not even `.skern/` — is created). | | `--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 a duplicate. -When run on a TTY without `--json` or any of the instruction flags, `skern init` prompts 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. +**Interactivity contract.** `skern init` asks its two questions (write instructions? include tool-forming loop?) only when **all** of these hold: no instruction flag was given, stdin is a terminal, and `--json` is not set. Both default to **No**. + +- Any instruction flag — `--instructions`, `--no-instructions`, `--print-instructions`, `--target`, `--tool-forming-loop` — disables **both** prompts; an unasked question keeps its default (so `--instructions` alone writes the snippet without the tool-forming section, and never waits on the second question). +- When stdin is **not** a terminal (installers, CI, piped or redirected input, `/dev/null`) or `--json` is set, skern never prompts and never blocks on input — both answers resolve to **No** and only flag values are honored. Terminal detection is a real isatty check, not a character-device test, so `< /dev/null` counts as non-interactive. + +This is a documented guarantee, not an accident of the prompt's default. Automated callers should still pass `--no-instructions` (or `--instructions`) so their intent is explicit rather than inferred from stdin. ## `skern skill create` diff --git a/go.mod b/go.mod index 536c6b5..cceb0cd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,4 +14,5 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 5352f4f..10cceb0 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,10 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/init.go b/internal/cli/init.go index 54eb2b9..744393c 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -11,11 +11,13 @@ import ( "github.com/devrimcavusoglu/skern/internal/cli/instructions" "github.com/devrimcavusoglu/skern/internal/output" "github.com/spf13/cobra" + "golang.org/x/term" ) func newInitCmd() *cobra.Command { var ( writeInstr bool + noInstr bool toolForming bool printInstr bool targetPaths []string @@ -30,11 +32,22 @@ Optionally writes a skern usage snippet into agent instruction files 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.`, +wrapped in start/end markers so re-running updates the block in place. + +Interactivity: init asks its two questions (write the snippet? include the +tool-forming loop?) only when no instruction flag is given, stdin is a +terminal, and --json is not set. Any instruction flag (--instructions, +--no-instructions, --print-instructions, --target, --tool-forming-loop) +disables both prompts. When stdin is not a TTY (installers, CI, piped or +redirected input, /dev/null) or --json is set, skern never prompts and never +blocks on input — both answers default to "no". Pass --no-instructions to +state that opt-out explicitly instead of relying on the non-TTY default, or +--instructions to opt in.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runInit(cmd, runInitOpts{ writeInstr: writeInstr, + noInstr: noInstr, toolForming: toolForming, printInstr: printInstr, targetPaths: targetPaths, @@ -44,6 +57,8 @@ wrapped in start/end markers so re-running updates the block in place.`, 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(&noInstr, "no-instructions", false, + "do not write or offer the instruction snippet; never prompts (explicit opt-out for installers and CI)") 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, @@ -56,6 +71,7 @@ wrapped in start/end markers so re-running updates the block in place.`, type runInitOpts struct { writeInstr bool + noInstr bool toolForming bool printInstr bool targetPaths []string @@ -64,6 +80,12 @@ type runInitOpts struct { func runInit(cmd *cobra.Command, opts runInitOpts) error { cc := getContext(cmd) + // Flag contradictions are usage errors; reject them before creating + // anything on disk so a failed run leaves no trace. + if err := validateInitFlags(opts); err != nil { + return err + } + skillsDir := filepath.Join(".", ".skern", "skills") skernPath := filepath.Join(".", ".skern") created := true @@ -136,34 +158,69 @@ func handleInstructions(cmd *cobra.Command, cc *CommandContext, opts runInitOpts return res, nil } +// validateInitFlags rejects contradictory flag combinations (#104): the +// explicit opt-out cannot be combined with any opt-in. Values, not +// "changed" state, are compared, so `--instructions=false --no-instructions` +// is accepted as the consistent statement it is. +func validateInitFlags(opts runInitOpts) error { + if !opts.noInstr { + return nil + } + switch { + case opts.writeInstr: + return &ValidationError{Message: "--no-instructions cannot be combined with --instructions"} + case opts.printInstr: + return &ValidationError{Message: "--no-instructions cannot be combined with --print-instructions"} + case len(opts.targetPaths) > 0: + return &ValidationError{Message: "--no-instructions cannot be combined with --target"} + case opts.toolForming: + return &ValidationError{Message: "--no-instructions cannot be combined with --tool-forming-loop"} + } + return nil +} + // resolveInstructionChoices folds flag values + TTY interactivity into the // final (writeInstructions, toolFormingLoop) decision. +// +// The contract: prompts appear only when no instruction flag was given, +// stdin is a terminal, and output is not JSON. Any instruction flag — +// including the explicit opt-out — silences both prompts, and a +// non-terminal stdin (pipe, file, /dev/null, CI) never prompts and never +// blocks, resolving both questions to "no". func resolveInstructionChoices(cmd *cobra.Command, cc *CommandContext, opts runInitOpts) (bool, bool, error) { flags := cmd.Flags() + // Explicit opt-out (#104): nothing is written and neither prompt runs, + // regardless of TTY state (flag conflicts were rejected up front). + if opts.noInstr { + return false, false, nil + } + 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). + // Any instruction flag means the caller chose flags over prompts; only + // the no-flag, interactive case asks. + flagged := flags.Changed("instructions") || flags.Changed("print-instructions") || + flags.Changed("target") || flags.Changed("tool-forming-loop") in := cmd.InOrStdin() - canPrompt := !cc.Printer.IsJSON() && isTerminal(in) + canPrompt := !flagged && !cc.Printer.IsJSON() && isTerminalFn(in) + if !canPrompt { + return wantInstr, wantToolForming, nil + } // 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 + 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") { + if wantInstr { yes, err := promptYesNo(in, promptOut, "Include tool-forming-loop section (instructs the agent to search before creating)?", false) if err != nil { @@ -184,19 +241,21 @@ func resolveTargets(opts runInitOpts) ([]string, error) { 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. +// isTerminalFn decides whether stdin is interactive. A package variable so +// tests can simulate a terminal without a pty. +var isTerminalFn = isTerminal + +// isTerminal reports whether r is a *os.File attached to a terminal, using a +// real isatty check. A character-device test is not enough: /dev/null (and +// NUL on Windows) is a character device but not a terminal, and an installer +// running `skern init < /dev/null` must not see a prompt. Non-file readers +// (test injectees) are never terminals. 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 + return term.IsTerminal(int(f.Fd())) } // promptYesNo writes prompt to w and reads a y/n answer from r. The default diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index aee1f6f..99113ee 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -1,7 +1,9 @@ package cli import ( + "bytes" "encoding/json" + "io" "os" "path/filepath" "strings" @@ -240,3 +242,205 @@ func TestInit_Instructions_NoPromptInJSONMode(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(out), &result)) assert.Nil(t, result.Instructions) } + +// #104: --no-instructions is the explicit non-interactive opt-out. It must +// write nothing, prompt nothing, and report no instructions result — even +// when an instruction file is present to be discovered. +func TestInit_NoInstructions_WritesNothing(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", "--no-instructions", "--json") + require.NoError(t, err) + + var result output.InitResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Nil(t, result.Instructions) + assert.True(t, result.Created) + + got, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + require.NoError(t, err) + assert.Equal(t, "# Project\n", string(got), "AGENTS.md must be untouched") + assert.NotContains(t, out, "Append skern usage instructions", "no prompt text may be emitted") +} + +func TestInit_NoInstructions_TextMode(t *testing.T) { + withTempCwd(t) + + out, err := runCmd(t, nil, "init", "--no-instructions") + require.NoError(t, err) + assert.Contains(t, out, "Initialized") + assert.NotContains(t, out, "instruction") +} + +// Opting out and opting in at once is a contradiction: validation error +// (exit 2), nothing written — not even .skern/. +func TestInit_NoInstructions_ConflictsWithOptIn(t *testing.T) { + for _, optIn := range [][]string{ + {"--instructions"}, + {"--print-instructions"}, + {"--target", "AGENTS.md"}, + {"--tool-forming-loop"}, + } { + t.Run(optIn[0], func(t *testing.T) { + dir := withTempCwd(t) + args := append([]string{"init", "--no-instructions"}, optIn...) + _, err := runCmd(t, nil, args...) + require.Error(t, err) + var ve *ValidationError + require.ErrorAs(t, err, &ve) + assert.Contains(t, err.Error(), "--no-instructions cannot be combined with "+optIn[0]) + _, statErr := os.Stat(filepath.Join(dir, "AGENTS.md")) + assert.True(t, os.IsNotExist(statErr), "nothing should be written on a flag conflict") + _, statErr = os.Stat(filepath.Join(dir, ".skern")) + assert.True(t, os.IsNotExist(statErr), ".skern/ must not be created when flags are rejected") + }) + } + + // Values are compared, not "changed" state: an explicit false is consistent. + t.Run("explicit false is allowed", func(t *testing.T) { + dir := withTempCwd(t) + _, err := runCmd(t, nil, "init", "--no-instructions", "--instructions=false", "--tool-forming-loop=false") + require.NoError(t, err) + _, statErr := os.Stat(filepath.Join(dir, ".skern", "skills")) + require.NoError(t, statErr) + }) +} + +// lineReader hands out at most one line per Read, the way a terminal in +// canonical mode does, so successive bufio.Scanners over the same stdin each +// see exactly one answer (a plain strings.Reader would be slurped whole by +// the first scanner). +type lineReader struct{ rest string } + +func (l *lineReader) Read(p []byte) (int, error) { + if l.rest == "" { + return 0, io.EOF + } + n := strings.IndexByte(l.rest, '\n') + 1 + if n == 0 { + n = len(l.rest) + } + n = copy(p, l.rest[:n]) + l.rest = l.rest[n:] + return n, nil +} + +// runInitWithTTY runs init with a simulated terminal on stdin feeding `input`. +// It returns the combined stdout+stderr, the error, and whatever of `input` +// was left unread (so tests can prove no prompt consumed it). +func runInitWithTTY(t *testing.T, input string, args ...string) (out string, rest string, err error) { + t.Helper() + orig := isTerminalFn + isTerminalFn = func(io.Reader) bool { return true } + t.Cleanup(func() { isTerminalFn = orig }) + + in := &lineReader{rest: input} + cmd := newRootCmd(nil) + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetIn(in) + cmd.SetArgs(append([]string{"init"}, args...)) + err = cmd.Execute() + return buf.String(), in.rest, err +} + +const promptInstr = "Append skern usage instructions" +const promptToolForming = "Include tool-forming-loop section" + +// With a terminal and no flags, both prompts fire and are honored — this +// proves the TTY simulation reaches the prompt path, so the negative tests +// below are meaningful. +func TestInit_TTY_NoFlags_Prompts(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Project\n"), 0o644)) + + out, rest, err := runInitWithTTY(t, "y\ny\n") + require.NoError(t, err) + assert.Contains(t, out, promptInstr) + assert.Contains(t, out, promptToolForming) + assert.Empty(t, rest, "both answers should have been consumed") + got, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + assert.Contains(t, string(got), instructions.StartMarker) + assert.Contains(t, string(got), "Tool-forming loop") + + // Declining the first question skips the second. + withTempCwd(t) + out, rest, err = runInitWithTTY(t, "n\ny\n") + require.NoError(t, err) + assert.Contains(t, out, promptInstr) + assert.NotContains(t, out, promptToolForming) + assert.Equal(t, "y\n", rest) +} + +// --no-instructions on a real terminal: no prompt text, stdin untouched, +// nothing written. This is the guarantee #104 asks for. +func TestInit_TTY_NoInstructions_NeverPrompts(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Project\n"), 0o644)) + + out, rest, err := runInitWithTTY(t, "y\ny\n", "--no-instructions") + require.NoError(t, err) + assert.NotContains(t, out, promptInstr) + assert.NotContains(t, out, promptToolForming) + assert.Equal(t, "y\ny\n", rest, "stdin must not be read") + got, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + assert.Equal(t, "# Project\n", string(got)) +} + +// Any instruction flag silences both prompts, including the second one: a +// setup script running `skern init --instructions` in a terminal must not +// hang on the tool-forming question. +func TestInit_TTY_InstructionFlagsSilencePrompts(t *testing.T) { + for _, args := range [][]string{ + {"--instructions"}, + {"--print-instructions"}, + {"--target", "AGENTS.md"}, + {"--tool-forming-loop"}, + {"--instructions=false"}, + {"--json"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Project\n"), 0o644)) + out, rest, err := runInitWithTTY(t, "y\ny\n", args...) + require.NoError(t, err) + assert.NotContains(t, out, promptInstr) + assert.NotContains(t, out, promptToolForming) + assert.Equal(t, "y\ny\n", rest, "stdin must not be read") + }) + } + + // And --instructions alone writes the snippet without the tool-forming + // section (the unasked question defaults to no). + dir := withTempCwd(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Project\n"), 0o644)) + _, _, err := runInitWithTTY(t, "", "--instructions") + require.NoError(t, err) + got, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + assert.Contains(t, string(got), instructions.StartMarker) + assert.NotContains(t, string(got), "Tool-forming loop") +} + +// The real isTerminal must say "not a terminal" for the non-TTY inputs an +// installer is likely to hand us: /dev/null, a pipe, a regular file, a +// non-file reader. (A real pty is not available under go test.) +func TestIsTerminal_NonTTYInputs(t *testing.T) { + devnull, err := os.Open(os.DevNull) + require.NoError(t, err) + defer func() { _ = devnull.Close() }() + assert.False(t, isTerminal(devnull), os.DevNull+" is a character device but not a terminal") + + f, err := os.CreateTemp(t.TempDir(), "stdin") + require.NoError(t, err) + defer func() { _ = f.Close() }() + assert.False(t, isTerminal(f)) + + r, w, err := os.Pipe() + require.NoError(t, err) + defer func() { _ = r.Close(); _ = w.Close() }() + assert.False(t, isTerminal(r)) + + assert.False(t, isTerminal(strings.NewReader(""))) +} diff --git a/tests/manual/setup.sh b/tests/manual/setup.sh index 875cfed..4359e5d 100755 --- a/tests/manual/setup.sh +++ b/tests/manual/setup.sh @@ -119,13 +119,13 @@ setup_scenario() { # Scenario 01: Fresh Project — empty registry echo "Setting up 01-fresh-project..." DIR="$(setup_scenario 01 fresh-project)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) init_git "$DIR" # Scenario 02: Existing Skills — 3 pre-populated skills echo "Setting up 02-existing-skills..." DIR="$(setup_scenario 02 existing-skills)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) write_skill "$DIR" "go-formatter" "Formats Go source files using gofmt and goimports" write_skill "$DIR" "python-linter" "Lints Python code with ruff and reports issues" write_skill "$DIR" "markdown-toc" "Generates table of contents for markdown files" @@ -134,7 +134,7 @@ init_git "$DIR" # Scenario 03: Overlap Detection — 2 skills with overlapping descriptions echo "Setting up 03-overlap-detection..." DIR="$(setup_scenario 03 overlap-detection)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) write_skill "$DIR" "code-review" "Review code changes and suggest code improvements" write_skill "$DIR" "lint-python" "Lint Python source code and report lint errors" init_git "$DIR" @@ -142,7 +142,7 @@ init_git "$DIR" # Scenario 04: Multi-Platform Install — 1 skill, all 3 platform dirs echo "Setting up 04-multi-platform-install..." DIR="$(setup_scenario 04 multi-platform-install)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) write_skill "$DIR" "deploy-helper" "Assists with deployment steps and checklists" mkdir -p "$DIR/.claude" "$DIR/.agents" "$DIR/.opencode" init_git "$DIR" @@ -150,14 +150,14 @@ init_git "$DIR" # Scenario 05: Full Lifecycle JSON — empty registry, .claude/ present echo "Setting up 05-full-lifecycle-json..." DIR="$(setup_scenario 05 full-lifecycle-json)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) mkdir -p "$DIR/.claude" init_git "$DIR" # Scenario 06: Error Recovery — 1 skill, .claude/ present echo "Setting up 06-error-recovery..." DIR="$(setup_scenario 06 error-recovery)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) write_skill "$DIR" "test-runner" "Run test suites and report test results for the project" mkdir -p "$DIR/.claude" init_git "$DIR" @@ -165,13 +165,13 @@ init_git "$DIR" # Scenario 07: Scoped Skill Management — empty registry echo "Setting up 07-scoped-skill-management..." DIR="$(setup_scenario 07 scoped-skill-management)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) init_git "$DIR" # Scenario 08: Deduplication Advisory — 5 overlapping skills echo "Setting up 08-deduplication-advisory..." DIR="$(setup_scenario 08 deduplication-advisory)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) write_skill "$DIR" "test-runner" "Run test suites and report test results for the project" write_skill "$DIR" "run-tests" "Run test suites and report results across the project" write_skill "$DIR" "test-runner-v2" "Run test suites and report test results with coverage" @@ -182,7 +182,7 @@ init_git "$DIR" # Scenario 09: Template Skills — empty registry, template files provided echo "Setting up 09-template-skills..." DIR="$(setup_scenario 09 template-skills)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) # Copy template files if [ -d "$SCENARIOS_DIR/09-template-skills/templates" ]; then mkdir -p "$DIR/templates" @@ -193,7 +193,7 @@ init_git "$DIR" # Scenario 10: Platform Status Matrix — 3 skills, partial installs echo "Setting up 10-platform-status-matrix..." DIR="$(setup_scenario 10 platform-status-matrix)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) write_skill "$DIR" "go-formatter" "Formats Go source files using gofmt and goimports" write_skill "$DIR" "db-migrate" "Run database migrations and track schema changes" write_skill "$DIR" "api-docs" "Generate API documentation from source code annotations" @@ -207,7 +207,7 @@ init_git "$DIR" # Scenario 11: Autonomous Skill Creation — Go project with inconsistencies, no mention of skills echo "Setting up 11-autonomous-skill-creation..." DIR="$(setup_scenario 11 autonomous-skill-creation)" -(cd "$DIR" && skern init --quiet 2>/dev/null) +(cd "$DIR" && skern init --no-instructions --quiet 2>/dev/null) mkdir -p "$DIR/.claude" # pkg/auth/auth.go — Clean formatting, no doc comments