Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b8c1f7b
Add experimental `entire learn` tour command
alishakawaguchi May 7, 2026
c01b18a
Rename `entire learn` to `entire tour` and add `--latest` blog digest
alishakawaguchi May 7, 2026
a886ca9
Pre-render `entire tour` markdown via release pipeline
alishakawaguchi May 7, 2026
0690196
Address tour code review and make tour regen non-blocking
alishakawaguchi May 7, 2026
17ce868
Apply pass-2 review and codex adversarial review fixes
alishakawaguchi May 7, 2026
4dce5cf
Fix NBSP-mid-tagname bypass and stale entire-labs-tour refs
alishakawaguchi May 7, 2026
0817da7
Apply pass-4 simplify and review fixes
alishakawaguchi May 7, 2026
9f4a616
Fix golangci-lint findings on tour feature
alishakawaguchi May 7, 2026
c8ca02b
Fix CI lint failures and address PR review feedback
alishakawaguchi May 8, 2026
68a03d9
Add t.Parallel to new tour test functions and subtests
alishakawaguchi May 8, 2026
4d93e7c
Skip ResolveState on --regenerate to fix CI tour stub
alishakawaguchi May 8, 2026
6dc31bc
Merge branch 'main' into entire-learn
alishakawaguchi May 8, 2026
c851c7e
Replace embedded tour stub with real generated content
alishakawaguchi May 8, 2026
5d83d6f
Require per-command descriptions in capability sections
alishakawaguchi May 8, 2026
ed04a6d
Rename entire tour to entire learn
alishakawaguchi May 11, 2026
cda534b
Address review findings on learn rename
alishakawaguchi May 11, 2026
eeeba11
Address second-round review of learn rename
alishakawaguchi May 11, 2026
a5c7c6a
Address third-round review of learn rename
alishakawaguchi May 11, 2026
eff67a7
Harden learn package tests against host environment
alishakawaguchi May 11, 2026
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
72 changes: 72 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,78 @@ jobs:
git log --oneline "${BASE_TAG}..HEAD" --no-merges >> "$RUNNER_TEMP/release_notes.md"
fi

# Tour regeneration is best-effort: when it fails we still ship,
# but with the stub committed in the repo. Both steps below are
# continue-on-error so a flaky agent, a missing secret, or an npm
# registry blip can't block a release. The follow-up step turns a
# failed regen into a visible GitHub annotation so it's not silent.
- name: Install Claude CLI for tour regeneration
# Pinned to a known-good version. Bump deliberately — the agent's
# output format is part of the tour-regeneration contract.
continue-on-error: true
run: npm install -g @anthropic-ai/[email protected]

- name: Regenerate embedded tour
id: regenerate-tour
continue-on-error: true
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "::error::ANTHROPIC_API_KEY secret is required to regenerate the embedded tour"
exit 1
fi
# The mise task itself does the atomic write and validation
# (>=4 ## headers, docs.entire.io footer present). Anything
# short of that aborts before clobbering the committed stub.
mise run tour:regenerate

- name: Note tour regeneration outcome
if: always()
run: |
if [ "${{ steps.regenerate-tour.outcome }}" != "success" ]; then
echo "::warning::Tour regeneration failed (or was skipped); the released binary will ship the committed stub for embedded/tour.md. Users running 'entire tour' will see the placeholder until the next release."
else
echo "Tour regenerated successfully ($(wc -l < cmd/entire/cli/tour/embedded/tour.md) lines)."
fi

# Tour regeneration is best-effort, so a failure here does NOT fail
# the release — but degraded releases (stub shipped) deserve their
# own Slack signal. The existing notify-slack job at the bottom only
# fires on full job failure, which would no longer trigger.
#
# `always()` is required so this fires even after a `continue-on-error`
# step failed; the strict `== 'failure'` (rather than `!= 'success'`)
# avoids firing on 'skipped' outcomes from earlier-step failures
# before regen had a chance to run.
- name: Notify Slack of tour regeneration failure
if: always() && steps.regenerate-tour.outcome == 'failure'
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.E2E_SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":warning: *Tour regeneration failed* for `${{ env.RELEASE_TAG }}` — release will proceed with the committed stub. Users will see the placeholder when running `entire tour` until the next release.\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run details>"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Tag: `${{ env.RELEASE_TAG }}` by ${{ github.actor }}"
}
]
}
]
}

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7
with:
Expand Down
35 changes: 30 additions & 5 deletions cmd/entire/cli/labs.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ var experimentalCommands = []experimentalCommandInfo{
Invocation: "entire review",
Summary: "Run configured review skills against the current branch",
},
{
Name: "tour",
Invocation: "entire tour",
Summary: "Tour the Entire CLI",
},
}

func newLabsCmd() *cobra.Command {
Expand All @@ -30,10 +35,9 @@ func newLabsCmd() *cobra.Command {
if len(args) == 0 {
return nil
}
err := fmt.Errorf("unknown labs topic %q", args[0])
fmt.Fprintf(cmd.ErrOrStderr(),
"%v\n\nRun `entire labs` to see available experimental commands, or run `entire review --help` for command-specific help.\n",
err)
topic := args[0]
err := fmt.Errorf("unknown labs topic %q", topic)
fmt.Fprintf(cmd.ErrOrStderr(), "%v\n\n%s\n", err, labsTopicHint(topic))
return NewSilentError(err)
},
Run: func(cmd *cobra.Command, _ []string) {
Expand All @@ -59,15 +63,36 @@ to try now, but details may change based on feedback.
Available experimental commands:
` + renderExperimentalCommands(experimentalCommands) + `
Try:
entire tour --help
entire review --help
`
}

// labsTopicHint returns the redirect string shown when the user types
// `entire labs <topic>` and topic is not a real labs subcommand. When the
// topic matches a known experimental command (e.g. `entire labs review`
// when review actually lives at the top level), point at its canonical
// invocation instead of leaving the user to guess.
func labsTopicHint(topic string) string {
for _, info := range experimentalCommands {
if info.Name == topic {
return fmt.Sprintf("%s lives at `%s`. Run `%s --help` for command-specific help.", info.Name, info.Invocation, info.Invocation)
}
}
return "Run `entire labs` to see available experimental commands."
}

func renderExperimentalCommands(commands []experimentalCommandInfo) string {
width := 16
for _, info := range commands {
if l := len(info.Invocation); l > width {
width = l
}
}
var out strings.Builder
for _, info := range commands {
out.WriteString(" ")
out.WriteString(padRight(info.Invocation, 16))
out.WriteString(padRight(info.Invocation, width))
out.WriteByte(' ')
out.WriteString(info.Summary)
out.WriteByte('\n')
Expand Down
12 changes: 10 additions & 2 deletions cmd/entire/cli/labs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,20 @@ func TestLabsRegistryCommandsExistAtCanonicalPaths(t *testing.T) {

root := NewRootCmd()
for _, info := range experimentalCommands {
cmd, _, err := root.Find([]string{info.Name})
// Invocation has the form "entire <segments...>"; cobra's Find
// takes the path after "entire". Splitting on whitespace handles
// both top-level commands ("entire review", "entire tour") and
// any future subcommand-shaped entries.
segments := strings.Fields(strings.TrimPrefix(info.Invocation, "entire "))
cmd, _, err := root.Find(segments)
if err != nil {
t.Fatalf("labs command %q should exist at canonical path: %v", info.Name, err)
t.Fatalf("labs command %q should exist at canonical path %q: %v", info.Name, info.Invocation, err)
}
if cmd == nil {
t.Fatalf("labs command %q resolved to nil command", info.Name)
}
if cmd.Name() != info.Name {
t.Fatalf("labs command %q resolved to %q at path %q", info.Name, cmd.Name(), info.Invocation)
}
}
}
37 changes: 35 additions & 2 deletions cmd/entire/cli/mdrender/mdrender.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ import (
// is available. Matches the cap used by status_style.getTerminalWidth.
const DefaultTerminalWidth = 80

// StyleOverride mutates the resolved StyleConfig before glamour builds
// the renderer. Used by commands that want to tweak a single field of the
// shared palette (e.g., `entire tour` recoloring H2 to match its
// capability framing) without forking the whole config.
type StyleOverride func(*ansi.StyleConfig)

// Render produces a glamour-styled string from markdown using the entire
// CLI palette. width is the word-wrap target; darkBackground selects the
// dark or light palette variant.
Expand All @@ -37,15 +43,29 @@ const DefaultTerminalWidth = 80
// than a runtime condition. Renderer panics are recovered and returned as
// errors so callers can fall back to raw markdown instead of crashing.
func Render(markdown string, width int, darkBackground bool) (rendered string, err error) {
return RenderWithOverride(markdown, width, darkBackground, nil)
}

// RenderWithOverride is Render with an optional palette transform applied
// after the shared palette is resolved. A nil override is equivalent to
// calling Render. Callers that need to recolor a single heading level or
// adjust list bullets should reach for this rather than reimplementing
// the renderer construction.
func RenderWithOverride(markdown string, width int, darkBackground bool, override StyleOverride) (rendered string, err error) {
defer func() {
if r := recover(); r != nil {
rendered = ""
err = fmt.Errorf("render markdown panic: %v", r)
}
}()

styles := stylesForBackground(darkBackground)
if override != nil {
override(&styles)
}

renderer, err := glamour.NewTermRenderer(
glamour.WithStyles(stylesForBackground(darkBackground)),
glamour.WithStyles(styles),
glamour.WithWordWrap(width),
glamour.WithPreservedNewLines(),
)
Expand All @@ -67,12 +87,25 @@ func Render(markdown string, width int, darkBackground bool) (rendered string, e
// Width is auto-detected from w (capped at 80); background palette is
// detected via termenv.HasDarkBackground.
func RenderForWriter(w io.Writer, markdown string) (string, error) {
return RenderForWriterWithOverride(w, markdown, nil)
}

// RenderForWriterWithOverride is RenderForWriter plus an optional palette
// transform. Like RenderForWriter, it returns the input unchanged when w is
// not a terminal or NO_COLOR is set, so the override never applies in those
// cases — pipelines stay grep-friendly.
func RenderForWriterWithOverride(w io.Writer, markdown string, override StyleOverride) (string, error) {
if !shouldRender(w) {
return markdown, nil
}
return Render(markdown, terminalWidth(w), termenv.HasDarkBackground())
return RenderWithOverride(markdown, terminalWidth(w), termenv.HasDarkBackground(), override)
}

// StringPtr returns a pointer to v. Exposed so callers building a
// StyleOverride can set glamour's `*string` color fields without
// reimplementing the helper.
func StringPtr(v string) *string { return &v }

// shouldRender returns true if w is a terminal writer and NO_COLOR is unset.
func shouldRender(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func NewRootCmd() *cobra.Command {

// Top-level lifecycle and standalone commands.
cmd.AddCommand(cliReview.NewCommand(buildReviewDeps(newReviewAttachCmd()))) // hidden during maturation; runs configured review skills
cmd.AddCommand(newTourCmd()) // hidden during maturation; advertised under 'entire labs'
cmd.AddCommand(newCleanCmd())
cmd.AddCommand(newSetupCmd()) // 'configure' — non-agent settings; agent CRUD lives under 'agent'
cmd.AddCommand(newEnableCmd())
Expand Down
114 changes: 114 additions & 0 deletions cmd/entire/cli/tour/blog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package tour

import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)

// BlogFeedURL is the canonical RSS feed for entire.io.
const BlogFeedURL = "https://entire.io/feed.xml"

// blogFetchTimeout caps how long --latest waits on the feed before
// bailing. The user already pays for the agent call after this; keeping
// the fetch tight avoids stacking two long waits.
const blogFetchTimeout = 8 * time.Second

// blogFetchMaxBytes caps how much of the feed body we'll read. RSS feeds
// in practice are well under a MiB; the cap exists so a malformed,
// hijacked, or unbounded response can't pull arbitrary memory into the
// CLI process.
const blogFetchMaxBytes = 5 << 20 // 5 MiB

// BlogPost is the subset of a feed <item> the agent prompt cares about.
type BlogPost struct {
Title string `json:"title"`
Link string `json:"link"`
PubDate string `json:"pub_date,omitempty"`
Description string `json:"description,omitempty"`
Content string `json:"content,omitempty"`
}

// rssEnvelope models the bits of RSS 2.0 we read. We deliberately keep
// the schema permissive — extra/unknown elements are ignored, and we
// match content:encoded on its local name so the standard xmlns prefix
// shape is enough.
type rssEnvelope struct {
XMLName xml.Name `xml:"rss"`
Channel rssChannel `xml:"channel"`
}

type rssChannel struct {
Items []rssItem `xml:"item"`
}

type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Description string `xml:"description"`
// content:encoded — encoding/xml resolves namespaces when we declare
// the full namespace URL on the field tag. The W3C content namespace
// is the canonical one used by virtually every RSS publisher.
Encoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
}

// errNoBlogPosts is returned when the feed parses successfully but
// contains no items.
var errNoBlogPosts = errors.New("entire blog feed contains no posts")

// FetchLatestBlogPost GETs the configured feed URL and returns the first
// (most recent) <item>. The HTTP client uses a hard timeout, so this
// won't hang `entire tour --latest` when the feed is slow or
// unreachable; the body is also size-capped so a malformed feed can't
// pull unbounded memory.
var FetchLatestBlogPost = defaultFetchLatestBlogPost

func defaultFetchLatestBlogPost(ctx context.Context) (*BlogPost, error) {
ctx, cancel := context.WithTimeout(ctx, blogFetchTimeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, BlogFeedURL, nil)
if err != nil {
return nil, fmt.Errorf("build feed request: %w", err)
}
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.8, */*;q=0.5")
req.Header.Set("User-Agent", "entire-cli")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", BlogFeedURL, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch %s: unexpected status %s", BlogFeedURL, resp.Status)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, blogFetchMaxBytes))
if err != nil {
return nil, fmt.Errorf("read feed body: %w", err)
}

var envelope rssEnvelope
if err := xml.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("parse feed: %w", err)
}
if len(envelope.Channel.Items) == 0 {
return nil, errNoBlogPosts
}

first := envelope.Channel.Items[0]
return &BlogPost{
Title: strings.TrimSpace(first.Title),
Link: strings.TrimSpace(first.Link),
PubDate: strings.TrimSpace(first.PubDate),
Description: strings.TrimSpace(first.Description),
Content: strings.TrimSpace(first.Encoded),
}, nil
}
Loading
Loading