diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 1c328a08..b180f1a1 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -112,9 +112,18 @@ them. `claude_code_registrations` lists everything registered, with who register rendered, beside what the operator set for this node at the terminal. Its `by` is the node whose tool runner served the call, never the caller, whom the runner is not told. -The agent's configuration that is text a session reads (hq ADR 0216), each registered at a **scope** — `mesh` (the -default), `node` (`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the -operator account's own `~/.claude` on those nodes): +**Text for every node or some is set at the controller's terminal too, for now** (hq issue 340, the operator's +decision of 2026-10-09; a surface of the operator's own may update it later). Instruction sections, output styles, +skills, commands and subagents at the `mesh` or `node` scope are refused through the tools, and the import tool is +refused. They are keys of this module's settings, each text a list of its lines, because a setting holds no line +break: `instructions`, `output_styles`, `commands` and `agents` map a name to its text; `skills` maps a name to its +files, each a list of lines. For example `mesh-controller settings set claude-code '{"instructions": {"conventions": +["Commit in the imperative.", "", "Test before pushing."]}}'`. They land where a registration did, laid over what was +registered before; what the operator writes there is not judged, but an item that is not well formed is left out and +said in the render's answer. What was registered before is still rendered and may be unregistered. + +The home scope, the operator account's own `~/.claude` (`nodes`: a list, or `"all"` for every node running +claude-code; absent is this node), is still registered through the tools: - for each kind — `skill`, `agent`, `command`, `output_style`, `instructions` — `claude_code__list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a lone SKILL.md); diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index fc8f5f05..de001b50 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -32,6 +32,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path" @@ -264,6 +265,15 @@ func knownKind(k string) bool { // Problem says why an item cannot be registered, or "". func (it Item) Problem() string { + if problem := it.shapeProblem(); problem != "" { + return problem + } + return runsProblem(it) +} + +// shapeProblem says why an item is not well formed, or "": its kind, scope, name, paths, files and size, without +// judging what its text asks of a session — which the operator's own text, set at the terminal, may. +func (it Item) shapeProblem() string { if !knownKind(it.Kind) { return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", ")) } @@ -299,7 +309,7 @@ func (it Item) Problem() string { if n := it.size(); n > MaxItemBytes { return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes) } - return runsProblem(it) + return "" } // pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every @@ -420,6 +430,9 @@ func (v *ConfigView) writeThroughLocked() bool { // Config is what applies to this node, sorted for rendering. type Config struct { Mesh, Node, Home []Item + // Terminal is the shared text the operator set at the controller's terminal, in this module's settings + // (novox/hq issue 340): laid over what was registered for the mesh and the node, for every session here. + Terminal []Item } // ConfigOf sorts the items that apply here by scope, each scope by kind and name. @@ -498,7 +511,7 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile { })}, } chosen := map[string]Item{} - for _, layer := range [][]Item{c.Mesh, c.Node} { + for _, layer := range [][]Item{c.Mesh, c.Node, c.Terminal} { for _, it := range layer { chosen[it.Kind+"/"+it.Name] = it } @@ -593,10 +606,12 @@ func mergeSettings(a, b map[string]any) map[string]any { // registered for the mesh, then those for this node. func InstructionSections(c Config) string { var b strings.Builder + registered := "Registered through the `claude-code` module before that moved to the controller's terminal; change them there, in the module's `instructions` setting." for _, part := range []struct { - title string - items []Item - }{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} { + title, note string + items []Item + }{{"Instructions for every node", registered, c.Mesh}, {"Instructions for this node", registered, c.Node}, + {"Instructions set at the controller's terminal", "Set in the `claude-code` module's `instructions` setting, at the controller's terminal.", c.Terminal}} { var sections []Item for _, it := range part.items { if it.Kind == KindInstructions { @@ -606,7 +621,7 @@ func InstructionSections(c Config) string { if len(sections) == 0 { continue } - fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title) + fmt.Fprintf(&b, "\n## %s\n\n%s\n", part.title, part.note) for _, it := range sections { fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"])) } @@ -807,7 +822,7 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem { var placed Placed _ = readJSON(p.placed(), &placed) inPlugin := map[string]bool{} - for _, layer := range [][]Item{c.Mesh, c.Node} { + for _, layer := range [][]Item{c.Mesh, c.Node, c.Terminal} { for _, it := range layer { inPlugin[it.Kind+"/"+it.Name] = true } @@ -910,10 +925,28 @@ type ConfigState interface { Get(key string) (json.RawMessage, bool, error) } +// SharedTextAtTheTerminal is why text for every node or some is not registered through this module (novox/hq issue +// 340, the operator's decision of 2026-10-09): an instruction section, an output style, a skill, a command or a +// subagent at the mesh or node scope reaches every session on those nodes as the mesh's own word, and the tool +// runner cannot tell an agent from the operator. Until a surface of the operator's own updates it, it is set at +// the controller's terminal, in this module's settings; a person's own home stays theirs to register into. +func SharedTextAtTheTerminal(kind string) string { + key := map[string]string{KindSkill: "skills", KindAgent: "agents", KindCommand: "commands", + KindOutputStyle: "output_styles", KindInstructions: "instructions"}[kind] + return "a " + kind + " for every node or some reaches every session there as the mesh's own word, and this tool " + + "cannot tell an agent from the operator. It is set at the controller's terminal only, under `" + key + + "` in this module's settings, its text as a list of lines: mesh-controller settings set claude-code " + + "'{\"" + key + "\": {\"\": [\"line\", …]}}' [--node ] (novox/hq issue 340). The home scope, " + + "one account's own directory, is still registered here. Nothing was registered" +} + // Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the // node and home scopes one key per node — this node when none is given. Taken into this node's view at once, // so the answer says what it did here. func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) { + if !unregister && it.Scope != ScopeHome && it.Kind != KindHook && it.Kind != KindSettings && knownKind(it.Kind) { + return nil, errors.New(SharedTextAtTheTerminal(it.Kind)) + } if !unregister { it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339) if problem := it.Problem(); problem != "" { diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index e6420097..3c827500 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -41,6 +41,26 @@ func deliver(m memConfig, v *ConfigView) { } } +// registeredBefore puts an item on the bus as a registration made before mesh- and node-scope text moved to the +// controller's terminal (novox/hq issue 340), for each node given (none at the mesh scope), as every node's watch +// would hand it over to this one, and renders. +func registeredBefore(t *testing.T, p Paths, w map[string]string, state memConfig, view *ConfigView, it Item, nodes ...string) { + t.Helper() + if it.Scope == ScopeMesh { + nodes = []string{""} + } + for _, n := range nodes { + item := it + if err := state.Put(it.Key(n), item); err != nil { + t.Fatal(err) + } + view.Take(it.Key(n), "put", &item) + } + if _, err := RenderNow(p, writer(w)); err != nil { + t.Fatal(err) + } +} + func one(name, content string) map[string]string { return map[string]string{name + ".md": content} } func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile { @@ -57,10 +77,10 @@ func TestEachKindLandsInItsOnePlace(t *testing.T) { state, view := memConfig{}, NewConfigView(p) register := func(it Item) { t.Helper() - answer, err := Register(p, it, nil, false, state, view, writer(w)) - if err != nil || answer["registered"] == false { - t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err) + if problem := it.Problem(); problem != "" { + t.Fatalf("%s %s: %s", it.Kind, it.Name, problem) } + registeredBefore(t, p, w, state, view, it) } register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}}) @@ -107,9 +127,13 @@ func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) {Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}}, {Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}}, } { - if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil { - t.Fatal(err) + if r.it.Scope == ScopeHome { + if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil { + t.Fatal(err) + } + continue } + registeredBefore(t, a, wa, state, va, r.it, r.nodes...) } deliver(state, vb) if _, err := RenderNow(b, writer(wb)); err != nil { @@ -289,9 +313,7 @@ func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) { p, w := node(t, "laptop") state, view := memConfig{}, NewConfigView(p) for _, name := range []string{"kept", "gone"} { - if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil { - t.Fatal(err) - } + registeredBefore(t, p, w, state, view, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}) } delete(state, "mesh.command.gone") // removed from another node while this one was off back := NewConfigView(p) // the node starts again from what it last wrote @@ -338,7 +360,7 @@ func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) { t.Fatalf("%v %v", nodes, err) } p, w := node(t, "laptop") - answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w)) + answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w)) if answer["registered"] != false { t.Fatalf("a node name with a dot was used in a key: %v", answer) } diff --git a/modules/claude-code/cmd/claude-code/config_tools.go b/modules/claude-code/cmd/claude-code/config_tools.go index 4b62e7b2..2705c7fd 100644 --- a/modules/claude-code/cmd/claude-code/config_tools.go +++ b/modules/claude-code/cmd/claude-code/config_tools.go @@ -104,7 +104,7 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node..…` one node, `home..…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".", Run: func(map[string]any) (any, error) { return List(state, k.kind) }}, stdio.Tool{Name: "claude_code_" + k.tool + "_register", - Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, at home where the person already has one of that name, and whenever it would run something by itself in a session: a shell line (!`…` or ```!), or hooks, mcpServers, permissionMode or allowed-tools in its front matter (novox/hq issue 340).", + Description: "Register " + k.what + " in an account's own directory (scope home) on this node or the nodes given. For every node (mesh, the default) or some (node) it is refused and says why (novox/hq issue 340): shared text is set at the controller's terminal, in this module's settings, until a surface of the operator's own updates it. Kept on the bus. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, at home where the person already has one of that name, and whenever it would run something by itself in a session: a shell line (!`…` or ```!), or hooks, mcpServers, permissionMode or allowed-tools in its front matter (novox/hq issue 340).", Input: input, Run: func(a map[string]any) (any, error) { if k.kind == KindHook { @@ -232,7 +232,7 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { }, nil }}, stdio.Tool{Name: "claude_code_config_import", - Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.", + Description: "Refused for now, and says why (novox/hq issue 340): it registered an item of this node's own ~/.claude for every node or some, and text for every node or some is set at the controller's terminal, in this module's settings. Read the item with claude_code_home_show and set it there.", Input: map[string]any{ "kind": str("skill, agent, command, output-style or instructions (a rule file)"), "name": str("its name in the home: the skill's folder, or the file without .md"), diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 962b474a..da067da3 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -127,6 +127,8 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { var items map[string]Item _ = readJSON(p.config(), &items) config := ConfigOf(Taken(items)) + var leftOut []string + config.Terminal, leftOut = terminalText(p) mesh := ReadMeshTools(p) if mesh != nil { mesh.Refused = RefusedCounts(GuardDataOf(p, mesh).Log) @@ -165,6 +167,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { for _, line := range left { out = append(out, "home "+line) } + for _, line := range leftOut { + out = append(out, "left out of the terminal's shared text: "+line) + } return out, errors.Join(failed...) } diff --git a/modules/claude-code/cmd/claude-code/registrations.go b/modules/claude-code/cmd/claude-code/registrations.go index b6dfb746..0e275e2a 100644 --- a/modules/claude-code/cmd/claude-code/registrations.go +++ b/modules/claude-code/cmd/claude-code/registrations.go @@ -91,9 +91,15 @@ func Registrations(p Paths, servers ServerState, config ConfigState) (map[string var settings Settings _ = readJSON(p.Settings, &settings) + text, leftOut := terminalText(p) + shared := []string{} + for _, it := range text { + shared = append(shared, it.Kind+" "+it.Name) + } return map[string]any{ "set at the controller's terminal, as this node composes it": map[string]any{ - "managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers}, + "managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers, + "shared text": shared, "shared text left out": leftOut}, "tool servers registered through this module": registered, "configuration registered through this module": items, "by": ByIs, diff --git a/modules/claude-code/cmd/claude-code/terminal_only_test.go b/modules/claude-code/cmd/claude-code/terminal_only_test.go index 67828338..40408840 100644 --- a/modules/claude-code/cmd/claude-code/terminal_only_test.go +++ b/modules/claude-code/cmd/claude-code/terminal_only_test.go @@ -339,3 +339,83 @@ func TestAFileReferenceOutsideTheItemIsRefused(t *testing.T) { t.Errorf("a reference to its own file or a mail address was refused: %s", problem) } } + +// Text for every node or some is set at the controller's terminal for now (novox/hq issue 340, the operator's +// decision of 2026-10-09): through the tools, an instruction section, an output style, a skill, a command or a +// subagent at the mesh or node scope is refused, saying where it is set, and so is an import to those scopes. The +// home scope, one account's own directory, is still registered through them. +func TestSharedTextIsRefusedThroughTheToolsAndHomeIsNot(t *testing.T) { + p, _ := node(t, "laptop") + servers, config := &bus{kept: map[string]map[string]any{}}, memConfig{} + s := serve(t, p, servers, config) + writeFile(t, filepath.Join(p.Home, ".claude", "x.md"), "") // the home exists + _ = os.MkdirAll(filepath.Join(p.Home, ".claude", "commands"), 0o755) + writeFile(t, filepath.Join(p.Home, ".claude", "commands", "mine.md"), "Ship it.") + for _, tool := range []string{"instructions", "output_style", "skill", "command", "agent"} { + for _, scope := range []map[string]any{{}, {"scope": "mesh"}, {"scope": "node"}, {"scope": "node", "nodes": "laptop,server"}} { + args := map[string]any{"name": "x", "content": "---\nname: x\ndescription: d\n---\nRead the mesh's rules.\n"} + for k, v := range scope { + args[k] = v + } + text, isError := s.call(t, "claude_code_"+tool+"_register", args) + if !isError || !strings.Contains(text, "controller's terminal") || !strings.Contains(text, "issue 340") { + t.Errorf("%s %v was not refused, or did not say where it is set: %s", tool, scope, text) + } + } + } + if text, isError := s.call(t, "claude_code_config_import", map[string]any{"kind": "command", "name": "mine", "scope": "mesh"}); !isError || + !strings.Contains(text, "issue 340") { + t.Errorf("an import to the mesh was not refused: %s", text) + } + if len(config) != 0 { + t.Fatalf("a refused call kept something on the bus: %v", config) + } + text, isError := s.call(t, "claude_code_command_register", map[string]any{"name": "ship", "scope": "home", + "content": "---\ndescription: ship it\n---\nShip it."}) + if isError || config["home.laptop.command.ship"] == nil { + t.Fatalf("a command for this home was refused: %s", text) + } +} + +// What the operator sets at the terminal lands where a registration did: each text a list of its lines, laid over +// what was registered before. It is the operator's word, so what it asks of a session is not judged; an item that +// is not well formed is left out, and said. +func TestSharedTextSetAtTheTerminalLands(t *testing.T) { + p, w := node(t, "laptop") + writeFile(t, p.Settings, `{"role":"","mcp_servers":{}, + "instructions":{"conventions":["Commit in the imperative.","","Test before pushing."]}, + "output_styles":{"terse":["---","name: terse","---","Short."]}, + "skills":{"review":{"SKILL.md":["---","name: review","description: d","allowed-tools: Read","---","Review it."],"scripts/run.sh":["#!/bin/sh","echo hi"]}, + "broken":{"notes.md":"no SKILL.md"}}, + "commands":{"ship":"Ship it."}, + "agents":{"reviewer":["---","name: reviewer","---","x"],"Bad Name":"x"}}`) + state, view := memConfig{}, NewConfigView(p) + registeredBefore(t, p, w, state, view, Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, + Files: one("conventions", "The registration from before.")}) + out, err := RenderNow(p, writer(w)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(w["CLAUDE.md"], "## Instructions set at the controller's terminal") || + !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.\n\nTest before pushing.") { + t.Errorf("the instructions:\n%s", w["CLAUDE.md"]) + } + plugin := pluginOf(t, w) + root := "plugins/" + Plugin + "/" + for _, want := range []string{"skills/review/SKILL.md", "skills/review/scripts/run.sh", "output-styles/terse.md", + "commands/ship.md", "agents/reviewer.md"} { + if _, ok := plugin[root+want]; !ok { + t.Errorf("the plugin lacks %s; %v", want, out) + } + } + if !plugin[root+"skills/review/scripts/run.sh"].Executable || !strings.Contains(plugin[root+"skills/review/SKILL.md"].Content, "allowed-tools: Read") { + t.Error("the operator's skill was not taken as written") + } + if _, ok := plugin[root+"skills/broken/notes.md"]; ok { + t.Error("a skill without its SKILL.md was placed") + } + said := strings.Join(out, "\n") + if !strings.Contains(said, "skill broken") || !strings.Contains(said, "agent Bad Name") { + t.Errorf("what was left out was not said:\n%s", said) + } +} diff --git a/modules/claude-code/cmd/claude-code/terminal_text.go b/modules/claude-code/cmd/claude-code/terminal_text.go new file mode 100644 index 00000000..dfb5ae5f --- /dev/null +++ b/modules/claude-code/cmd/claude-code/terminal_text.go @@ -0,0 +1,89 @@ +package main + +// The shared text the operator sets at the controller's terminal (novox/hq issue 340, the operator's decision of +// 2026-10-09): instruction sections, output styles, skills, commands and subagents for every node or some. They are +// keys of this module's settings, which the controller takes at its terminal alone. A setting holds no line break +// (issue 339), so each text is a list of its lines; a one-line text may be a string. + +import ( + "encoding/json" + "errors" + "os" + "sort" + "strings" +) + +// Lines is a text as a setting holds it: a list of its lines, or one line as a string. +type Lines string + +// UnmarshalJSON takes a string or a list of strings, joined by line breaks. +func (l *Lines) UnmarshalJSON(raw []byte) error { + var one string + if json.Unmarshal(raw, &one) == nil { + *l = Lines(one) + return nil + } + var many []string + if err := json.Unmarshal(raw, &many); err != nil { + return errors.New("a text is a list of its lines, or one line") + } + *l = Lines(strings.Join(many, "\n") + "\n") + return nil +} + +// SharedText is the text keys of this module's settings. +type SharedText struct { + Instructions map[string]Lines `json:"instructions"` + OutputStyles map[string]Lines `json:"output_styles"` + Agents map[string]Lines `json:"agents"` + Commands map[string]Lines `json:"commands"` + Skills map[string]map[string]Lines `json:"skills"` +} + +// TerminalItems are the items the operator's settings hold, as the plugin and the instruction file take them, and +// what was left out and why: an item that is not well formed (a name, a path, a skill without its SKILL.md). What +// the text asks of a session is the operator's to say there, so it is not judged. +func TerminalItems(text SharedText) ([]Item, []string) { + var out []Item + var left []string + add := func(it Item) { + it.Scope, it.By = ScopeMesh, "the controller's terminal" + if problem := it.shapeProblem(); problem != "" { + left = append(left, it.Kind+" "+it.Name+": "+problem) + return + } + out = append(out, it) + } + for kind, texts := range map[string]map[string]Lines{KindInstructions: text.Instructions, + KindOutputStyle: text.OutputStyles, KindAgent: text.Agents, KindCommand: text.Commands} { + for name, body := range texts { + add(Item{Kind: kind, Name: name, Files: map[string]string{name + ".md": string(body)}}) + } + } + for name, files := range text.Skills { + it := Item{Kind: KindSkill, Name: name, Files: map[string]string{}} + for rel, body := range files { + it.Files[rel] = string(body) + } + add(it) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].Name < out[j].Name + }) + sort.Strings(left) + return out, left +} + +// terminalText reads the shared text from this module's settings file. +func terminalText(p Paths) ([]Item, []string) { + var text SharedText + if raw, err := os.ReadFile(p.Settings); err == nil { + if err := json.Unmarshal(raw, &text); err != nil { + return nil, []string{"the shared text in this module's settings could not be read: " + err.Error()} + } + } + return TerminalItems(text) +} diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index deacc8bb..1e7082b1 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -113,7 +113,7 @@ "mode": "0600", "owner": "${machine:account}", "merge": "json", - "content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n" + "content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n" } ], "build": {