Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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])
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/concepts/platform-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/contributing/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/guide/agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/platforms/github-copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,27 @@ 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:**

| 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 <path>` | 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 `<!-- skern:instructions:start -->` / `<!-- skern:instructions:end -->` 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`

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
101 changes: 80 additions & 21 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading