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 .surface
Original file line number Diff line number Diff line change
Expand Up @@ -12044,6 +12044,7 @@ FLAG basecamp inbox --types type=stringSlice
FLAG basecamp inbox --verbose type=count
FLAG basecamp instructions --account type=string
FLAG basecamp instructions --agent type=bool
FLAG basecamp instructions --brief type=bool
FLAG basecamp instructions --cache-dir type=string
FLAG basecamp instructions --count type=bool
FLAG basecamp instructions --help type=bool
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ CLI needs upgrading.

**Claude Code:** `basecamp setup claude` — installs the plugin with skills, hooks, and agent workflow support.

**Codex:** `basecamp setup codex` — registers the 37signals marketplace and installs the native plugin with Basecamp skills, diagnostics, and opt-in commit-reference hooks. In Codex, review and trust the plugin hooks with `/hooks`, then start a new thread to load the skills and hooks. The plugin does not inject Basecamp context at session start (beyond a pointer to `basecamp instructions` when instructions are set); Codex selects the skill when a request is relevant or explicitly references Basecamp.
**Codex:** `basecamp setup codex` — registers the 37signals marketplace and installs the native plugin with Basecamp skills, diagnostics, and opt-in commit-reference hooks. In Codex, review and trust the plugin hooks with `/hooks`, then start a new thread to load the skills and hooks. The plugin does not inject Basecamp context at session start (beyond a pointer to `basecamp instructions` when instructions are set, plus their gist when it can be read without the network); Codex selects the skill when a request is relevant or explicitly references Basecamp.

Manual Codex installation uses the same marketplace:

Expand Down
2 changes: 1 addition & 1 deletion install.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ plugin, run `basecamp upgrade` and start a new session; `basecamp agent-hook
basecamp setup codex
```

This installs the shared Basecamp skill, registers the 37signals Codex marketplace, and installs the native plugin. After setup, review and trust the plugin's commit-reference hooks with `/hooks` (Codex lists untrusted hooks but does not run them until trusted), then start a new Codex thread to load the skills and hooks. The plugin does not inject Basecamp context at session start (beyond a pointer to `basecamp instructions` when instructions are set); Codex selects the skill when a request is relevant or explicitly references Basecamp.
This installs the shared Basecamp skill, registers the 37signals Codex marketplace, and installs the native plugin. After setup, review and trust the plugin's commit-reference hooks with `/hooks` (Codex lists untrusted hooks but does not run them until trusted), then start a new Codex thread to load the skills and hooks. The plugin does not inject Basecamp context at session start (beyond a pointer to `basecamp instructions` when instructions are set, plus their gist when it can be read without the network); Codex selects the skill when a request is relevant or explicitly references Basecamp.
Comment thread
jorgemanrubia marked this conversation as resolved.
Resolved

For a manual install:

Expand Down
69 changes: 65 additions & 4 deletions internal/commands/agent_hook_instructions.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package commands

import (
"os"
"path/filepath"
"strings"
"unicode/utf8"

"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/richtext"
)

// instructionsNotice points a session at `basecamp instructions` when the
Expand All @@ -15,19 +21,60 @@ import (
// at its start and again after each compaction, so the instructions reach the
// agent before it drafts, whichever way its text gets to Basecamp.
//
// It names the command rather than carrying the text: a URL source may need a
// fetch, which a hook with a 5-second budget can't wait on, and the command
// reads the profile the post goes out as. No instructions set says nothing.
// It names the command rather than carrying the full text: a URL source may
// need a fetch, which a hook with a 5-second budget can't wait on, and the
// command reads the profile the post goes out as. A pointer alone is often
// skipped, or read through `head`, so when every source of the active profile
// can be read without the network (a file, or a URL's cached copy, however
// old) the notice also carries their brief: one line per rule, enough to
// write by and short enough to sit in every session's context. No
// instructions set says nothing.
func instructionsNotice(cmd *cobra.Command) string {
app := appctx.FromContext(cmd.Context())
if app == nil || app.Config == nil || !anyInstructionsConfigured(app) {
return ""
}
return "Your operator set instructions for anything an agent writes to Basecamp on this computer. " +
pointer := "Your operator set instructions for anything an agent writes to Basecamp on this computer. " +
"Before you write to Basecamp (a comment, card, message, document, to-do or chat line), run " +
"`basecamp instructions`, with `-P <profile>` when the post goes out as a profile other than the default, " +
"and follow what it prints. This holds when you run the basecamp CLI yourself and when you hand your " +
"text to a script or tool that posts it. If the command fails, say so in what you post."
if gist := instructionsGist(app); gist != "" {
return pointer + "\n\n" + gist
Comment thread
jorgemanrubia marked this conversation as resolved.
Resolved
}
return pointer
}

// instructionsGistMaxRunes bounds the gist. A hook's additionalContext is
// capped at 10,000 characters and the gist shares it with the pointer and
// the other SessionStart notices; a cut-off gist would drop the later
// sources, which win, so one that doesn't fit isn't sent at all.
const instructionsGistMaxRunes = 6000

// instructionsGist is the brief of the active profile's sources, read
// without the network, or "": when some source has no copy yet, when some
// source has no list to cut down (a gist without it would read as the whole
// of the rules), or when it is too long to fit the hook's context.
func instructionsGist(app *appctx.App) string {
result, ok := loadCachedInstructions(instructionsHookApp(app))
if !ok || len(result.Documents) == 0 {
return ""
}
var lines []string
for _, doc := range result.Documents {
if len(doc.Brief) == 0 {
return ""
}
if len(result.Documents) > 1 {
lines = append(lines, "From "+richtext.SanitizeSingleLine(doc.Scope)+":")
}
lines = append(lines, doc.Brief...)
}
gist := "The gist (run the command for the full text):\n" + strings.Join(lines, "\n")
if utf8.RuneCountInString(gist) > instructionsGistMaxRunes {
return ""
}
return gist
}

// anyInstructionsConfigured reports whether any post could go out under
Expand All @@ -47,3 +94,17 @@ func anyInstructionsConfigured(app *appctx.App) bool {
}
return false
}

// instructionsHookApp is app with the cache dir the command itself would use.
// The command keeps a profile's cache under profiles/<name> (root's
// PersistentPreRunE scopes it), but the hook's app keeps the unscoped dir for
// its own state, so the instructions cache would be looked for in the wrong
// place. Only the config is copied: the hook's own state stays where it is.
func instructionsHookApp(app *appctx.App) *appctx.App {
cfg := *app.Config
name := cfg.ActiveProfile
if name != "" && name != "." && filepath.IsLocal(name) && filepath.Base(name) == name && os.Getenv("BASECAMP_CACHE_DIR") == "" {
cfg.CacheDir = filepath.Join(cfg.CacheDir, "profiles", name)
}
return &appctx.App{Config: &cfg}
}
Comment thread
jorgemanrubia marked this conversation as resolved.
Resolved
158 changes: 158 additions & 0 deletions internal/commands/agent_hook_instructions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -20,6 +25,9 @@ func runAgentHookWithConfig(t *testing.T, cfg *config.Config, input string) stri
t.Setenv("CLAUDE_PLUGIN_ROOT", "/home/u/.claude/plugins/cache/37signals/basecamp-cli/0.12.0")
t.Setenv("CLAUDE_PLUGIN_DATA", t.TempDir())
t.Setenv("PLUGIN_ROOT", "")
if cfg.CacheDir == config.Default().CacheDir {
cfg.CacheDir = t.TempDir() // never the developer's own cached copy
}
app := appctx.NewApp(cfg)
t.Cleanup(app.Close)
cmd := NewAgentHookCmd()
Expand Down Expand Up @@ -94,3 +102,153 @@ func TestAgentHookInstructionsPointerOnlyAtSessionStart(t *testing.T) {
`{"hook_event_name":"PreToolUse","session_id":"s","tool_use_id":"t","tool_input":{"command":"basecamp comments create 1 hi"}}`)
assert.Empty(t, output)
}

// failInstructionsFetches makes any fetch of an instructions URL fail the
// test: the hook has a 5-second budget and must never wait on the network.
func failInstructionsFetches(t *testing.T) {
t.Helper()
client, gh := instructionsHTTPClient, instructionsGH
t.Cleanup(func() { instructionsHTTPClient, instructionsGH = client, gh })
instructionsHTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
t.Errorf("the hook fetched %s", r.URL)
return nil, errors.New("no fetching in the hook")
})}
instructionsGH = func(context.Context, instructionsSource) ([]byte, error) {
t.Error("the hook ran gh")
return nil, errors.New("no fetching in the hook")
}
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

// cacheInstructions stores content as the cached copy of source, fetched at.
func cacheInstructions(t *testing.T, cfg *config.Config, source, content string, at time.Time) {
t.Helper()
src, err := parseInstructionsSource(source)
require.NoError(t, err)
path := instructionsCachePath(&appctx.App{Config: cfg}, src)
require.NotEmpty(t, path)
writeInstructionsCache(path, instructionsCacheEntry{Source: src.Raw, Content: content, FetchedAt: at})
_, err = os.Stat(path)
require.NoError(t, err)
}

// With the source cached, however long ago, the pointer carries the gist,
// read from the cache with no fetch.
func TestAgentHookCarriesTheGistFromTheCache(t *testing.T) {
failInstructionsFetches(t)
cfg := config.Default()
cfg.CacheDir = t.TempDir()
cfg.Instructions = "https://github.com/basecamp/coworker/blob/main/docs/basecamp-style.md"
cacheInstructions(t, cfg, cfg.Instructions, instructionsStyleFixture, time.Now().Add(-30*24*time.Hour))

output := runAgentHookWithConfig(t, cfg, sessionStartPayload)
_, context := hookSpecificOutput(t, output)
pointer, gist, found := strings.Cut(context, "\n\nThe gist (run the command for the full text):\n")
require.True(t, found, context)
assert.Contains(t, pointer, "`basecamp instructions`")
assert.True(t, strings.HasPrefix(gist, "1. Speak only when it helps someone.\n2. Keep private context private.\n"), gist)
assert.True(t, strings.HasSuffix(gist, "11. Show what is visual, and check what readers see."), gist)
assert.NotContains(t, gist, "Post when asked", "the gist, not the text")
assert.Less(t, len(context), 1500, "the whole notice stays short:\n%s", context)
}

// A source never fetched has no copy to read, and the hook won't fetch it,
// so it points at the command alone.
func TestAgentHookPointerAloneWithoutACache(t *testing.T) {
failInstructionsFetches(t)
cfg := config.Default()
cfg.CacheDir = t.TempDir()
cfg.Instructions = "https://github.com/basecamp/coworker/blob/main/docs/basecamp-style.md"

_, context := hookSpecificOutput(t, runAgentHookWithConfig(t, cfg, sessionStartPayload))
assert.Contains(t, context, "`basecamp instructions`")
assert.NotContains(t, context, "The gist")
}

// One layer cached and the other not is still a gist that would leave
// rules out: the pointer goes alone.
func TestAgentHookPointerAloneWhenOneLayerIsUncached(t *testing.T) {
failInstructionsFetches(t)
cfg := config.Default()
cfg.CacheDir = t.TempDir()
cfg.Instructions = writeInstructions(t, "house.md", instructionsStyleFixture)
cfg.ActiveProfile = "marie"
cfg.ProfileInstructions = "https://example.com/marie.md"

_, context := hookSpecificOutput(t, runAgentHookWithConfig(t, cfg, sessionStartPayload))
assert.NotContains(t, context, "The gist")
}

// A local file is read as the command reads it, and with two layers each is
// named, baseline first.
func TestAgentHookGistNamesEachLayer(t *testing.T) {
failInstructionsFetches(t)
cfg := config.Default()
cfg.Instructions = writeInstructions(t, "house.md", "- **Lead with the point.** Then the rest.")
cfg.Sources = map[string]string{"instructions": "global"}
cfg.ActiveProfile = "marie"
cfg.ProfileInstructions = writeInstructions(t, "marie.md", "- Sign off as Marie. Always.")

_, context := hookSpecificOutput(t, runAgentHookWithConfig(t, cfg, sessionStartPayload))
assert.Contains(t, context, "The gist (run the command for the full text):\n"+
"From global:\n- Lead with the point.\nFrom profile:marie:\n- Sign off as Marie.")
}

// Sources with no list have no gist to give.
func TestAgentHookPointerAloneWhenNothingIsAList(t *testing.T) {
cfg := config.Default()
cfg.Instructions = writeInstructions(t, "house.md", "Write plainly. Lead with the point.")

_, context := hookSpecificOutput(t, runAgentHookWithConfig(t, cfg, sessionStartPayload))
assert.Contains(t, context, "`basecamp instructions`")
assert.NotContains(t, context, "The gist")
}

// One source with no list would leave its rules out of a gist built from
// the others, the later, winning source included: the pointer goes alone.
func TestAgentHookPointerAloneWhenOneSourceIsNotAList(t *testing.T) {
cfg := config.Default()
cfg.Instructions = writeInstructions(t, "house.md", "- **Lead with the point.** Then the rest.")
cfg.ActiveProfile = "marie"
cfg.ProfileInstructions = writeInstructions(t, "marie.md", "Sign off as Marie. Always.")

_, context := hookSpecificOutput(t, runAgentHookWithConfig(t, cfg, sessionStartPayload))
assert.Contains(t, context, "`basecamp instructions`")
assert.NotContains(t, context, "The gist")
}

// A gist too long for the hook's context isn't cut off, which would drop the
// later sources: the pointer goes alone.
func TestAgentHookPointerAloneWhenTheGistIsTooLong(t *testing.T) {
var rules strings.Builder
for range 60 {
rules.WriteString("- " + strings.Repeat("word ", 39) + "end.\n")
}
cfg := config.Default()
cfg.Instructions = writeInstructions(t, "house.md", rules.String())

_, context := hookSpecificOutput(t, runAgentHookWithConfig(t, cfg, sessionStartPayload))
assert.Contains(t, context, "`basecamp instructions`")
assert.NotContains(t, context, "The gist")
assert.Less(t, len(context), 10000)
}

// The command keeps a profile's cached copy under profiles/<name>, so that
// is where the hook looks for it, while its own state stays where it was.
func TestAgentHookReadsTheProfilesCache(t *testing.T) {
failInstructionsFetches(t)
t.Setenv("BASECAMP_CACHE_DIR", "")
cfg := config.Default()
cfg.CacheDir = t.TempDir()
cfg.ActiveProfile = "chef"
cfg.Instructions = "https://github.com/basecamp/coworker/blob/main/docs/basecamp-style.md"
scoped := *cfg
scoped.CacheDir = filepath.Join(cfg.CacheDir, "profiles", "chef")
cacheInstructions(t, &scoped, cfg.Instructions, instructionsStyleFixture, time.Now())

_, context := hookSpecificOutput(t, runAgentHookWithConfig(t, cfg, sessionStartPayload))
assert.Contains(t, context, "The gist (run the command for the full text):\n1. Speak only when it helps someone.")
}
Loading
Loading