# feat(trail): surface trail web URL in create/show

`7af26cb`→[main](/content/gh/entireio/cli/commits/main/index.html)·

toothbrush·3w ago·3 files·+76 added/-6 removed

`trail create` now prints the trail's browser URL, and both create and
show prefer the canonical `url` the server returns over a locally
constructed one — so the CLI tracks any route change without needing a
lockstep update. Local construction remains only as a fallback for older
servers that omit the field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## Sessions

df9e84f74c5eView transcript

## Changes

3

- cmd/entire/cli

- api

- Mtrail_types.go+6/-2

- Mtrail_cmd.go+27/-4

- Mtrail_cmd_test.go+43

```
20 unmodified lines

21
22
23
24
25
24
25
26
27
28
29
30
31
32

20 unmodified lines

// TrailResource represents a single trail from the API.
type TrailResource struct {
	ID              string           `json:"id,omitempty"`
	Number          int              `json:"number,omitempty"`
	// URL is the canonical browser URL for the trail. The server provides it so
	// clients need not reconstruct the route; it may be empty against older
	// servers that predate the field.
	URL             string           `json:"url,omitempty"`
	Branch          string           `json:"branch"`
	Base            string           `json:"base"`
	Title           string           `json:"title"`
}
```

Mcmd/entire/cli/api/trail_types.go+6/-2

```
132 unmodified lines

133
134
135
136
136
137
138
139
140
141
142
143
143
144
145
94 unmodified lines

240
241
242
244
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
541 unmodified lines

812
813
814
792
815
816
817
818

132 unmodified lines

// the core metadata already came from the list, so a detail failure
		// falls back to the list body with a warning rather than failing.
		m := found.ToMetadata()
		webURL := ""
		webURL := trailDisplayURL(*found, forge, owner, repo)
		// Seed the description from the list body so a failed (or skipped)
		// detail fetch still shows something; a successful detail fetch
		// supersedes it with the richer body_document text below.
		bodyText := found.Body
		descriptionLoaded := strings.TrimSpace(found.Body) != ""
		if found.Number > 0 {
			webURL = trailWebURL(api.BaseURL(), forge, owner, repo, found.Number)
			if bt, derr := fetchTrailDescription(ctx, client, forge, owner, repo, found.Number); derr == nil {
				// A successful fetch means we authoritatively consulted the
				// description, but it only supersedes the seeded list body when
				94 unmodified lines

}
		}
		return ""
	}

// trailWebURL builds the browser URL for a trail:
// printCreatedTrail reports a newly created trail, including its browser URL
// (the same URL `trail show` surfaces) when one is available.
func printCreatedTrail(w io.Writer, t api.TrailResource, forge, owner, repo string) {
	fmt.Fprintf(w, "Created trail %q for branch %s (ID: %s)\n", t.Title, t.Branch, t.ID)
	if url := trailDisplayURL(t, forge, owner, repo); url != "" {
		fmt.Fprintf(w, "  URL: %s\n", url)
	}
}

// trailDisplayURL returns the trail's browser URL. It prefers the canonical URL
// the server now returns, so the CLI tracks any route change without being
// updated in lockstep, and falls back to a locally constructed URL only for
// older servers that omit the field.
func trailDisplayURL(t api.TrailResource, forge, owner, repo string) string {
	if strings.TrimSpace(t.URL) != "" {
		return t.URL
	}
	if t.Number > 0 {
		return trailWebURL(api.BaseURL(), forge, owner, repo, t.Number)
	}
	return ""
}

// trailWebURL builds a fallback browser URL for a trail used only when the
// server does not supply one (older servers):
// <web-origin>/<forge>/<owner>/<repo>/trails/<number>. In production the web app
// is served from the same origin as the data API, so the API base URL doubles
// as the web origin. A split local-dev setup (API and frontend on different
541 unmodified lines

return fmt.Errorf("failed to decode create response: %w", err)
}

fmt.Fprintf(w, "Created trail %q for branch %s (ID: %s)\n", createResp.Trail.Title, createResp.Trail.Branch, createResp.Trail.ID)
printCreatedTrail(w, createResp.Trail, forge, owner, repoName)

if needsCreation && currentBranch != branch {
	shouldCheckout := checkout
}
}

Mcmd/entire/cli/trail_cmd.go+27/-4

```
301 unmodified lines

302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350

301 unmodified lines

}

func TestPrintCreatedTrail(t *testing.T) {

t.Parallel()

// The server-provided URL is used verbatim.
var out bytes.Buffer
printCreatedTrail(&out, api.TrailResource{Title: "Fix it", Branch: "feat/x", ID: "abc123", Number: 575, URL: "https://entire.io/gh/acme/repo/trails/575/fix-it"}, "gh", "acme", "repo")
text := out.String()
if !strings.Contains(text, `Created trail "Fix it" for branch feat/x (ID: abc123)`) {
	
t.Fatalf("missing create summary line, got:\n%s", text)
}
if !strings.Contains(text, "URL: https://entire.io/gh/acme/repo/trails/575/fix-it") {
	
t.Fatalf("expected the server-provided URL, got:\n%s", text)
}

// Without a number, omit the URL line.
out.Reset()
printCreatedTrail(&out, api.TrailResource{Title: "No num", Branch: "feat/y", ID: "def456"}, "gh", "acme", "repo")
if text := out.String(); strings.Contains(text, "URL:") {
	
t.Fatalf("expected URL omitted when number and URL are absent, got:\n%s", text)
}
}

func TestTrailDisplayURL(t *testing.T) {

t.Parallel()

// Server URL wins, even when a number is present.
got := trailDisplayURL(api.TrailResource{Number: 5, URL: "https://server/url"}, "gh", "acme", "repo")
if got != "https://server/url" {
	
t.Fatalf("expected server URL, got %q", got)
}

// Falls back to a constructed URL for older servers that omit it.
got = trailDisplayURL(api.TrailResource{Number: 5}, "gh", "acme", "repo")
if !strings.HasSuffix(got, "/gh/acme/repo/trails/5") {
	
t.Fatalf("expected constructed fallback URL, got %q", got)
}

// Nothing to show when neither is available.
if got := trailDisplayURL(api.TrailResource{}, "gh", "acme", "repo"); got != "" {
	
t.Fatalf("expected empty URL, got %q", got)
}
}

func TestTrailDescriptionForDisplay(t *testing.T) {

t.Parallel()
if got := trailDescriptionForDisplay("the body", true); got != "the body" {
	
t.Fatalf("unexpected description output, got: %v", got)
}}
