| name | add-tool |
| description | Adds new tool/IDE converters to agency-cli. Use this skill when asked to add support for a new tool or IDE. Researches official docs, infers config paths for macOS/Linux/Windows, handles local/global scope, and generates production-ready Go code following project conventions. |
Mission
You are a senior Go developer specialised in the agency-cli codebase. When asked to add support for a new tool, you carry out the full implementation end-to-end: research, code, tests, registration, and verification.
Steps to follow — in order:
- Research — Search the tool's official documentation. Find: how it loads AI agents / custom instructions / rules / skills; the expected file format; and where config files live on macOS, Linux, and Windows.
- Plan — Decide scope (project vs global), paths per OS, and file format.
- Implement — Create
internal/converter/<toolname>.go.
- Test — Create
internal/converter/<toolname>_test.go.
- Register — Update
internal/installer/installer.go, internal/converter/converter.go, and cmd/root.go.
- Document — Update
README.md Supported Tools table.
- Verify — Run
go test ./... and make lint and confirm everything passes.
Project Layout
internal/
converter/
converter.go ← registry, Converter interface, SupportedTools
<toolname>.go ← one file per tool
<toolname>_test.go
installer/
installer.go ← DestinationDir switch
cmd/
root.go ← --tool flag description
Converter Interface
type Converter interface {
Convert(a *agent.Agent, destDir string, scope string) ([]string, error)
Name() string
Description() string
IsProjectScoped() bool
}
Register via init():
func init() {
Register("toolname", &myTool{})
}
Agent Fields
type Agent struct {
Name string
Description string
Color string
Emoji string
Vibe string
Tools string
Category string
Slug string
Body string
FilePath string
}
Scope Handling
| Scenario | IsProjectScoped() | Behaviour |
|---|
| Project-only | true | Return errors.New("... is project-scoped; --scope global is not supported") when scope == ScopeGlobal |
| Global-only | false | Ignore scope, always install to user home; use _ for scope param |
| Dual-scope | true | Branch on scope: project dir for ScopeLocal/ScopeDefault, user home for ScopeGlobal |
Available constants: ScopeLocal = "local", ScopeGlobal = "global", ScopeDefault = "".
Cross-Platform Path Rules
- Always use
os.UserHomeDir() for the user home directory — returns ~ on macOS/Linux and %USERPROFILE% on Windows.
- Always use
filepath.Join() for path construction — handles OS path separators automatically.
%APPDATA% on Windows — some tools store config in %APPDATA% instead of ~. Use an env-var lookup with fallback:
func appDataDir() (string, error) {
if appdata := os.Getenv("APPDATA"); appdata != "" {
return appdata, nil
}
return os.UserHomeDir()
}
- Prefer
os.UserHomeDir() + tool-specific subfolder unless the tool's documentation explicitly calls out a Windows-specific path (e.g. %APPDATA%\Tool).
File Permissions (lint-compliant)
Always use distinct variable names and split the WriteFile call onto its own line — the //nolint comment pushes inline if forms over the 120-char golines limit:
if mkdirErr := os.MkdirAll(dir, 0o755); mkdirErr != nil {
return nil, mkdirErr
}
writeErr := os.WriteFile(file, []byte(content), 0o644)
if writeErr != nil {
return nil, writeErr
}
If a function writes multiple files, use distinct variable names (writeErr, manifestErr, writeConfigErr, …) to avoid govet shadow warnings.
Existing Converter Reference
Dual-scope — claude.go / copilot.go / kimi.go / opencode.go / gemini.go / qwen.go
Description() shows both paths separated by +:
func (c *myTool) Description() string { return ".mytool/agents/ + ~/.mytool/agents/" }
func (c *myTool) IsProjectScoped() bool { return true }
Convert() resolves directories itself — ignores destDir:
func (c *myTool) Convert(a *agent.Agent, _ string, scope string) ([]string, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
var dir string
switch scope {
case ScopeGlobal:
dir = filepath.Join(home, ".mytool", "agents")
default:
dir = filepath.Join(cwd, ".mytool", "agents")
}
if mkdirErr := os.MkdirAll(dir, 0o755); mkdirErr != nil {
return nil, mkdirErr
}
outFile := filepath.Join(dir, a.Slug+".md")
content := "---\nname: " + a.Name + "\n---\n" + a.Body
writeErr := os.WriteFile(outFile, []byte(content), 0o644)
if writeErr != nil {
return nil, writeErr
}
return []string{outFile}, nil
}
Project-scoped with global error — cursor.go
func (c *cursor) Convert(a *agent.Agent, destDir string, scope string) ([]string, error) {
if scope == ScopeGlobal {
return nil, errors.New("cursor is project-scoped; --scope global is not supported")
}
}
Global-only — openclaw.go / antigravity.go
IsProjectScoped() returns false. Scope param is _. Description() shows only the global path.
Append-to-single-file — windsurf.go / aider.go
Read existing file and append; write header only on first run.
var content string
if existing, err := os.ReadFile(outFile); err == nil {
content = string(existing) + entry
} else {
content = header + entry
}
Multi-file per agent — kimi.go
Some tools produce multiple files per agent (e.g. a YAML config + a Markdown system prompt). Return all file paths from Convert():
systemFile := filepath.Join(dir, a.Slug+".md")
writeErr := os.WriteFile(systemFile, []byte(a.Body), 0o644)
if writeErr != nil {
return nil, writeErr
}
configFile := filepath.Join(dir, a.Slug+".yaml")
writeConfigErr := os.WriteFile(configFile, []byte(yamlContent), 0o644)
if writeConfigErr != nil {
return nil, writeConfigErr
}
return []string{configFile, systemFile}, nil
Subdirectory + manifest — gemini.go
Create a per-agent subdirectory and only write a manifest if it doesn't exist yet.
skillDir := filepath.Join(baseDir, "skills", a.Slug)
if mkdirErr := os.MkdirAll(skillDir, 0o755); mkdirErr != nil {
return nil, mkdirErr
}
manifestFile := filepath.Join(baseDir, "gemini-extension.json")
if _, statErr := os.Stat(manifestFile); os.IsNotExist(statErr) {
manifestErr := os.WriteFile(manifestFile, []byte(manifest), 0o644)
if manifestErr != nil {
return nil, manifestErr
}
}
Multi-file split — openclaw.go
Split agent body into domain-specific files (SOUL.md, AGENTS.md, IDENTITY.md).
Files to Update
1. internal/converter/converter.go — append to SupportedTools
var SupportedTools = []string{
"new-tool",
}
2. internal/installer/installer.go — add case to DestinationDir
Global-only (converter uses destDir):
case "new-tool":
return filepath.Join(home, ".newtool", "agents"), nil
Project-only (converter uses destDir):
case "new-tool":
return filepath.Join(cwd, ".newtool", "agents"), nil
Dual-scope (converter resolves paths itself — return the local/cwd path as default):
case "new-tool":
return filepath.Join(cwd, ".newtool", "agents"), nil
3. cmd/root.go — extend --tool flag description
const toolDesc = "target tool (claude-code, copilot, cursor, ..., new-tool)"
4. README.md — add row to Supported Tools table
| new-tool | `.newtool/agents/ + ~/.newtool/agents/` | project + user |
Use project for project-only, user for global-only, project + user for dual-scope.
Test Pattern
All test files must start with the //nolint:testpackage directive (tests share the newTestAgent() helper defined in converter_test.go):
package converter
Local scope test — uses t.Chdir (incompatible with t.Parallel)
func TestMyTool_Convert_Local(t *testing.T) {
t.Chdir(t.TempDir())
cwd, err := os.Getwd()
require.NoError(t, err)
a := newTestAgent()
c, _ := Get("my-tool")
files, err := c.Convert(a, "", ScopeLocal)
require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, filepath.Join(cwd, ".mytool", "agents", "test-agent.md"), files[0])
content, err := os.ReadFile(files[0])
require.NoError(t, err)
assert.Contains(t, string(content), "name: Test Agent")
assert.Contains(t, string(content), "## Mission")
}
Default scope test — same as local (uses t.Chdir)
func TestMyTool_Convert_Default(t *testing.T) {
t.Chdir(t.TempDir())
cwd, err := os.Getwd()
require.NoError(t, err)
c, _ := Get("my-tool")
files, err := c.Convert(newTestAgent(), "", ScopeDefault)
require.NoError(t, err)
assert.Equal(t, filepath.Join(cwd, ".mytool", "agents", "test-agent.md"), files[0])
}
Global scope test — uses t.Parallel + cleanup
func TestMyTool_Convert_Global(t *testing.T) {
t.Parallel()
home, err := os.UserHomeDir()
require.NoError(t, err)
c, _ := Get("my-tool")
files, err := c.Convert(newTestAgent(), "", ScopeGlobal)
require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, filepath.Join(home, ".mytool", "agents", "test-agent.md"), files[0])
t.Cleanup(func() { os.Remove(files[0]) })
}
Project-only global error test
func TestMyTool_Convert_GlobalErrors(t *testing.T) {
t.Parallel()
c, _ := Get("my-tool")
_, err := c.Convert(newTestAgent(), t.TempDir(), ScopeGlobal)
assert.Error(t, err)
}
Variable naming in assertions — avoid encoded-compare lint
Do not use variable names containing yaml, json, or xml in assert.Equal calls — testifylint will flag them. Use neutral names:
wantConfigFile := filepath.Join(cwd, ".mytool", "agents", "test-agent.yaml")
assert.Equal(t, wantConfigFile, files[0])
expectedYAMLPath := filepath.Join(cwd, ".mytool", "agents", "test-agent.yaml")
assert.Equal(t, expectedYAMLPath, files[0])
Minimum test cases per converter:
| Case | Required |
|---|
| Local install — verify file path and key content | ✅ |
| Default scope — verify same path as local | ✅ (dual-scope only) |
| Global install — success or expected error | ✅ |
| Scope ignored (global-only tools) | ✅ |
| Append behaviour (single-file converters) | ✅ |
Optional fields omitted (e.g. no Tools, no Emoji) | when applicable |
Checklist
Before finishing, verify all of the following: