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
66 changes: 60 additions & 6 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ func NewConnectCmd() *cobra.Command {
Long: `Run a local agent connector: it listens to the account event feed as a
Basecamp agent, admits what a trusted person asks of that agent, and prints
each trusted request for the session that started it to handle. It starts no
workers and posts nothing itself: the basecamp-connect skill, run in your own
Claude Code session, acknowledges each request, picks the repo and hands it to
a subagent that replies as the agent.
workers and posts only the acknowledgement boost, unless setup turns it off
(--no-ack-boost): a request someone made of the agent is boosted as the agent
before it is printed. The basecamp-connect skill, run in your own Claude Code
session, picks the repo and hands each request to a subagent that replies 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
Expand Down Expand Up @@ -95,7 +97,8 @@ func newConnectShowCmd() *cobra.Command {
Use: "show",
Short: "Show a profile's connector setup without changing it",
Long: `Show the connect.json a profile's setup wrote: the agent, the operator and
trust mode, each served project, and who is let into a Ping.
trust mode, each served project, who is let into a Ping, and the boost the
connector acknowledges requests with (ack_boost, the default included).

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
Expand Down Expand Up @@ -156,7 +159,7 @@ func runConnectShow(app *appctx.App) error {
// agent, the trust and the served projects 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},
return app.OK(connectShowResult{Path: path, File: f, AckBoost: f.Ack()},
output.WithDisplayData(connectShowDisplay(path, f, markdown)),
output.WithSummary(fmt.Sprintf("Connector setup for profile %q: trust %s, %d served project(s)", name, f.Trust.Mode, len(f.Projects))))
}
Expand Down Expand Up @@ -207,6 +210,7 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any
"trust": trust,
"agents": agents,
"projects": strconv.Itoa(len(f.Projects)) + " served",
"ack": connectAckDisplay(f.Ack()),
}
for id, entry := range f.Pings {
ids := make([]string, len(entry.Participants))
Expand All @@ -228,6 +232,14 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any
return d
}

// connectAckDisplay says how the connector acknowledges a request.
func connectAckDisplay(ack setup.AckBoost) string {
if ack.Off {
return "off: the session boosts each request"
}
return "boosts " + strconv.Quote(ack.Content) + " before handing each request off"
}

// 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
Expand Down Expand Up @@ -267,9 +279,12 @@ func markdownCode(s string) string {
}

// connectShowResult is show's data: where the file is, and what it holds.
// AckBoost is the acknowledgement in effect, the default included, which
// the file leaves out.
type connectShowResult struct {
Path string `json:"path"`
setup.File
AckBoost setup.AckBoost `json:"ack_boost"`
}

// connectSetupFlags are setup's flags, as typed.
Expand Down Expand Up @@ -297,6 +312,11 @@ type connectSetupFlags struct {
allowInPing []string
disallowInPing []string

// ackBoost is --ack-boost, the boost the connector acknowledges a
// request with; noAckBoost turns it off.
ackBoost optionalString
noAckBoost bool

serve []string
classes []string
watch []string
Expand Down Expand Up @@ -385,6 +405,14 @@ person's in basecamp pings show <ping-id> --json. Anyone else's line in a
Ping is discarded, as is any agent's. Restart the connector after changing
who is let into a Ping.

Acknowledgement. When someone asks the agent for something (a mention, an
assignment, a ping), the connector boosts that recording as the agent before
printing the request, and the request line carries the boost's id. Followed
threads, completions and boosts are context and are not acknowledged.
--ack-boost <content> sets the boost (default 馃憖, at most 16 characters);
--no-ack-boost turns it off, and the session acknowledges instead. Restart
the connector after changing it.

No directory is associated with a project here: the session handling the
requests chooses the repo for each one.

Expand Down Expand Up @@ -423,7 +451,9 @@ Examples:
basecamp connect setup -P agent --class 12345=internal
basecamp connect setup -P agent --allow-agent 333
basecamp connect setup -P agent --allow-in-ping 2085958513:111
basecamp connect setup -P agent --agent-thread-cap 5 --agent-daily-cap 30`,
basecamp connect setup -P agent --agent-thread-cap 5 --agent-daily-cap 30
basecamp connect setup -P agent --ack-boost 'On it'
basecamp connect setup -P agent --no-ack-boost`,
Annotations: map[string]string{AnnotationProfileMayCreate: "true"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -457,7 +487,10 @@ Examples:
fl.StringArrayVar(&f.classes, "class", nil, "Classify a served project: <project-id>=<class>, or <project-id>= to clear it (repeatable)")
fl.StringArrayVar(&f.watch, "watch-completions", nil, "Admit every trusted completion in a served project (repeatable)")
fl.StringArrayVar(&f.unwatch, "no-watch-completions", nil, "Stop watching a project's completions (repeatable)")
fl.Var(&f.ackBoost, "ack-boost", fmt.Sprintf("Boost the connector acknowledges each request with, as the agent, before handing it off (default %s; at most %d characters)", setup.DefaultAckContent, setup.AckContentMax))
fl.BoolVar(&f.noAckBoost, "no-ack-boost", false, "Don't acknowledge requests in the connector; the session reading them boosts instead")
cmd.MarkFlagsMutuallyExclusive("operator", "operator-profile")
cmd.MarkFlagsMutuallyExclusive("ack-boost", "no-ack-boost")

return cmd
}
Expand Down Expand Up @@ -980,6 +1013,12 @@ func (f *connectSetupFlags) changes() (setup.Changes, error) {
}
ch.Remove = append(ch.Remove, id)
}
switch {
case f.ackBoost.set:
ch.AckBoost = &setup.AckBoost{Content: f.ackBoost.value}
case f.noAckBoost:
ch.AckBoost = &setup.AckBoost{Off: true}
}

return ch, nil
}
Expand Down Expand Up @@ -1441,3 +1480,18 @@ func (n *optionalInt) Set(s string) error {
n.set, n.value = true, v
return nil
}

// optionalString is a string flag that remembers whether it was passed, so
// an empty value given is told apart from none.
type optionalString struct {
set bool
value string
}

func (o *optionalString) String() string { return o.value }
func (o *optionalString) Type() string { return "string" }

func (o *optionalString) Set(s string) error {
o.set, o.value = true, s
return nil
}
102 changes: 102 additions & 0 deletions internal/commands/connect_ack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//go:build unix

package commands

import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-cli/internal/connector/setup"
"github.com/basecamp/basecamp-cli/internal/output"
)

func loadAckBoost(t *testing.T) setup.AckBoost {
t.Helper()
f, err := setup.Load(connectSetupPath(t, "agent"))
require.NoError(t, err)
return f.Ack()
}

// --ack-boost sets the boost the connector acknowledges with, --no-ack-boost
// turns it off, and a run that passes neither keeps it.
func TestConnectSetupSetsTheAckBoost(t *testing.T) {
s := startConnectSetupServer(t)
firstSetup(t, s)
assert.Equal(t, setup.AckBoost{Content: "馃憖"}, loadAckBoost(t), "on by default")

out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--ack-boost", "On it")
require.NoError(t, err, out)
assert.Equal(t, setup.AckBoost{Content: "On it"}, loadAckBoost(t))

out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), serveArg())
require.NoError(t, err, out)
assert.Equal(t, setup.AckBoost{Content: "On it"}, loadAckBoost(t), "kept by a run that does not pass it")

out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--no-ack-boost")
require.NoError(t, err, out)
assert.Equal(t, setup.AckBoost{Off: true}, loadAckBoost(t))
}

// A boost Basecamp would refuse, and both flags at once, are refused before
// anything is written.
func TestConnectSetupRefusesABadAckBoost(t *testing.T) {
for name, args := range map[string][]string{
"empty": {"--ack-boost", ""},
"seventeen runes": {"--ack-boost", strings.Repeat("馃憖", 17)},
"both flags": {"--ack-boost", "馃憤", "--no-ack-boost"},
} {
t.Run(name, func(t *testing.T) {
s := startConnectSetupServer(t)
args = append([]string{"--operator", fmt.Sprint(setupOperatorPerson), serveArg()}, args...)
out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), args...)
require.Error(t, err, out)
assertNotWritten(t, "agent")
})
}
}

// Show says how requests are acknowledged, the default included, which the
// file itself leaves out.
func TestConnectShowSaysHowRequestsAreAcknowledged(t *testing.T) {
s := startConnectSetupServer(t)
firstSetup(t, s)

show := func(t *testing.T, format output.Format) string {
t.Helper()
app := newConnectSetupApp(t, s, "agent")
var buf bytes.Buffer
app.Output = output.New(output.Options{Format: format, Writer: &buf})
out, err := runConnectShowCmd(t, app)
require.NoError(t, err, out)
return buf.String()
}
ackOf := func(t *testing.T) setup.AckBoost {
t.Helper()
var envelope struct {
Data struct {
AckBoost setup.AckBoost `json:"ack_boost"`
} `json:"data"`
}
raw := show(t, output.FormatJSON)
require.NoError(t, json.Unmarshal([]byte(raw), &envelope), raw)
return envelope.Data.AckBoost
}

data, err := os.ReadFile(connectSetupPath(t, "agent"))
require.NoError(t, err)
require.NotContains(t, string(data), "ack_boost", "the default is not in the file")
assert.Equal(t, setup.AckBoost{Content: "馃憖"}, ackOf(t))
assert.Contains(t, show(t, output.FormatStyled), `boosts "馃憖"`)

out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--no-ack-boost")
require.NoError(t, err, out)
assert.Equal(t, setup.AckBoost{Off: true}, ackOf(t))
assert.Contains(t, show(t, output.FormatStyled), "off: the session boosts each request")
}
8 changes: 6 additions & 2 deletions internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,17 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
return connector.RunAdmission(ctx, connector.AdmissionOptions{Ledger: ledger, Queue: queue, Admitter: admitter, Lines: lines, Logger: logger})
})
if !f.shadow {
// Each trusted request goes to stdout for the session that started
// the connector, and the connector is done with it.
// Each trusted request someone made of the agent is acknowledged
// with a boost, unless connect.json turns that off, then goes to
// stdout for the session that started the connector, and the
// connector is done with it. A shadow runs no handoff, so it posts
// nothing; a hold hands nothing off, so it boosts nothing.
started := time.Now()
runPart("handoff", func(ctx context.Context) error {
return connector.RunHandoff(ctx, connector.HandoffOptions{
Ledger: ledger, Served: served.Current, Buckets: buckets, AgentID: agentID,
Lines: lines, Logger: logger, Started: started,
AckContent: file.Ack().Content, Booster: connector.SDKBooster{Client: accountClient},
})
})
}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/connect_setup_guided.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ var connectSetupPolicyFlags = []string{
"serve", "unserve", "class", "watch-completions", "no-watch-completions",
"allow-assignments-from-authorized",
"allow-agent", "disallow-agent", "agent-thread-cap", "agent-daily-cap",
"allow-in-ping", "disallow-in-ping",
"allow-in-ping", "disallow-in-ping", "ack-boost", "no-ack-boost",
}

// connectGOOS is the platform guided setup checks before connecting. A
Expand Down
Loading
Loading