diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index ae7c4c4f..ec45df45 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '>=1.17.0'
+ go-version: '>=1.26.0'
- name: Run tests
run: |
make tests
@@ -29,6 +29,6 @@ jobs:
uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '>=1.17.0'
+ go-version: '>=1.26.0'
- run: |
make tests-e2e
\ No newline at end of file
diff --git a/Dockerfile.webui b/Dockerfile.webui
index 25164314..2e8cb3bd 100644
--- a/Dockerfile.webui
+++ b/Dockerfile.webui
@@ -16,8 +16,8 @@ COPY webui/react-ui/ ./
# Build the React UI
RUN bun run build
-# Use a temporary build image based on Golang 1.24-alpine
-FROM golang:1.24-alpine AS builder
+# Use a temporary build image based on Golang 1.26-alpine
+FROM golang:1.26-alpine AS builder
# Define argument for linker flags
ARG LDFLAGS="-s -w"
diff --git a/README.md b/README.md
index c4cd18fa..4775a246 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Try on [ and a web browser.
-**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP and give it skills with [skillserver](https://github.com/mudler/skillserver). Every agent exposes a complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
+**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP, and use built-in **Skills** (manage skills in the Web UI and enable them per agent). Every agent exposes a complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU). Skills follow the [skillserver](https://github.com/mudler/skillserver) format and can be created, imported, or synced from git.
## π‘οΈ Take Back Your Privacy
@@ -39,6 +39,7 @@ LocalAGI ensures your data stays exactly where you want itβon your hardware. N
- πΎ **Memory Management**: Control memory usage with options for long-term and summary memory.
- πΌ **Multimodal Support**: Ready for vision, text, and more.
- π§ **Extensible Custom Actions**: Easily script dynamic agent behaviors in Go (interpreted, no compilation!).
+- π **Built-in Skills**: Manage reusable agent skills in the Web UI (create, edit, import/export, git sync). Enable "Skills" per agent to inject skill tools and the skill list into the agent.
- π **Fully Customizable Models**: Use your own models or integrate seamlessly with [LocalAI](https://github.com/mudler/LocalAI).
- π **Observability**: Monitor agent status and view detailed observable updates in real-time.
@@ -195,7 +196,7 @@ Good (relatively small) models that have been tested are:
- **β Flexible Model Integration**: Supports GGUF, GGML, and more thanks to [LocalAI](https://github.com/mudler/LocalAI).
- **β Developer-Friendly**: Rich APIs and intuitive interfaces.
- **β Effortless Setup**: Simple Docker compose setups and pre-built binaries.
-- **β Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, LocalAGI has it all.
+- **β Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, built-in Skills, LocalAGI has it all.
## π Screenshots
@@ -224,6 +225,7 @@ Explore detailed documentation including:
- [REST API Documentation](#rest-api)
- [Connector Configuration](#connectors)
- [Agent Configuration](#agent-configuration-reference)
+- [Skills](#3-skills)
### Environment Configuration
@@ -242,6 +244,8 @@ LocalAGI supports environment configurations. Note that these environment variab
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
| `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded |
+Skills are stored in a fixed `skills` subdirectory under `LOCALAGI_STATE_DIR` (e.g. `/pool/skills` in Docker). Git repo config for skills lives in that directory. No extra environment variables are required.
+
## Installation Options
### Pre-Built Binaries
@@ -693,6 +697,16 @@ You can create MCP servers in any language that supports the MCP protocol and ad
- **Testing**: Test your MCP servers independently before integrating with LocalAGI
- **Resource Management**: Ensure your MCP servers properly clean up resources
+### 3. Skills
+
+LocalAGI includes built-in **Skills** management. Skills are reusable instructions and resources (scripts, references, assets) that agents can use when "Enable Skills" is turned on for that agent.
+
+- **Skills section (Web UI)**: Open **Skills** in the sidebar. Skills are stored under the state directory (`STATE_DIR/skills`). Create, edit, search, import, and export skills. You can also add git repositories to sync skills from.
+- **Per-agent**: In agent creation or settings, enable **Enable Skills** in Advanced Settings. The agent will receive a list of available skills in its context and have access to skill tools (list, read, search, resources) via the built-in skills MCP.
+- Skills use the same format as [skillserver](https://github.com/mudler/skillserver) (e.g. `SKILL.md` in a directory). You can export skills from LocalAGI and use them with the standalone skillserver, or import skills created elsewhere.
+
+In Docker, the state directory is persisted (`/pool`), so skills are stored in `/pool/skills`. To use a host folder for skills, mount it over that path in your compose file (e.g. `- ./my-skills:/pool/skills`).
+
### Development
The development workflow is similar to the source build, but with additional steps for hot reloading of the frontend:
diff --git a/core/agent/mcp.go b/core/agent/mcp.go
index 9d521bf0..918c5728 100644
--- a/core/agent/mcp.go
+++ b/core/agent/mcp.go
@@ -211,13 +211,34 @@ func (a *Agent) initMCPActions() error {
generatedActions = append(generatedActions, actions...)
}
+ // Pre-connected MCP sessions (e.g. in-process skills server); already in a.mcpSessions after closeMCPServers()
+ for _, session := range a.options.extraMCPSessions {
+ actions, err := a.addTools(session)
+ if err != nil {
+ xlog.Error("Failed to add tools for extra MCP session", "error", err.Error())
+ continue
+ }
+ a.mcpSessions = append(a.mcpSessions, session)
+ generatedActions = append(generatedActions, actions...)
+ }
+
a.mcpActionDefinitions = generatedActions
return err
}
func (a *Agent) closeMCPServers() {
+ extraSet := make(map[*mcp.ClientSession]bool)
+ for _, e := range a.options.extraMCPSessions {
+ extraSet[e] = true
+ }
+ var keep []*mcp.ClientSession
for _, s := range a.mcpSessions {
- s.Close()
+ if extraSet[s] {
+ keep = append(keep, s)
+ } else {
+ s.Close()
+ }
}
+ a.mcpSessions = keep
}
diff --git a/core/agent/options.go b/core/agent/options.go
index 7f489a32..cd58b035 100644
--- a/core/agent/options.go
+++ b/core/agent/options.go
@@ -5,6 +5,7 @@ import (
"strings"
"time"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/LocalAGI/core/types"
)
@@ -80,6 +81,7 @@ type options struct {
mcpServers []MCPServer
mcpStdioServers []MCPSTDIOServer
mcpPrepareScript string
+ extraMCPSessions []*mcp.ClientSession
newConversationsSubscribers []func(*types.ConversationMessage)
observer Observer
@@ -329,6 +331,14 @@ func WithPrompts(prompts ...DynamicPrompt) Option {
}
}
+// WithMCPSession adds a pre-connected MCP client session (e.g. in-process skills MCP) to the agent.
+func WithMCPSession(session *mcp.ClientSession) Option {
+ return func(o *options) error {
+ o.extraMCPSessions = append(o.extraMCPSessions, session)
+ return nil
+ }
+}
+
// WithDynamicPrompts is a helper function to create dynamic prompts
// Dynamic prompts contains golang code which is executed dynamically
// // to render a prompt to the LLM
diff --git a/core/state/config.go b/core/state/config.go
index 9abd3afe..2bcdaf3b 100644
--- a/core/state/config.go
+++ b/core/state/config.go
@@ -95,9 +95,11 @@ type AgentConfig struct {
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
EnableForceReasoningTool bool `json:"enable_reasoning_tool" form:"enable_reasoning_tool"`
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
+ EnableSkills bool `json:"enable_skills" form:"enable_skills"`
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
+ SkillsPrompt string `json:"skills_prompt" form:"skills_prompt"`
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
ConversationStorageMode string `json:"conversation_storage_mode" form:"conversation_storage_mode"`
@@ -330,6 +332,14 @@ func NewAgentConfigMeta(
HelpText: "Long-term objective for the agent to pursue",
Tags: config.Tags{Section: "PromptsGoals"},
},
+ {
+ Name: "skills_prompt",
+ Label: "Skills Prompt",
+ Type: "textarea",
+ DefaultValue: "",
+ HelpText: "Optional instructions for using skills. Used when Enable Skills is on. If empty, default instructions are used.",
+ Tags: config.Tags{Section: "PromptsGoals"},
+ },
{
Name: "standalone_job",
Label: "Standalone Job",
@@ -404,6 +414,14 @@ func NewAgentConfigMeta(
HelpText: "Filter tools through guidance using their descriptions; creates virtual guidelines when none exist",
Tags: config.Tags{Section: "AdvancedSettings"},
},
+ {
+ Name: "enable_skills",
+ Label: "Enable Skills",
+ Type: "checkbox",
+ DefaultValue: false,
+ HelpText: "Inject available skills into the agent and expose skill tools (list, read, search, resources) via MCP",
+ Tags: config.Tags{Section: "AdvancedSettings"},
+ },
{
Name: "parallel_jobs",
Label: "Parallel Jobs",
diff --git a/core/state/pool.go b/core/state/pool.go
index 66a754de..8984a8b1 100644
--- a/core/state/pool.go
+++ b/core/state/pool.go
@@ -17,9 +17,16 @@ import (
"github.com/mudler/LocalAGI/pkg/localrag"
"github.com/mudler/LocalAGI/pkg/utils"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/xlog"
)
+// SkillsProvider supplies the skills dynamic prompt and MCP session when skills are enabled for an agent.
+type SkillsProvider interface {
+ GetSkillsPrompt(config *AgentConfig) (DynamicPrompt, error)
+ GetMCPSession(ctx context.Context) (*mcp.ClientSession, error)
+}
+
type AgentPool struct {
sync.Mutex
file string
@@ -37,6 +44,7 @@ type AgentPool struct {
filters func(*AgentConfig) types.JobFilters
timeout string
conversationLogs string
+ skillsService SkillsProvider
}
type Status struct {
@@ -78,6 +86,7 @@ func NewAgentPool(
filters func(*AgentConfig) types.JobFilters,
timeout string,
withLogs bool,
+ skillsService SkillsProvider,
) (*AgentPool, error) {
// if file exists, try to load an existing pool.
// if file does not exist, create a new pool.
@@ -111,6 +120,7 @@ func NewAgentPool(
filters: filters,
timeout: timeout,
conversationLogs: conversationPath,
+ skillsService: skillsService,
}, nil
}
@@ -139,6 +149,7 @@ func NewAgentPool(
availableActions: availableActions,
timeout: timeout,
conversationLogs: conversationPath,
+ skillsService: skillsService,
}, nil
}
@@ -303,6 +314,11 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
connectors := a.connectors(config)
promptBlocks := a.dynamicPrompt(config)(ctx, a)
+ if a.skillsService != nil && config.EnableSkills {
+ if prompt, err := a.skillsService.GetSkillsPrompt(config); err == nil && prompt != nil {
+ promptBlocks = append(promptBlocks, prompt)
+ }
+ }
actions := a.availableActions(config)(ctx, a)
filters := a.filters(config)
stateFile, characterFile := a.stateFiles(name)
@@ -488,6 +504,12 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
}
}
+ if a.skillsService != nil && config.EnableSkills {
+ if session, err := a.skillsService.GetMCPSession(ctx); err == nil && session != nil {
+ opts = append(opts, WithMCPSession(session))
+ }
+ }
+
var ragClient *localrag.WrappedClient
if config.EnableKnowledgeBase {
ragClient = localrag.NewWrappedClient(effectiveLocalRAGAPI, effectiveLocalRAGKey, name)
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 37d8defc..6e8c218e 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -126,6 +126,8 @@ services:
- "host.docker.internal:host-gateway"
volumes:
- localagi_pool:/pool
+ # Optional: mount a host directory for skills (replaces the default state-dir/skills path)
+ # - ./skills:/pool/skills
volumes:
postgres_data:
diff --git a/go.mod b/go.mod
index e8d723af..3e8e720c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/mudler/LocalAGI
-go 1.24.4
+go 1.26.0
require (
github.com/Masterminds/sprig/v3 v3.3.0
@@ -16,11 +16,11 @@ require (
github.com/google/go-github/v69 v69.2.0
github.com/google/uuid v1.6.0
github.com/jung-kurt/gofpdf v1.16.2
- github.com/modelcontextprotocol/go-sdk v1.1.0
+ github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/mudler/cogito v0.9.2-0.20260220220546-7e5c0264aac4
github.com/mudler/xlog v0.0.1
- github.com/onsi/ginkgo/v2 v2.25.3
- github.com/onsi/gomega v1.38.2
+ github.com/onsi/ginkgo/v2 v2.27.5
+ github.com/onsi/gomega v1.39.0
github.com/philippgille/chromem-go v0.7.0
github.com/robfig/cron/v3 v3.0.1
github.com/sashabaranov/go-openai v1.41.2
@@ -29,7 +29,7 @@ require (
github.com/tmc/langchaingo v0.1.14
github.com/traefik/yaegi v0.16.1
github.com/valyala/fasthttp v1.68.0
- golang.org/x/crypto v0.43.0
+ golang.org/x/crypto v0.47.0
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056
maunium.net/go/mautrix v0.17.0
mvdan.cc/xurls/v2 v2.6.0
@@ -40,6 +40,8 @@ require (
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
@@ -59,24 +61,41 @@ require (
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
+ github.com/cloudflare/circl v1.6.1 // indirect
+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
+ github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
+ github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 // indirect
+ github.com/pjbgf/sha1cd v0.3.2 // indirect
+ github.com/sergi/go-diff v1.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/cast v1.7.0 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/mod v0.31.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
@@ -121,12 +140,11 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.mau.fi/util v0.3.0 // indirect
go.starlark.net v0.0.0-20250417143717-f57e51f710eb // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
- golang.org/x/net v0.46.0 // indirect
- golang.org/x/sys v0.37.0 // indirect
- golang.org/x/text v0.30.0 // indirect
- golang.org/x/tools v0.37.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/text v0.33.0 // indirect
+ golang.org/x/tools v0.40.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.8 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
diff --git a/go.sum b/go.sum
index 66b44cce..7125a67b 100644
--- a/go.sum
+++ b/go.sum
@@ -12,8 +12,11 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
+github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
@@ -22,6 +25,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM=
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
@@ -29,6 +34,8 @@ github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fus
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4=
github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
@@ -76,6 +83,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/chasefleming/elem-go v0.30.0 h1:BlhV1ekv1RbFiM8XZUQeln1Ikb4D+bu2eDO4agREvok=
github.com/chasefleming/elem-go v0.30.0/go.mod h1:hz73qILBIKnTgOujnSMtEj20/epI+f6vg71RUilJAA4=
+github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
+github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -87,6 +96,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2 h1:flLYmnQFZNo04x2NPehMbf30m7Pli57xwZ0NFqR/hb0=
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2/go.mod h1:NtWqRzAp/1tw+twkW8uuBenEVVYndEAZACWU3F3xdoQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -107,6 +118,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
+github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA=
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
@@ -115,12 +128,30 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4=
github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
+github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
+github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
+github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -135,6 +166,8 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -148,6 +181,8 @@ github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
@@ -180,6 +215,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
@@ -187,16 +226,23 @@ github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5Pt
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -207,6 +253,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -225,8 +273,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
-github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
-github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
+github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
+github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -236,22 +284,18 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
-github.com/mudler/cogito v0.9.1-0.20260217143801-bb7f986ed2c7 h1:z3AcM7LbaQb+C955JdSXksHB9B0uWGQpdgl05gJM+9Y=
-github.com/mudler/cogito v0.9.1-0.20260217143801-bb7f986ed2c7/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
-github.com/mudler/cogito v0.9.2-0.20260219212236-5c89648cf8cc h1:AJm0Xy+UtKQXrI2+QIowl+0MDTdZn2qAmXWTeTTwWFw=
-github.com/mudler/cogito v0.9.2-0.20260219212236-5c89648cf8cc/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
-github.com/mudler/cogito v0.9.2-0.20260220090138-b8aaf55307f1 h1:G3P2JNCSlmT88iQp4V+rAq0Y1hm6wMxFD/rRsOl/uA8=
-github.com/mudler/cogito v0.9.2-0.20260220090138-b8aaf55307f1/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/cogito v0.9.2-0.20260220220546-7e5c0264aac4 h1:5fzvXeWsFebrlnG8fKSUR956hw4BuT3/s8azqzTxqBY=
github.com/mudler/cogito v0.9.2-0.20260220220546-7e5c0264aac4/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
+github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 h1:dAF1ALXqqapRZo80x56BIBBcPrPbRNerbd66rdyO8J4=
+github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
github.com/mudler/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg=
github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
-github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
-github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
-github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
+github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
+github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
+github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -259,6 +303,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr
github.com/philippgille/chromem-go v0.7.0 h1:4jfvfyKymjKNfGxBUhHUcj1kp7B17NL/I1P+vGh1RvY=
github.com/philippgille/chromem-go v0.7.0/go.mod h1:hTd+wGEm/fFPQl7ilfCwQXkgEUxceYh86iIdoKMolPo=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
+github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -269,15 +315,13 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
-github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
-github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
@@ -294,8 +338,11 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
@@ -307,6 +354,7 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -338,6 +386,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -363,19 +413,18 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.starlark.net v0.0.0-20250417143717-f57e51f710eb h1:zOg9DxxrorEmgGUr5UPdCEwKqiqG0MlZciuCuA3XiDE=
go.starlark.net v0.0.0-20250417143717-f57e51f710eb/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
-go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
-go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -384,9 +433,12 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@@ -394,8 +446,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
-golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -405,13 +457,16 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -421,8 +476,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -432,8 +487,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
-golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -445,16 +500,16 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
-golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
@@ -464,8 +519,12 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index c0c9818b..6e9eb510 100644
--- a/main.go
+++ b/main.go
@@ -8,6 +8,7 @@ import (
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/services"
+ "github.com/mudler/LocalAGI/services/skills"
"github.com/mudler/LocalAGI/webui"
)
@@ -56,6 +57,12 @@ func main() {
apiKeys = strings.Split(apiKeysEnv, ",")
}
+ // Skills service (optional: provides skills prompt and MCP when agents have EnableSkills)
+ skillsService, err := skills.NewService(stateDir)
+ if err != nil {
+ panic(err)
+ }
+
// Create the agent pool
pool, err := state.NewAgentPool(
baseModel,
@@ -80,6 +87,7 @@ func main() {
services.Filters,
timeout,
withLogs,
+ skillsService,
)
if err != nil {
panic(err)
@@ -88,6 +96,7 @@ func main() {
// Create the application
app := webui.NewApp(
webui.WithPool(pool),
+ webui.WithSkillsService(skillsService),
webui.WithConversationStoreduration(conversationDuration),
webui.WithApiKeys(apiKeys...),
webui.WithLLMAPIUrl(apiURL),
diff --git a/services/skills/prompt.go b/services/skills/prompt.go
new file mode 100644
index 00000000..fb74cf0a
--- /dev/null
+++ b/services/skills/prompt.go
@@ -0,0 +1,65 @@
+package skills
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/mudler/LocalAGI/core/agent"
+ "github.com/mudler/LocalAGI/core/types"
+
+ skilldomain "github.com/mudler/skillserver/pkg/domain"
+)
+
+const defaultSkillsIntro = "You can use the following skills to help with the task.\nTo request the skill, you need to use the `request_skill` tool. The skill name is the name of the skill you want to use.\n"
+
+// skillsPrompt implements agent.DynamicPrompt and injects the available skills XML block
+type skillsPrompt struct {
+ listSkills func() ([]skilldomain.Skill, error)
+ customIntro string
+}
+
+// NewSkillsPrompt returns a DynamicPrompt that renders the list of available skills as XML.
+// If customIntro is non-empty, it is used as the intro before the skills list; otherwise the default intro is used.
+func NewSkillsPrompt(listSkills func() ([]skilldomain.Skill, error), customIntro string) agent.DynamicPrompt {
+ return &skillsPrompt{listSkills: listSkills, customIntro: customIntro}
+}
+
+func (p *skillsPrompt) Render(a *agent.Agent) (types.PromptResult, error) {
+ skills, err := p.listSkills()
+ if err != nil {
+ return types.PromptResult{}, err
+ }
+ var sb strings.Builder
+ intro := defaultSkillsIntro
+ if p.customIntro != "" {
+ intro = strings.TrimSpace(p.customIntro) + "\n"
+ }
+ sb.WriteString(intro)
+ sb.WriteString("\n")
+ for _, s := range skills {
+ name := s.ID
+ desc := ""
+ if s.Metadata != nil && s.Metadata.Description != "" {
+ desc = s.Metadata.Description
+ }
+ sb.WriteString(" \n")
+ sb.WriteString(fmt.Sprintf(" %s \n", escapeXML(name)))
+ sb.WriteString(fmt.Sprintf(" %s \n", escapeXML(desc)))
+ sb.WriteString(" \n")
+ }
+ sb.WriteString(" ")
+ return types.PromptResult{Content: sb.String()}, nil
+}
+
+func (p *skillsPrompt) Role() string {
+ return "system"
+}
+
+func escapeXML(s string) string {
+ s = strings.ReplaceAll(s, "&", "&")
+ s = strings.ReplaceAll(s, "<", "<")
+ s = strings.ReplaceAll(s, ">", ">")
+ s = strings.ReplaceAll(s, "\"", """)
+ s = strings.ReplaceAll(s, "'", "'")
+ return s
+}
diff --git a/services/skills/service.go b/services/skills/service.go
new file mode 100644
index 00000000..8b2b0821
--- /dev/null
+++ b/services/skills/service.go
@@ -0,0 +1,182 @@
+package skills
+
+import (
+ "context"
+ "path/filepath"
+ "sync"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/mudler/LocalAGI/core/agent"
+ "github.com/mudler/LocalAGI/core/state"
+ "github.com/mudler/xlog"
+
+ skilldomain "github.com/mudler/skillserver/pkg/domain"
+ skillgit "github.com/mudler/skillserver/pkg/git"
+ skillmcp "github.com/mudler/skillserver/pkg/mcp"
+)
+
+// SkillsDirName is the subdirectory under state dir where skills are stored
+const SkillsDirName = "skills"
+
+// Service manages the skills directory (fixed at stateDir/skills), lazy SkillManager, dynamic prompt, and in-process MCP session
+type Service struct {
+ stateDir string
+ mu sync.Mutex
+ createMu sync.Mutex // serializes manager creation so only one createManager() runs at a time
+ manager skilldomain.SkillManager
+ mcpSrv *skillmcp.Server
+ session *mcp.ClientSession
+}
+
+// NewService creates a skills service. Skills are stored under stateDir/skills.
+func NewService(stateDir string) (*Service, error) {
+ return &Service{
+ stateDir: stateDir,
+ }, nil
+}
+
+// GetSkillsDir returns the skills directory path (always stateDir/skills)
+func (s *Service) GetSkillsDir() string {
+ return filepath.Join(s.stateDir, SkillsDirName)
+}
+
+// RefreshManagerFromConfig updates the existing manager's git repo list and rebuilds the index
+// (same as skillserver: UpdateGitRepos + RebuildIndex in place). Does nothing if no manager exists yet.
+// Call this when git repo config changes instead of invalidating; avoids blocking ListSkills on full recreate.
+func (s *Service) RefreshManagerFromConfig() {
+ skillsDir := s.GetSkillsDir()
+ cm := skillgit.NewConfigManager(skillsDir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ xlog.Warn("[skills] RefreshManagerFromConfig: could not load config", "error", err)
+ return
+ }
+ gitRepoNames := make([]string, 0, len(repos))
+ for _, r := range repos {
+ if r.Enabled && r.Name != "" {
+ gitRepoNames = append(gitRepoNames, r.Name)
+ }
+ }
+ s.mu.Lock()
+ mgr := s.manager
+ s.mu.Unlock()
+ if mgr == nil {
+ return
+ }
+ if fm, ok := mgr.(*skilldomain.FileSystemManager); ok {
+ fm.UpdateGitRepos(gitRepoNames)
+ if err := mgr.RebuildIndex(); err != nil {
+ xlog.Warn("[skills] RefreshManagerFromConfig: RebuildIndex failed", "error", err)
+ }
+ }
+}
+
+// createManager builds a new SkillManager (reads config and calls NewFileSystemManager).
+// Must be called without holding s.mu because NewFileSystemManager runs RebuildIndex() which is slow.
+func (s *Service) createManager() (skilldomain.SkillManager, error) {
+ skillsDir := s.GetSkillsDir()
+ gitRepos := []string{}
+ cm := skillgit.NewConfigManager(skillsDir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ xlog.Warn("Could not load git-repos config for skills", "error", err)
+ } else {
+ for _, r := range repos {
+ if r.Enabled && r.Name != "" {
+ gitRepos = append(gitRepos, r.Name)
+ }
+ }
+ }
+ mgr, err := skilldomain.NewFileSystemManager(skillsDir, gitRepos)
+ if err != nil {
+ return nil, err
+ }
+ return mgr, nil
+}
+
+// GetManager returns the SkillManager if the skills dir is set, otherwise nil.
+// Manager creation is serialized (createMu) so only one createManager() runs at a time,
+// avoiding concurrent RebuildIndex and filesystem contention.
+func (s *Service) GetManager() (skilldomain.SkillManager, error) {
+ s.mu.Lock()
+ if s.manager != nil {
+ mgr := s.manager
+ s.mu.Unlock()
+ return mgr, nil
+ }
+ s.mu.Unlock()
+
+ s.createMu.Lock()
+ defer s.createMu.Unlock()
+
+ s.mu.Lock()
+ if s.manager != nil {
+ mgr := s.manager
+ s.mu.Unlock()
+ return mgr, nil
+ }
+ s.mu.Unlock()
+
+ mgr, err := s.createManager()
+ if err != nil {
+ return nil, err
+ }
+
+ s.mu.Lock()
+ s.manager = mgr
+ s.mu.Unlock()
+ return mgr, nil
+}
+
+// GetSkillsPrompt returns a DynamicPrompt that injects the available skills XML (or nil if no manager).
+// When config is non-nil and config.SkillsPrompt is set, that text is used as the intro; otherwise the default intro is used.
+func (s *Service) GetSkillsPrompt(config *state.AgentConfig) (agent.DynamicPrompt, error) {
+ mgr, err := s.GetManager()
+ if err != nil || mgr == nil {
+ return nil, err
+ }
+ customIntro := ""
+ if config != nil && config.SkillsPrompt != "" {
+ customIntro = config.SkillsPrompt
+ }
+ return NewSkillsPrompt(mgr.ListSkills, customIntro), nil
+}
+
+// GetMCPSession returns a shared MCP client session connected to the in-process skillserver (starts on first use)
+func (s *Service) GetMCPSession(ctx context.Context) (*mcp.ClientSession, error) {
+ s.mu.Lock()
+ if s.session != nil {
+ sess := s.session
+ s.mu.Unlock()
+ return sess, nil
+ }
+ s.mu.Unlock()
+
+ mgr, err := s.GetManager()
+ if err != nil || mgr == nil {
+ return nil, err
+ }
+
+ s.mu.Lock()
+ if s.session != nil {
+ sess := s.session
+ s.mu.Unlock()
+ return sess, nil
+ }
+ serverTransport, clientTransport := mcp.NewInMemoryTransports()
+ s.mcpSrv = skillmcp.NewServer(mgr)
+ go func() {
+ if err := s.mcpSrv.RunWithTransport(ctx, serverTransport); err != nil && ctx.Err() == nil {
+ xlog.Error("Skills MCP server exited", "error", err)
+ }
+ }()
+ client := mcp.NewClient(&mcp.Implementation{Name: "LocalAGI", Version: "v1.0.0"}, nil)
+ session, err := client.Connect(ctx, clientTransport, nil)
+ if err != nil {
+ s.mu.Unlock()
+ return nil, err
+ }
+ s.session = session
+ s.mu.Unlock()
+ return session, nil
+}
diff --git a/webui/app.go b/webui/app.go
index 37e602d6..3d2dce7a 100644
--- a/webui/app.go
+++ b/webui/app.go
@@ -385,7 +385,20 @@ func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error {
// Ask the agent for a response
response := agent.Ask(coreTypes.WithText(message))
- if response.Error != nil {
+ if response == nil {
+ // Ask returned nil (e.g. context cancelled or WaitResult failed)
+ xlog.Error("Agent returned nil response", "agent", agentName)
+ errorData, err := json.Marshal(map[string]interface{}{
+ "error": "agent request failed or was cancelled",
+ "timestamp": time.Now().Format(time.RFC3339),
+ })
+ if err != nil {
+ xlog.Error("Error marshaling error message", "error", err)
+ } else {
+ manager.Send(
+ sse.NewMessage(string(errorData)).WithEvent("json_error"))
+ }
+ } else if response.Error != nil {
// Send error message
xlog.Error("Error asking agent", "agent", agentName, "error", response.Error)
errorData, err := json.Marshal(map[string]interface{}{
diff --git a/webui/options.go b/webui/options.go
index c5754f09..1d7ee347 100644
--- a/webui/options.go
+++ b/webui/options.go
@@ -4,11 +4,13 @@ import (
"time"
"github.com/mudler/LocalAGI/core/state"
+ "github.com/mudler/LocalAGI/services/skills"
)
type Config struct {
DefaultChunkSize int
Pool *state.AgentPool
+ SkillsService *skills.Service
ApiKeys []string
LLMAPIURL string
LLMAPIKey string
@@ -72,6 +74,12 @@ func WithPool(pool *state.AgentPool) Option {
}
}
+func WithSkillsService(svc *skills.Service) Option {
+ return func(c *Config) {
+ c.SkillsService = svc
+ }
+}
+
func WithApiKeys(keys ...string) Option {
return func(c *Config) {
c.ApiKeys = keys
diff --git a/webui/react-ui/src/App.jsx b/webui/react-ui/src/App.jsx
index 3a930ae9..a3521a4a 100644
--- a/webui/react-ui/src/App.jsx
+++ b/webui/react-ui/src/App.jsx
@@ -23,6 +23,7 @@ function App() {
{ path: '/', icon: 'fas fa-home', label: 'Home' },
{ path: '/agents', icon: 'fas fa-users', label: 'Agents' },
{ path: '/actions-playground', icon: 'fas fa-bolt', label: 'Actions' },
+ { path: '/skills', icon: 'fas fa-book', label: 'Skills' },
{ path: '/group-create', icon: 'fas fa-users-cog', label: 'Groups' },
];
diff --git a/webui/react-ui/src/pages/SkillEdit.jsx b/webui/react-ui/src/pages/SkillEdit.jsx
new file mode 100644
index 00000000..e75ab10a
--- /dev/null
+++ b/webui/react-ui/src/pages/SkillEdit.jsx
@@ -0,0 +1,461 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate, useLocation, Link, useOutletContext } from 'react-router-dom';
+import { skillsApi } from '../utils/api';
+
+const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/'];
+function isValidResourcePath(path) {
+ return RESOURCE_PREFIXES.some((p) => path.startsWith(p)) && !path.includes('..');
+}
+
+function ResourceGroup({ title, icon, items, readOnly, pathPrefix, onView, onDelete, onUpload }) {
+ return (
+
+
+
+ {title}
+
+ {!readOnly && (
+ onUpload(pathPrefix)}>
+ Upload
+
+ )}
+
+
+
+ );
+}
+
+function ResourcesSection({ skillName, showToast }) {
+ const [data, setData] = useState({ scripts: [], references: [], assets: [], readOnly: false });
+ const [loading, setLoading] = useState(true);
+ const [editor, setEditor] = useState({ open: false, path: '', name: '', content: '', readable: true, saving: false });
+ const [upload, setUpload] = useState({ open: false, pathPrefix: 'assets/', file: null, pathInput: '', uploading: false });
+ const [deletePath, setDeletePath] = useState(null);
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const res = await skillsApi.listResources(skillName);
+ setData({
+ scripts: res.scripts || [],
+ references: res.references || [],
+ assets: res.assets || [],
+ readOnly: res.readOnly === true,
+ });
+ } catch (err) {
+ showToast(err.message || 'Failed to load resources', 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ load();
+ }, [skillName]);
+
+ const handleView = async (res) => {
+ setEditor({ open: true, path: res.path, name: res.name, content: '', readable: res.readable !== false, saving: false });
+ if (res.readable !== false) {
+ try {
+ const json = await skillsApi.getResource(skillName, res.path, { json: true });
+ const content = json.encoding === 'base64' && json.content ? atob(json.content) : (json.content || '');
+ setEditor((e) => ({ ...e, content }));
+ } catch (err) {
+ showToast(err.message || 'Failed to load file', 'error');
+ }
+ }
+ };
+
+ const handleEditorSave = async () => {
+ setEditor((e) => ({ ...e, saving: true }));
+ try {
+ await skillsApi.updateResource(skillName, editor.path, editor.content);
+ showToast('Resource updated', 'success');
+ setEditor((e) => ({ ...e, open: false }));
+ load();
+ } catch (err) {
+ showToast(err.message || 'Update failed', 'error');
+ } finally {
+ setEditor((e) => ({ ...e, saving: false }));
+ }
+ };
+
+ const handleUploadOpen = (pathPrefix) => {
+ setUpload({ open: true, pathPrefix, file: null, pathInput: '', uploading: false });
+ };
+
+ const handleUploadSubmit = async () => {
+ const path = upload.pathInput.trim() || (upload.file ? upload.pathPrefix + upload.file.name : '');
+ if (!path || !upload.file) {
+ showToast('Select a file and ensure path is set', 'error');
+ return;
+ }
+ if (!isValidResourcePath(path)) {
+ showToast('Path must start with scripts/, references/, or assets/', 'error');
+ return;
+ }
+ setUpload((u) => ({ ...u, uploading: true }));
+ try {
+ await skillsApi.createResource(skillName, path, upload.file);
+ showToast('Resource added', 'success');
+ setUpload((u) => ({ ...u, open: false }));
+ load();
+ } catch (err) {
+ showToast(err.message || 'Upload failed', 'error');
+ } finally {
+ setUpload((u) => ({ ...u, uploading: false }));
+ }
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!deletePath) return;
+ try {
+ await skillsApi.deleteResource(skillName, deletePath);
+ showToast('Resource deleted', 'success');
+ setDeletePath(null);
+ load();
+ } catch (err) {
+ showToast(err.message || 'Delete failed', 'error');
+ }
+ };
+
+ return (
+ <>
+
+
Resources
+
Scripts, references, and assets for this skill. Paths must start with scripts/, references/, or assets/.
+ {loading ? (
+
Loading resources...
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+ {editor.open && (
+ !editor.saving && setEditor((e) => ({ ...e, open: false }))}>
+
e.stopPropagation()}>
+
Edit {editor.name}
+ {editor.readable ? (
+ <>
+
+
+ )}
+
+ {upload.open && (
+ !upload.uploading && setUpload((u) => ({ ...u, open: false }))}>
+
e.stopPropagation()}>
+
Upload to {upload.pathPrefix}
+
+ File
+ setUpload((u) => ({ ...u, file: e.target.files?.[0] || null }))} />
+
+
+ Path (default: {upload.pathPrefix} + filename)
+ setUpload((u) => ({ ...u, pathInput: e.target.value }))} />
+
+
+ setUpload((u) => ({ ...u, open: false }))}>Cancel
+ {upload.uploading ? 'Uploading...' : 'Upload'}
+
+
+
+ )}
+
+ {deletePath && (
+ setDeletePath(null)}>
+
e.stopPropagation()}>
+
Delete resource {deletePath} ?
+
+ setDeletePath(null)}>Cancel
+ Delete
+
+
+
+ )}
+ >
+ );
+}
+
+function SkillEdit() {
+ const { name: nameParam } = useParams();
+ const location = useLocation();
+ const isNew = location.pathname.endsWith('/new');
+ const name = nameParam ? decodeURIComponent(nameParam) : undefined;
+ const navigate = useNavigate();
+ const { showToast } = useOutletContext();
+ const [loading, setLoading] = useState(!isNew);
+ const [saving, setSaving] = useState(false);
+ const [activeSection, setActiveSection] = useState('basic-section');
+ const [form, setForm] = useState({
+ name: '',
+ description: '',
+ content: '',
+ license: '',
+ compatibility: '',
+ metadata: {},
+ allowedTools: '',
+ });
+
+ useEffect(() => {
+ document.title = isNew ? 'New skill - LocalAGI' : `Edit ${name} - LocalAGI`;
+ if (isNew) {
+ setLoading(false);
+ return;
+ }
+ if (name) {
+ skillsApi.get(name)
+ .then((data) => {
+ setForm({
+ name: data.name || '',
+ description: data.description || '',
+ content: data.content || '',
+ license: data.license || '',
+ compatibility: data.compatibility || '',
+ metadata: data.metadata || {},
+ allowedTools: data['allowed-tools'] || '',
+ });
+ })
+ .catch((err) => {
+ showToast(err.message || 'Failed to load skill', 'error');
+ navigate('/skills');
+ })
+ .finally(() => setLoading(false));
+ }
+ }, [isNew, name, navigate, showToast]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSaving(true);
+ try {
+ const payload = {
+ name: form.name,
+ description: form.description,
+ content: form.content,
+ license: form.license || undefined,
+ compatibility: form.compatibility || undefined,
+ metadata: Object.keys(form.metadata).length ? form.metadata : undefined,
+ 'allowed-tools': form.allowedTools || undefined,
+ };
+ if (isNew) {
+ await skillsApi.create(payload);
+ showToast('Skill created', 'success');
+ } else {
+ await skillsApi.update(name, { ...payload, name: undefined });
+ showToast('Skill updated', 'success');
+ }
+ navigate('/skills');
+ } catch (err) {
+ showToast(err.message || 'Save failed', 'error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Skill configuration
+
+
+
+
+
+ setActiveSection('basic-section')}
+ >
+ Basic information
+
+ setActiveSection('content-section')}
+ >
+ Content
+
+ setActiveSection('resources-section')}
+ >
+ Resources
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SkillEdit;
diff --git a/webui/react-ui/src/pages/Skills.jsx b/webui/react-ui/src/pages/Skills.jsx
new file mode 100644
index 00000000..479e85b6
--- /dev/null
+++ b/webui/react-ui/src/pages/Skills.jsx
@@ -0,0 +1,310 @@
+import { useState, useEffect } from 'react';
+import { Link, useOutletContext } from 'react-router-dom';
+import { skillsApi } from '../utils/api';
+
+function Skills() {
+ const [skills, setSkills] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [importing, setImporting] = useState(false);
+ const [unavailable, setUnavailable] = useState(false);
+ const [showGitRepos, setShowGitRepos] = useState(false);
+ const [gitRepos, setGitRepos] = useState([]);
+ const [gitRepoUrl, setGitRepoUrl] = useState('');
+ const [gitReposLoading, setGitReposLoading] = useState(false);
+ const [gitReposAction, setGitReposAction] = useState(null);
+ const { showToast } = useOutletContext();
+
+ const fetchSkills = async () => {
+ setLoading(true);
+ setUnavailable(false);
+ const timeoutMs = 15000;
+ const withTimeout = (p) =>
+ Promise.race([
+ p,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
+ ),
+ ]);
+ try {
+ if (searchQuery.trim()) {
+ const data = await withTimeout(skillsApi.search(searchQuery.trim()));
+ setSkills(Array.isArray(data) ? data : []);
+ } else {
+ const data = await withTimeout(skillsApi.list());
+ setSkills(Array.isArray(data) ? data : []);
+ }
+ } catch (err) {
+ if (err.message?.includes('503') || err.message?.includes('skills')) {
+ setUnavailable(true);
+ setSkills([]);
+ } else {
+ showToast(err.message || 'Failed to load skills', 'error');
+ setSkills([]);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ document.title = 'Skills - LocalAGI';
+ }, []);
+
+ useEffect(() => {
+ fetchSkills();
+ }, [searchQuery]);
+
+ const deleteSkill = async (name) => {
+ if (!confirm(`Delete skill "${name}"?`)) return;
+ try {
+ await skillsApi.delete(name);
+ showToast('Skill deleted', 'success');
+ fetchSkills();
+ } catch (err) {
+ showToast(err.message || 'Failed to delete skill', 'error');
+ }
+ };
+
+ const exportSkill = async (name) => {
+ try {
+ const url = skillsApi.exportUrl(name);
+ const res = await fetch(url, { credentials: 'same-origin' });
+ if (!res.ok) throw new Error(res.statusText || 'Export failed');
+ const blob = await res.blob();
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `${name.replace(/\//g, '-')}.tar.gz`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ showToast('Export started', 'success');
+ } catch (err) {
+ showToast(err.message || 'Export failed', 'error');
+ }
+ };
+
+ const handleImport = async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setImporting(true);
+ try {
+ await skillsApi.import(file);
+ showToast('Skill imported', 'success');
+ fetchSkills();
+ } catch (err) {
+ showToast(err.message || 'Import failed', 'error');
+ } finally {
+ setImporting(false);
+ e.target.value = '';
+ }
+ };
+
+ const loadGitRepos = async () => {
+ setGitReposLoading(true);
+ try {
+ const list = await skillsApi.listGitRepos();
+ setGitRepos(Array.isArray(list) ? list : []);
+ } catch (err) {
+ showToast(err.message || 'Failed to load Git repos', 'error');
+ setGitRepos([]);
+ } finally {
+ setGitReposLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (showGitRepos) loadGitRepos();
+ }, [showGitRepos]);
+
+ const addGitRepo = async (e) => {
+ e.preventDefault();
+ const url = gitRepoUrl.trim();
+ if (!url) return;
+ setGitReposAction('add');
+ try {
+ await skillsApi.addGitRepo(url);
+ setGitRepoUrl('');
+ await loadGitRepos();
+ fetchSkills();
+ showToast('Git repo added and syncing', 'success');
+ } catch (err) {
+ showToast(err.message || 'Failed to add repo', 'error');
+ } finally {
+ setGitReposAction(null);
+ }
+ };
+
+ const syncGitRepo = async (id) => {
+ setGitReposAction(id);
+ try {
+ await skillsApi.syncGitRepo(id);
+ await loadGitRepos();
+ fetchSkills();
+ showToast('Repo synced', 'success');
+ } catch (err) {
+ showToast(err.message || 'Sync failed', 'error');
+ } finally {
+ setGitReposAction(null);
+ }
+ };
+
+ const toggleGitRepo = async (id) => {
+ try {
+ await skillsApi.toggleGitRepo(id);
+ await loadGitRepos();
+ fetchSkills();
+ showToast('Repo toggled', 'success');
+ } catch (err) {
+ showToast(err.message || 'Toggle failed', 'error');
+ }
+ };
+
+ const deleteGitRepo = async (id) => {
+ if (!confirm('Remove this Git repository? Skills from it will no longer be available.')) return;
+ try {
+ await skillsApi.deleteGitRepo(id);
+ await loadGitRepos();
+ fetchSkills();
+ showToast('Repo removed', 'success');
+ } catch (err) {
+ showToast(err.message || 'Remove failed', 'error');
+ }
+ };
+
+ if (unavailable) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {showGitRepos && (
+
+
+ Git repositories
+
+
+ Add Git repositories to pull skills from. Skills will appear in the list after sync.
+
+
+ setGitRepoUrl(e.target.value)}
+ style={{ flex: '1', minWidth: '200px' }}
+ />
+
+ {gitReposAction === 'add' ? 'Adding...' : 'Add repo'}
+
+
+ {gitReposLoading ? (
+
Loading repos...
+ ) : gitRepos.length === 0 ? (
+
No Git repos configured. Add one above.
+ ) : (
+
+ {gitRepos.map((r) => (
+
+
+ {r.name || r.url}
+ {r.url}
+ {!r.enabled && Disabled }
+
+
+ syncGitRepo(r.id)} disabled={gitReposAction === r.id}>
+ {gitReposAction === r.id ? 'Syncing...' : <> Sync>}
+
+ toggleGitRepo(r.id)} title={r.enabled ? 'Disable' : 'Enable'}>
+
+
+ deleteGitRepo(r.id)} title="Remove repo">
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {loading ? (
+
Loading skills...
+ ) : skills.length === 0 ? (
+
+
No skills found. Create a skill or import one.
+
Create skill
+
+ ) : (
+
+ {skills.map((s) => (
+
+
+
{s.name}
+ {s.readOnly && Read-only }
+
+
+ {s.description || 'No description'}
+
+
+ {!s.readOnly && (
+
+ Edit
+
+ )}
+ {!s.readOnly && (
+ deleteSkill(s.name)} title="Delete skill">
+ Delete
+
+ )}
+ exportSkill(s.name)} title="Export as .tar.gz">
+ Export
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default Skills;
diff --git a/webui/react-ui/src/router.jsx b/webui/react-ui/src/router.jsx
index f7d5c34c..3e4ddee3 100644
--- a/webui/react-ui/src/router.jsx
+++ b/webui/react-ui/src/router.jsx
@@ -9,6 +9,8 @@ import ActionsPlayground from './pages/ActionsPlayground';
import GroupCreate from './pages/GroupCreate';
import AgentStatus from './pages/AgentStatus';
import ImportAgent from './pages/ImportAgent';
+import Skills from './pages/Skills';
+import SkillEdit from './pages/SkillEdit';
// Get the base URL from Vite's environment variables or default to '/app/'
const BASE_URL = import.meta.env.BASE_URL || '/app';
@@ -54,6 +56,18 @@ export const router = createBrowserRouter([
{
path: 'status/:name',
element:
+ },
+ {
+ path: 'skills',
+ element:
+ },
+ {
+ path: 'skills/new',
+ element:
+ },
+ {
+ path: 'skills/edit/:name',
+ element:
}
]
}
diff --git a/webui/react-ui/src/utils/api.js b/webui/react-ui/src/utils/api.js
index 2ae9aee1..0b368f66 100644
--- a/webui/react-ui/src/utils/api.js
+++ b/webui/react-ui/src/utils/api.js
@@ -293,3 +293,141 @@ export const statusApi = {
return handleResponse(response);
},
};
+
+// Skills API (skills are stored under state dir / skills, not configurable)
+export const skillsApi = {
+ getConfig: async () => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsConfig));
+ return handleResponse(response);
+ },
+ list: async () => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsList));
+ return handleResponse(response);
+ },
+ search: async (q) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsSearch(q)));
+ return handleResponse(response);
+ },
+ get: async (name) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skill(name)));
+ return handleResponse(response);
+ },
+ create: async (data) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsList), {
+ method: 'POST',
+ headers: API_CONFIG.headers,
+ body: JSON.stringify(data),
+ });
+ return handleResponse(response);
+ },
+ update: async (name, data) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skill(name)), {
+ method: 'PUT',
+ headers: API_CONFIG.headers,
+ body: JSON.stringify(data),
+ });
+ return handleResponse(response);
+ },
+ delete: async (name) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skill(name)), { method: 'DELETE' });
+ if (response.status === 204) return;
+ return handleResponse(response);
+ },
+ import: async (file) => {
+ const form = new FormData();
+ form.append('file', file);
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsImport), {
+ method: 'POST',
+ body: form,
+ });
+ return handleResponse(response);
+ },
+ exportUrl: (name) => buildUrl(API_CONFIG.endpoints.skillExport(name)),
+ listResources: async (name) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResources(name)));
+ return handleResponse(response);
+ },
+ getResource: async (name, path, { json = false } = {}) => {
+ const url = buildUrl(API_CONFIG.endpoints.skillResource(name, path)) + (json ? '?encoding=base64' : '');
+ const response = await fetch(url, { credentials: 'same-origin' });
+ if (!response.ok) {
+ const err = await response.json().catch(() => ({}));
+ throw new Error(err.error || `Failed to get resource: ${response.status}`);
+ }
+ if (json) return response.json();
+ const ct = response.headers.get('content-type') || '';
+ if (ct.includes('application/json')) return response.json();
+ if (ct.includes('text/') || ct.includes('application/javascript')) return response.text();
+ return response.blob();
+ },
+ createResource: async (name, path, file) => {
+ const form = new FormData();
+ form.append('file', file);
+ form.append('path', path);
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResources(name)), {
+ method: 'POST',
+ body: form,
+ credentials: 'same-origin',
+ });
+ if (!response.ok) {
+ const err = await response.json().catch(() => ({}));
+ throw new Error(err.error || `Failed to create resource: ${response.status}`);
+ }
+ return response.json();
+ },
+ updateResource: async (name, path, content) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResource(name, path)), {
+ method: 'PUT',
+ headers: API_CONFIG.headers,
+ body: JSON.stringify({ content }),
+ credentials: 'same-origin',
+ });
+ if (response.status !== 204) {
+ const err = await response.json().catch(() => ({}));
+ throw new Error(err.error || `Failed to update resource: ${response.status}`);
+ }
+ },
+ deleteResource: async (name, path) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResource(name, path)), {
+ method: 'DELETE',
+ credentials: 'same-origin',
+ });
+ if (response.status !== 204) {
+ const err = await response.json().catch(() => ({}));
+ throw new Error(err.error || `Failed to delete resource: ${response.status}`);
+ }
+ },
+ listGitRepos: async () => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepos));
+ return handleResponse(response);
+ },
+ addGitRepo: async (url) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepos), {
+ method: 'POST',
+ headers: API_CONFIG.headers,
+ body: JSON.stringify({ url }),
+ });
+ return handleResponse(response);
+ },
+ updateGitRepo: async (id, data) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepo(id)), {
+ method: 'PUT',
+ headers: API_CONFIG.headers,
+ body: JSON.stringify(data),
+ });
+ return handleResponse(response);
+ },
+ deleteGitRepo: async (id) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepo(id)), { method: 'DELETE' });
+ if (response.status === 204) return;
+ return handleResponse(response);
+ },
+ syncGitRepo: async (id) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepoSync(id)), { method: 'POST' });
+ return handleResponse(response);
+ },
+ toggleGitRepo: async (id) => {
+ const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepoToggle(id)), { method: 'POST' });
+ return handleResponse(response);
+ },
+};
diff --git a/webui/react-ui/src/utils/config.js b/webui/react-ui/src/utils/config.js
index f5beedc3..81203155 100644
--- a/webui/react-ui/src/utils/config.js
+++ b/webui/react-ui/src/utils/config.js
@@ -48,5 +48,19 @@ export const API_CONFIG = {
// Status endpoint
status: (name) => `/status/${name}`,
+
+ // Skills endpoints
+ skillsConfig: '/api/skills/config',
+ skillsList: '/api/skills',
+ skillsSearch: (q) => `/api/skills/search?q=${encodeURIComponent(q)}`,
+ skill: (name) => `/api/skills/${encodeURIComponent(name)}`,
+ skillsImport: '/api/skills/import',
+ skillExport: (name) => `/api/skills/export/${encodeURIComponent(name)}`,
+ skillResources: (name) => `/api/skills/${encodeURIComponent(name)}/resources`,
+ skillResource: (name, path) => `/api/skills/${encodeURIComponent(name)}/resources/${path.split('/').map(encodeURIComponent).join('/')}`,
+ gitRepos: '/api/git-repos',
+ gitRepo: (id) => `/api/git-repos/${id}`,
+ gitRepoSync: (id) => `/api/git-repos/${id}/sync`,
+ gitRepoToggle: (id) => `/api/git-repos/${id}/toggle`,
}
};
diff --git a/webui/routes.go b/webui/routes.go
index 3120afda..a9c97376 100644
--- a/webui/routes.go
+++ b/webui/routes.go
@@ -191,6 +191,27 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
webapp.Post("/settings/import", app.ImportAgent(pool))
webapp.Get("/settings/export/:name", app.ExportAgent(pool))
+ // Skills API (when app.config.SkillsService is set)
+ webapp.Get("/api/skills/config", app.GetSkillsConfig)
+ webapp.Get("/api/skills", app.ListSkills)
+ webapp.Get("/api/skills/search", app.SearchSkills)
+ webapp.Post("/api/skills", app.CreateSkill)
+ webapp.Get("/api/skills/export/*", app.ExportSkill)
+ webapp.Post("/api/skills/import", app.ImportSkill)
+ webapp.Get("/api/skills/:name", app.GetSkill)
+ webapp.Put("/api/skills/:name", app.UpdateSkill)
+ webapp.Delete("/api/skills/:name", app.DeleteSkill)
+ webapp.Get("/api/skills/:name/resources", app.ListSkillResources)
+ webapp.Get("/api/skills/:name/resources/*", app.GetSkillResource)
+ webapp.Post("/api/skills/:name/resources", app.CreateSkillResource)
+ webapp.Put("/api/skills/:name/resources/*", app.UpdateSkillResource)
+ webapp.Delete("/api/skills/:name/resources/*", app.DeleteSkillResource)
+ webapp.Get("/api/git-repos", app.ListGitRepos)
+ webapp.Post("/api/git-repos", app.AddGitRepo)
+ webapp.Put("/api/git-repos/:id", app.UpdateGitRepo)
+ webapp.Delete("/api/git-repos/:id", app.DeleteGitRepo)
+ webapp.Post("/api/git-repos/:id/sync", app.SyncGitRepo)
+ webapp.Post("/api/git-repos/:id/toggle", app.ToggleGitRepo)
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
diff --git a/webui/skills_handlers.go b/webui/skills_handlers.go
new file mode 100644
index 00000000..33bded9d
--- /dev/null
+++ b/webui/skills_handlers.go
@@ -0,0 +1,861 @@
+package webui
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+
+ "github.com/mudler/LocalAGI/services/skills"
+ "github.com/mudler/xlog"
+ skilldomain "github.com/mudler/skillserver/pkg/domain"
+ skillgit "github.com/mudler/skillserver/pkg/git"
+)
+
+type skillResponse struct {
+ Name string `json:"name"`
+ Content string `json:"content"`
+ Description string `json:"description,omitempty"`
+ License string `json:"license,omitempty"`
+ Compatibility string `json:"compatibility,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+ AllowedTools string `json:"allowed-tools,omitempty"`
+ ReadOnly bool `json:"readOnly"`
+}
+
+type createSkillRequest struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Content string `json:"content"`
+ License string `json:"license,omitempty"`
+ Compatibility string `json:"compatibility,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+ AllowedTools string `json:"allowed-tools,omitempty"`
+}
+
+type updateSkillRequest struct {
+ Description string `json:"description"`
+ Content string `json:"content"`
+ License string `json:"license,omitempty"`
+ Compatibility string `json:"compatibility,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+ AllowedTools string `json:"allowed-tools,omitempty"`
+}
+
+func skillToResponse(s skilldomain.Skill) skillResponse {
+ out := skillResponse{Name: s.Name, Content: s.Content, ReadOnly: s.ReadOnly}
+ if s.Metadata != nil {
+ out.Description = s.Metadata.Description
+ out.License = s.Metadata.License
+ out.Compatibility = s.Metadata.Compatibility
+ out.Metadata = s.Metadata.Metadata
+ out.AllowedTools = s.Metadata.AllowedTools
+ }
+ return out
+}
+
+func (a *App) skillsSvc() *skills.Service {
+ if a.config == nil {
+ return nil
+ }
+ return a.config.SkillsService
+}
+
+func skillsUnavailable(c *fiber.Ctx) error {
+ return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "skills service not available"})
+}
+
+func skillsNoDir(c *fiber.Ctx) error {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "skills directory not configured"})
+}
+
+// decodeSkillNameParam decodes a URL-encoded skill name (e.g. repo%2Fskill -> repo/skill).
+func decodeSkillNameParam(raw string) string {
+ if raw == "" {
+ return ""
+ }
+ decoded, err := url.PathUnescape(raw)
+ if err != nil {
+ return raw
+ }
+ return decoded
+}
+
+func (a *App) GetSkillsConfig(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ return c.JSON(fiber.Map{"skills_dir": svc.GetSkillsDir()})
+}
+
+func (a *App) ListSkills(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ if mgr == nil {
+ return c.Status(http.StatusOK).JSON([]skillResponse{})
+ }
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ list, err := mgr.ListSkills()
+ if err != nil {
+ xlog.Error("[skills] ListSkills: mgr.ListSkills failed", "error", err)
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ out := make([]skillResponse, len(list))
+ for i, s := range list {
+ out[i] = skillToResponse(s)
+ }
+ return c.JSON(out)
+}
+
+func (a *App) SearchSkills(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ q := c.Query("q")
+ if q == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"})
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ list, err := mgr.SearchSkills(q)
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ out := make([]skillResponse, len(list))
+ for i, s := range list {
+ out[i] = skillToResponse(s)
+ }
+ return c.JSON(out)
+}
+
+func (a *App) GetSkill(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ name := decodeSkillNameParam(c.Params("name"))
+ skill, err := mgr.ReadSkill(name)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ return c.JSON(skillToResponse(*skill))
+}
+
+func (a *App) CreateSkill(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ fsManager, ok := mgr.(*skilldomain.FileSystemManager)
+ if !ok {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
+ }
+ var req createSkillRequest
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
+ }
+ if req.Name == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
+ }
+ if err := skilldomain.ValidateSkillName(req.Name); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ if req.Description == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description is required"})
+ }
+ if len(req.Description) > 1024 {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description must be 1-1024 characters"})
+ }
+ if req.Compatibility != "" && len(req.Compatibility) > 500 {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "compatibility must be max 500 characters"})
+ }
+ skillsDir := fsManager.GetSkillsDir()
+ skillDir := filepath.Join(skillsDir, req.Name)
+
+ // Prevent overwriting an existing skill directory/content
+ if _, err := os.Stat(skillDir); err == nil {
+ return c.Status(http.StatusConflict).JSON(fiber.Map{"error": "skill already exists"})
+ } else if !os.IsNotExist(err) {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ if err := os.MkdirAll(skillDir, 0755); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\n", req.Name, req.Description)
+ if req.License != "" {
+ frontmatter += fmt.Sprintf("license: %s\n", req.License)
+ }
+ if req.Compatibility != "" {
+ frontmatter += fmt.Sprintf("compatibility: %s\n", req.Compatibility)
+ }
+ if len(req.Metadata) > 0 {
+ frontmatter += "metadata:\n"
+ for k, v := range req.Metadata {
+ frontmatter += fmt.Sprintf(" %s: %s\n", k, v)
+ }
+ }
+ if req.AllowedTools != "" {
+ frontmatter += fmt.Sprintf("allowed-tools: %s\n", req.AllowedTools)
+ }
+ frontmatter += "---\n\n"
+ skillMdPath := filepath.Join(skillDir, "SKILL.md")
+ if err := os.WriteFile(skillMdPath, []byte(frontmatter+req.Content), 0644); err != nil {
+ os.RemoveAll(skillDir)
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ if err := mgr.RebuildIndex(); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
+ }
+ skill, err := mgr.ReadSkill(req.Name)
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read created skill"})
+ }
+ return c.Status(http.StatusCreated).JSON(skillToResponse(*skill))
+}
+
+func (a *App) UpdateSkill(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ fsManager, ok := mgr.(*skilldomain.FileSystemManager)
+ if !ok {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
+ }
+ name := decodeSkillNameParam(c.Params("name"))
+ existing, err := mgr.ReadSkill(name)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ if existing.ReadOnly {
+ return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot update read-only skill from git repository"})
+ }
+ var req updateSkillRequest
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
+ }
+ if req.Description == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description is required"})
+ }
+ if len(req.Description) > 1024 {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description must be 1-1024 characters"})
+ }
+ if req.Compatibility != "" && len(req.Compatibility) > 500 {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "compatibility must be max 500 characters"})
+ }
+ skillDir := filepath.Join(fsManager.GetSkillsDir(), name)
+ frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\n", name, req.Description)
+ if req.License != "" {
+ frontmatter += fmt.Sprintf("license: %s\n", req.License)
+ }
+ if req.Compatibility != "" {
+ frontmatter += fmt.Sprintf("compatibility: %s\n", req.Compatibility)
+ }
+ if len(req.Metadata) > 0 {
+ frontmatter += "metadata:\n"
+ for k, v := range req.Metadata {
+ frontmatter += fmt.Sprintf(" %s: %s\n", k, v)
+ }
+ }
+ if req.AllowedTools != "" {
+ frontmatter += fmt.Sprintf("allowed-tools: %s\n", req.AllowedTools)
+ }
+ frontmatter += "---\n\n"
+ skillMdPath := filepath.Join(skillDir, "SKILL.md")
+ if err := os.WriteFile(skillMdPath, []byte(frontmatter+req.Content), 0644); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ if err := mgr.RebuildIndex(); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
+ }
+ skill, err := mgr.ReadSkill(name)
+ if err != nil || skill == nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read updated skill"})
+ }
+ return c.JSON(skillToResponse(*skill))
+}
+
+func (a *App) DeleteSkill(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ fsManager, ok := mgr.(*skilldomain.FileSystemManager)
+ if !ok {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
+ }
+ name := decodeSkillNameParam(c.Params("name"))
+ existing, err := mgr.ReadSkill(name)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ if existing.ReadOnly {
+ return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot delete read-only skill from git repository"})
+ }
+ skillDir := filepath.Join(fsManager.GetSkillsDir(), name)
+ if err := os.RemoveAll(skillDir); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ if err := mgr.RebuildIndex(); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
+ }
+ return c.SendStatus(http.StatusNoContent)
+}
+
+func (a *App) ExportSkill(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ fsManager, ok := mgr.(*skilldomain.FileSystemManager)
+ if !ok {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
+ }
+ rawName := strings.TrimPrefix(c.Params("*"), "/")
+ if rawName == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "skill name required"})
+ }
+ name := decodeSkillNameParam(rawName)
+ skill, err := mgr.ReadSkill(name)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ archiveData, err := skilldomain.ExportSkill(skill.ID, fsManager.GetSkillsDir())
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ c.Set("Content-Type", "application/gzip")
+ c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar.gz\"", name))
+ return c.Send(archiveData)
+}
+
+func (a *App) ImportSkill(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ fsManager, ok := mgr.(*skilldomain.FileSystemManager)
+ if !ok {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
+ }
+ file, err := c.FormFile("file")
+ if err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
+ }
+ src, err := file.Open()
+ if err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "failed to open uploaded file"})
+ }
+ defer src.Close()
+ const maxArchiveSize = 50 * 1024 * 1024
+ if file.Size > maxArchiveSize {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "archive too large"})
+ }
+ if file.Size <= 0 {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid file size"})
+ }
+ archiveData := make([]byte, int(file.Size))
+ n, err := io.ReadFull(src, archiveData)
+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "failed to read file"})
+ }
+ archiveData = archiveData[:n]
+ skillName, err := skilldomain.ImportSkill(archiveData, fsManager.GetSkillsDir())
+ if err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ if err := mgr.RebuildIndex(); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
+ }
+ skill, err := mgr.ReadSkill(skillName)
+ if err != nil || skill == nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read imported skill"})
+ }
+ return c.Status(http.StatusCreated).JSON(skillToResponse(*skill))
+}
+
+func (a *App) ListSkillResources(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ skillName := decodeSkillNameParam(c.Params("name"))
+ skill, err := mgr.ReadSkill(skillName)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ resources, err := mgr.ListSkillResources(skill.ID)
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ scripts := []map[string]interface{}{}
+ references := []map[string]interface{}{}
+ assets := []map[string]interface{}{}
+ for _, res := range resources {
+ m := map[string]interface{}{
+ "path": res.Path,
+ "name": res.Name,
+ "size": res.Size,
+ "mime_type": res.MimeType,
+ "readable": res.Readable,
+ "modified": res.Modified.Format("2006-01-02T15:04:05Z07:00"),
+ }
+ switch res.Type {
+ case skilldomain.ResourceTypeScript:
+ scripts = append(scripts, m)
+ case skilldomain.ResourceTypeReference:
+ references = append(references, m)
+ case skilldomain.ResourceTypeAsset:
+ assets = append(assets, m)
+ }
+ }
+ return c.JSON(fiber.Map{"scripts": scripts, "references": references, "assets": assets, "readOnly": skill.ReadOnly})
+}
+
+func (a *App) GetSkillResource(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ skillName := decodeSkillNameParam(c.Params("name"))
+ resourcePath := c.Params("*")
+ if resourcePath == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "resource path is required"})
+ }
+ skill, err := mgr.ReadSkill(skillName)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ info, err := mgr.GetSkillResourceInfo(skill.ID, resourcePath)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "resource not found"})
+ }
+ content, err := mgr.ReadSkillResource(skill.ID, resourcePath)
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ if c.Query("encoding") == "base64" || !info.Readable {
+ return c.JSON(fiber.Map{"content": content.Content, "encoding": content.Encoding, "mime_type": content.MimeType, "size": content.Size})
+ }
+ c.Set("Content-Type", content.MimeType)
+ return c.SendString(content.Content)
+}
+
+func (a *App) CreateSkillResource(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ skillName := decodeSkillNameParam(c.Params("name"))
+ skill, err := mgr.ReadSkill(skillName)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ if skill.ReadOnly {
+ return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot add resources to read-only skill"})
+ }
+ file, err := c.FormFile("file")
+ if err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
+ }
+ path := c.FormValue("path")
+ if path == "" {
+ path = file.Filename
+ }
+ if err := skilldomain.ValidateResourcePath(path); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ fullPath := filepath.Join(skill.SourcePath, path)
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ src, err := file.Open()
+ if err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "failed to open file"})
+ }
+ defer src.Close()
+ data, err := io.ReadAll(src)
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ if err := os.WriteFile(fullPath, data, 0644); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.Status(http.StatusCreated).JSON(fiber.Map{"path": path})
+}
+
+func (a *App) UpdateSkillResource(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ skillName := decodeSkillNameParam(c.Params("name"))
+ resourcePath := c.Params("*")
+ if resourcePath == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "resource path is required"})
+ }
+ skill, err := mgr.ReadSkill(skillName)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ if skill.ReadOnly {
+ return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot update resources in read-only skill"})
+ }
+ if err := skilldomain.ValidateResourcePath(resourcePath); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ fullPath := filepath.Join(skill.SourcePath, resourcePath)
+ var body struct {
+ Content string `json:"content"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
+ }
+ if err := os.WriteFile(fullPath, []byte(body.Content), 0644); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.SendStatus(http.StatusNoContent)
+}
+
+func (a *App) DeleteSkillResource(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ return skillsNoDir(c)
+ }
+ skillName := decodeSkillNameParam(c.Params("name"))
+ resourcePath := c.Params("*")
+ if resourcePath == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "resource path is required"})
+ }
+ skill, err := mgr.ReadSkill(skillName)
+ if err != nil {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
+ }
+ if skill.ReadOnly {
+ return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot delete resources from read-only skill"})
+ }
+ if err := skilldomain.ValidateResourcePath(resourcePath); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ fullPath := filepath.Join(skill.SourcePath, resourcePath)
+ if err := os.Remove(fullPath); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.SendStatus(http.StatusNoContent)
+}
+
+// Git repos: list, add, update, delete, sync, toggle (using ConfigManager in skills dir)
+func (a *App) ListGitRepos(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ dir := svc.GetSkillsDir()
+ if dir == "" {
+ return c.Status(http.StatusOK).JSON([]gitRepoResponse{})
+ }
+ cm := skillgit.NewConfigManager(dir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ out := make([]gitRepoResponse, len(repos))
+ for i, r := range repos {
+ out[i] = gitRepoResponse{ID: r.ID, URL: r.URL, Name: r.Name, Enabled: r.Enabled}
+ }
+ return c.JSON(out)
+}
+
+type gitRepoResponse struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+}
+
+func (a *App) AddGitRepo(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ dir := svc.GetSkillsDir()
+ if dir == "" {
+ return skillsNoDir(c)
+ }
+ var req struct {
+ URL string `json:"url"`
+ }
+ if err := c.BodyParser(&req); err != nil || req.URL == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "URL is required"})
+ }
+ if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "git@") {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid URL format"})
+ }
+ cm := skillgit.NewConfigManager(dir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ for _, r := range repos {
+ if r.URL == req.URL {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "repository already exists"})
+ }
+ }
+ newRepo := skillgit.GitRepoConfig{
+ ID: skillgit.GenerateID(req.URL),
+ URL: req.URL,
+ Name: skillgit.ExtractRepoName(req.URL),
+ Enabled: true,
+ }
+ repos = append(repos, newRepo)
+ if err := cm.SaveConfig(repos); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ // Do not invalidate here: the new repo is not cloned yet. Keep the current manager
+ // so ListSkills returns immediately and the sync goroutine gets the cache without contention.
+ urlToSync := req.URL
+ xlog.Debug("[skills] AddGitRepo: repo saved, starting background sync", "url", urlToSync)
+ go func() {
+ xlog.Debug("[skills] background sync: started", "url", urlToSync)
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ xlog.Error("[skills] background sync: GetManager failed", "url", urlToSync, "error", err)
+ return
+ }
+ xlog.Debug("[skills] background sync: got manager, running syncer", "url", urlToSync)
+ syncer := skillgit.NewGitSyncer(dir, []string{urlToSync}, mgr.RebuildIndex)
+ if err := syncer.Start(); err != nil {
+ xlog.Error("[skills] background sync: sync failed", "url", urlToSync, "error", err)
+ svc.RefreshManagerFromConfig()
+ return
+ }
+ syncer.Stop()
+ svc.RefreshManagerFromConfig()
+ xlog.Debug("[skills] background sync: finished", "url", urlToSync)
+ }()
+ xlog.Debug("[skills] AddGitRepo: returning 201 (sync in progress)")
+ return c.Status(http.StatusCreated).JSON(gitRepoResponse{ID: newRepo.ID, URL: newRepo.URL, Name: newRepo.Name, Enabled: newRepo.Enabled})
+}
+
+func (a *App) UpdateGitRepo(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ dir := svc.GetSkillsDir()
+ if dir == "" {
+ return skillsNoDir(c)
+ }
+ id := c.Params("id")
+ var req struct {
+ URL string `json:"url"`
+ Enabled *bool `json:"enabled"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
+ }
+ cm := skillgit.NewConfigManager(dir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ var found int
+ for i, r := range repos {
+ if r.ID == id {
+ found = i
+ if req.URL != "" {
+ parsedURL, err := url.Parse(req.URL)
+ if err != nil || parsedURL.Scheme == "" {
+ return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid repository URL"})
+ }
+ repos[i].URL = req.URL
+ repos[i].Name = skillgit.ExtractRepoName(req.URL)
+ }
+ if req.Enabled != nil {
+ repos[i].Enabled = *req.Enabled
+ }
+ break
+ }
+ }
+ if found >= len(repos) || repos[found].ID != id {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
+ }
+ if err := cm.SaveConfig(repos); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ svc.RefreshManagerFromConfig()
+ return c.JSON(gitRepoResponse{ID: repos[found].ID, URL: repos[found].URL, Name: repos[found].Name, Enabled: repos[found].Enabled})
+}
+
+func (a *App) DeleteGitRepo(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ dir := svc.GetSkillsDir()
+ if dir == "" {
+ return skillsNoDir(c)
+ }
+ id := c.Params("id")
+ cm := skillgit.NewConfigManager(dir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ var newRepos []skillgit.GitRepoConfig
+ var repoName string
+ for _, r := range repos {
+ if r.ID == id {
+ repoName = r.Name
+ } else {
+ newRepos = append(newRepos, r)
+ }
+ }
+ if len(newRepos) == len(repos) {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
+ }
+ if err := cm.SaveConfig(newRepos); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ if repoName != "" {
+ repoDir := filepath.Join(dir, repoName)
+ if err := os.RemoveAll(repoDir); err != nil {
+ xlog.Debug("[skills] DeleteGitRepo: failed to remove repo directory", "dir", repoDir, "error", err)
+ }
+ }
+ svc.RefreshManagerFromConfig()
+ return c.SendStatus(http.StatusNoContent)
+}
+
+func (a *App) SyncGitRepo(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ dir := svc.GetSkillsDir()
+ if dir == "" {
+ return skillsNoDir(c)
+ }
+ id := c.Params("id")
+ cm := skillgit.NewConfigManager(dir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ var url string
+ for _, r := range repos {
+ if r.ID == id {
+ url = r.URL
+ break
+ }
+ }
+ if url == "" {
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
+ }
+ xlog.Debug("[skills] SyncGitRepo: requested", "id", id, "url", url)
+ mgr, err := svc.GetManager()
+ if err != nil || mgr == nil {
+ xlog.Error("[skills] SyncGitRepo: GetManager failed", "id", id, "error", err)
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "manager not ready"})
+ }
+ go func() {
+ xlog.Debug("[skills] SyncGitRepo: background sync started", "id", id, "url", url)
+ syncer := skillgit.NewGitSyncer(dir, []string{url}, mgr.RebuildIndex)
+ if err := syncer.Start(); err != nil {
+ xlog.Error("[skills] SyncGitRepo: background sync failed", "id", id, "error", err)
+ svc.RefreshManagerFromConfig()
+ return
+ }
+ syncer.Stop()
+ svc.RefreshManagerFromConfig()
+ xlog.Debug("[skills] SyncGitRepo: background sync finished", "id", id)
+ }()
+ xlog.Debug("[skills] SyncGitRepo: returning 200 (sync in progress)")
+ return c.JSON(fiber.Map{"status": "ok", "message": "Sync started in background"})
+}
+
+func (a *App) ToggleGitRepo(c *fiber.Ctx) error {
+ svc := a.skillsSvc()
+ if svc == nil {
+ return skillsUnavailable(c)
+ }
+ dir := svc.GetSkillsDir()
+ if dir == "" {
+ return skillsNoDir(c)
+ }
+ id := c.Params("id")
+ cm := skillgit.NewConfigManager(dir)
+ repos, err := cm.LoadConfig()
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ for i, r := range repos {
+ if r.ID == id {
+ repos[i].Enabled = !repos[i].Enabled
+ if err := cm.SaveConfig(repos); err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ svc.RefreshManagerFromConfig()
+ return c.JSON(gitRepoResponse{ID: repos[i].ID, URL: repos[i].URL, Name: repos[i].Name, Enabled: repos[i].Enabled})
+ }
+ }
+ return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
+}