Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/workflows/examples-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,29 @@ jobs:
set -euo pipefail
test -f "examples/smoke/${{ matrix.sample }}/.cursor/rules/${{ matrix.expected_rule }}"

skills-smoke:
name: skills-smoke
runs-on: ubuntu-latest

steps:
- name: Checkout ballast repo
uses: actions/checkout@v6

- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: packages/ballast-go/go.mod

- name: Build ballast-go CLI
run: |
set -euo pipefail
mkdir -p "$GITHUB_WORKSPACE/.ci/bin"
go build -C packages/ballast-go -o "$GITHUB_WORKSPACE/.ci/bin/ballast-go" ./cmd/ballast-go
echo "$GITHUB_WORKSPACE/.ci/bin" >> "$GITHUB_PATH"

- name: Run skills smoke test
run: ./scripts/smoke-skills.sh "$GITHUB_WORKSPACE"

monorepo-patch-smoke:
name: monorepo-patch-smoke
runs-on: ubuntu-latest
Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![Lint](https://github.com/everydaydevopsio/ballast/actions/workflows/lint.yaml/badge.svg)](https://github.com/everydaydevopsio/ballast/actions/workflows/lint.yaml)
[![Release](https://github.com/everydaydevopsio/ballast/actions/workflows/publish.yml/badge.svg)](https://github.com/everydaydevopsio/ballast/actions/workflows/publish.yml)

Ballast installs AI agent rules for Cursor, Claude Code, OpenCode, and Codex.
Ballast installs AI agent rules and skills for Cursor, Claude Code, OpenCode, and Codex.

Release `v4.0.0` supports three first-class language profiles in this repository:

Expand Down Expand Up @@ -40,6 +40,16 @@ Agent sources in this repo:
- `agents/python/*`
- `agents/go/*`

## Skill Model

Common skills (all languages):

- `owasp-security-scan`

Skill sources in this repo:

- `skills/common/*`

## Install and Use (Single Language)

`ballast` is the wrapper command (intended for Homebrew) that detects repo language and dispatches to the matching language CLI.
Expand Down Expand Up @@ -91,6 +101,7 @@ Notes:
```bash
pnpm add -D @everydaydevopsio/ballast
pnpm exec ballast-typescript install --target cursor --all
pnpm exec ballast-typescript install --target claude --skill owasp-security-scan
```

### Python
Expand All @@ -101,6 +112,8 @@ uv tool install --from "https://github.com/everydaydevopsio/ballast/releases/dow
ballast-python install --target cursor --all
# or
uvx --from "https://github.com/everydaydevopsio/ballast/releases/download/v${VERSION}/ballast_python-${VERSION}-py3-none-any.whl" ballast-python install --target codex --agent linting
# or
uvx --from "https://github.com/everydaydevopsio/ballast/releases/download/v${VERSION}/ballast_python-${VERSION}-py3-none-any.whl" ballast-python install --target claude --skill owasp-security-scan
```

### Go
Expand All @@ -118,6 +131,7 @@ tar -xzf /tmp/ballast-go.tar.gz -C /tmp
mkdir -p "${HOME}/.local/bin"
install -m 0755 /tmp/ballast-go "${HOME}/.local/bin/ballast-go"
ballast-go install --target cursor --all
ballast-go install --target opencode --skill owasp-security-scan
```

## Monorepo: Install and Use by Language
Expand Down Expand Up @@ -149,13 +163,15 @@ Recommended order for one repository that uses all three languages:
2. Run the Python command.
3. Run the Go command.

Ballast only installs shipped agents and follows the single overwrite policy (existing rule files are preserved unless `--force` is passed). Use `--patch` to merge new Ballast content into an existing rule file while preserving the user's version of edited sections.
Ballast only installs shipped agents and skills and follows the single overwrite policy (existing rule files are preserved unless `--force` is passed). Use `--patch` to merge new Ballast content into an existing rule file while preserving the user's version of edited sections.

## CLI Flags

- `--target, -t`: `cursor`, `claude`, `opencode`, `codex`
- `--agent, -a`: comma-separated agent list
- `--skill, -s`: comma-separated skill list
- `--all`: install all agents for the selected language
- `--all-skills`: install all available skills for the selected language
- `--force, -f`: overwrite existing rule files
- `--patch, -p`: merge upstream rule updates into existing rule files while preserving user-edited sections (`--force` wins if both are set)
- `--yes, -y`: non-interactive mode
Expand All @@ -172,13 +188,16 @@ Ballast only installs shipped agents and follows the single overwrite policy (ex
- TypeScript CLI: `.rulesrc.ts.json`
- Python CLI: `.rulesrc.python.json`
- Go CLI: `.rulesrc.go.json`
- Saved settings include `target`, `agents`, and `skills`

## Install Locations

- Cursor: `.cursor/rules/<agent>.mdc`
- Claude: `.claude/rules/<agent>.md`
- OpenCode: `.opencode/<agent>.md`
- Claude: `.claude/rules/<agent>.md` and `.claude/skills/<skill>.skill`
- OpenCode: `.opencode/<agent>.md` and `.opencode/skills/<skill>.md`
- Codex: `.codex/rules/<agent>.md` and root `AGENTS.md`
- Cursor skills: `.cursor/rules/<skill>.mdc`
- Codex skills: `.codex/rules/<skill>.md`, with root `AGENTS.md` listing installed skills

## Development

Expand Down
74 changes: 64 additions & 10 deletions cli/ballast/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,12 @@ var collectDoctorBackendsFunc = collectDoctorBackends
var commonAgents = []string{"local-dev", "cicd", "observability"}
var languageAgents = []string{"linting", "logging", "testing"}
var supportedAgents = append(slices.Clone(commonAgents), languageAgents...)
var supportedSkills = []string{"owasp-security-scan"}

type monorepoConfig struct {
Target string `json:"target,omitempty"`
Agents []string `json:"agents,omitempty"`
Skills []string `json:"skills,omitempty"`
BallastVersion string `json:"ballastVersion,omitempty"`
Languages []string `json:"languages,omitempty"`
Paths map[string][]string `json:"paths,omitempty"`
Expand Down Expand Up @@ -339,6 +341,7 @@ func printUsage() {
fmt.Println("Examples:")
fmt.Println(" ballast")
fmt.Println(" ballast install --target cursor --all")
fmt.Println(" ballast install --target claude --skill owasp-security-scan")
fmt.Println(" ballast install --refresh-config")
fmt.Println(" ballast install-cli --language python")
fmt.Println(" ballast doctor")
Expand Down Expand Up @@ -1190,28 +1193,36 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
}

installTarget := findFlagValue(args, "--target", "-t")
installAgents, installAll := parseInstallSelection(args)
installAgents, installAll, installSkills, installAllSkills := parseInstallSelection(args)
explicitAgentSelection := len(installAgents) > 0 || installAll
explicitSkillSelection := len(installSkills) > 0 || installAllSkills
if installTarget == "" && config != nil {
installTarget = config.Target
}
if len(installAgents) == 0 && !installAll && config != nil {
if !explicitAgentSelection && !explicitSkillSelection && config != nil {
installAgents = slices.Clone(config.Agents)
installSkills = slices.Clone(config.Skills)
}
if installTarget == "" || (len(installAgents) == 0 && !installAll) {
return nil, errors.New("monorepo install requires --target and --agent/--all, or a root .rulesrc.json with target, agents, languages, and paths")
if installTarget == "" || ((len(installAgents) == 0 && !installAll) && (len(installSkills) == 0 && !installAllSkills)) {
return nil, errors.New("monorepo install requires --target and at least one of --agent/--all or --skill/--all-skills, or a root .rulesrc.json with target, agents/skills, languages, and paths")
}

selectedAgents := installAgents
if installAll {
selectedAgents = append(slices.Clone(commonAgents), languageAgents...)
}
selectedSkills := installSkills
if installAllSkills {
selectedSkills = slices.Clone(supportedSkills)
}
if err := validateSelectedAgents(selectedAgents); err != nil {
return nil, err
}

configToSave := monorepoConfig{
Target: installTarget,
Agents: selectedAgents,
Skills: selectedSkills,
BallastVersion: normalizeVersion(resolveVersion()),
Languages: make([]string, 0, len(profiles)),
Paths: map[string][]string{},
Expand All @@ -1226,7 +1237,8 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
baseArgs := stripMonorepoFlags(args)

plan := make([]backendInvocation, 0, len(profiles)+1)
if len(commonSelection) > 0 {
commonArgs := withSkillSelection(withAgentSelection(baseArgs, commonSelection), selectedSkills)
if len(commonSelection) > 0 || len(selectedSkills) > 0 {
commonLanguage := profiles[0].Language
if hasLanguage(profiles, langTypeScript) {
commonLanguage = langTypeScript
Expand All @@ -1237,7 +1249,7 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
Binary: tool.binary,
Dir: root,
Env: monorepoInvocationEnv("common"),
Args: withAgentSelection(baseArgs, commonSelection),
Args: commonArgs,
})
}
for _, profile := range profiles {
Expand All @@ -1250,7 +1262,7 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
Binary: tool.binary,
Dir: root,
Env: monorepoInvocationEnv(string(profile.Language)),
Args: withAgentSelection(baseArgs, languageSelection),
Args: withSkillSelection(withAgentSelection(baseArgs, languageSelection), nil),
})
}

Expand Down Expand Up @@ -1423,26 +1435,46 @@ func detectRepoProfiles(root string) ([]repoProfile, error) {
return profiles, nil
}

func parseInstallSelection(args []string) ([]string, bool) {
func parseInstallSelection(args []string) ([]string, bool, []string, bool) {
agents := []string{}
skills := []string{}
allAgents := false
allSkills := false
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--all" {
return nil, true
allAgents = true
continue
}
if arg == "--all-skills" {
allSkills = true
continue
}
if strings.HasPrefix(arg, "--agent=") {
agents = append(agents, splitAgentValues(strings.TrimPrefix(arg, "--agent="))...)
continue
}
if strings.HasPrefix(arg, "--skill=") {
skills = append(skills, splitAgentValues(strings.TrimPrefix(arg, "--skill="))...)
continue
}
if arg == "--agent" || arg == "-a" {
if i+1 >= len(args) {
continue
}
agents = append(agents, splitAgentValues(args[i+1])...)
i++
continue
}
if arg == "--skill" || arg == "-s" {
if i+1 >= len(args) {
continue
}
skills = append(skills, splitAgentValues(args[i+1])...)
i++
}
}
return uniqueStrings(agents), false
return uniqueStrings(agents), allAgents, uniqueStrings(skills), allSkills
}

func splitAgentValues(raw string) []string {
Expand Down Expand Up @@ -1512,6 +1544,28 @@ func withAgentSelection(baseArgs []string, agents []string) []string {
return filtered
}

func withSkillSelection(baseArgs []string, skills []string) []string {
filtered := make([]string, 0, len(baseArgs))
for i := 0; i < len(baseArgs); i++ {
arg := baseArgs[i]
if arg == "--all-skills" {
continue
}
if arg == "--skill" || arg == "-s" {
i++
continue
}
if strings.HasPrefix(arg, "--skill=") {
continue
}
filtered = append(filtered, arg)
}
if len(skills) > 0 {
filtered = append(filtered, "--skill", strings.Join(skills, ","))
}
return filtered
}

func filterAgents(selected []string, allowed []string) []string {
allowedSet := map[string]struct{}{}
for _, agent := range allowed {
Expand Down
Loading
Loading