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
3 changes: 3 additions & 0 deletions .github/workflows/examples-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ jobs:
- name: repository-facts-populated-e2e
script: ./scripts/e2e-repository-facts-populated.sh
needs_typescript: true
- name: add-remove-language-existing-install-e2e
script: ./scripts/e2e-add-remove-language-existing-install.sh
needs_typescript: true

steps:
- name: Checkout ballast repo
Expand Down
14 changes: 2 additions & 12 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,18 +1,6 @@
repos:
- repo: local
hooks:
- id: check-trailing-whitespace
name: check trailing whitespace
entry: node scripts/check-whitespace.mjs trailing-whitespace
language: system
types: [text]

- id: check-end-of-file-newline
name: check end of file newline
entry: node scripts/check-whitespace.mjs end-of-file-newline
language: system
types: [text]

- id: ballast-unit-tests-pre-push
name: pre-push unit tests for cli and language packages
entry: scripts/run-unit-tests-pre-push.sh
Expand All @@ -24,6 +12,8 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
Comment on lines +15 to +16
- id: check-json
- id: check-yaml
exclude: ^(agents/.+/templates/.*\.ya?ml|packages/ballast-typescript/(agents|skills)/|packages/ballast-python/ballast/agents/|packages/ballast-go/cmd/ballast-go/agents/|packages/ballast-go/cmd/ballast/agents/)
Expand Down
2 changes: 1 addition & 1 deletion .rulesrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"logging",
"testing"
],
"ballastVersion": "5.10.3",
"skills": [
"owasp-security-scan",
"github-health-check",
Expand All @@ -23,7 +24,6 @@
"aws-weekly-security-review",
"ballast-audit"
],
"ballastVersion": "5.10.0",
"languages": [
"typescript",
"python",
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ Use `--force` when you want to reset a managed rule or skill file to canonical B

- `--target, -t`: `cursor`, `claude`, `opencode`, `codex`, `gemini`; adds to saved targets in `.rulesrc.json`
- `--remove-target`: remove one or more saved targets and clean up Ballast-managed files for them
- `--remove-language`: remove one or more languages from `.rulesrc.json`, remove their `paths`, and clean up unused Ballast-managed rules
- `--agent, -a`: comma-separated agent list
- `--skill, -s`: comma-separated skill list
- `--all`: install all agents for the selected language
Expand All @@ -316,7 +317,7 @@ Use `--force` when you want to reset a managed rule or skill file to canonical B

## Wrapper Commands

- `ballast install`: install rules for the detected or selected language; `--target` merges into saved targets, `--remove-target` removes saved targets with Ballast-managed cleanup, and `--refresh-config` reapplies saved `.rulesrc.json` settings
- `ballast install`: install rules for the detected or selected language; `--target` merges into saved targets, `--remove-target` removes saved targets with Ballast-managed cleanup, `--remove-language` removes language surfaces plus saved `paths` with cleanup, and `--refresh-config` reapplies saved `.rulesrc.json` settings
- `ballast doctor`: inspect local Ballast CLI versions and `.rulesrc.json` metadata; add `--fix` to install/upgrade backend CLIs and refresh config automatically, and add `--patch` to merge backend file updates during that refresh
- `ballast upgrade [--patch] [--force]`: rewrite `.rulesrc.json` to the running Ballast wrapper version, then sync backend CLIs to match it; `--patch` and `--force` forward to the backend refresh
- `ballast install-cli [--language <typescript|python|go|ansible|terraform>] [--version <x.y.z>]`: install or upgrade backend CLIs into the current repo’s `.ballast/` directory; omit `--version` for the latest release. The `ansible` and `terraform` selections reuse the `ballast-go` backend.
Expand Down
78 changes: 75 additions & 3 deletions cli/ballast/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ func printUsage() {
fmt.Println(" ballast install --target cursor,claude --all")
fmt.Println(" ballast install --target gemini --all")
fmt.Println(" ballast install --remove-target codex")
fmt.Println(" ballast install --remove-language python")
fmt.Println(" ballast install --target claude --skill owasp-security-scan")
fmt.Println(" ballast install --refresh-config")
fmt.Println(" ballast install-cli --language python")
Expand All @@ -430,6 +431,7 @@ func printUsage() {
fmt.Println()
fmt.Println("When --language is omitted, ballast detects the repository layout.")
fmt.Println("Install target behavior: `--target` adds to the saved targets in `.rulesrc.json`; use `--remove-target` to stop managing a target and clean up Ballast-managed files for it.")
fmt.Println("Install language behavior: `--remove-language` removes languages from `.rulesrc.json`, removes their `paths`, and prunes stale Ballast-managed rule files.")
fmt.Println("Single-language repos are forwarded to the matching backend CLI.")
fmt.Println("Mixed TypeScript/Python/Go/Ansible/Terraform repos install all rules at the repo root under per-language directories (for example `.claude/rules/typescript/`, `.gemini/rules/python/`, and `.codex/rules/terraform/`).")
}
Expand Down Expand Up @@ -917,6 +919,57 @@ func normalizeInstallArgs(args []string, root string) ([]string, error) {
return filtered, nil
}

func parseRemoveLanguageValues(args []string) []string {
values := findFlagValues(args, "--remove-language", "")
normalized := make([]string, 0, len(values))
for _, value := range values {
token := strings.ToLower(strings.TrimSpace(value))
if token == "" {
continue
}
normalized = append(normalized, token)
}
return uniqueStrings(normalized)
}

func validateSelectedLanguages(values []string) error {
if len(values) == 0 {
return nil
}
allowed := map[string]struct{}{}
for _, lang := range supportedLanguages {
allowed[string(lang)] = struct{}{}
}
for _, value := range values {
if _, ok := allowed[value]; !ok {
return fmt.Errorf(
"invalid --remove-language: %s (valid: %s)",
value,
strings.Join(languageNames(), ", "),
)
}
}
return nil
Comment on lines +922 to +952
}

func filterProfilesByLanguage(profiles []repoProfile, removed []string) []repoProfile {
if len(removed) == 0 {
return profiles
}
removedSet := map[string]struct{}{}
for _, lang := range removed {
removedSet[lang] = struct{}{}
}
filtered := make([]repoProfile, 0, len(profiles))
for _, profile := range profiles {
if _, remove := removedSet[string(profile.Language)]; remove {
continue
}
filtered = append(filtered, profile)
}
return filtered
}

func ensureLocalToolDirs(root string) error {
if err := ensureGitignoreEntry(root, ".ballast/"); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update .gitignore for .ballast/: %v\n", err)
Expand Down Expand Up @@ -1863,9 +1916,17 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
profiles = configProfiles
}
}
removeLanguages := parseRemoveLanguageValues(args)
if err := validateSelectedLanguages(removeLanguages); err != nil {
return nil, err
}
profiles = filterProfilesByLanguage(profiles, removeLanguages)

if len(profiles) < 2 {
return nil, nil
allowLanguageRemovalPlan := len(removeLanguages) > 0 && config != nil && len(config.Languages) > 1
if !allowLanguageRemovalPlan {
return nil, nil
}
}
if warning := javascriptComponentWarning(root); warning != "" && !profilesIncludeLanguage(profiles, langTypeScript) {
fmt.Fprintln(os.Stderr, "warning:", warning)
Expand Down Expand Up @@ -1905,7 +1966,8 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
}
}
cleanupOnly := len(removeTargets) > 0 && len(requestedTargets) == 0 && !explicitAgentSelection && !explicitSkillSelection
if !cleanupOnly && (len(requestedTargets) == 0 || ((len(installAgents) == 0 && !installAll) && (len(installSkills) == 0 && !installAllSkills))) {
languageCleanupOnly := len(removeLanguages) > 0 && len(installTargets) == 0 && len(removeTargets) == 0 && !explicitAgentSelection && !explicitSkillSelection
if !cleanupOnly && !languageCleanupOnly && (len(requestedTargets) == 0 || ((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")
}

Expand Down Expand Up @@ -1971,7 +2033,7 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
configToSave.Languages = append(configToSave.Languages, string(profile.Language))
configToSave.Paths[string(profile.Language)] = relativePaths(root, profile.Paths)
}
if cleanupOnly {
if cleanupOnly || languageCleanupOnly {
return &monorepoPlan{
Invocations: nil,
Config: configToSave,
Expand All @@ -1982,6 +2044,9 @@ func resolveMonorepoPlan(root string, args []string) (*monorepoPlan, error) {
Previous: config,
}, nil
}
if len(profiles) == 0 {
return nil, errors.New("no languages remain after --remove-language; run with only --remove-language to clean up and persist config, or select a language with --language for single-language installs")
}

commonSelection := filterAgents(selectedAgents, commonAgentIDs())
languageSelection := filterAgents(selectedAgents, languageAgentIDs())
Expand Down Expand Up @@ -2373,6 +2438,10 @@ func stripMonorepoFlags(args []string) []string {
i++
continue
}
if arg == "--remove-language" {
i++
continue
}
if arg == "--task-system" {
i++
continue
Expand All @@ -2383,6 +2452,9 @@ func stripMonorepoFlags(args []string) []string {
if strings.HasPrefix(arg, "--remove-target=") {
continue
}
if strings.HasPrefix(arg, "--remove-language=") {
continue
}
if strings.HasPrefix(arg, "--task-system=") {
continue
}
Expand Down
172 changes: 172 additions & 0 deletions cli/ballast/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2286,6 +2286,121 @@ func TestResolveMonorepoPlanRemoveLastTargetCleanupOnly(t *testing.T) {
}
}

func TestResolveMonorepoPlanRemoveLanguageCleanupOnly(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, ".rulesrc.json"), `{
"targets": ["codex"],
"agents": ["local-dev", "linting"],
"languages": ["typescript", "python"],
"paths": {
"typescript": ["apps/frontend"],
"python": ["services/api"]
}
}`)

plan, err := resolveMonorepoPlan(root, []string{"install", "--remove-language", "python", "--yes"})
if err != nil {
t.Fatalf("resolveMonorepoPlan returned error: %v", err)
}
if plan == nil {
t.Fatal("expected monorepo plan, got nil")
}
if len(plan.Invocations) != 0 {
t.Fatalf("expected cleanup-only plan with no backend invocations, got %#v", plan.Invocations)
}
if !reflect.DeepEqual(plan.Config.Languages, []string{"typescript"}) {
t.Fatalf("expected python to be removed from languages, got %#v", plan.Config.Languages)
}
if _, ok := plan.Config.Paths["python"]; ok {
t.Fatalf("expected python paths to be removed, got %#v", plan.Config.Paths)
}
if got := plan.Config.Paths["typescript"]; !reflect.DeepEqual(got, []string{"apps/frontend"}) {
t.Fatalf("expected typescript path to remain, got %#v", plan.Config.Paths)
}
}

func TestResolveMonorepoPlanRemoveLanguageWithTargetRunsInstallPath(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "apps", "frontend", "tsconfig.json"), "{}")
mustWriteFile(t, filepath.Join(root, "services", "api", "pyproject.toml"), "[project]\nname='api'\n")
mustWriteFile(t, filepath.Join(root, ".rulesrc.json"), `{
"targets": ["claude"],
"agents": ["linting"],
"skills": [],
"languages": ["typescript", "python"],
"paths": {
"typescript": ["apps/frontend"],
"python": ["services/api"]
}
}`)

plan, err := resolveMonorepoPlan(
root,
[]string{"install", "--target", "codex", "--remove-language", "python", "--yes"},
)
if err != nil {
t.Fatalf("resolveMonorepoPlan returned error: %v", err)
}
if plan == nil {
t.Fatal("expected monorepo plan, got nil")
}
if len(plan.Invocations) == 0 {
t.Fatalf("expected non-cleanup install plan with backend invocations, got %#v", plan.Invocations)
}
if !slices.Contains(plan.Config.Targets, "codex") {
t.Fatalf("expected saved config targets to include codex, got %#v", plan.Config.Targets)
}
if slices.Contains(plan.Config.Languages, "python") {
t.Fatalf("expected python removed from languages, got %#v", plan.Config.Languages)
}
if _, ok := plan.Config.Paths["python"]; ok {
t.Fatalf("expected python removed from paths, got %#v", plan.Config.Paths)
}
}

func TestParseRemoveLanguageValues(t *testing.T) {
values := parseRemoveLanguageValues([]string{
"install",
"--remove-language=python",
})
if !reflect.DeepEqual(values, []string{"python"}) {
t.Fatalf("expected remove-language values, got %#v", values)
}
}

func TestParseRemoveLanguageValuesNormalizesCase(t *testing.T) {
values := parseRemoveLanguageValues([]string{
"install",
"--remove-language=Python,GO",
"--remove-language",
"TypeScript",
})
if !reflect.DeepEqual(values, []string{"python", "go", "typescript"}) {
t.Fatalf("expected normalized remove-language values, got %#v", values)
}
}

func TestResolveMonorepoPlanRejectsInvalidRemoveLanguage(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, ".rulesrc.json"), `{
"targets": ["codex"],
"agents": ["linting"],
"languages": ["typescript", "python"],
"paths": {
"typescript": ["apps/frontend"],
"python": ["services/api"]
}
}`)

plan, err := resolveMonorepoPlan(root, []string{"install", "--remove-language", "ruby", "--yes"})
if err == nil {
t.Fatalf("expected invalid remove-language error, got plan %#v", plan)
}
if !strings.Contains(err.Error(), "invalid --remove-language: ruby") {
t.Fatalf("expected invalid remove-language error, got %v", err)
}
}

func TestResolveMonorepoPlanRejectsInvalidConfiguredTargets(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, ".rulesrc.json"), `{
Expand Down Expand Up @@ -3240,6 +3355,63 @@ func TestRunMonorepoRemoveTargetDoesNotPersistConfigWhenCleanupFails(t *testing.
}
}

func TestRunMonorepoRemoveLanguageCleansManagedRulesAndConfig(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, ".rulesrc.json"), `{
"targets": ["codex"],
"agents": ["linting"],
"languages": ["typescript", "python"],
"paths": {
"typescript": ["apps/frontend"],
"python": ["services/api"]
}
}`)
mustWriteFile(
t,
filepath.Join(root, ".codex", "rules", "python", "python-linting.md"),
"# rule\n\n<!-- Created by [Ballast](https://github.com/everydaydevopsio/ballast). Do not edit this section. -->\n",
)
mustWriteFile(
t,
filepath.Join(root, ".codex", "rules", "typescript", "typescript-linting.md"),
"# rule\n\n<!-- Created by [Ballast](https://github.com/everydaydevopsio/ballast). Do not edit this section. -->\n",
)

originalEnsure := ensureInstalledFunc
originalExec := execToolFunc
t.Cleanup(func() {
ensureInstalledFunc = originalEnsure
execToolFunc = originalExec
})
ensureInstalledFunc = func(tool toolConfig) error { return nil }
execToolFunc = func(binary string, args []string, dir string, env map[string]string) (int, error) {
return 0, nil
}

withWorkingDir(t, root, func() {
exitCode := run([]string{"install", "--remove-language", "python", "--yes"})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
})

if _, err := os.Stat(filepath.Join(root, ".codex", "rules", "python", "python-linting.md")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected python rule to be removed, got err=%v", err)
}
if _, err := os.Stat(filepath.Join(root, ".codex", "rules", "typescript", "typescript-linting.md")); err != nil {
t.Fatalf("expected typescript rule to remain, got %v", err)
}

config, err := os.ReadFile(filepath.Join(root, ".rulesrc.json"))
if err != nil {
t.Fatalf("read saved config: %v", err)
}
text := string(config)
if strings.Contains(text, `"python"`) || !strings.Contains(text, `"typescript"`) {
t.Fatalf("expected python removed and typescript retained in config, got %q", text)
}
}

// TestRunMonorepoInstallPreservesTaskSystemWrittenByBackend asserts that a
// taskSystem value written to .rulesrc.json by a backend invocation (simulating
// what ballast-typescript does after prompting the user) is not clobbered by
Expand Down
Loading
Loading