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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ jobs:
filters: |
skill:
- 'skills/basecamp/SKILL.md'
- 'skills/basecamp-connect/SKILL.md'
- 'skill-evals/**'

- name: Set up Ruby
Expand Down
23 changes: 23 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ CMD basecamp config unset
CMD basecamp config untrust
CMD basecamp connect
CMD basecamp connect setup
CMD basecamp connect show
CMD basecamp docs
CMD basecamp docs archive
CMD basecamp docs doc
Expand Down Expand Up @@ -5399,6 +5400,27 @@ FLAG basecamp connect setup --trust type=string
FLAG basecamp connect setup --verbose type=count
FLAG basecamp connect setup --watch-completions type=stringArray
FLAG basecamp connect setup --worktrees type=bool
FLAG basecamp connect show --account type=string
FLAG basecamp connect show --agent type=bool
FLAG basecamp connect show --cache-dir type=string
FLAG basecamp connect show --count type=bool
FLAG basecamp connect show --help type=bool
FLAG basecamp connect show --hints type=bool
FLAG basecamp connect show --ids-only type=bool
FLAG basecamp connect show --in type=string
FLAG basecamp connect show --jq type=string
FLAG basecamp connect show --json type=bool
FLAG basecamp connect show --markdown type=bool
FLAG basecamp connect show --md type=bool
FLAG basecamp connect show --no-hints type=bool
FLAG basecamp connect show --no-stats type=bool
FLAG basecamp connect show --profile type=string
FLAG basecamp connect show --project type=string
FLAG basecamp connect show --quiet type=bool
FLAG basecamp connect show --stats type=bool
FLAG basecamp connect show --styled type=bool
FLAG basecamp connect show --todolist type=string
FLAG basecamp connect show --verbose type=count
FLAG basecamp docs --account type=string
FLAG basecamp docs --agent type=bool
FLAG basecamp docs --cache-dir type=string
Expand Down Expand Up @@ -18577,6 +18599,7 @@ SUB basecamp config unset
SUB basecamp config untrust
SUB basecamp connect
SUB basecamp connect setup
SUB basecamp connect show
SUB basecamp docs
SUB basecamp docs archive
SUB basecamp docs doc
Expand Down
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,18 @@ Run it early and often — after finishing a feature, after fixing a bug, before
pushing. If you're about to `git push` and haven't run `bin/ci` in this
session, stop and run it first.

**Skill drift**: `make check-skill-drift` runs over both `skills/basecamp/SKILL.md`
and `skills/basecamp-doctor/SKILL.md`, checking that the commands and flags each one
**Skill drift**: `make check-skill-drift` runs over `skills/basecamp/SKILL.md`,
`skills/basecamp-doctor/SKILL.md` and `skills/basecamp-connect/SKILL.md`, checking that the commands and flags each one
*references* still exist in the `.surface` snapshot. It catches stale references, not
missing coverage, so adding a command breaks neither skill.
missing coverage, so adding a command breaks none of them.

Removal is only partly caught. `resolve_cmd` walks up to the nearest existing ancestor,
so dropping a nested subcommand leaves the reference resolving against its parent and the
check still passes — `basecamp setup <removed>` resolves as `basecamp setup`. Removing a
top-level command is caught; removing a subcommand is not. Don't lean on CI for this. Update the skill the change actually affects;
basecamp-doctor deliberately covers only doctor, setup and auth remediation.
basecamp-doctor deliberately covers only doctor, setup and auth remediation;
basecamp-connect covers `basecamp auth agent connect` and `basecamp connect`, and its
evals run against its own SKILL.md (`make -C skill-evals eval-connect`).

```bash
bin/ci # The single command — run this
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ check-surface-compat: build
check-skill-drift:
@scripts/check-skill-drift.sh
@scripts/check-skill-drift.sh skills/basecamp-doctor/SKILL.md
@scripts/check-skill-drift.sh skills/basecamp-connect/SKILL.md

# Verify group commands show help bare (no RunE on parents with subcommands)
.PHONY: check-bare-groups
Expand Down Expand Up @@ -555,6 +556,7 @@ tools:
.PHONY: skill-eval
skill-eval:
$(MAKE) -C skill-evals eval
$(MAKE) -C skill-evals eval-connect

# Sync skills to basecamp/skills distribution repo
# Usage: make sync-skills TAG=v1.2.3
Expand Down
4 changes: 4 additions & 0 deletions e2e/smoke/smoke_lifecycle.bats
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ load smoke_helper
mark_out_of_scope "Needs a connected agent profile and writes local connector policy — covered by Go tests in internal/commands and internal/connector/setup"
}

@test "connect show is out of scope" {
mark_out_of_scope "Reads the connector policy a connected profile's setup wrote — covered by Go tests in internal/commands"
}

@test "auth refresh is out of scope" {
mark_out_of_scope "Requires OAuth credentials"
}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func CommandCategories() []CommandCategory {
{Name: "bonfire", Category: "additional", Description: "Multi-chat orchestration", Actions: []string{"split", "layout"}, Experimental: true, DevOnly: true},
{Name: "api", Category: "additional", Description: "Raw API access"},
{Name: "mcp", Category: "additional", Description: "Serve Basecamp to MCP clients over stdio"},
{Name: "connect", Category: "additional", Description: "Run a local agent connector for a Basecamp agent", Actions: []string{"setup"}},
{Name: "connect", Category: "additional", Description: "Set up a local agent connector for a Basecamp agent", Actions: []string{"setup", "show"}},
{Name: "help", Category: "additional", Description: "Show help"},
{Name: "version", Category: "additional", Description: "Show version"},
},
Expand Down
176 changes: 175 additions & 1 deletion internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (
"fmt"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"time"
"unicode"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -36,12 +38,184 @@ the work to a local coding agent that replies in Basecamp as the agent.
Connect the agent to a profile first (basecamp auth agent connect -P <profile>),
then run setup on that profile: it records who may drive the agent, maps
projects to the directories their work runs in, and checks the connector is
ready.`,
ready. Show prints what setup recorded.`,
}
cmd.AddCommand(newConnectSetupCmd())
cmd.AddCommand(newConnectShowCmd())
return cmd
}

// newConnectShowCmd shows a profile's connect.json, read the way the
// connector reads it: through setup.Load's safety checks, so a symlink, a
// file someone else could have written, or anything oversized is refused
// before a byte of it is shown.
func newConnectShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show",
Short: "Show a profile's connector setup without changing it",
Comment thread
jorgemanrubia marked this conversation as resolved.
Long: `Show the connect.json a profile's setup wrote: the agent, the operator and
trust mode, each routed project and the worker settings.

The file is read through the same checks setup and the connector apply:
it is refused, and nothing of it shown, when it is a symlink, is not a
regular file, could have been changed by another user, or does not parse.
Nothing is changed and nothing is fetched: show reads no credential and
makes no request. To check readiness, run setup again.

A profile that does not exist is refused before this runs, naming the
profiles there are, as it is for every command; a profile that exists and
has never been set up is reported as not found.

Examples:
basecamp connect show -P agent
basecamp connect show -P agent --json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}
return runConnectShow(app)
},
}
}

func runConnectShow(app *appctx.App) error {
name := app.Config.ActiveProfile
if name == "" {
return output.ErrUsageHint("Show needs the agent's profile", "Pass -P/--profile <name>.")
}
if !isValidProfileName(name) {
return output.ErrUsage(fmt.Sprintf("Invalid profile name %q: use only letters, numbers, hyphens, and underscores", name))
}
path, err := setup.Path(config.GlobalConfigDir(), name)
if err != nil {
return output.ErrUsage(err.Error())
}
f, err := setup.Load(path)
switch {
case errors.Is(err, os.ErrNotExist):
return output.ErrNotFoundHint("connect.json for profile", name,
"The profile has not been set up. Set it up: basecamp connect setup -P "+shellQuote(name)+" --operator-profile '<your profile>' --route '<project-id>=<dir>'")
case err != nil && runtime.GOOS == "windows":
return output.ErrUsageHint("connect.json cannot be used: "+setup.ErrorText(err),
"The connector's setup is not supported on Windows: this CLI cannot verify who can change connect.json there.")
case err != nil:
return output.ErrUsageHint("connect.json cannot be used: "+setup.ErrorText(err),
"Nothing of it was shown. Fix or remove "+richtext.SanitizeSingleLine(path)+", then run setup again.")
}
if f.Profile != name {
// A policy copied from another profile's directory is not this
// profile's, however valid it is on its own: setup refuses it too.
return output.ErrUsageHint(fmt.Sprintf("%s names profile %q, not %q", richtext.SanitizeSingleLine(path), f.Profile, name),
"Nothing of it was shown. Remove "+richtext.SanitizeSingleLine(path)+" and run setup again for this profile.")
}

// The generic object renderer drops nested maps, which is where the
// agent, the trust and the routes live, so a person's formats get them
// flattened to one line each; JSON keeps the file's own shape.
markdown := app.Output.EffectiveFormat() == output.FormatMarkdown
return app.OK(connectShowResult{Path: path, File: f},
output.WithDisplayData(connectShowDisplay(path, f, markdown)),
output.WithSummary(fmt.Sprintf("Connector setup for profile %q: trust %s, %d routed project(s)", name, f.Trust.Mode, len(f.Projects))))
}

// connectShowDisplay is show's data for a person: every setting connect.json
// records as a flat field, one per route. Paths are shown exactly: quoted,
// or as a code span in Markdown, so nothing in one renders as formatting or
// reaches a terminal as a control byte.
func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any {
exact := func(s string) string {
// strconv.Quote escapes controls and backslashes; "<" is escaped too,
// or the renderer would take a path holding "<b>" for HTML and
// rewrite it.
return strings.ReplaceAll(strconv.Quote(s), "<", `\x3c`)
}
if markdown {
exact = func(s string) string { return markdownCode(escapeControls(s)) }
}
agent := fmt.Sprintf("person %d (%s)", f.Agent.PersonID, f.Agent.Kind)
if f.Agent.IdentityID != 0 {
agent += fmt.Sprintf(", identity %d", f.Agent.IdentityID)
}
trust := string(f.Trust.Mode)
if len(f.Trust.AllowlistIDs) > 0 {
ids := make([]string, len(f.Trust.AllowlistIDs))
for i, id := range f.Trust.AllowlistIDs {
ids[i] = strconv.FormatInt(id, 10)
}
trust += ": people " + strings.Join(ids, ", ")
}
worktrees := "off"
if f.Worktrees {
worktrees = "on"
}
d := map[string]any{
"file": exact(path),
"account": f.AccountID,
"agent": agent,
"operator": fmt.Sprintf("person %d", f.Trust.OperatorID),
"trust": trust,
"workers": fmt.Sprintf("%s, concurrency %d, deadline %s, worktrees %s", f.Driver, f.Concurrency, time.Duration(f.Deadline), worktrees),
"projects": strconv.Itoa(len(f.Projects)) + " routed",
}
for id, r := range f.Projects {
route := exact(r.Path)
if r.Class != "" {
route += ", class " + r.Class
}
if r.WatchCompletions {
route += ", watches completions"
}
d[fmt.Sprintf("route_%d", id)] = route
}
return d
}

// escapeControls writes every control or non-printable rune in s, the
// backslash and "<" (which the renderer would read as HTML) as a Go escape
// (\x1b, \u009b, \\, \x3c), so a path can reach a
// terminal or a pager without a single control byte and still reads exactly:
// an escape in the output never stands for text the path already held.
func escapeControls(s string) string {
var b strings.Builder
for _, r := range s {
if r == '<' {
b.WriteString(`\x3c`)
continue
}
if r == '\\' || unicode.IsControl(r) || !unicode.IsPrint(r) {
q := strconv.QuoteRune(r)
b.WriteString(q[1 : len(q)-1])
continue
}
b.WriteRune(r)
}
return b.String()
}

// markdownCode is s as a Markdown code span, fenced with one more backtick
// than the longest run inside it so no backtick in s can close it.
func markdownCode(s string) string {
longest, run := 0, 0
for _, r := range s {
if r == '`' {
run++
longest = max(longest, run)
} else {
run = 0
}
}
fence := strings.Repeat("`", longest+1)
return fence + " " + s + " " + fence
}

// connectShowResult is show's data: where the file is, and what it holds.
type connectShowResult struct {
Path string `json:"path"`
setup.File
}

// connectSetupFlags are setup's flags, as typed.
type connectSetupFlags struct {
expectIdentity string
Expand Down
Loading
Loading