Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .infer/prompts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ agent:
<system-reminder>
This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.
</system-reminder>
wrap_up_text: ""
wrap_up_threshold: 0
git:
commit_message:
system_prompt: |-
Expand Down
13 changes: 7 additions & 6 deletions cmd/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ var skillsCmd = &cobra.Command{

Skills are folders containing a SKILL.md file with YAML frontmatter (name,
description). The format matches the contract shared by the official spec, so existing skill folders authored for
any of them can be dropped into .infer/skills/ unchanged.
any of them can be dropped into .infer/skills/ (or the .agents/skills/ open standard) unchanged.

Locations scanned (project precedes user-global on name collision):
Locations scanned (highest precedence first; first match wins on name collision):
- .infer/skills/<name>/SKILL.md (project)
- .agents/skills/<name>/SKILL.md (open standard)
- ~/.infer/skills/<name>/SKILL.md (user-global)

Skills are enabled by default - disable via agent.skills.enabled=false in config or
Expand All @@ -34,11 +35,11 @@ the enable flag so you can verify discovery.`,
var skillsListCmd = &cobra.Command{
Use: "list",
Short: "List discovered skills",
Long: `List discovered Agent Skills from .infer/skills/ and ~/.infer/skills/.
Long: `List discovered Agent Skills from .infer/skills/, .agents/skills/, and ~/.infer/skills/.

Output includes each skill's name, scope (project / user), one-line description,
and the absolute path to its SKILL.md. Validation errors for skipped skills are
shown so you can fix authoring mistakes.`,
Output includes each skill's name, scope (project / agents / user), one-line
description, and the absolute path to its SKILL.md. Validation errors for
skipped skills are shown so you can fix authoring mistakes.`,
RunE: listSkills,
}

Expand Down
11 changes: 8 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

const (
ConfigDirName = ".infer"
AgentsDirName = ".agents" // open-standard skills dir (.agents/skills/), scanned project-relative only
ConfigFileName = "config.yaml"
GitignoreFileName = ".gitignore"
LogsDirName = "logs"
Expand Down Expand Up @@ -1161,17 +1162,21 @@ func (c *Config) ValidatePathInSandbox(path string) error {
return fmt.Errorf("path '%s' is outside configured sandbox directories", path)
}

// isWithinSkillsDir reports whether absPath lives inside either the project
// (./.infer/skills) or user-global (~/.infer/skills) skills directory. Feeds
// isWithinSkillsDir reports whether absPath lives inside one of the skills
// directories: the project (./.infer/skills), the open-standard
// (./.agents/skills), or the user-global (~/.infer/skills) location. Feeds
// the carveOut path in ValidatePathInSandbox - gated there on
// agent.skills.enabled - so reads of SKILL.md and references/*.md succeed even
// though the broader .infer/ directory is in ProtectedPaths. File-level
// protections like *.env still apply.
func isWithinSkillsDir(absPath string) bool {
dirs := make([]string, 0, 2)
dirs := make([]string, 0, 3)
if projectDir, err := filepath.Abs(filepath.Join(ConfigDirName, "skills")); err == nil {
dirs = append(dirs, projectDir)
}
if agentsDir, err := filepath.Abs(filepath.Join(AgentsDirName, "skills")); err == nil {
dirs = append(dirs, agentsDir)
}
if homeDir, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(homeDir, ConfigDirName, "skills"))
}
Expand Down
51 changes: 51 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,57 @@ func TestValidatePathInSandbox_SkillsCarveOut(t *testing.T) {
})
}

// TestValidatePathInSandbox_AgentsSkillsCarveOut covers the .agents/skills open
// standard. Unlike .infer/, the .agents/ directory is not in ProtectedPaths, so
// the carve-out is only observable against a *restrictive* sandbox: with skills
// enabled, .agents/skills/** must be readable even though it sits outside the
// configured sandbox dirs (parity with .infer/skills), while non-skills paths
// under .agents and lookalike siblings stay denied.
func TestValidatePathInSandbox_AgentsSkillsCarveOut(t *testing.T) {
sandboxDir := t.TempDir()

agentsSkill, err := filepath.Abs(filepath.Join(AgentsDirName, "skills", "demo", "SKILL.md"))
if err != nil {
t.Fatalf("failed to resolve .agents skill path: %v", err)
}
relAgentsSkill := filepath.Join(AgentsDirName, "skills", "demo", "SKILL.md")
agentsRef := filepath.Join(AgentsDirName, "skills", "demo", "references", "guide.md")
agentsNonSkill := filepath.Join(AgentsDirName, "config.yaml")
agentsLookalike := filepath.Join(AgentsDirName, "skills-evil", "demo", "SKILL.md")

t.Run("skills enabled: .agents/skills carved out of a restrictive sandbox", func(t *testing.T) {
cfg := DefaultConfig()
cfg.Tools.Sandbox.Directories = []string{sandboxDir}
if !cfg.Agent.Skills.Enabled {
t.Fatalf("expected skills enabled by default")
}

for _, p := range []string{agentsSkill, relAgentsSkill, agentsRef} {
if err := cfg.ValidatePathInSandbox(p); err != nil {
t.Fatalf("expected %s allowed via carve-out, got %v", p, err)
}
}

for _, p := range []string{agentsNonSkill, agentsLookalike} {
if err := cfg.ValidatePathInSandbox(p); err == nil {
t.Fatalf("expected %s denied (not a skills path, outside sandbox)", p)
}
}
})

t.Run("skills disabled: .agents/skills denied by the restrictive sandbox", func(t *testing.T) {
cfg := DefaultConfig()
cfg.Tools.Sandbox.Directories = []string{sandboxDir}
cfg.Agent.Skills.Enabled = false

for _, p := range []string{agentsSkill, relAgentsSkill} {
if err := cfg.ValidatePathInSandbox(p); err == nil {
t.Fatalf("expected %s denied while skills are disabled", p)
}
}
})
}

// TestValidatePathInSandbox_ConfigDir locks in the directory-wide protection of
// the config dir: sensitive config files are denied wholesale, while the
// operational subdirs (tmp, plans) stay reachable - except for files that match
Expand Down
8 changes: 8 additions & 0 deletions docs/directory-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ for the full precedence rules.
│ ├── shells.yaml
│ ├── export.yaml
│ └── a2a.yaml
├── skills/ # Agent Skills - SKILL.md folders, see docs/skills.md
├── .gitignore # ignores the runtime-generated files below
│ # --- created at runtime, not by `infer init` ---
Expand All @@ -64,6 +65,10 @@ for the full precedence rules.
├── plans/ # plan-mode plans saved by RequestPlanApproval (one .md per plan)
└── history # chat input history (one entry per line)

.agents/ # open-standard project layer (cross-tool skills)
└── skills/ # Agent Skills - SKILL.md folders (read-only discovery)
└── <name>/SKILL.md # e.g. .agents/skills/pdf/SKILL.md

~/.infer/ # userspace layer - same set of config files,
# plus one extra:
└── schedules/ # cron-driven scheduled jobs (one YAML per job)
Expand Down Expand Up @@ -98,6 +103,9 @@ them exist in both layers (project and userspace).
- **`shortcuts/*.yaml`** - `/git`, `/scm`, `/mcp`, `/shells`, `/export`,
`/agents` shortcuts plus any you add. Drop new YAML files into
`shortcuts/`. See [Shortcuts Guide](shortcuts-guide.md).
- **`skills/`** - Agent Skills directory. Drop a `SKILL.md` folder here (or
into the cross-tool `.agents/skills/` open standard) to extend the agent.
See [Skills](skills.md).
- **`.gitignore`** - pre-populated to exclude the runtime-generated files
below.

Expand Down
20 changes: 13 additions & 7 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Skills are reusable, model-readable instruction folders that the agent loads
on demand. The Inference Gateway CLI uses the **same on-disk format** as per standard, so a folder
authored for any of those tools drops into `.infer/skills/` unchanged.
authored for any of those tools drops into `.infer/skills/` (or the
`.agents/skills/` open standard) unchanged.

## Format

Expand Down Expand Up @@ -41,13 +42,18 @@ failures even though the CLI ignores them.

## Locations

The CLI scans two directories:
The CLI scans three directories, in precedence order (first match wins on a
`name` collision):

- Project-local: `.infer/skills/<name>/SKILL.md`
- User-global: `~/.infer/skills/<name>/SKILL.md`
1. Project-local: `.infer/skills/<name>/SKILL.md`
2. Open standard: `.agents/skills/<name>/SKILL.md`
3. User-global: `~/.infer/skills/<name>/SKILL.md`

Project skills override user-global skills with the same `name` - useful for
overriding a personal default with a per-project variant.
`.agents/skills/` is the emerging cross-tool convention (Claude Code, Gemini
CLI, Codex CLI), so a repo that ships skills there works without moving them
into `.infer/skills/`. A project's `.infer/skills/` still wins over both the
open-standard and user-global locations - useful for overriding a personal or
shared default with a per-project variant.

## Enabling

Expand Down Expand Up @@ -180,7 +186,7 @@ The on-disk contract is intentionally identical to:
- OpenAI Codex CLI - <https://simonwillison.net/2025/Dec/12/openai-skills/>

Folders from `github.com/anthropics/skills` and `github.com/google/skills`
work without modification when copied into `.infer/skills/`.
work without modification when copied into `.infer/skills/` or `.agents/skills/`.

## Out of scope (for now)

Expand Down
10 changes: 6 additions & 4 deletions internal/domain/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ package domain

import "context"

// SkillScope identifies whether a skill came from the project (.infer/skills/)
// or the user-global location (~/.infer/skills/). Used by the system-prompt
// injection and `infer skills list` to disambiguate same-name skills and to
// tell the user where each skill lives.
// SkillScope identifies where a skill came from: the project (.infer/skills/),
// the open-standard location (.agents/skills/), or the user-global location
// (~/.infer/skills/). Used by the system-prompt injection and `infer skills
// list` to disambiguate same-name skills and to tell the user where each skill
// lives.
type SkillScope string

const (
SkillScopeProject SkillScope = "project"
SkillScopeAgents SkillScope = "agents"
SkillScopeUser SkillScope = "user"
)

Expand Down
7 changes: 5 additions & 2 deletions internal/services/skills/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,14 @@ type scopedDir struct {
scope domain.SkillScope
}

// searchScopes returns project-first then user-global. Project precedence is
// implemented by the caller via the `seen` map.
// searchScopes returns the skill directories in precedence order: project
// (.infer/skills), then the open-standard .agents/skills, then user-global
// (~/.infer/skills). Precedence is implemented by the caller via the `seen`
// map (first match wins on name collision).
func searchScopes() []scopedDir {
scopes := []scopedDir{
{dir: filepath.Join(config.ConfigDirName, skillsSubdir), scope: domain.SkillScopeProject},
{dir: filepath.Join(config.AgentsDirName, skillsSubdir), scope: domain.SkillScopeAgents},
}
if home, err := os.UserHomeDir(); err == nil {
scopes = append(scopes, scopedDir{
Expand Down
63 changes: 63 additions & 0 deletions internal/services/skills/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,69 @@ func TestPrecedence_ProjectOverridesUser(t *testing.T) {
require.Equal(t, domain.SkillScopeUser, byName["user-only"].Scope)
}

// TestPrecedence_AgentsMiddleScope locks in the three-way precedence introduced
// for the .agents/skills open standard: project > agents > user, first match
// wins on a name collision.
func TestPrecedence_AgentsMiddleScope(t *testing.T) {
projDir := t.TempDir()
agentsDir := t.TempDir()
userDir := t.TempDir()

writeSkill(t, projDir, "all-three", validSkillBody("all-three", "Project version."))
writeSkill(t, agentsDir, "all-three", validSkillBody("all-three", "Agents version."))
writeSkill(t, userDir, "all-three", validSkillBody("all-three", "User version."))

writeSkill(t, agentsDir, "agents-and-user", validSkillBody("agents-and-user", "Agents version."))
writeSkill(t, userDir, "agents-and-user", validSkillBody("agents-and-user", "User version."))

writeSkill(t, agentsDir, "agents-only", validSkillBody("agents-only", "Only in .agents scope."))
writeSkill(t, userDir, "user-only", validSkillBody("user-only", "Only in user scope."))

s := newWithScopes(enabledCfg(), []scopedDir{
{dir: projDir, scope: domain.SkillScopeProject},
{dir: agentsDir, scope: domain.SkillScopeAgents},
{dir: userDir, scope: domain.SkillScopeUser},
})
require.NoError(t, s.Load(context.Background()))

got := s.List()
require.Len(t, got, 4)

byName := map[string]domain.Skill{}
for _, sk := range got {
byName[sk.Name] = sk
}

require.Equal(t, "Project version.", byName["all-three"].Description)
require.Equal(t, domain.SkillScopeProject, byName["all-three"].Scope)

require.Equal(t, "Agents version.", byName["agents-and-user"].Description)
require.Equal(t, domain.SkillScopeAgents, byName["agents-and-user"].Scope)

require.Equal(t, domain.SkillScopeAgents, byName["agents-only"].Scope)
require.Equal(t, domain.SkillScopeUser, byName["user-only"].Scope)
}

// TestSearchScopes_Order guards the scan order that the precedence dedup relies
// on: project (.infer/skills), then the open-standard .agents/skills, then
// user-global (~/.infer/skills).
func TestSearchScopes_Order(t *testing.T) {
scopes := searchScopes()

require.GreaterOrEqual(t, len(scopes), 2)
require.Equal(t, domain.SkillScopeProject, scopes[0].scope)
require.Equal(t, filepath.Join(config.ConfigDirName, skillsSubdir), scopes[0].dir)

require.Equal(t, domain.SkillScopeAgents, scopes[1].scope)
require.Equal(t, filepath.Join(config.AgentsDirName, skillsSubdir), scopes[1].dir)

if home, err := os.UserHomeDir(); err == nil {
require.Len(t, scopes, 3)
require.Equal(t, domain.SkillScopeUser, scopes[2].scope)
require.Equal(t, filepath.Join(home, config.ConfigDirName, skillsSubdir), scopes[2].dir)
}
}

func TestDisabledFilter(t *testing.T) {
tmp := t.TempDir()
writeSkill(t, tmp, "alpha", validSkillBody("alpha", "First."))
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/components/snippet_attachments_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type SnippetAttachmentsView struct {
snippets []SnippetSelection
order []int
focused bool
cursor int
cursor int
focusHint string
}

Expand Down