diff --git a/.infer/prompts.yaml b/.infer/prompts.yaml
index 76d76754..04af4784 100644
--- a/.infer/prompts.yaml
+++ b/.infer/prompts.yaml
@@ -117,6 +117,8 @@ agent:
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.
+ wrap_up_text: ""
+ wrap_up_threshold: 0
git:
commit_message:
system_prompt: |-
diff --git a/cmd/skills.go b/cmd/skills.go
index 63f2cfcb..a84a5414 100644
--- a/cmd/skills.go
+++ b/cmd/skills.go
@@ -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//SKILL.md (project)
+ - .agents/skills//SKILL.md (open standard)
- ~/.infer/skills//SKILL.md (user-global)
Skills are enabled by default - disable via agent.skills.enabled=false in config or
@@ -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,
}
diff --git a/config/config.go b/config/config.go
index c9a65a47..81dfae80 100644
--- a/config/config.go
+++ b/config/config.go
@@ -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"
@@ -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"))
}
diff --git a/config/config_test.go b/config/config_test.go
index 68b7d829..14a22930 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -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
diff --git a/docs/directory-structure.md b/docs/directory-structure.md
index 7afb27d8..94c7d44f 100644
--- a/docs/directory-structure.md
+++ b/docs/directory-structure.md
@@ -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` ---
@@ -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)
+ └── /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)
@@ -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.
diff --git a/docs/skills.md b/docs/skills.md
index 93c05784..e8616c52 100644
--- a/docs/skills.md
+++ b/docs/skills.md
@@ -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
@@ -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//SKILL.md`
-- User-global: `~/.infer/skills//SKILL.md`
+1. Project-local: `.infer/skills//SKILL.md`
+2. Open standard: `.agents/skills//SKILL.md`
+3. User-global: `~/.infer/skills//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
@@ -180,7 +186,7 @@ The on-disk contract is intentionally identical to:
- OpenAI Codex CLI -
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)
diff --git a/internal/domain/skills.go b/internal/domain/skills.go
index e1639b92..b069f0f6 100644
--- a/internal/domain/skills.go
+++ b/internal/domain/skills.go
@@ -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"
)
diff --git a/internal/services/skills/skills.go b/internal/services/skills/skills.go
index 607b22ea..d3401610 100644
--- a/internal/services/skills/skills.go
+++ b/internal/services/skills/skills.go
@@ -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{
diff --git a/internal/services/skills/skills_test.go b/internal/services/skills/skills_test.go
index 47e8177a..20dbdc33 100644
--- a/internal/services/skills/skills_test.go
+++ b/internal/services/skills/skills_test.go
@@ -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."))
diff --git a/internal/ui/components/snippet_attachments_view.go b/internal/ui/components/snippet_attachments_view.go
index b6dcb7e8..7bc32370 100644
--- a/internal/ui/components/snippet_attachments_view.go
+++ b/internal/ui/components/snippet_attachments_view.go
@@ -23,7 +23,7 @@ type SnippetAttachmentsView struct {
snippets []SnippetSelection
order []int
focused bool
- cursor int
+ cursor int
focusHint string
}