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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ ANTHROPIC_API_KEY=
ANTHROPIC_AGENT_ID=
ANTHROPIC_ENVIRONMENT_ID=

# Skills (optional). Comma-separated list of directories whose immediate
# children are skill folders (each containing a SKILL.md at root). Used
# by `make skills-sync` to bulk-upload skills to Anthropic. Example:
# SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills
SKILLS_SOURCES=

# SKILLS_AGENT_IDS (optional) attaches custom skill IDs at bootstrap
# (cmd/provision). Most setups leave this empty and let the runtime
# per-user provisioner pick up whatever the skills service has uploaded.
# SKILLS_AGENT_IDS=skill_abc,skill_def

AGENT_SYSTEM_PROMPT=You are a helpful assistant.
AGENT_RUN_RATE_LIMIT=10
AGENT_RUN_RATE_WINDOW_SECONDS=60
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ token: seed ## Alias for seed
managed-agents-provision: ## Create Anthropic Agent + Environment (run once, store IDs in .env)
cd backend && go run ./cmd/provision

skills-sync: ## Upload every SKILL.md folder under $$SKILLS_SOURCES to Anthropic
cd backend && go run ./cmd/skills-sync

clean: ## Remove generated artifacts
$(COMPOSE) down -v
rm -rf backend/bin mobile/node_modules mobile/.expo
Expand Down
15 changes: 15 additions & 0 deletions backend/cmd/provision/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ func main() {
client := anthropic.NewClient(option.WithAPIKey(apiKey))
ctx := context.Background()

// SKILLS_AGENT_IDS=skill_abc,skill_def attaches custom skills at
// bootstrap time. Optional — most forks use cmd/skills-sync to
// upload skills after the agent exists, then rely on the runtime
// per-user provisioner (internal/agent/provision.go) to attach them
// to lazily-created per-user agents.
var skillParams []anthropic.BetaManagedAgentsSkillParamsUnion
if raw := strings.TrimSpace(os.Getenv("SKILLS_AGENT_IDS")); raw != "" {
for _, id := range strings.Split(raw, ",") {
if id = strings.TrimSpace(id); id != "" {
skillParams = append(skillParams, anthropic.BetaManagedAgentsSkillParamsOfCustom(id))
}
}
}

fmt.Println("Creating Anthropic Agent...")
agent, err := client.Beta.Agents.New(ctx, anthropic.BetaAgentNewParams{
Name: "agent-setup",
Expand All @@ -43,6 +57,7 @@ func main() {
Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401,
},
}},
Skills: skillParams,
})
if err != nil {
log.Fatalf("create agent: %v", err)
Expand Down
42 changes: 39 additions & 3 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/teslashibe/agent-setup/backend/internal/mcp/platforms"
"github.com/teslashibe/agent-setup/backend/internal/brand"
"github.com/teslashibe/agent-setup/backend/internal/notifications"
"github.com/teslashibe/agent-setup/backend/internal/skills"
"github.com/teslashibe/agent-setup/backend/internal/teams"
"github.com/teslashibe/agent-setup/backend/internal/uploads"
)
Expand Down Expand Up @@ -69,10 +70,21 @@ func main() {
log.Fatalf("agent: %v", err)
}

// Skills are optional: when ANTHROPIC_API_KEY is unset (covered by
// the agent service check above) or no skills have been uploaded
// yet, the service still safely returns an empty list.
skillsSvc := skills.NewService(agentSvc.Client(), pool)

app := fiber.New(fiber.Config{
AppName: "Claude Agent Go",
StreamRequestBody: true,
ErrorHandler: apperrors.FiberHandler,
// Fiber defaults to 4 MiB which is too small for skill bundles
// (canonical example: the astrology skill ships ~5 MiB of
// pyswisseph wheels). 64 MiB matches Anthropic's own per-skill
// size limit and keeps `expo/fetch` multipart uploads within
// memory bounds we're comfortable with.
BodyLimit: 64 * 1024 * 1024,
ErrorHandler: apperrors.FiberHandler,
})
app.Use(recover.New(), logger.New(), cors.New(cors.Config{
AllowOrigins: cfg.CORSAllowedOrigins,
Expand Down Expand Up @@ -153,6 +165,11 @@ func main() {
invitesH.MountPublicRoutes(app, cfg.MobileAppScheme)
}

// Skills CRUD: workspace-wide, just JWT-required (no team scope —
// Anthropic skills are organization-wide so per-team isolation
// would be an illusion at this layer).
skills.NewHandler(skillsSvc).Mount(api)

// Agent routes are team-scoped. RequireTeam reads X-Team-ID (or falls
// back to the caller's personal team) and stamps team_id + team_role.
agentGroup := api.Group("", teamMW.RequireTeam())
Expand Down Expand Up @@ -201,7 +218,7 @@ func main() {
cfg.NotificationsDefaultPageSize, cfg.NotificationsMaxPageSize)
}

if err := mountMCP(app, api, authMW, cfg, pool, magicSvc, agentSvc, notifSvc); err != nil {
if err := mountMCP(app, api, authMW, cfg, pool, magicSvc, agentSvc, notifSvc, skillsSvc); err != nil {
log.Fatalf("mcp: %v", err)
}

Expand Down Expand Up @@ -274,6 +291,7 @@ func mountMCP(
magicSvc *magiclink.Service,
agentSvc *agent.Service,
notifSvc *notifications.Service,
skillsSvc *skills.Service,
) error {
if cfg.CredentialsEncryptionKey == "" {
log.Printf("mcp: CREDENTIALS_ENCRYPTION_KEY not set — MCP routes and per-user provisioner disabled")
Expand Down Expand Up @@ -321,7 +339,25 @@ func mountMCP(
if err != nil {
return fmt.Errorf("mcp endpoint factory: %w", err)
}
provOpts := agent.ProvisionerOptions{}
provOpts := agent.ProvisionerOptions{
SkillIDs: skillsSvc.AnthropicIDs,
SkillMCPServers: func(ctx context.Context) ([]agent.SkillMCPServerEntry, error) {
servers, err := skillsSvc.HealthyMCPServers(ctx)
if err != nil {
return nil, err
}
out := make([]agent.SkillMCPServerEntry, 0, len(servers))
for _, s := range servers {
name := s.URL // default to URL as name
// Try to look up the skill name for a cleaner MCPServer name
if sk, err := skillsSvc.SkillByID(ctx, s.SkillID); err == nil && sk != nil {
name = sk.Name
}
out = append(out, agent.SkillMCPServerEntry{Name: name, URL: s.URL})
}
return out, nil
},
}
if cfg.NotificationsEnabled {
provOpts.SystemPrompt = agent.NotificationsSystemPrompt()
}
Expand Down
67 changes: 67 additions & 0 deletions backend/cmd/skills-sync/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// skills-sync walks every directory in $SKILLS_SOURCES (comma-
// separated), finds folders containing a SKILL.md, uploads each to
// Anthropic via the Beta Skills API, and persists the resulting IDs
// in the skills table. Idempotent: re-running updates existing rows.
//
// make skills-sync
// # or, ad-hoc:
// SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills go run ./cmd/skills-sync
package main

import (
"context"
"log"
"os"
"strings"
"time"

"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"

"github.com/teslashibe/agent-setup/backend/internal/skills"
)

func main() {
_ = godotenv.Load()
apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY"))
if apiKey == "" {
log.Fatal("ANTHROPIC_API_KEY is required")
}
dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dbURL == "" {
log.Fatal("DATABASE_URL is required")
}
sourcesEnv := strings.TrimSpace(os.Getenv("SKILLS_SOURCES"))
if sourcesEnv == "" {
log.Fatal("SKILLS_SOURCES is required (comma-separated list of directories containing skill folders)")
}
sources := strings.Split(sourcesEnv, ",")

ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()

client := anthropic.NewClient(option.WithAPIKey(apiKey))
svc := skills.NewService(client, pool)

uploaded, errs := svc.SyncDirs(ctx, sources, func(f string, a ...any) { log.Printf(f, a...) })
log.Printf("done: %d uploaded, %d errors", len(uploaded), len(errs))
for _, e := range errs {
log.Printf(" ! %v", e)
}
// SyncDirs spawns refreshAgentsAsync goroutines per upload to push
// the new skill list onto every cached per-user agent. Give them a
// moment to drain before we close the pool — without this the CLI
// exits and the goroutines log "closed pool" trying to query users.
if len(uploaded) > 0 {
time.Sleep(2 * time.Second)
}
if len(errs) > 0 {
os.Exit(1)
}
}
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ require (
github.com/teslashibe/x-go v1.6.1
github.com/teslashibe/x-viral-go v0.2.0
github.com/valyala/fasthttp v1.70.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down Expand Up @@ -60,5 +61,4 @@ require (
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading