Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 27 additions & 0 deletions .claude/skills/changelog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,33 @@ The user provides:
- **Version number** -- e.g., `0.5.3`
- **Additional PRs** -- optionally, PRs not yet merged that should be included

## Step 0: Regenerate the embedded `entire learn` markdown

Before bumping the changelog, refresh the markdown that ships inside the
binary so `entire learn` reflects the live command surface.

```bash
mise run learn:regenerate
```

This rewrites `cmd/entire/cli/learn/embedded/learn.md` via an agent call.
The mise task does atomic-write + validation (>=4 `##` headers, docs.entire.io
footer); a transient agent failure leaves the committed file untouched.

After it runs:

- `git diff cmd/entire/cli/learn/embedded/learn.md` — eyeball the diff.
Capability sections, blurbs, and command lists should match what's in
this release. Hand-edit the file if you want to refine wording, fix a
capability grouping, or correct an out-of-date command line. The
committed file is the source of truth — re-running regenerate later
will overwrite hand edits unless you commit them first.
- Commit the refreshed `learn.md` in the same PR as the CHANGELOG bump so
the embedded markdown and the release notes ship together.

Requires `claude` (or another TextGenerator-capable agent) on PATH and the
corresponding auth (typically `ANTHROPIC_API_KEY`) in the environment.

## Step 1: Gather Data

1. Find the previous release tag: `git tag --sort=-version:refname | head -1`
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/learn.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Learn

on:
pull_request:
paths:
- cmd/entire/cli/learn/embedded/learn.md
# embedded.go owns the //go:embed directive — a PR that points it
# at a different filename or wraps the embed must re-trigger
# validation even if learn.md itself wasn't touched.
- cmd/entire/cli/learn/embedded.go
- .github/workflows/learn.yml

permissions:
contents: read

# Same well-formedness checks `mise run learn:regenerate` enforces, but
# run on PRs that touch the embedded markdown so a hand edit, bad
# merge, or truncated paste can't ship a malformed file. Cheap and
# agent-free, complements the release-time gate in release.yml.
#
# No user-controlled inputs are interpolated into the run script — the
# file path is a repo-relative constant — so the standard injection
# guidance for workflow_run/pull_request_target doesn't apply here.
jobs:
validate-embedded-learn:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Validate embedded learn markdown
run: |
learn_md=cmd/entire/cli/learn/embedded/learn.md
if [ ! -s "$learn_md" ]; then
echo "::error file=$learn_md::is missing or empty"
exit 1
fi
header_count=$(grep -c '^## ' "$learn_md" || true)
if [ "$header_count" -lt 4 ]; then
echo "::error file=$learn_md::has only $header_count ## headers (expected >= 4) — run 'mise run learn:regenerate' and commit the refreshed file"
exit 1
fi
if ! grep -q 'https://docs.entire.io/cli' "$learn_md"; then
echo "::error file=$learn_md::is missing the docs.entire.io footer — run 'mise run learn:regenerate' and commit the refreshed file"
exit 1
fi
27 changes: 27 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,33 @@ jobs:
git log --oneline "${BASE_TAG}..HEAD" --no-merges >> "$RUNNER_TEMP/release_notes.md"
fi

# The embedded `entire learn` markdown is regenerated during the
# changelog PR for each release (see .claude/skills/changelog),
# not here, so the release pipeline ships whatever's committed in
# cmd/entire/cli/learn/embedded/learn.md without touching it.
#
# Smoke-validate the committed file matches the well-formedness
# the regenerate script enforces (>=4 ## headers, docs footer).
# This catches a stub or truncated file landing in the tree
# without re-running the agent — runs on both stable and nightly
# tags so prerelease builds don't silently ship a placeholder.
- name: Validate embedded learn markdown
run: |
learn_md=cmd/entire/cli/learn/embedded/learn.md
if [ ! -s "$learn_md" ]; then
echo "::error::$learn_md is missing or empty"
exit 1
fi
header_count=$(grep -c '^## ' "$learn_md" || true)
if [ "$header_count" -lt 4 ]; then
echo "::error::$learn_md has only $header_count ## headers (expected >= 4) — run 'mise run learn:regenerate' and commit the refreshed file"
exit 1
fi
if ! grep -q 'https://docs.entire.io/cli' "$learn_md"; then
echo "::error::$learn_md is missing the docs.entire.io footer — run 'mise run learn:regenerate' and commit the refreshed file"
exit 1
fi

- 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: "learn",
Invocation: "entire learn",
Summary: "Learn 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 learn --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 learn") 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)
}
}
}
103 changes: 103 additions & 0 deletions cmd/entire/cli/learn/discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Package learn powers `entire learn` — a state-aware tour of the
// installed CLI, served from a pre-rendered embedded markdown for the
// default path and from a TextGenerator-capable agent for `--regenerate`
// (refreshes the committed embedded template; run by the changelog flow
// before each release).
package learn

import (
"strings"

"github.com/spf13/cobra"
)

// CommandNode is one node in the discovered cobra command tree.
//
// The shape mirrors what `entire learn` hands to a TextGenerator: enough
// detail for the model to write recipes for each capability without being
// told to invent any specific command name.
type CommandNode struct {
Path string `json:"path"`
Name string `json:"name"`
Short string `json:"short,omitempty"`
Long string `json:"long,omitempty"`
Example string `json:"example,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Deprecated string `json:"deprecated,omitempty"`
Subcommands []CommandNode `json:"subcommands,omitempty"`
}

// CommandSurface is the discovered top-level command tree under `entire`.
// Hidden, deprecated, and built-in cobra plumbing (help/completion) are
// stripped — everything in this struct is something we'd render to a user.
type CommandSurface struct {
Root CommandNode `json:"root"`
}

// Discover walks the cobra command tree rooted at root and returns the
// user-facing surface. Hidden and deprecated commands are excluded.
func Discover(root *cobra.Command) CommandSurface {
return CommandSurface{Root: walkCommand(root, "")}
}

func walkCommand(cmd *cobra.Command, parentPath string) CommandNode {
name := cmd.Name()
path := strings.TrimSpace(parentPath + " " + name)
if parentPath == "" {
path = name
}

node := CommandNode{
Path: path,
Name: name,
Short: strings.TrimSpace(cmd.Short),
Long: trimDescription(cmd.Long),
Example: strings.TrimSpace(cmd.Example),
Aliases: append([]string(nil), cmd.Aliases...),
Hidden: cmd.Hidden,
Deprecated: strings.TrimSpace(cmd.Deprecated),
}

for _, sub := range cmd.Commands() {
if !shouldRender(sub) {
continue
}
node.Subcommands = append(node.Subcommands, walkCommand(sub, path))
}
return node
}

// shouldRender returns true when a cobra command should appear in the
// rendered tour. We exclude:
// - cobra-built-in plumbing (help/completion) which adds noise without
// teaching anything Entire-specific
// - commands explicitly marked Hidden — these are either internal
// infrastructure (e.g. __send_analytics) or aliases the user is
// already taught about under their canonical name
// - deprecated commands — they still work but we don't want to teach
// them as the recommended path
func shouldRender(cmd *cobra.Command) bool {
if cmd.Hidden || cmd.Deprecated != "" {
return false
}
switch cmd.Name() {
case "help", "completion":
return false
}
return true
}

// trimDescription collapses the verbose Long help text to its first
// substantive paragraph. The full help is still available via
// `entire <cmd> --help`; the tour just needs enough to summarize.
func trimDescription(long string) string {
long = strings.TrimSpace(long)
if long == "" {
return ""
}
if idx := strings.Index(long, "\n\n"); idx > 0 {
return strings.TrimSpace(long[:idx])
}
return long
}
86 changes: 86 additions & 0 deletions cmd/entire/cli/learn/discovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package learn

import (
"testing"

"github.com/spf13/cobra"
)

func TestDiscover_StripsHiddenAndDeprecated(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "entire", Short: "root"}
root.AddCommand(&cobra.Command{Use: "enable", Short: "enable entire"})
root.AddCommand(&cobra.Command{Use: "internal-thing", Short: "private", Hidden: true})
root.AddCommand(&cobra.Command{Use: "old", Short: "old", Deprecated: "use new"})
root.AddCommand(&cobra.Command{Use: "completion", Short: "shell completion"})

surface := Discover(root)

got := childNames(surface.Root)
want := []string{"enable"}
if !equalStrings(got, want) {
t.Fatalf("Discover() child names = %v, want %v", got, want)
}
}

func TestDiscover_RecursesIntoSubcommands(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "entire"}
checkpoint := &cobra.Command{Use: "checkpoint", Short: "checkpoint group"}
checkpoint.AddCommand(&cobra.Command{Use: "list", Short: "list checkpoints"})
checkpoint.AddCommand(&cobra.Command{Use: "search", Short: "search checkpoints"})
root.AddCommand(checkpoint)

surface := Discover(root)

cp := findChildOrFail(t, surface.Root, "checkpoint")
got := childNames(*cp)
want := []string{"list", "search"}
if !equalStrings(got, want) {
t.Fatalf("checkpoint child names = %v, want %v", got, want)
}
if cp.Path != "entire checkpoint" {
t.Errorf("checkpoint.Path = %q, want %q", cp.Path, "entire checkpoint")
}
}

func TestTrimDescription_KeepsFirstParagraph(t *testing.T) {
t.Parallel()
long := "First paragraph that explains the command.\n\nSecond paragraph with examples and details that should be omitted from the tour."
got := trimDescription(long)
want := "First paragraph that explains the command."
if got != want {
t.Errorf("trimDescription = %q, want %q", got, want)
}
}

func childNames(node CommandNode) []string {
out := make([]string, 0, len(node.Subcommands))
for _, sub := range node.Subcommands {
out = append(out, sub.Name)
}
return out
}

func findChildOrFail(t *testing.T, node CommandNode, name string) *CommandNode {
t.Helper()
for i := range node.Subcommands {
if node.Subcommands[i].Name == name {
return &node.Subcommands[i]
}
}
t.Fatalf("missing subcommand %q under %q", name, node.Name)
return nil
}

func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
Loading
Loading