cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

012 agent --install-mcp claude|codex|desktop

Dami committed ad47f19
Writes the entry that starts this binary's 012 mcp, by its absolute path
and with no workbook, into Claude Code's ~/.claude.json (as claude mcp
add --scope user does), Codex's config.toml or Claude Desktop's JSON:
the file backed up first, other entries and keys kept, nothing written
when the entry is already there. --print shows it instead; --root
passes folders on. It points out other entries that start 012, such as
one made for a single workbook.
commit
ad47f191df64dc36d9c7e595fb31f7e165f2c852
parent
ca67f3ed171c76a829e303f6a150aa5442c4bda8
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 0.9 of 3, Jev's estimate
3 files changed, +458 -5
Mcmd/012/agentskill.go18+-
Acmd/012/installmcp.go311++++++++++++++++++++++++++++++
Acmd/012/installmcp_test.go134+++++++++++++
M cmd/012/agentskill.go +13 -5
@@ -14,12 +14,13 @@ import (
1414 // 012 agent --skill and 012 agent --install-skill: the Claude Code1515 // skill that teaches agents with a shell to use 012 (see1616 // docs/agents/README.md), installed as 012 nu --install-module installs17-// the nushell module.17+// the nushell module. 012 agent --install-mcp adds 012 mcp to a host18+// (installmcp.go).1819 1920 //go:embed skill/SKILL.md2021 var skillSource string2122 22-const agentUsage = "usage: 012 agent --skill | --install-skill [--force] [dir]"23+const agentUsage = "usage: 012 agent --skill | --install-skill [--force] [dir] | --install-mcp claude|codex|desktop [--root dir]... [--print]"2324 2425 // skillHome is where Claude Code looks for a person's own skills; tests2526 // replace it.
@@ -31,16 +32,23 @@ var skillHome = func() (string, error) {
3132 return filepath.Join(home, ".claude", "skills", "012"), nil3233 }3334 34-// runAgent is 012 agent --skill (print the skill) or --install-skill35-// [--force] [dir].35+// runAgent is 012 agent --skill (print the skill), --install-skill36+// [--force] [dir], or --install-mcp host [--root dir]... [--print].3637 func runAgent(args []string, e env) error {37- a, err := parseArgs(args, nil, []string{"skill", "install-skill", "force", "help"})38+ a, err := parseArgs(args, []string{"install-mcp", "root"}, []string{"skill", "install-skill", "force", "print", "help"})3839 switch {3940 case err != nil:4041 return usageError(err.Error(), agentUsage)4142 case a.has("help"):4243 fmt.Fprintln(e.stdout, agentUsage)4344 return nil45+ case a.has("install-mcp"):46+ if a.has("skill") || a.has("install-skill") || a.has("force") || len(a.pos) > 0 {47+ return usageError("", agentUsage)48+ }49+ return installMCP(e, a.flags["install-mcp"], a.all["root"], a.has("print"))50+ case a.has("root") || a.has("print"):51+ return usageError("--root and --print go with --install-mcp", agentUsage)4452 case a.has("skill") && !a.has("install-skill") && len(a.pos) == 0:4553 _, err := fmt.Fprint(e.stdout, skillSource)4654 return err
A cmd/012/installmcp.go +311 -0
@@ -0,0 +1,311 @@
1+package main2+3+import (4+ "bytes"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "reflect"12+ "regexp"13+ "strings"14+ "time"15+)16+17+// 012 agent --install-mcp claude|codex|desktop: the entry that starts18+// 012 mcp, written into the host's configuration (see19+// docs/agents/mcp.md#adding-it-to-a-host). The entry runs this binary20+// by its absolute path with no workbook, so one server works on every21+// workbook the host's roots or --root open to it. A file edited is22+// copied beside itself first; an entry already the same is left alone.23+24+// mcpHost is a host whose configuration holds MCP servers.25+type mcpHost struct {26+ name string // as --install-mcp takes it27+ app string // as people call it28+ path func(e env) (string, error)29+ // entry is the configuration's text with 012's entry set, old being30+ // what's there ("" for no file); same is set when it was already.31+ entry func(old []byte, command string, args []string) (text []byte, same bool, err error)32+ // show is the entry alone, for --print.33+ show func(command string, args []string) string34+}35+36+var mcpHosts = []mcpHost{37+ {name: "claude", app: "Claude Code", path: claudeConfig, entry: jsonEntry(true), show: jsonShow(true)},38+ {name: "codex", app: "Codex", path: codexConfig, entry: tomlEntry, show: tomlShow},39+ {name: "desktop", app: "Claude Desktop", path: desktopConfig, entry: jsonEntry(false), show: jsonShow(false)},40+}41+42+// executable is this binary's path; tests replace it.43+var executable = os.Executable44+45+// installMCP writes the entry for host, or prints it with print.46+func installMCP(e env, host string, roots []string, print bool) error {47+ var h *mcpHost48+ for i := range mcpHosts {49+ if mcpHosts[i].name == host {50+ h = &mcpHosts[i]51+ }52+ }53+ if h == nil {54+ return usageError(fmt.Sprintf("--install-mcp takes claude, codex or desktop, not %q", host), agentUsage)55+ }56+ command, err := executable()57+ if err != nil {58+ return err59+ }60+ if command, err = filepath.Abs(command); err != nil {61+ return err62+ }63+ args := []string{"mcp"}64+ for _, r := range roots {65+ abs, err := filepath.Abs(r)66+ if err != nil {67+ return err68+ }69+ args = append(args, "--root", abs)70+ }71+ path, err := h.path(e)72+ if err != nil {73+ return err74+ }75+ if print {76+ _, err := fmt.Fprintf(e.stdout, "%s, in %s:\n\n%s", h.app, path, h.show(command, args))77+ return err78+ }79+ return writeEntry(e, h, path, command, args)80+}81+82+// writeEntry sets 012's entry in the configuration at path, backing the83+// file up first.84+func writeEntry(e env, h *mcpHost, path, command string, args []string) error {85+ old, err := os.ReadFile(path)86+ if err != nil && !errors.Is(err, fs.ErrNotExist) {87+ return err88+ }89+ text, same, err := h.entry(old, command, args)90+ if err != nil {91+ return fmt.Errorf("%s: %w", path, err)92+ }93+ if same {94+ fmt.Fprintf(e.stdout, "%s already runs %s mcp: %s is up to date\n", h.app, command, path)95+ return nil96+ }97+ mode := fs.FileMode(0o600)98+ if st, err := os.Stat(path); err == nil {99+ mode = st.Mode().Perm()100+ backup := path + ".bak-" + time.Now().Format("20060102-150405")101+ if err := os.WriteFile(backup, old, mode); err != nil {102+ return err103+ }104+ fmt.Fprintln(e.stdout, "Backed up", path, "to", backup)105+ } else if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {106+ return err107+ }108+ if err := os.WriteFile(path, text, mode); err != nil {109+ return err110+ }111+ fmt.Fprintf(e.stdout, "Wrote the 012 entry to %s: %s %s\n", path, command, strings.Join(args, " "))112+ fmt.Fprintf(e.stdout, "Restart %s to start it; it works on the workbooks in the folders it's given, without naming one.\n", h.app)113+ for _, other := range otherEntries(h, text, command) {114+ fmt.Fprintf(e.stdout, "%s also starts 012 as %q; remove that entry if it was for one workbook.\n", path, other)115+ }116+ return nil117+}118+119+// claudeConfig is Claude Code's user configuration, where claude mcp add120+// --scope user puts servers.121+func claudeConfig(e env) (string, error) {122+ if dir := e.getenv("CLAUDE_CONFIG_DIR"); dir != "" {123+ return filepath.Join(dir, ".claude.json"), nil124+ }125+ home, err := os.UserHomeDir()126+ return filepath.Join(home, ".claude.json"), err127+}128+129+// codexConfig is Codex's configuration, in CODEX_HOME or ~/.codex.130+func codexConfig(e env) (string, error) {131+ if dir := e.getenv("CODEX_HOME"); dir != "" {132+ return filepath.Join(dir, "config.toml"), nil133+ }134+ home, err := os.UserHomeDir()135+ return filepath.Join(home, ".codex", "config.toml"), err136+}137+138+// desktopConfig is Claude Desktop's configuration, in the folder the139+// system keeps applications' settings in.140+func desktopConfig(env) (string, error) {141+ dir, err := os.UserConfigDir()142+ return filepath.Join(dir, "Claude", "claude_desktop_config.json"), err143+}144+145+// jsonServer is an entry of mcpServers; Claude Code's says its type.146+func jsonServer(typed bool, command string, args []string) map[string]any {147+ s := map[string]any{"command": command, "args": args}148+ if typed {149+ s["type"], s["env"] = "stdio", map[string]any{}150+ }151+ return s152+}153+154+func jsonShow(typed bool) func(string, []string) string {155+ return func(command string, args []string) string {156+ data, _ := json.MarshalIndent(map[string]any{"mcpServers": map[string]any{"012": jsonServer(typed, command, args)}}, "", " ")157+ return string(data) + "\n"158+ }159+}160+161+// jsonEntry sets mcpServers["012"] in a JSON configuration, keeping the162+// rest of it as it was, keys in order.163+func jsonEntry(typed bool) func([]byte, string, []string) ([]byte, bool, error) {164+ return func(old []byte, command string, args []string) ([]byte, bool, error) {165+ top := map[string]json.RawMessage{}166+ if len(bytes.TrimSpace(old)) > 0 {167+ if err := json.Unmarshal(old, &top); err != nil {168+ return nil, false, fmt.Errorf("not JSON 012 can add to: %w", err)169+ }170+ }171+ servers := map[string]json.RawMessage{}172+ if raw, ok := top["mcpServers"]; ok {173+ if err := json.Unmarshal(raw, &servers); err != nil {174+ return nil, false, fmt.Errorf("mcpServers isn't an object: %w", err)175+ }176+ }177+ entry, _ := json.Marshal(jsonServer(typed, command, args))178+ if sameJSON(servers["012"], entry) {179+ return old, true, nil180+ }181+ servers["012"] = entry182+ top["mcpServers"], _ = json.Marshal(servers)183+ text, err := json.MarshalIndent(top, "", " ")184+ return append(text, '\n'), false, err185+ }186+}187+188+func sameJSON(a, b []byte) bool {189+ var x, y any190+ return json.Unmarshal(a, &x) == nil && json.Unmarshal(b, &y) == nil && reflect.DeepEqual(x, y)191+}192+193+// otherEntries are the names of the configuration's other servers that194+// start this 012.195+func otherEntries(h *mcpHost, text []byte, command string) []string {196+ var out []string197+ if h.name == "codex" {198+ for _, m := range tomlTable.FindAllSubmatchIndex(text, -1) {199+ name := string(text[m[2]:m[3]])200+ if unquote(name) != "012" && bytes.Contains(text[m[1]:tomlTableEnd(text, m[1])], []byte(tomlString(command))) {201+ out = append(out, name)202+ }203+ }204+ return out205+ }206+ var top struct {207+ MCPServers map[string]struct {208+ Command string `json:"command"`209+ } `json:"mcpServers"`210+ }211+ _ = json.Unmarshal(text, &top)212+ for name, s := range top.MCPServers {213+ if name != "012" && s.Command == command {214+ out = append(out, name)215+ }216+ }217+ return out218+}219+220+// tomlTable finds [mcp_servers.<name>] tables' headers.221+var tomlTable = regexp.MustCompile(`(?m)^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*("[^"\n]*"|'[^'\n]*'|[A-Za-z0-9_-]+)[ \t]*\][ \t]*(?:#.*)?$`)222+223+// tomlKey finds a command or args key's line in a table.224+var tomlKey = regexp.MustCompile(`^[ \t]*(command|args)[ \t]*=`)225+226+func unquote(name string) string { return strings.Trim(name, `"'`) }227+228+// tomlTableEnd is where the table whose header ends at i ends: the next229+// table's header, or the end.230+func tomlTableEnd(text []byte, i int) int {231+ if m := tomlHeader.FindIndex(text[i:]); m != nil {232+ return i + m[0]233+ }234+ return len(text)235+}236+237+// tomlHeader finds any table's header; tomlInline012 a key 012 set238+// inline in [mcp_servers].239+var (240+ tomlHeader = regexp.MustCompile(`(?m)^[ \t]*\[`)241+ tomlInline012 = regexp.MustCompile(`(?m)^[ \t]*("012"|'012'|012)[ \t]*=`)242+)243+244+// tomlString is s as a TOML basic string.245+func tomlString(s string) string {246+ data, _ := json.Marshal(s) // JSON's escapes are TOML's247+ return string(data)248+}249+250+func tomlLines(command string, args []string) (string, string) {251+ quoted := make([]string, len(args))252+ for i, a := range args {253+ quoted[i] = tomlString(a)254+ }255+ return "command = " + tomlString(command), "args = [" + strings.Join(quoted, ", ") + "]"256+}257+258+func tomlShow(command string, args []string) string {259+ c, a := tomlLines(command, args)260+ return "[mcp_servers.012]\n" + c + "\n" + a + "\n"261+}262+263+// tomlEntry sets command and args in Codex's [mcp_servers.012] table,264+// keeping its other keys (env, timeouts) and everything else in the265+// file as written, or adds the table at the end.266+func tomlEntry(old []byte, command string, args []string) ([]byte, bool, error) {267+ c, a := tomlLines(command, args)268+ var header []int269+ for _, m := range tomlTable.FindAllSubmatchIndex(old, -1) {270+ if unquote(string(old[m[2]:m[3]])) == "012" {271+ header = m272+ }273+ }274+ if header == nil {275+ if tomlInline012.Match(old) {276+ return nil, false, errors.New("012 is set inline in [mcp_servers]; replace it with --print's table")277+ }278+ text := append([]byte{}, old...)279+ if len(text) > 0 && !bytes.HasSuffix(text, []byte("\n")) {280+ text = append(text, '\n')281+ }282+ if len(text) > 0 {283+ text = append(text, '\n')284+ }285+ return append(text, tomlShow(command, args)...), false, nil286+ }287+ start, end := header[1]+1, tomlTableEnd(old, min(header[1]+1, len(old)))288+ if start > len(old) {289+ start = len(old)290+ }291+ body := strings.SplitAfter(string(old[start:end]), "\n")292+ var kept []string293+ for i := 0; i < len(body); i++ {294+ m := tomlKey.FindStringSubmatch(body[i])295+ if m == nil {296+ kept = append(kept, body[i])297+ continue298+ }299+ if m[1] == "args" && strings.Count(body[i], "[") > strings.Count(body[i], "]") {300+ for i+1 < len(body) && !strings.Contains(body[i], "]") {301+ i++ // an array written over several lines302+ }303+ }304+ }305+ newBody := c + "\n" + a + "\n" + strings.Join(kept, "")306+ if newBody == string(old[start:end]) {307+ return old, true, nil308+ }309+ text := string(old[:start]) + newBody + string(old[end:])310+ return []byte(text), false, nil311+}
A cmd/012/installmcp_test.go +134 -0
@@ -0,0 +1,134 @@
1+package main2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+// mcpHome gives the test a home folder of its own and this 012 a path.12+func mcpHome(t *testing.T) string {13+ t.Helper()14+ home := t.TempDir()15+ t.Setenv("HOME", home)16+ t.Setenv("USERPROFILE", home)17+ t.Setenv("APPDATA", filepath.Join(home, "AppData"))18+ old := executable19+ executable = func() (string, error) { return "/opt/012/bin/012", nil }20+ t.Cleanup(func() { executable = old })21+ return home22+}23+24+func backups(t *testing.T, path string) int {25+ t.Helper()26+ found, _ := filepath.Glob(path + ".bak-*")27+ return len(found)28+}29+30+func TestInstallMCPClaude(t *testing.T) {31+ home := mcpHome(t)32+ e, out, _ := scriptEnv(t)33+ path := filepath.Join(home, ".claude.json")34+ os.WriteFile(path, []byte(`{"numStartups": 3, "mcpServers": {"other": {"command": "x"}}, "projects": {"/p": {"allowedTools": []}}}`), 0o600)35+ if code, err := status(e, "agent", "--install-mcp", "claude"); code != 0 {36+ t.Fatal(err)37+ }38+ var got struct {39+ NumStartups int `json:"numStartups"`40+ Projects map[string]any `json:"projects"`41+ MCPServers map[string]json.RawMessage `json:"mcpServers"`42+ }43+ data, _ := os.ReadFile(path)44+ if err := json.Unmarshal(data, &got); err != nil {45+ t.Fatal(err)46+ }47+ if got.NumStartups != 3 || got.Projects["/p"] == nil || got.MCPServers["other"] == nil ||48+ !sameJSON(got.MCPServers["012"], []byte(`{"type":"stdio","command":"/opt/012/bin/012","args":["mcp"],"env":{}}`)) {49+ t.Errorf("~/.claude.json: %s", data)50+ }51+ if backups(t, path) != 1 || !strings.Contains(out.String(), "Restart Claude Code") {52+ t.Errorf("backups %d, said %q", backups(t, path), out)53+ }54+ out.Reset()55+ if status(e, "agent", "--install-mcp", "claude"); !strings.Contains(out.String(), "is up to date") || backups(t, path) != 1 {56+ t.Errorf("again: %q, %d backups", out, backups(t, path))57+ }58+}59+60+func TestInstallMCPCodex(t *testing.T) {61+ home := mcpHome(t)62+ e, out, _ := scriptEnv(t)63+ path := filepath.Join(home, ".codex", "config.toml")64+ os.MkdirAll(filepath.Dir(path), 0o755)65+ old := `model = "gpt-5" # a comment66+67+[mcp_servers.012-demo]68+command = "/opt/012/bin/012"69+args = ["mcp", "/tmp/demo.012"]70+71+[mcp_servers.other.http_headers]72+Authorization = "Bearer secret"73+`74+ os.WriteFile(path, []byte(old), 0o600)75+ if code, err := status(e, "agent", "--install-mcp", "codex"); code != 0 {76+ t.Fatal(err)77+ }78+ data, _ := os.ReadFile(path)79+ want := old + "\n[mcp_servers.012]\ncommand = \"/opt/012/bin/012\"\nargs = [\"mcp\"]\n"80+ if string(data) != want {81+ t.Errorf("config.toml:\n%s\nwant\n%s", data, want)82+ }83+ if !strings.Contains(out.String(), `also starts 012 as "012-demo"`) || backups(t, path) != 1 {84+ t.Errorf("said %q, %d backups", out, backups(t, path))85+ }86+ out.Reset()87+ if status(e, "agent", "--install-mcp", "codex"); !strings.Contains(out.String(), "is up to date") {88+ t.Errorf("again: %q", out)89+ }90+91+ // A table already there keeps its other keys; its command and args92+ // (an array over several lines) are replaced.93+ os.WriteFile(path, []byte("[mcp_servers.\"012\"]\ncommand = \"012\"\nargs = [\n \"mcp\",\n \"a.012\",\n]\nstartup_timeout_sec = 20\n\n[mcp_servers.\"012\".env]\nX = \"1\"\n"), 0o600)94+ if code, err := status(e, "agent", "--install-mcp", "codex", "--root", home); code != 0 {95+ t.Fatal(err)96+ }97+ data, _ = os.ReadFile(path)98+ want = "[mcp_servers.\"012\"]\ncommand = \"/opt/012/bin/012\"\nargs = [\"mcp\", \"--root\", \"" + home + "\"]\nstartup_timeout_sec = 20\n\n[mcp_servers.\"012\".env]\nX = \"1\"\n"99+ if string(data) != want {100+ t.Errorf("replaced:\n%s\nwant\n%s", data, want)101+ }102+}103+104+func TestInstallMCPDesktopAndPrint(t *testing.T) {105+ mcpHome(t)106+ e, out, _ := scriptEnv(t)107+ path, err := desktopConfig(e)108+ if err != nil {109+ t.Fatal(err)110+ }111+ if code, err := status(e, "agent", "--install-mcp", "desktop", "--print"); code != 0 || !strings.Contains(out.String(), `"command": "/opt/012/bin/012"`) {112+ t.Fatalf("--print: %v %q", err, out)113+ }114+ if _, err := os.Stat(path); err == nil {115+ t.Error("--print wrote the file")116+ }117+ if code, err := status(e, "agent", "--install-mcp", "desktop"); code != 0 {118+ t.Fatal(err)119+ }120+ data, _ := os.ReadFile(path)121+ var got map[string]map[string]json.RawMessage122+ if json.Unmarshal(data, &got) != nil || !sameJSON(got["mcpServers"]["012"], []byte(`{"command":"/opt/012/bin/012","args":["mcp"]}`)) || backups(t, path) != 0 {123+ t.Errorf("claude_desktop_config.json: %s", data)124+ }125+ out.Reset()126+ if status(e, "agent", "--install-mcp", "codex", "--print"); !strings.Contains(out.String(), "[mcp_servers.012]\ncommand = \"/opt/012/bin/012\"\nargs = [\"mcp\"]\n") {127+ t.Errorf("codex --print: %q", out)128+ }129+ for _, args := range [][]string{{"--install-mcp", "vscode"}, {"--install-mcp", "codex", "--skill"}, {"--print"}} {130+ if code, _ := status(e, append([]string{"agent"}, args...)...); code != 2 {131+ t.Errorf("%v: status %d", args, code)132+ }133+ }134+}