diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index 021e0d56..cf6e83f9 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -495,3 +495,22 @@ func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) { t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p)) } } + +// The tips preference reaches the managed settings as spinnerTipsEnabled, either way, and is left out when the +// mesh filled none: set in one call through the controller, never at the terminal. +func TestTheTipsPreferenceIsRenderedAsSpinnerTipsEnabled(t *testing.T) { + off, on := false, true + for _, c := range []struct { + tips *bool + want any + }{{&off, false}, {&on, true}, {nil, nil}} { + out := Render(Facts{Console: "x"}, Settings{Preferences: Preferences{Tips: c.tips}}, nil, "/h", nil, Config{}) + var managed map[string]any + if err := json.Unmarshal([]byte(out["managed-settings.json"]), &managed); err != nil { + t.Fatal(err) + } + if got, has := managed["spinnerTipsEnabled"]; got != c.want || has != (c.want != nil) { + t.Errorf("tips %v rendered spinnerTipsEnabled %v (present %v)", c.tips, got, has) + } + } +} diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 854e6061..6c63676f 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -124,6 +124,7 @@ func HomeScopeRefused(p Paths, target string) string { func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") } func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") } func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") } +func (p Paths) preferences() string { return filepath.Join(p.State, "preferences.json") } func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") } func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") } @@ -218,6 +219,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { } var settings Settings readJSON(p.Settings, &settings) + readJSON(p.preferences(), &settings.Preferences) var binding *Binding var b Binding if readJSON(p.binding(), &b) { diff --git a/modules/claude-code/cmd/claude-code/render.go b/modules/claude-code/cmd/claude-code/render.go index 80fb011c..c7d0d377 100644 --- a/modules/claude-code/cmd/claude-code/render.go +++ b/modules/claude-code/cmd/claude-code/render.go @@ -42,6 +42,15 @@ type Settings struct { MCPServers map[string]map[string]any `json:"mcp_servers"` // ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node. ManagedSettings map[string]any `json:"managed_settings"` + // Preferences are this module's own preferences, from its untrusted preferences file: set in one call + // with the controller's `settings` verb, for the mesh or a node, like any module's preference (ADR 0262). + Preferences Preferences `json:"-"` +} + +// Preferences are the module's preferences as the mesh filled them; nil is a value the file did not carry. +type Preferences struct { + // Tips is whether Claude Code shows a tip while it works (`spinnerTipsEnabled`). + Tips *bool `json:"tips"` } // Binding is the licence this node holds, as it was last applied. @@ -121,6 +130,11 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath // The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered // through this module's tools is laid over it (novox/hq issue 340). managed := mergeSettings(map[string]any{}, settings.ManagedSettings) + // The module's preferences: keys of how a session looks, never of what it may do, so they are set in one + // call through the controller rather than at the terminal, and laid over the operator's keys of the same name. + if settings.Preferences.Tips != nil { + managed["spinnerTipsEnabled"] = *settings.Preferences.Tips + } // The mesh's own keys are laid last: a setting never replaces them. managed["attribution"] = map[string]any{"commit": "", "pr": ""} managed["allowAllClaudeAiMcps"] = true diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 264844b9..e3eee50b 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -1,6 +1,13 @@ { "module": "claude-code", "version": "1", + "settings": { + "tips": { + "kind": "preference", + "default": true, + "why": "Claude Code shows a tip while it works; false turns tips off in every session on the machine, written as spinnerTipsEnabled in its managed settings" + } + }, "slug": "agent", "capabilities": [ "package-manager" @@ -146,6 +153,15 @@ "owner": "${machine:account}", "merge": "json", "content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n" + }, + { + "id": "preferences", + "trusted": false, + "type": "file", + "path": "${dir:state}/preferences.json", + "mode": "0600", + "owner": "${machine:account}", + "content": "{\n \"tips\": ${setting:tips}\n}\n" } ], "shell": [