Skip to content

Commit 7b0dd1e

Browse files
authored
Add support for profiles from URL, including git and raw file (#65)
* feat: add support for adding profiles from a URL, including git repo and raw file URLs * bump version * feat: enhance profile addition from URLs to support multiple file types and improve error handling
1 parent fa61749 commit 7b0dd1e

12 files changed

Lines changed: 920 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Layout: main.go→src/cmd. src/cmd/raid.go=root cmd+subcommand registration+vers
66

77
Config: raid.yaml=per-repo (environments+tasks: Shell|Script|HTTP|Wait|Template|Group|Git|Prompt|Confirm|Set|Print). profile.raid.yml=user profile (tracked repos, global settings).
88

9+
Versioning: version in src/resources/app.properties. Bump second position (minor) for feature/large changes; bump third position (patch) for small changes or bug fixes.
10+
911
Non-obvious:
1012
- applyConfigFlag in src/cmd/raid.go scans os.Args for --config/-c BEFORE Cobra parses, because config must load before subcommand registration
1113
- Async version check goroutine on every invocation; info cmds (help/version/completion) wait up to 1.5s, others non-blocking
@@ -19,6 +21,7 @@ Non-obvious:
1921
- Cross-process mutation lock at ~/.raid/.lock via gofrs/flock. raid.WithMutationLock(fn) wraps the lock+release; every mutating cobra entry point and every MCP mutating handler must call it so CLI usage and the MCP server serialize against each other. Read paths don't acquire the lock. Tests must redirect lib.LockPathOverride (alongside RecentPathOverride) in any setup helper that exercises a mutating path; cmd/context tests use a TestMain to do this once for the whole package.
2022
- Cobra commands: prefer RunE over Run on read commands so enc.Encode errors and arg-validation errors propagate to the root error handler instead of being silently swallowed. Use cmd.OutOrStdout()/cmd.OutOrStderr() (not fmt.Println / os.Stdout) so tests can capture output via root.SetOut(&buf). When changing Run → RunE, also update any test that calls Command.Run(...) directly to Command.RunE(...).
2123
- JSON output is public CLI contract: --json field names and types are breaking-change surface. Use camelCase tags consistent with `raid context --json`; severities/enums encode as strings ("ok"/"warn"/"error", not ints). Renaming or removing a field needs a whats-new entry.
24+
- `raid profile add <url>` accepts HTTP/HTTPS/git@ URLs. URL type detection in src/cmd/profile/fetch.go: git@ prefix or .git suffix → clone; .yaml/.yml/.json extension → raw HTTP download; otherwise probed live with sys.DetectGitDefaultBranch. Profiles are copied to ~/<name>.raid.yaml (home dir root, not ~/.raid/). detectGitURL/gitCloneFunc/httpGetFunc are package-level vars for test injection; tests must set detectGitURL to avoid live network probes.
2225

2326
CI: .github/workflows/ — build.yml (build+test), deploy.yml (release), preview.yml (preview releases), codecov.yml (coverage), docs.yml (deploy Pages from site/), docs-build.yml (PR build check for site/)
2427

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Raid is written in Go, distributed as a single self-contained binary, and publis
2828

2929
- [raid install](https://raidcli.dev/docs/usage/install): Clone repositories and run install tasks across your profile
3030
- [raid env](https://raidcli.dev/docs/usage/env): Switch between development, staging, and production environments
31-
- [raid profile](https://raidcli.dev/docs/usage/profile): Add, list, switch, and remove profiles
31+
- [raid profile](https://raidcli.dev/docs/usage/profile): Add profiles from a git repo URL, raw file URL, or local path; list, switch, and remove profiles
3232
- [Custom commands](https://raidcli.dev/docs/usage/custom): Define and invoke `raid <cmd>` team workflows
3333
- [raid doctor](https://raidcli.dev/docs/usage/doctor): Diagnose profile and repo configuration issues
3434
- [raid root command](https://raidcli.dev/docs/usage/raid): Global flags and top-level invocation

site/docs/features/profiles.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ commands:
5151
path: "~/dev/frontend"
5252
```
5353
54+
## Adding profiles from a URL
55+
56+
`raid profile add` accepts a git repo URL, a raw file URL, or a local path:
57+
58+
```bash
59+
# Shallow-clone a repo and import *.raid.yaml, *.raid.yml, and profile.json files found at the root
60+
raid profile add https://github.com/my-org/raid-profiles
61+
62+
# Download a single profile file directly
63+
raid profile add https://raw.githubusercontent.com/my-org/repo/main/team.raid.yaml
64+
65+
# Register a local file
66+
raid profile add ./team.raid.yaml
67+
```
68+
69+
Raid auto-detects the argument type. Git URLs (including `git@` and `.git`-suffix URLs) are cloned; HTTP URLs ending with `.yaml`, `.yml`, or `.json` are downloaded directly; ambiguous HTTP URLs are probed with `git ls-remote`. Downloaded profiles are saved to `~/<name>.raid.yaml` before registration.
70+
5471
## Top-level fields
5572

5673
| Field | Required | Description |

site/docs/usage/profile.mdx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,21 @@ Create a new profile interactively:
2828
raid profile create
2929
```
3030

31-
Register your team's shared profile from a URL:
31+
Add a profile from a git repository (raid shallow-clones it and looks for `*.raid.yaml`, `*.raid.yml`, and `profile.json` files at the repo root):
32+
33+
```bash
34+
raid profile add https://github.com/my-org/raid-profiles
35+
raid profile add git@github.com:my-org/raid-profiles.git
36+
```
37+
38+
Add a profile from a raw URL pointing directly to a YAML or JSON file:
3239

3340
```bash
3441
raid profile add https://example.com/team-profile.yaml
42+
raid profile add https://raw.githubusercontent.com/my-org/repo/main/team.raid.yaml
3543
```
3644

37-
Register a local file:
45+
The profile is saved to `~/<name>.raid.yaml` and registered automatically. If the argument has no URL scheme it is treated as a local file path:
3846

3947
```bash
4048
raid profile add ./my-profile.yaml

site/docs/whats-new.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ User-visible changes per release, latest first. For full commit history see the
1111

1212
## 0.7.0 — upcoming
1313

14+
**Add profiles from a URL.** `raid profile add` now accepts a git repo URL or a direct file URL in addition to a local path. Pass a GitHub (or any git host) URL and raid shallow-clones the repo, finds `*.raid.yaml`, `*.raid.yml`, or `profile.json` files at the root, and saves them to `~/<name>.raid.yaml`. Pass a raw HTTPS URL ending in `.yaml`/`.yml`/`.json` and raid downloads and registers it directly. Ambiguous HTTPS URLs are probed with `git ls-remote` to determine the right strategy automatically.
15+
1416
**MCP server.** `raid context serve` runs raid as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, so MCP-aware hosts (Claude Code, Cursor, Cline) can read the active workspace as resources and invoke raid tools (`raid_install`, `raid_env_switch`, `raid_run_task`, `raid_list_profiles`, `raid_list_repos`, `raid_describe_repo`) directly. Output from mutating tools is captured into the tool result so it doesn't corrupt JSON-RPC framing on stdout. See [Context → MCP server](/docs/usage/context).
1517

1618
**Set variables reach subprocesses.** Variables defined by a `Set` task are now exported as environment variables to Script and Shell task subprocesses, so a script's source can reference `$VAR` directly without raid pre-expanding the script body. Raid values take precedence over OS-environment variables of the same name.

src/cmd/profile/add.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ var AddProfileCmd = &cobra.Command{
3636
// Extracted from AddProfileCmd.Run so tests can observe the exit code
3737
// without os.Exit terminating the test process.
3838
func runAddProfile(path string) int {
39+
if isURL(path) {
40+
return runAddProfileFromURL(path)
41+
}
3942
path = sys.ExpandPath(path)
4043

4144
if !sys.FileExists(path) {

src/cmd/profile/fetch.go

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package profile
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/url"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strings"
12+
"time"
13+
14+
sys "github.com/8bitalex/raid/src/internal/sys"
15+
"github.com/8bitalex/raid/src/raid"
16+
pro "github.com/8bitalex/raid/src/raid/profile"
17+
)
18+
19+
// Injectable for testing.
20+
var (
21+
gitCloneFunc = func(repoURL, dir string) error {
22+
return exec.Command("git", "clone", "--depth", "1", repoURL, dir).Run()
23+
}
24+
httpGetFunc = func(rawURL string) ([]byte, error) {
25+
client := &http.Client{Timeout: 30 * time.Second}
26+
resp, err := client.Get(rawURL) //nolint:gosec
27+
if err != nil {
28+
return nil, err
29+
}
30+
defer resp.Body.Close()
31+
if resp.StatusCode != http.StatusOK {
32+
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL)
33+
}
34+
const maxBytes = 10 * 1024 * 1024
35+
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
36+
if err != nil {
37+
return nil, err
38+
}
39+
if int64(len(data)) > maxBytes {
40+
return nil, fmt.Errorf("response from %s exceeds 10 MB limit", rawURL)
41+
}
42+
return data, nil
43+
}
44+
// detectGitURL is injectable so tests can skip the live git ls-remote probe.
45+
detectGitURL = isGitURL
46+
getHomeDir = sys.GetHomeDir
47+
)
48+
49+
// isURL reports whether s is an HTTP, HTTPS, or git SSH URL.
50+
func isURL(s string) bool {
51+
return strings.HasPrefix(s, "http://") ||
52+
strings.HasPrefix(s, "https://") ||
53+
strings.HasPrefix(s, "git@")
54+
}
55+
56+
// isGitURL reports whether rawURL points to a clonable git repository.
57+
// git@ and .git-suffix URLs are always git. HTTP URLs with a recognised file
58+
// extension (.yaml/.yml/.json) are raw files; all others are probed with ls-remote.
59+
func isGitURL(rawURL string) bool {
60+
if strings.HasPrefix(rawURL, "git@") || strings.HasSuffix(rawURL, ".git") {
61+
return true
62+
}
63+
u, err := url.Parse(rawURL)
64+
if err != nil {
65+
return false
66+
}
67+
switch strings.ToLower(filepath.Ext(u.Path)) {
68+
case ".yaml", ".yml", ".json":
69+
return false
70+
}
71+
return sys.DetectGitDefaultBranch(rawURL) != ""
72+
}
73+
74+
// runAddProfileFromURL is the entry point when the add argument is a URL.
75+
func runAddProfileFromURL(rawURL string) int {
76+
if detectGitURL(rawURL) {
77+
return addProfilesFromGitURL(rawURL)
78+
}
79+
return addProfilesFromHTTPURL(rawURL)
80+
}
81+
82+
func addProfilesFromGitURL(repoURL string) int {
83+
tmpDir, err := os.MkdirTemp("", "raid-profile-*")
84+
if err != nil {
85+
fmt.Printf("Failed to create temp directory: %v\n", err)
86+
return 1
87+
}
88+
defer os.RemoveAll(tmpDir)
89+
90+
fmt.Printf("Cloning %s...\n", repoURL)
91+
if err := gitCloneFunc(repoURL, tmpDir); err != nil {
92+
fmt.Printf("Failed to clone repository: %v\n", err)
93+
return 1
94+
}
95+
96+
paths := findProfileFilesInDir(tmpDir)
97+
if len(paths) == 0 {
98+
fmt.Println("No profile files found in repository")
99+
return 1
100+
}
101+
102+
return processProfileFiles(paths)
103+
}
104+
105+
func addProfilesFromHTTPURL(rawURL string) int {
106+
data, err := httpGetFunc(rawURL)
107+
if err != nil {
108+
fmt.Printf("Failed to download profile: %v\n", err)
109+
return 1
110+
}
111+
112+
u, err := url.Parse(rawURL)
113+
if err != nil {
114+
fmt.Printf("Invalid URL: %v\n", err)
115+
return 1
116+
}
117+
ext := strings.ToLower(filepath.Ext(u.Path))
118+
if ext == "" {
119+
ext = ".yaml"
120+
}
121+
122+
tmpFile, err := os.CreateTemp("", "raid-profile-*"+ext)
123+
if err != nil {
124+
fmt.Printf("Failed to create temp file: %v\n", err)
125+
return 1
126+
}
127+
tmpPath := tmpFile.Name()
128+
defer os.Remove(tmpPath)
129+
130+
if _, err := tmpFile.Write(data); err != nil {
131+
tmpFile.Close()
132+
fmt.Printf("Failed to write temp file: %v\n", err)
133+
return 1
134+
}
135+
tmpFile.Close()
136+
137+
return processProfileFiles([]string{tmpPath})
138+
}
139+
140+
// findProfileFilesInDir returns profile YAML/JSON files found at the root of dir.
141+
// Priority: profile.raid.yaml/yml first, then any *.raid.yaml/yml, then profile.json.
142+
func findProfileFilesInDir(dir string) []string {
143+
seen := map[string]bool{}
144+
var found []string
145+
146+
add := func(name string) {
147+
if seen[name] {
148+
return
149+
}
150+
seen[name] = true
151+
if full := filepath.Join(dir, name); sys.FileExists(full) {
152+
found = append(found, full)
153+
}
154+
}
155+
156+
add("profile.raid.yaml")
157+
add("profile.raid.yml")
158+
159+
entries, rdErr := os.ReadDir(dir)
160+
if rdErr != nil {
161+
fmt.Fprintf(os.Stderr, "warning: failed to read directory %q: %v\n", dir, rdErr)
162+
}
163+
for _, e := range entries {
164+
if e.IsDir() {
165+
continue
166+
}
167+
name := e.Name()
168+
lower := strings.ToLower(name)
169+
ext := filepath.Ext(lower)
170+
stem := strings.TrimSuffix(lower, ext)
171+
if (ext == ".yaml" || ext == ".yml") && strings.HasSuffix(stem, ".raid") {
172+
add(name)
173+
}
174+
}
175+
176+
add("profile.json")
177+
178+
return found
179+
}
180+
181+
// processProfileFiles validates, saves, and registers profiles from the given local paths.
182+
func processProfileFiles(paths []string) int {
183+
type pending struct {
184+
p pro.Profile
185+
srcPath string
186+
}
187+
188+
var queued []pending
189+
var existingNames []string
190+
seenQueued := map[string]bool{}
191+
192+
for _, srcPath := range paths {
193+
if err := proValidate(srcPath); err != nil {
194+
fmt.Printf("Skipping %s: invalid profile (%v)\n", filepath.Base(srcPath), err)
195+
continue
196+
}
197+
profiles, err := proUnmarshal(srcPath)
198+
if err != nil {
199+
fmt.Printf("Skipping %s: could not read profiles (%v)\n", filepath.Base(srcPath), err)
200+
continue
201+
}
202+
for _, p := range profiles {
203+
if err := sys.ValidateFileName(p.Name); err != nil {
204+
fmt.Printf("Skipping profile with invalid name %q: %v\n", p.Name, err)
205+
continue
206+
}
207+
if proContains(p.Name) {
208+
existingNames = append(existingNames, p.Name)
209+
continue
210+
}
211+
if seenQueued[p.Name] {
212+
fmt.Printf("Skipping duplicate profile name %q\n", p.Name)
213+
continue
214+
}
215+
seenQueued[p.Name] = true
216+
queued = append(queued, pending{p: p, srcPath: srcPath})
217+
}
218+
}
219+
220+
if len(existingNames) > 0 {
221+
fmt.Printf("Profiles already exist with names:\n\t%s\n\n", strings.Join(existingNames, ",\n\t"))
222+
}
223+
224+
if len(queued) == 0 {
225+
fmt.Println("No new profiles found")
226+
return 0
227+
}
228+
229+
// Copy each profile to a stable home-dir path before registering.
230+
home := getHomeDir()
231+
var toRegister []pro.Profile
232+
var destPaths []string
233+
for _, q := range queued {
234+
destPath := filepath.Join(home, q.p.Name+".raid.yaml")
235+
if err := sys.CopyFile(q.srcPath, destPath); err != nil {
236+
fmt.Printf("Failed to save profile '%s': %v\n", q.p.Name, err)
237+
continue
238+
}
239+
q.p.Path = destPath
240+
toRegister = append(toRegister, q.p)
241+
destPaths = append(destPaths, destPath)
242+
}
243+
244+
if len(toRegister) == 0 {
245+
fmt.Println("No new profiles found")
246+
return 0
247+
}
248+
249+
writeErr := raid.WithMutationLock(func() error {
250+
if err := proAddAll(toRegister); err != nil {
251+
return fmt.Errorf("save: %w", err)
252+
}
253+
if proGet().IsZero() {
254+
if err := proSet(toRegister[0].Name); err != nil {
255+
return fmt.Errorf("set active: %w", err)
256+
}
257+
fmt.Printf("Profile '%s' set as active\n", toRegister[0].Name)
258+
}
259+
return nil
260+
})
261+
if writeErr != nil {
262+
fmt.Printf("Failed to save profiles: %v\n", writeErr)
263+
return 1
264+
}
265+
266+
for i, p := range toRegister {
267+
fmt.Printf("Profile '%s' added from URL, saved to %s\n", p.Name, destPaths[i])
268+
}
269+
return 0
270+
}

0 commit comments

Comments
 (0)