- commit
- d668a31ea6e2722c5efd376348ae3cd951baf50d
- parent
- b60592be62d0d706430c85404ae934f721ce18e3
- parent
- e2ec615e5dfa15f28d5ddc7e606184eaf3e82cac
- author
- Dami <dami.etoile@gmail.com>
@@ -45,9 +45,11 @@ shown by `012 diff`, under the same trust rules as macros. Live mode is
4545 |---|---|---|4646 | Live mode: `012 --listen` and `012 mcp --attach`; the agent's cursor and name in the grid, its changes arriving as a suggestion (marked cells, accepted or rejected whole or by cell) unless direct edits are allowed, its own undo, a scope (sheet, range, read-only), elicitation to ask the person | Coworking with an agent in the grid | M to L |4747 48-## Later: other transports (shelved)48+## Shelved4949 50-Explored, not scheduled: carrying the same shared sessions over iroh50+Explored, not scheduled; the fit is still open.51+52+Other transports: carrying the same shared sessions over iroh5153 tickets (the Go transport in `FelineStateMachine/allons`5254 `local/transport/iroh`, which needs cgo and a prebuilt iroh-ffi archive, so5355 it would sit behind a build tag) and the web (`NimbleMarkets/go-booba`@@ -56,6 +58,15 @@ v2 support unverified). They would reuse the rooms of `012 serve`, whose
5658 server orders every participant's operations, rather than a design of5759 their own.5860 61+SQL cells and connections: SQL as a second cell language beside nu, on62+an in-process SQLite (pure Go) over the workbook's tables, outputs and63+linked files by default, with saved connections to SQLite files,64+Postgres and MySQL whose secrets stay in the keychain. Results would be65+outputs like a nu cell's (grid, `G`, `$name`, stale), `@name`66+parameters bound from cells, and `012 query` and an MCP `query` tool67+for scripts and agents. DuckDB would only be an external kernel, as it68+needs cgo.69+5970 ## Shipped6071 6172 **Engine**@@ -136,8 +147,8 @@ their own.
136147 **Agents**137148 138149 - `012 describe`, results as JSON with stable schemas, `012 set --dry-run` as a diff, and a Claude Code skill installed by `012 agent --install-skill`: [Agents](docs/agents/README.md)139-- `012 mcp`, an MCP server on a workbook file: tools that read, evaluate, write, sort, filter, chart, pivot and run notebook cells through the same checks as `012 set`, resources and prompts: [MCP server](docs/agents/mcp.md)140-- MCP Apps views: ranges read and charts made drawn in the chat from the HTML export, where the host supports the extension: [MCP server](docs/agents/mcp.md#views-in-the-chat)150+- `012 mcp`, an MCP server on the workbooks in its folders, each tool taking a path, added to hosts by `012 agent --install-mcp`: tools that read, evaluate, write, sort, filter, chart, pivot and run notebook cells through the same checks as `012 set`, resources and prompts: [MCP server](docs/agents/mcp.md)151+- Views: ranges read and charts made drawn in the chat from the HTML export, in MCP Apps hosts and the OpenAI Apps SDK's: [MCP server](docs/agents/mcp.md#views-in-the-chat)141152 142153 **Upkeep**143154 @@ -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-code|claude-desktop|codex [--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@@ -46,9 +46,11 @@ func usageError(why, usage string) error {
4646 }4747 4848 // cliArgs are a subcommand's arguments: its flags, by name without the49-// dashes, and the rest in order.49+// dashes (a flag given more than once keeps its last value in flags and50+// every value in all), and the rest in order.5051 type cliArgs struct {5152 flags map[string]string53+ all map[string][]string5254 pos []string5355 }5456 @@ -59,7 +61,7 @@ func (a cliArgs) has(name string) bool { _, ok := a.flags[name]; return ok }
5961 // Only flags the command knows are flags, so an argument such as -5 or6062 // -A1 is a value; after --, everything is.6163 func parseArgs(args []string, valued, bools []string) (cliArgs, error) {62- out := cliArgs{flags: map[string]string{}}64+ out := cliArgs{flags: map[string]string{}, all: map[string][]string{}}6365 for i := 0; i < len(args); i++ {6466 arg := args[i]6567 if arg == "--" {@@ -86,6 +88,7 @@ func parseArgs(args []string, valued, bools []string) (cliArgs, error) {
8688 value = args[i]8789 }8890 out.flags[name] = value91+ out.all[name] = append(out.all[name], value)8992 default:9093 return out, fmt.Errorf("unknown flag --%s", name)9194 }@@ -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-code|claude-desktop|codex: the entry18+// that starts 012 mcp, written into the host's configuration (see19+// docs/agents/mcp.md#adding-it-to-a-host). The entry runs this binary by20+// 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-code", app: "Claude Code", path: claudeConfig, entry: jsonEntry(true), show: jsonShow(true)},38+ {name: "codex", app: "Codex", path: codexConfig, entry: tomlEntry, show: tomlShow},39+ {name: "claude-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-code, claude-desktop or codex, 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+}@@ -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"); 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-code"); !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", "claude-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", "claude-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", "claude"}, {"--install-mcp", "desktop"}, {"--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+}@@ -6,6 +6,7 @@ import (
66 "io"77 "os"88 "os/signal"9+ "path/filepath"910 "sync"1011 1112 sdk "github.com/modelcontextprotocol/go-sdk/mcp"@@ -18,49 +19,77 @@ import (
1819 )1920 2021 // 012 mcp: a Model Context Protocol server over standard input and21-// output on one workbook file (see docs/agents/mcp.md).22+// output on the workbooks in the folders open to it (see23+// docs/agents/mcp.md): the client's roots, else --root's, else the24+// working directory. A file named is the default of tools called25+// without a path.2226 23-const mcpUsage = "usage: 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]"27+const mcpUsage = "usage: 012 mcp [file.012] [--root dir]... [--read-only] [--force] [--notebooks [--trust]] [--jev]"2428 2529 // runMCP is 012 mcp: it serves until the client closes standard input.2630 // Nothing runs a program or reaches the network unless its flag says:2731 // --notebooks offers run_notebook_cell, --jev answers JEV functions.2832 func runMCP(args []string, e env) error {29- a, err := parseArgs(args, nil, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})33+ a, err := parseArgs(args, []string{"root"}, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})3034 switch {3135 case err != nil:3236 return usageError(err.Error(), mcpUsage)3337 case a.has("help"):3438 fmt.Fprintln(e.stdout, mcpUsage)3539 return nil36- case len(a.pos) != 1:40+ case len(a.pos) > 1:3741 return usageError("", mcpUsage)3842 case a.has("trust") && !a.has("notebooks"):3943 return usageError("--trust goes with --notebooks", mcpUsage)4044 }41- path := a.pos[0]42- if _, err := headless.Open(path, true); err != nil {45+ roots, err := mcp.NewRoots(rootDirs(a.all["root"]))46+ if err != nil {4347 return err4448 }49+ o := mcp.Options{Version: buildVersion(), ReadOnly: a.has("read-only"), Force: a.has("force"), Roots: roots}50+ if len(a.pos) == 1 {51+ if o.Default, err = filepath.Abs(a.pos[0]); err != nil {52+ return err53+ }54+ if _, err := headless.Open(o.Default, true); err != nil {55+ return err56+ }57+ }4558 cfg, err := loadConfig(e, nil)4659 if err != nil {4760 return err4861 }4962 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)5063 defer stop()51- b := &mcp.FileBackend{Path: path, Save: func(f *headless.File) error { return save(f, e) }}64+ o.Save = func(f *headless.File) error { return save(f, e) }5265 if a.has("jev") {53- b.Prepare = jevPreparer(cfg, e)66+ o.Prepare = jevPreparer(cfg, e)5467 }55- o := mcp.Options{Version: buildVersion(), ReadOnly: a.has("read-only"), Force: a.has("force")}5668 if a.has("notebooks") && !a.has("read-only") {5769 o.Notebooks = &headless.NotebookOptions{Runner: e.nuRunner(), Timeout: cfg.Duration("nu-timeout"), NuConfig: cfg.Bool("nu-config")}58- o.MayRun = func(w *sheet.Workbook) error {70+ o.MayRun = func(path string, w *sheet.Workbook) error {5971 return mayRun(&headless.File{Path: path, Book: w}, cfg, a.has("trust"))6072 }6173 }6274 t := &sdk.IOTransport{Reader: io.NopCloser(e.stdin), Writer: nopWriteCloser{e.stdout}}63- return mcp.New(b, o).Run(ctx, t)75+ return mcp.New(o).Run(ctx, t)76+}77+78+// rootDirs are the folders --root gave, else the working directory,79+// else, when that's the file system's root (where some hosts start80+// servers), the home folder.81+func rootDirs(given []string) []string {82+ if len(given) > 0 {83+ return given84+ }85+ wd, err := os.Getwd()86+ if err != nil || filepath.Dir(wd) != wd {87+ return []string{"."}88+ }89+ if home, err := os.UserHomeDir(); err == nil {90+ return []string{home}91+ }92+ return []string{"."}6493 }6594 6695 // jevPreparer answers a workbook's JEV functions each time it's opened,@@ -2,35 +2,47 @@ package main
22 33 import (44 "context"5+ "encoding/json"56 "io"67 "path/filepath"78 "slices"9+ "strings"810 "testing"911 1012 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1113 )1214 13-// TestMCPOverStdio runs 012 mcp on pipes standing for standard input14-// and output, as a host runs it, and lists its tools.15-func TestMCPOverStdio(t *testing.T) {16- e, _, _ := scriptEnv(t)17- path := filepath.Join(t.TempDir(), "book.012")18- status(e, "set", path, "A1", "1")15+// serveMCP runs 012 mcp with args on pipes standing for standard input16+// and output, as a host runs it, and connects a client; stop ends it17+// and returns what 012 mcp did.18+func serveMCP(t *testing.T, e env, args ...string) (cs *sdk.ClientSession, stop func() error) {19+ t.Helper()1920 inR, inW := io.Pipe()2021 outR, outW := io.Pipe()2122 e.stdin, e.stdout = inR, outW2223 done := make(chan error, 1)2324 go func() {24- done <- run([]string{"mcp", path, "--read-only"}, e)25+ done <- run(append([]string{"mcp"}, args...), e)2526 outW.Close()2627 }()27- ctx := context.Background()28- cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, &sdk.IOTransport{Reader: outR, Writer: inW}, nil)28+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(context.Background(), &sdk.IOTransport{Reader: outR, Writer: inW}, nil)2929 if err != nil {3030 t.Fatal(err)3131 }32+ return cs, func() error {33+ cs.Close()34+ inW.Close()35+ return <-done36+ }37+}38+39+func TestMCPOverStdio(t *testing.T) {40+ e, _, _ := scriptEnv(t)41+ path := filepath.Join(t.TempDir(), "book.012")42+ status(e, "set", path, "A1", "1")43+ cs, stop := serveMCP(t, e, path, "--read-only")3244 var names []string33- for tl, err := range cs.Tools(ctx, nil) {45+ for tl, err := range cs.Tools(context.Background(), nil) {3446 if err != nil {3547 t.Fatal(err)3648 }@@ -39,12 +51,38 @@ func TestMCPOverStdio(t *testing.T) {
3951 if !slices.Contains(names, "read_range") || slices.Contains(names, "write_cells") {4052 t.Errorf("tools of a read-only server: %v", names)4153 }42- cs.Close()43- inW.Close()44- if err := <-done; err != nil {54+ if err := stop(); err != nil {4555 t.Errorf("012 mcp: %v", err)4656 }4757 if code, _ := status(e, "mcp", path, "--trust"); code != 2 {4858 t.Errorf("--trust without --notebooks: status %d", code)4959 }5060 }61+62+// TestMCPRoots serves the workbooks of the folders --root gives, with63+// no file named.64+func TestMCPRoots(t *testing.T) {65+ e, _, _ := scriptEnv(t)66+ dir, other := t.TempDir(), t.TempDir()67+ status(e, "set", filepath.Join(dir, "a.012"), "A1", "1")68+ status(e, "set", filepath.Join(other, "b.012"), "A1", "2")69+ cs, stop := serveMCP(t, e, "--root", dir, "--root", other)70+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: "describe", Arguments: map[string]any{}})71+ if err != nil {72+ t.Fatal(err)73+ }74+ var d struct {75+ Workbooks []struct{ Path string }76+ }77+ data, _ := json.Marshal(res.StructuredContent)78+ json.Unmarshal(data, &d)79+ if len(d.Workbooks) != 2 || d.Workbooks[0].Path != "a.012" || !strings.HasSuffix(d.Workbooks[1].Path, "b.012") {80+ t.Errorf("describe: %s", data)81+ }82+ if err := stop(); err != nil {83+ t.Errorf("012 mcp: %v", err)84+ }85+ if _, err := status(e, "mcp", "--root", filepath.Join(dir, "missing")); err == nil || !strings.Contains(err.Error(), "--root") {86+ t.Errorf("a missing root: %v", err)87+ }88+}@@ -88,8 +88,26 @@ model service. Don't add them unless the user wants that.
8888 errors; for `diff`, the workbooks differ); 2 the command was used8989 wrongly, with its usage. Every command's `--help` prints its usage.9090 91-When the 012 MCP server (`012 mcp book.012`) is connected, its tools92-(describe, read_range, write_cells with dry_run, evaluate, list_errors)93-do the same with the same checks.91+## The MCP server92+93+To give Claude Code, Codex or Claude Desktop 012's tools, install94+the server once, for every workbook:95+96+```sh97+012 agent --install-mcp codex # or claude-code, or claude-desktop98+012 agent --install-mcp codex --print # show the entry without writing it99+```100+101+It writes the host's entry to run this 012's `mcp` by its full path,102+with no workbook: each tool takes a workbook's `path`, inside the103+host's project folders or the folders `--root dir` adds. Don't name a104+file in the entry (`012 mcp demo.012`), which ties the server to that105+workbook, and don't edit the host's configuration by hand. Restart the106+host afterwards.107+108+When the server is connected, its tools do what the commands do, with109+the same checks: describe without a path lists the workbooks, then110+describe, read_range, write_cells with dry_run, evaluate, list_errors111+and create_workbook, each given the workbook's path.94112 95113 The full reference: https://github.com/FelineStateMachine/012/blob/main/docs/agents/README.md@@ -15,7 +15,7 @@ started with the flags that allow it, as for scripts.
1515 | Page | For |1616 |---|---|1717 | This page | Agents with a shell: the commands, their JSON, and the Claude Code skill |18-| [MCP server](mcp.md) | `012 mcp`, for any MCP host: tools, resources, prompts, and adding it to Claude Code and Claude Desktop |18+| [MCP server](mcp.md) | `012 mcp`, for any MCP host: tools, resources, prompts, views in the chat, and adding it to Claude Code, Codex and Claude Desktop with `012 agent --install-mcp` |1919 | [JSON output](../reference/json.md) | The schemas of what commands write with `--format json`, and what the MCP tools return |2020 2121 ## With a shell@@ -5,57 +5,77 @@ sidebar_position: 2
55 66 # MCP server77 8-`012 mcp book.012` is a [Model Context Protocol](https://modelcontextprotocol.io)9-server on one workbook, over standard input and output, so any MCP host10-(Claude Code, Claude Desktop, editors) can read and change it. It is the11-same code as [the commands](README.md#with-a-shell): reads are12-`012 describe` and `012 get`, and every write is typed through the13-checks `012 set` makes, as one change, saved atomically at14-once.8+`012 mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)9+server over standard input and output, so any MCP host (Claude Code,10+Codex, Claude Desktop, ChatGPT, editors) can read and change the11+workbooks in the folders open to it. One server works on every12+workbook: each tool takes the workbook's `path`. It is the same code as13+[the commands](README.md#with-a-shell): reads are `012 describe` and14+`012 get`, and every write is typed through the checks `012 set` makes,15+as one change, saved atomically at once.1516 1617 ```mermaid1718 flowchart LR18- host[MCP host] -- "stdio: tools, resources, prompts" --> server["012 mcp book.012"]19- server -- "open, read" --> file[(book.012)]19+ host[MCP host] -- "stdio: tools with a path, resources, prompts" --> server["012 mcp"]20+ server -- "open, read" --> file[("budget.012, q3/plan.012, data.csv")]2021 server -- "one change, Batch, atomic save" --> file21- person["012 book.012, 012 set, git"] --> file22+ person["012 budget.012, 012 set, git"] --> file2223 ```2324 24-The file is opened afresh for every call, so what the screen, a script25-or git saved meanwhile is what the next call sees. A file that doesn't26-exist is an empty workbook, saved on the first change.25+A file is opened afresh for every call, so what the screen, a script26+or git saved meanwhile is what the next call sees.2727 2828 ## Adding it to a host2929 30-Claude Code, for one workbook (`--scope project` writes `.mcp.json`31-to commit with the project; the default keeps it to you):30+`012 agent --install-mcp` writes the entry that starts this 012, by its31+absolute path and with no workbook, into the host's configuration:3232 3333 ```sh34-claude mcp add budget -- 012 mcp /path/to/budget.01235-claude mcp add --scope project budget -- 012 mcp budget.01234+012 agent --install-mcp claude-code # Claude Code: ~/.claude.json, as claude mcp add --scope user does35+012 agent --install-mcp codex # Codex: [mcp_servers.012] in ~/.codex/config.toml36+012 agent --install-mcp claude-desktop # Claude Desktop: claude_desktop_config.json37+012 agent --install-mcp codex --print # show the entry, write nothing38+012 agent --install-mcp claude-desktop --root ~/Documents # open these folders to it3639 ```3740 38-Claude Desktop, in `claude_desktop_config.json` (Settings > Developer >39-Edit Config), with the full paths, since Desktop doesn't start in your40-folder:41-42-```json43-{44- "mcpServers": {45- "budget": {46- "command": "/usr/local/bin/012",47- "args": ["mcp", "/Users/me/Documents/budget.012"]48- }49- }50-}51-```52-53-Other hosts take the same command and arguments.41+The file is copied beside itself (`config.toml.bak-20260929-150405`)42+before it changes, the host's other servers and settings stay as they43+were, and running it again when the entry is there changes nothing.44+It names other entries that start 012, such as one made earlier for a45+single workbook, which the new entry replaces. Restart the host to46+start the server; install again after moving 012.47+48+Other hosts take the command and arguments `--print` shows. A file49+named after `mcp` (`012 mcp budget.012`) is the workbook of tools50+called without a path, open wherever it is.51+52+## Which files it opens53+54+A `path` is confined to the folders the host shares as its roots55+(Claude Code shares the project's), or when it shares none, to the56+folders `--root` names (any number of them), or else the working57+directory, or the home folder when that's the file system's root, as58+where Claude Desktop starts servers. A relative path is looked for in59+each folder in turn; an absolute one must be inside one. Hidden files60+and folders and links that lead outside are refused, as61+[`012 serve`](../terminal/ssh.md#security-model) refuses them. Roots are deprecated in62+the protocol's 2026-07-28 version, whose clients the server can't ask,63+so they get `--root`'s folders.64+65+`describe` without a path lists the workbooks in those folders: `.012`66+files, and the files 012 [imports](../files/README.md#import-and-download) (CSV, XLSX,67+JSON, SQLite and the rest), four folders deep, without hidden folders68+or `node_modules`. Imported files are read as the app opens them, and69+are read only: `create_workbook` with `from` makes a `.012` workbook70+of one, to change. A path that doesn't exist is an error naming71+`create_workbook`, which makes a new workbook and never replaces a file.5472 5573 ## Flags5674 5775 | Flag | Does |5876 |---|---|77+| `file.012` | The workbook of tools called without a path |78+| `--root dir` | A folder workbooks may be in, when the host shares no roots; give it again for more |5979 | `--read-only` | Offers only the tools that read |6080 | `--force` | Lets writes change [protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges), as `012 set --force` |6181 | `--notebooks` | Offers `run_notebook_cell`, which runs a [notebook](../nushell/notebooks.md) cell with nu, if the workbook was saved on this computer |@@ -69,18 +89,21 @@ notebook cells from running even with `--notebooks`.
6989 7090 ## Tools7191 72-References are written as in formulas (`B7`, `Q3!A1:C9`,73-`'Q3 plan'!A:A`, a named range, a table, `Sales[Amount]`, a sheet's74-name), and inputs as a person types them, in en-US form. Every write75-takes `dry_run`, which returns the change without making it.92+Every tool but `describe` takes `path`, the workbook, required unless93+the server was started with a file. References are written as in94+formulas (`B7`, `Q3!A1:C9`, `'Q3 plan'!A:A`, a named range, a table,95+`Sales[Amount]`, a sheet's name), and inputs as a person types them, in96+en-US form. Every write takes `dry_run`, which returns the change97+without making it.7698 7799 | Tool | Does |78100 |---|---|79-| `describe` | The workbook's sheets, used ranges, guessed header rows and column names, tables, outputs, charts, pivots, notebook cells and names, as [`012 describe`](../reference/json.md#describe) |101+| `describe` | Without a path, the workbooks open to the server; with one, its sheets, used ranges, guessed header rows and column names, tables, outputs, charts, pivots, notebook cells and names, as [`012 describe`](../reference/json.md#describe) |80102 | `read_range` | A range's values as rows, its formulas by cell, and with `text` each cell as shown |81103 | `evaluate` | A formula's value, computed in the workbook (at `at`, or below the data) without writing it: its text, why it's an error, an array's spill |82104 | `find` | Cells by what they show or, with `in_formulas`, their formulas' text, as Edit > Find |83105 | `list_errors` | The formulas showing errors after recalculating, as `012 recalc` lists them, without saving |106+| `create_workbook` | A new `.012` workbook at `path`, empty or made `from` a file 012 imports or another workbook; it never replaces a file |84107 | `write_cells` | Entries typed into cells as one change, as `012 set` |85108 | `apply_operations` | Several operations as one change: `set`, `clear`, `insert_rows`, `delete_rows`, `insert_columns`, `delete_columns`, `add_sheet`, `rename_sheet`, `delete_sheet`, `define_name`, `sort` |86109 | `sort` | A range's rows sorted by columns, named by letter or header |@@ -96,40 +119,57 @@ with nothing changed. Every result's schema is in
96119 97120 ## Views in the chat98121 99-Hosts that support [MCP Apps](https://github.com/modelcontextprotocol/ext-apps)100-(the `io.modelcontextprotocol/ui` extension) draw what `read_range`101-read and the chart `create_chart` made beside the result, as 012 draws102-them: the [web page](../files/README.md#web-pages) export's grid, with103-its fonts, colors, styles and charts as SVG, in the host's light or104-dark theme. Clicking a cell, or the arrow keys, puts the pointer on it105-and shows what was typed in it.106-107-Both tools name the view's resource, `ui://012/view`108-(`text/html;profile=mcp-app`), in their `_meta`; the host loads it into109-a sandboxed frame, and the result's `_meta` carries what to draw, at110-most 500 rows of a range. Hosts without the extension get the same111-results without it, and the page asks for nothing from the network.122+Hosts that draw tool results as pages show what `read_range` read and123+the chart `create_chart` made beside the result, as 012 draws them: the124+[web page](../files/README.md#web-pages) export's grid with its column125+and row headers, fonts, number formats, styles and charts as SVG, in126+the host's light or dark theme. Clicking a cell, or the arrow keys,127+puts the pointer on it and shows what was typed in it in the formula128+bar. The grid scrolls under its headers in at most 480 pixels inline129+(less when the host has less room, the whole frame full screen), and a130+smaller range takes only its own height.131+132+Two kinds of hosts do this, and the view works in both:133+134+| Host | Finds the page by | Its page | The page reads |135+|---|---|---|---|136+| [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) (Claude, and the extension's other hosts) | the tool's `_meta.ui.resourceUri` | `ui://012/view`, `text/html;profile=mcp-app` | the `ui/initialize` handshake's theme and room, then `ui/notifications/tool-result` |137+| The [OpenAI Apps SDK](https://developers.openai.com/apps-sdk) (ChatGPT, Codex) | the tool's `_meta["openai/outputTemplate"]` | `ui://012/view.skybridge`, `text/html+skybridge` | `window.openai`: `toolResponseMetadata`, `theme`, `maxHeight`, `displayMode`, and `openai:set_globals` events |138+139+What the page draws is in the result's `_meta["o12/view"]`, for every140+client, whether or not it declared the extension: the workbook's name,141+where the range or chart is, and its HTML, at most 200 rows of what142+`read_range` returned. Both kinds of hosts hand a result's `_meta` to143+the page (the Apps SDK as `window.openai.toolResponseMetadata`) and144+keep it from the model, which reads the result itself,145+`structuredContent` and the same JSON as text, with no HTML: a few146+kilobytes for 60 rows. The page asks for nothing from the network and147+calls no tools.112148 113149 ## Resources114150 115151 Hosts that let people attach data to a conversation list these; the116-scheme is `o12` since a URI's scheme starts with a letter, and a sheet's117-name is percent-encoded:152+scheme is `o12` since a URI's scheme starts with a letter. The153+workbook is named as tools name it (relative to the first folder open154+to the server, or absolute, `o12:///Users/me/budget.012/...`), each155+part of its path and a sheet's name percent-encoded:118156 119157 | URI | Is |120158 |---|---|121-| `o12://book/Sheet1!A1:D40` | A range, as `read_range` returns it; each sheet's used range is listed |122-| `o12://book/table/Sales` | A table, header row included; each table is listed |123-| `o12://book/notebook/Notes/2` | A notebook cell's source, and its output as NUON or why it failed; each code cell is listed |159+| `o12://budget.012/Sheet1!A1:D40` | A range, as `read_range` returns it; each sheet's used range is listed |160+| `o12://budget.012/table/Sales` | A table, header row included; each table is listed |161+| `o12://budget.012/notebook/Notes/2` | A notebook cell's source, and its output as NUON or why it failed; each code cell is listed |124162 125-The list follows the workbook: a change that adds a sheet or table126-updates it, and hosts that listen are told.163+The list holds the five workbooks the tools used last (and the file164+named on the command line) and follows them: a change that adds a sheet165+or table updates it, and hosts that listen are told. The templates166+reach any range of any workbook open to the server.127167 128168 ## Prompts129169 130170 | Prompt | Asks |131171 |---|---|132-| `summarize_workbook` | What each sheet holds and what its numbers say, without changing anything |133-| `fix_errors` | The formulas showing errors explained, fixes checked with `evaluate` and previewed before they're made |134-| `add_column` | A column of formulas computing something beside a range (`range`, `column`) |135-| `chart` | The chart that best answers a question about a range (`range`, `question`) |172+| `summarize_workbook` | What each sheet of a workbook (`path`) holds and what its numbers say, without changing anything |173+| `fix_errors` | The formulas showing errors in a workbook (`path`) explained, fixes checked with `evaluate` and previewed before they're made |174+| `add_column` | A column of formulas computing something beside a range (`path`, `range`, `column`) |175+| `chart` | The chart that best answers a question about a range (`path`, `range`, `question`) |@@ -63,6 +63,22 @@ changes handed to the others as their programs would get them
6363 the race detector, with the time an edit takes to reach another6464 session's frame (`sharelive_test.go`).6565 66+The MCP server is tested through the SDK's client over an in-memory67+transport. `TestViewInBrowser` (`internal/mcp`) draws its68+[view](../agents/mcp.md#views-in-the-chat) in Chromium with Playwright69+(`internal/mcp/testdata/view.mjs`), once with `window.openai` set as the70+OpenAI Apps SDK sets it and once behind a page speaking MCP Apps'71+messages, in both themes, and checks the grid, the pointer and formula72+bar, scrolling under sticky headers and the frame's height. It needs73+Node and the `playwright` package with its browser, and is skipped74+without them:75+76+```sh77+npm install --prefix ~/.cache/o12-playwright playwright && npx --prefix ~/.cache/o12-playwright playwright install chromium-headless-shell78+PLAYWRIGHT_DIR=~/.cache/o12-playwright go test ./internal/mcp -run TestViewInBrowser79+VIEW_SHOTS=/tmp/shots PLAYWRIGHT_DIR=~/.cache/o12-playwright go test ./internal/mcp -run TestViewInBrowser # and screenshots80+```81+6682 ## End to end, through libghostty6783 6884 `e2e/` builds the real `012` binary, runs it on a pseudo-terminal and feeds@@ -292,9 +292,11 @@ it the context line says what nu said:
292292 Tab asks `nu --ide-complete` too, after the notebook's own names, so it293293 completes flags, subcommands and paths as well as cells and commands.294294 nu doesn't know the variables a notebook binds, so it's asked about the295-cell with `$name` for each named cell and linked file, `$selection` and296-`$sheet` declared before it; a `$sheet.A1:C9` range reads as one297-variable, and `name =` isn't part of what nu reads.295+cell with `$name` for each name the cells assign and each linked file,296+`$selection` and `$sheet` declared before it; a `$sheet.A1:C9` range297+reads as one variable, and the cell's `name =`s ([Names and298+$name](#names-and-name)) aren't part of what nu reads, so what nu says299+is where it points in the cell.298300 299301 Questions to nu run in the background and never on each key: one300302 process for each question after a pause, at most two at a time, each@@ -346,7 +348,9 @@ printed, and the toolbar how many cells stream; streams run beside the
346348 cells run once, each on its own, without `nu-timeout`. `ii` (or `■`)347349 stops them all, keeping what they printed; running the cell once (`r`)348350 stops its stream first. A stream that ends on its own keeps its output349-as a run does, and one that fails says why.351+as a run does, and one that fails says why. A cell of several statements352+streams its last one's values; the names its earlier lines assign stay353+its own, as a stream hands later cells only its output.350354 351355 The output keeps a stream's last 10,000 values; a sheet it was sent to352356 keeps every row, up to `max-cells`. The rows arrive as the change@@ -355,10 +359,39 @@ undo history.
355359 356360 ## Names and $name357361 358-A cell that starts `name =` gives its output that name; `n` names a359-cell for you. Later cells read the output as `$name`, a nushell360-variable holding the value, and formulas on any sheet as `nu.name`, or361-a column of it as `name[column]`, once it's [sent to a sheet](#send-to-a-sheet):362+A cell holds one statement or several, a line each (or apart by `;`),363+as a nushell script does; a line starting with `|` goes on with the364+pipeline above it. `name =` before a pipeline, on any line, assigns `name` as365+nushell's `let` does, and the cell's later lines read366+it as `$name`. `#` starts a comment to the end of the line, outside a367+string, as in nushell: a commented-out line assigns and reads nothing.368+369+As in nushell and Jupyter, the cell's output is its last statement's370+value, and the names work across cells by one rule:371+372+- **The cell's name is its output's.** When the last statement is373+ an assignment (`name =`), the cell is named `name`; otherwise it has no374+ name until `n` names it, which puts `name =` before its last375+ statement. Other cells read the output as `$name`, and formulas on376+ any sheet as `nu.name`, or a column of it as `name[column]`, once377+ it's [sent to a sheet](#send-to-a-sheet).378+- **Every name assigned goes to later cells.** `$files` in another379+ cell reads the `files` a cell's earlier line assigned, as it was at380+ the end of that cell's run. A cell's name comes first: `$sales` reads381+ the cell named `sales`, and only when no cell is named so, the first382+ cell assigning `sales`.383+- **A cell's own names come first in it.** `$files` after the line384+ assigning `files` reads the cell's own, not another cell's, and isn't385+ a cell it reads.386+387+```nu388+files = ls | where type == file389+$files | where size > 1kb | sort-by size --reverse390+```391+392+This cell has no name (its last line assigns none) and reads no cell;393+its output is the big files, and later cells read `$files` too. Split394+over cells, each named after its output:362395 363396 ```nu364397 files = ls@@ -366,6 +399,12 @@ big = $files | where size > 1kb
366399 $big | get name | str join ", "367400 ```368401 402+The context line says what a cell reads, the names it sets for later403+cells and its own name (`reads $files; sets $big`). A file keeps each404+cell's output, not the other names it assigned: after opening one, a405+cell reading such a name runs the cell assigning it first, as it does406+a cell that hasn't run.407+369408 A name is letters, digits and `_`, not starting with a digit or `__`;370409 `in`, `env`, `nu`, `it`, `selection` and `sheet` are taken. Two cells371410 can't share a name: the second says so and doesn't run until it's@@ -29,12 +29,13 @@ The flags are the options' flags, listed in [Configuration](config.md#options).
2929 | `012 recalc file.012` | Recalculates, saves and lists the cells showing errors: [Scripts](../files/scripts.md#recalc) |3030 | `012 export file.012 out [ref]` | Writes the workbook, a sheet or a range in another format: [Scripts](../files/scripts.md#export) |3131 | `012 describe file.012` | Lists the sheets and what's on them: [Agents](../agents/README.md#with-a-shell) |32-| `012 mcp file.012` | Serves the workbook to MCP hosts over standard input and output: [MCP server](../agents/mcp.md) |32+| `012 mcp [file.012]` | Serves the workbooks in the folders open to it to MCP hosts over standard input and output: [MCP server](../agents/mcp.md) |3333 | `012 diff a.012 b.012` | Lists what changed, cell by cell: [Diff and merge in git](../files/git.md#012-diff) |3434 | `012 diff --textconv file.012` | Writes a workbook as lines, for git's textconv: [Diff and merge in git](../files/git.md#git) |3535 | `012 merge-driver base ours theirs [path]` | Merges theirs into ours, for git: [Diff and merge in git](../files/git.md#merging) |3636 | `012 nu --module`, `012 nu --install-module [--force] [path]` | Prints or installs the nushell module: [Nushell](../nushell/README.md#install-the-sheet-command) |3737 | `012 agent --skill`, `012 agent --install-skill [--force] [dir]` | Prints or installs the Claude Code skill: [Agents](../agents/README.md#the-claude-code-skill) |38+| `012 agent --install-mcp claude-code\|claude-desktop\|codex [--root dir]... [--print]` | Adds `012 mcp` to a host's configuration, or prints the entry: [MCP server](../agents/mcp.md#adding-it-to-a-host) |3839 | `012 config [path\|edit\|default\|themes\|set-key\|delete-key]` | The settings: [Configuration](config.md#commands) |3940 | `012 version [--format text\|json\|nuon]` | Prints the version |4041 @@ -57,7 +58,7 @@ flag.
5758 | `--chart n\|title` | `export` | One chart of the sheet as a web page, by its number or title |5859 | `--notebooks` | `get`, `recalc`, `export`, `describe` | Runs the notebooks' cells first, when the file is trusted here |5960 | `--trust` | with `--notebooks` | Runs the cells of a file saved on another computer |60-| `--read-only`, `--force`, `--notebooks`, `--jev` | `mcp` | The tools offered and what they may do: [MCP server](../agents/mcp.md#flags) |61+| `--root dir`, `--read-only`, `--force`, `--notebooks`, `--jev` | `mcp` | The folders open to it, the tools offered and what they may do: [MCP server](../agents/mcp.md#flags) |6162 | `--jev` | `get`, `recalc`, `export`, `describe` | Asks JEV for the JEV functions' answers first |6263 | `--format text\|json\|nuon` | `diff` | The form of the changes; text by default |6364 | `--color auto\|always\|never` | `diff` | Colors the text; auto colors on a terminal or git's pager unless `NO_COLOR` is set |@@ -129,9 +129,23 @@ are as without it.
129129 ## MCP tools130130 131131 The [MCP server](../agents/mcp.md)'s tools return these, as structured132-content with the same JSON as text beside it. `describe` returns133-[describe's](#describe) schema, `list_errors` recalc's `errors` and134-`circular`.132+content with the same JSON as text beside it. `describe` with a path returns `path` and133+[describe's](#describe) schema, and so does `create_workbook`, of the134+workbook it made; without a path, `describe` returns the folders open135+to the server and the workbooks in them, `more` set when there were136+more than it lists:137+138+```json139+{140+ "roots": ["/Users/me/Documents/budgets"],141+ "workbooks": [142+ {"path": "2026.012", "format": "012", "size": 18422, "modified": "2026-09-29T14:02:11Z"},143+ {"path": "bank/september.csv", "format": "CSV", "size": 5120, "modified": "2026-09-28T09:30:00Z"}144+ ]145+}146+```147+148+`list_errors` returns recalc's `errors` and `circular`.135149 136150 `read_range`, and the range resources:137151 @@ -148,13 +162,16 @@ content with the same JSON as text beside it. `describe` returns
148162 `values` are typed as [`get`](#get) types one cell (blank is `null`, an149163 error its text); `text` comes only when asked for; `truncated` is set150164 when the range held more cells than `max_cells`, and `rows` says how151-many came back.165+many came back. The result's `_meta["o12/view"]` holds what166+[the view](../agents/mcp.md#views-in-the-chat) draws: `title` (the167+workbook's name), `where` and `html`.152168 153169 Writes (`write_cells`, `apply_operations`, `sort`, `filter`) return154170 `{"saved", "changes", "warnings"}` as [`set`](#set) does, `saved` false155171 for a dry run or a change that changed nothing; `create_chart` adds156-`"chart": {"sheet", "number", "title"}` and `create_pivot` `"sheet"`,157-the pivot's.172+`"chart": {"sheet", "number", "title"}`, its view in `_meta` as173+`read_range`'s (with `name`, the chart's data), and `create_pivot`174+`"sheet"`, the pivot's.158175 159176 | Tool | Returns |160177 |---|---|@@ -46,6 +46,35 @@ func TestNotebookWithNu(t *testing.T) {
4646 s.waitFor("[1]:")4747 }4848 49+// A cell of two statements, the second reading the variable the first50+// assigns, runs with the real nu: its output is the second's, and a51+// later cell reads the variable.52+func TestNotebookStatementsWithNu(t *testing.T) {53+ needNu(t)54+ dir := t.TempDir()55+ os.WriteFile(filepath.Join(dir, "big.txt"), []byte(strings.Repeat("x", 3000)), 0o644)56+ os.WriteFile(filepath.Join(dir, "small.txt"), []byte("x"), 0o644)57+ os.Mkdir(filepath.Join(dir, "sub"), 0o755)58+ s := startWith(t, options{dir: dir, startsOn: "EDIT"}, "nu")59+ s.keys("files = ls | where type == file # not sub", "<enter>", "$files | where size > 1kb | sort-by size --reverse", "<shift+enter>")60+ s.waitFor("Out[1]:")61+ s.waitFor("big.txt")62+ s.keys("<enter>", "$files | length", "<shift+enter>")63+ s.waitFor("Out[2]:")64+ scr := s.screen()65+ if strings.Contains(scr, "failed") || strings.Contains(scr, "small.txt") {66+ t.Errorf("screen:\n%s", scr)67+ }68+ s.eventually("the second cell reads 2 files", func() bool {69+ for _, l := range strings.Split(s.screen(), "\n") {70+ if strings.Contains(l, "Out[2]:") && strings.HasSuffix(strings.TrimSpace(l), " 2") {71+ return true72+ }73+ }74+ return false75+ })76+}77+4978 func TestNotebookSendsToSheetWithNu(t *testing.T) {5079 needNu(t)5180 s := startWith(t, options{startsOn: "EDIT"}, "nu")@@ -59,7 +59,7 @@ func TestScreens(t *testing.T) {
5959 if got != string(want) {6060 actual := filepath.Join(t.TempDir(), sc.name+".html")6161 os.WriteFile(actual, []byte(got), 0o644)62- t.Errorf("screen %s changed; if intended, run make screens and review the gallery.\nplain text now:\n%s", sc.name, s.screen())62+ t.Errorf("screen %s changed; if intended, run make screens and review the gallery.\n%s\nplain text now:\n%s", sc.name, firstDiff(string(want), got), s.screen())6363 }6464 })6565 }@@ -194,3 +194,23 @@ h2{font:500 13px ui-monospace,monospace;color:#aaa;margin:0 0 8px}
194194 b.WriteString("</div>\n")195195 return os.WriteFile(filepath.Join(dir, "gallery.html"), []byte(b.String()), 0o644)196196 }197+198+// firstDiff shows the first line where a golden screen's HTML and the199+// screen now part, which the plain text can't show when only a color or200+// style changed.201+func firstDiff(want, got string) string {202+ w, g := strings.Split(want, "\n"), strings.Split(got, "\n")203+ for i := 0; i < len(w) || i < len(g); i++ {204+ var a, b string205+ if i < len(w) {206+ a = w[i]207+ }208+ if i < len(g) {209+ b = g[i]210+ }211+ if a != b {212+ return fmt.Sprintf("first difference, line %d:\n want %s\n got %s", i+1, a, b)213+ }214+ }215+ return "no line differs"216+}@@ -50,7 +50,7 @@ func runNotebook(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, o Noteb
5050 failed = append(failed, fmt.Sprintf("%s cell %d reads itself, through the cells it reads: it doesn't run", s.Name(), i+1))5151 }5252 for n, i := range order {53- if err := runCell(ctx, w, s, cells, i, n+1, o); err != nil {53+ if err := runCell(ctx, w, cells, i, n+1, o); err != nil {5454 return append(failed, fmt.Sprintf("%s cell %d: %s", s.Name(), i+1, err))5555 }5656 }@@ -59,7 +59,7 @@ func runNotebook(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, o Noteb
5959 6060 // runCell runs cell i, the count-th run, keeping its output or why it6161 // failed.62-func runCell(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, cells []notebook.Cell, i, count int, o NotebookOptions) error {62+func runCell(ctx context.Context, w *sheet.Workbook, cells []notebook.Cell, i, count int, o NotebookOptions) error {6363 c := cells[i]6464 out := ¬ebook.Output{Count: count, Source: c.Source}6565 defer func() {@@ -70,14 +70,14 @@ func runCell(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, cells []not
7070 out.Err = why7171 return errors.New(why)7272 }73- job, reads, err := jobFor(w, s, cells, c, o.NuConfig)73+ job, reads, err := jobFor(w, cells, i, o.NuConfig)7474 if err != nil {7575 out.Err = err.Error()7676 return err7777 }7878 out.Reads = reads7979 start := time.Now()80- data, err := nushell.Exec(ctx, o.Runner, job, o.Timeout, maxOutput)80+ data, vars, err := nushell.ExecVars(ctx, o.Runner, job, o.Timeout, maxOutput)8181 out.Took = time.Since(start)8282 if err != nil {8383 out.Err = err.Error()@@ -87,47 +87,31 @@ func runCell(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, cells []not
8787 }8888 return errors.New(out.Err)8989 }90- out.NUON = append([]byte{}, data...)90+ out.NUON, out.Vars = append([]byte{}, data...), vars9191 return nil9292 }9393 94-// jobFor is what running cell c takes: the outputs, linked files and95-// ranges it reads, as NUON, and the outputs it read by name.96-func jobFor(w *sheet.Workbook, s *sheet.Sheet, cells []notebook.Cell, c notebook.Cell, config bool) (nushell.Job, map[string]int, error) {97- pipeline, ranges := notebook.Bind(c.Pipeline())98- job := nushell.Job{Command: pipeline, Tables: map[string][]byte{}, Config: config}99- reads := map[string]int{}100- names := notebook.Names(cells)101- for _, name := range notebook.Refs(pipeline) {102- if j, ok := names[name]; ok {103- o := w.Output(cells[j].ID)104- switch {105- case o == nil || o.Unsaved:106- return job, nil, fmt.Errorf("it reads $%s, which hasn't run", name)107- case o.Failed():108- return job, nil, fmt.Errorf("it reads $%s, which failed", name)109- }110- job.Tables[name], reads[name] = o.NUON, o.Seq111- continue112- }94+// jobFor is what running cell i takes: the outputs, variables, linked95+// files and ranges it reads, as NUON, and the outputs it read by name.96+func jobFor(w *sheet.Workbook, cells []notebook.Cell, i int, config bool) (nushell.Job, map[string]int, error) {97+ p, err := notebook.Prepare(cells, i, w.Output)98+ job := nushell.Job{Command: p.Command, Vars: p.Exports, Tables: p.Tables, Config: config}99+ if err != nil {100+ return job, nil, err101+ }102+ for _, name := range p.Others {113103 if t, r, ok := w.Region(name); ok && r.Linked() && r.Name == name {114- rng, shown := t.RegionTable(r.Name)115- snap := fileio.Snap(t, rng, r.Name)116- if !shown {117- snap = fileio.Snap(t, sheet.Rect{From: r.At, To: r.At}, r.Name)118- snap.Cells = nil119- }120- data, err := encodeNUON(snap)104+ data, err := regionNUON(t, r)121105 if err != nil {122106 return job, nil, err123107 }124108 job.Tables[name] = data125109 }126110 }127- if notebook.ReadsSelection(pipeline) {111+ if p.Selection {128112 return job, nil, errors.New("it reads $selection, which only the screen has")129113 }130- for _, ref := range ranges {114+ for _, ref := range p.Ranges {131115 t, rng, err := rangeRef(w, ref.Ref)132116 if err != nil {133117 return job, nil, err@@ -138,7 +122,22 @@ func jobFor(w *sheet.Workbook, s *sheet.Sheet, cells []notebook.Cell, c notebook
138122 }139123 job.Tables[ref.Var] = data140124 }141- return job, reads, nil125+ return job, p.Reads, nil126+}127+128+// regionNUON is a linked file's table as NUON, read whole whatever its129+// filter shows, as on the screen.130+func regionNUON(t *sheet.Sheet, r sheet.Region) ([]byte, error) {131+ rng, ok := t.RegionTable(r.Name)132+ if !ok {133+ rng = sheet.Rect{From: r.At, To: r.At}134+ }135+ snap := fileio.Snap(t, rng, r.Name)136+ snap.HiddenRows = nil137+ if !ok {138+ snap.Cells = nil139+ }140+ return encodeNUON(snap)142141 }143142 144143 // rangeRef is the sheet and range $sheet.ref reads: of the sheet it@@ -0,0 +1,43 @@
1+package headless2+3+import (4+ "context"5+ "encoding/base64"6+ "testing"7+ "time"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// --notebooks runs a cell of several statements as the screen does: its14+// own variable isn't a cell it reads, and a later cell reads it from its15+// run.16+func TestRunNotebookStatements(t *testing.T) {17+ w := sheet.NewBook()18+ nb, err := w.AddNotebook("Notebook", w.Sheet(0))19+ if err != nil {20+ t.Fatal(err)21+ }22+ src := "files = ls | where type == file # the files\n$files | where size > 1kb"23+ nb.SetNotebookCells("add cells", []notebook.Cell{24+ {Kind: notebook.Code, Source: "$files | length"},25+ {Kind: notebook.Code, Source: src},26+ })27+ cmd, _, _ := notebook.Parse(src).Command()28+ enc := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }29+ nu := fakeNu{30+ cmd: "__out " + enc("[[name]; [b]]") + "\nfiles " + enc("[[name]; [a], [b]]") + "\n",31+ "$files | length": "2",32+ }33+ if failed := RunNotebooks(context.Background(), w, NotebookOptions{Runner: nu, Timeout: time.Second}); len(failed) != 0 {34+ t.Fatalf("failed: %q", failed)35+ }36+ cells := nb.NotebookCells()37+ if o := w.Output(cells[1].ID); o == nil || string(o.NUON) != "[[name]; [b]]" || string(o.Vars["files"]) != "[[name]; [a], [b]]" {38+ t.Errorf("the cell of two statements: %+v", o)39+ }40+ if o := w.Output(cells[0].ID); o == nil || string(o.NUON) != "2" || o.Reads["files"] == 0 {41+ t.Errorf("the cell reading $files: %+v", o)42+ }43+}@@ -41,7 +41,7 @@ func RunCell(ctx context.Context, w *sheet.Workbook, name string, n int, o Noteb
4141 return CellRun{}, fmt.Errorf("%s cell %d is a note, which doesn't run", name, n)4242 }4343 out := CellRun{Notebook: s.Name(), Cell: n, Name: c.Name(), State: "ran"}44- if err := runCell(ctx, w, s, cells, n-1, 1, o); err != nil {44+ if err := runCell(ctx, w, cells, n-1, 1, o); err != nil {4545 out.State, out.Error = "failed", err.Error()4646 return out, nil4747 }@@ -2,11 +2,13 @@ package mcp
22 33 import (44 "context"5+ "fmt"56 "path/filepath"67 "strings"78 "sync"89 910 "github.com/FelineStateMachine/012/internal/diff"11+ "github.com/FelineStateMachine/012/internal/fileio"1012 "github.com/FelineStateMachine/012/internal/headless"1113 "github.com/FelineStateMachine/012/internal/sheet"1214 )@@ -98,3 +100,48 @@ func (b *FileBackend) Change(ctx context.Context, label string, dryRun bool, fn
98100 }99101 return changes, f.Save()100102 }103+104+// ImportBackend is a file in a format 012 imports (CSV, XLSX, SQLite105+// and the rest), read afresh for every call as the app opens it. It is106+// read only: 012 writes a workbook as a .012 file, which create_workbook107+// makes from it.108+type ImportBackend struct {109+ Path string110+ Prepare func(context.Context, *headless.File) error111+}112+113+// Name is the file's name without its folder or extension.114+func (b *ImportBackend) Name() string {115+ return strings.TrimSuffix(filepath.Base(b.Path), filepath.Ext(b.Path))116+}117+118+func (b *ImportBackend) View(ctx context.Context, fn func(*sheet.Workbook) error) error {119+ w, err := importBook(ctx, b.Path)120+ if err != nil {121+ return err122+ }123+ if b.Prepare != nil {124+ if err := b.Prepare(ctx, &headless.File{Path: b.Path, Book: w}); err != nil {125+ return err126+ }127+ }128+ return fn(w)129+}130+131+// Scratch is View: every call imports its own copy.132+func (b *ImportBackend) Scratch(ctx context.Context, fn func(*sheet.Workbook) error) error {133+ return b.View(ctx, fn)134+}135+136+func (b *ImportBackend) Change(context.Context, string, bool, func(*sheet.Workbook) error) ([]diff.Change, error) {137+ return nil, fmt.Errorf("%s is read only: 012 saves workbooks as %s files; create_workbook with from set to it makes one to change", filepath.Base(b.Path), sheet.FileExt)138+}139+140+// importBook reads a file 012 imports as a workbook.141+func importBook(ctx context.Context, path string) (*sheet.Workbook, error) {142+ res, err := fileio.Import(ctx, path, fileio.Options{})143+ if err != nil {144+ return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)145+ }146+ return res.Sheet.Book(), nil147+}@@ -0,0 +1,190 @@
1+package mcp2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "io/fs"8+ "net/url"9+ "os"10+ "path/filepath"11+ "strings"12+13+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"14+15+ "github.com/FelineStateMachine/012/internal/confine"16+ "github.com/FelineStateMachine/012/internal/fileio"17+ "github.com/FelineStateMachine/012/internal/sheet"18+)19+20+// Workbooks are named by path in every tool. A path is confined to the21+// folders the client shares as its roots (the MCP roots capability) or,22+// when it shares none, to Options.Roots, each a confine.Root: a23+// relative path is looked for in each folder in turn, an absolute one24+// must lie inside one, and hidden files and links leading out are25+// refused as confine refuses them. Options.Default, the file named on26+// the command line, is open to tools without a path wherever it is.27+28+// bookIn is the argument naming a tool's workbook.29+type bookIn struct {30+ Path string `json:"path,omitempty" jsonschema:"the workbook: a .012 file, or a file 012 imports (CSV, XLSX, JSON, SQLite...) to read it; relative to the folder open to the server, or absolute inside it"`31+}32+33+// book is a workbook a tool works on: its backend and the path that34+// names it to the client.35+type book struct {36+ Backend37+ path string // absolute, as resolved38+ name string // as tools and resources name it: relative to its root, or absolute39+}40+41+// NewRoots confines paths to dirs, the working directory when there42+// are none.43+func NewRoots(dirs []string) ([]confine.Root, error) {44+ if len(dirs) == 0 {45+ dirs = []string{"."}46+ }47+ out := make([]confine.Root, 0, len(dirs))48+ for _, d := range dirs {49+ r, err := confine.New(d)50+ if err != nil {51+ return nil, fmt.Errorf("--root %s: %w", d, err)52+ }53+ out = append(out, r)54+ }55+ return out, nil56+}57+58+// resolve is the file on disk a tool's path names, within roots.59+func resolve(roots []confine.Root, name string) (string, error) {60+ if u, err := url.Parse(name); err == nil && u.Scheme == "file" {61+ name = filepath.FromSlash(u.Path)62+ }63+ if len(roots) == 0 {64+ return "", errors.New("no folder is open to this server")65+ }66+ if !filepath.IsAbs(name) {67+ found := ""68+ for _, r := range roots {69+ p, err := r.Resolve(name)70+ if err != nil {71+ return "", outside(roots, name, err)72+ }73+ if _, err := os.Stat(p); err == nil {74+ return p, nil75+ }76+ if found == "" {77+ found = p78+ }79+ }80+ return found, nil81+ }82+ real := realPath(name)83+ for _, r := range roots {84+ if rel, err := filepath.Rel(r.Dir(), real); err == nil && filepath.IsLocal(rel) {85+ p, err := r.Resolve(rel)86+ if err != nil {87+ return "", outside(roots, name, err)88+ }89+ return p, nil90+ }91+ }92+ return "", outside(roots, name, confine.ErrOutside)93+}94+95+// outside is why name was refused, err, naming the folders open.96+func outside(roots []confine.Root, name string, err error) error {97+ return fmt.Errorf("%s: %w; the folders open to this server are %s, without their hidden files", name, err, strings.Join(rootDirs(roots), ", "))98+}99+100+// realPath is p with the symbolic links along the part of it that101+// exists followed, so it compares with a root's resolved folder.102+func realPath(p string) string {103+ p = filepath.Clean(p)104+ var rest []string105+ for cur := p; ; cur = filepath.Dir(cur) {106+ if real, err := filepath.EvalSymlinks(cur); err == nil {107+ return filepath.Join(append([]string{real}, rest...)...)108+ }109+ if filepath.Dir(cur) == cur {110+ return p111+ }112+ rest = append([]string{filepath.Base(cur)}, rest...)113+ }114+}115+116+// displayName is how tools and resources name the file at path: relative117+// to the first root when it's in it, else absolute.118+func displayName(roots []confine.Root, path string) string {119+ if len(roots) > 0 {120+ if rel, err := filepath.Rel(roots[0].Dir(), path); err == nil && filepath.IsLocal(rel) {121+ return filepath.ToSlash(rel)122+ }123+ }124+ return path125+}126+127+// isWorkbook reports whether a file's name is a .012 workbook's, and128+// readable whether it's one 012 at least imports.129+func isWorkbook(name string) (own, readable bool) {130+ if strings.EqualFold(filepath.Ext(name), sheet.FileExt) {131+ return true, true132+ }133+ k, ok := fileio.KindOf(name)134+ return false, ok && k.CanImport()135+}136+137+// book is the workbook a tool's path names for the client of req, or138+// the default one when the path is empty.139+func (s *Server) book(ctx context.Context, ss *sdk.ServerSession, name string) (*book, error) {140+ roots := s.roots(ctx, ss)141+ var path string142+ switch {143+ case name == "" && s.opts.Default == "":144+ return nil, errors.New("name the workbook with path; describe without one lists the workbooks open to this server")145+ case name == "" || s.opts.Default != "" && filepath.IsAbs(name) && realPath(name) == s.opts.Default:146+ path = s.opts.Default147+ default:148+ p, err := resolve(roots, name)149+ if err != nil {150+ return nil, err151+ }152+ path = p153+ }154+ if path != s.opts.Default {155+ if _, ok := isWorkbook(path); !ok {156+ return nil, fmt.Errorf("%s isn't a workbook: a %s file, or one 012 imports", name, sheet.FileExt)157+ }158+ if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {159+ return nil, fmt.Errorf("%s doesn't exist: create_workbook makes a workbook, and describe without a path lists them", name)160+ }161+ }162+ return &book{Backend: s.backend(path), path: path, name: displayName(roots, path)}, nil163+}164+165+// backend is the one backend of the file at path, so calls on the same166+// file take turns.167+func (s *Server) backend(path string) Backend {168+ s.mu.Lock()169+ defer s.mu.Unlock()170+ if b, ok := s.backends[path]; ok {171+ return b172+ }173+ var b Backend174+ if own, _ := isWorkbook(path); own || path == s.opts.Default {175+ b = &FileBackend{Path: path, Prepare: s.opts.Prepare, Save: s.opts.Save}176+ } else {177+ b = &ImportBackend{Path: path, Prepare: s.opts.Prepare}178+ }179+ s.backends[path] = b180+ return b181+}182+183+// rootDirs are the roots' folders, for describe.184+func rootDirs(roots []confine.Root) []string {185+ dirs := make([]string, len(roots))186+ for i, r := range roots {187+ dirs[i] = r.Dir()188+ }189+ return dirs190+}@@ -0,0 +1,177 @@
1+package mcp2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "slices"9+ "strings"10+ "testing"11+12+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"13+14+ "github.com/FelineStateMachine/012/internal/confine"15+ "github.com/FelineStateMachine/012/internal/headless"16+)17+18+// folder makes a folder of workbooks: book.012 and sub/data.csv, a19+// hidden .secret.012, a note that isn't a workbook, and a link leading20+// out to another folder's workbook.21+func folder(t *testing.T) (dir, outsideBook string) {22+ t.Helper()23+ dir = t.TempDir()24+ out := t.TempDir()25+ outsideBook = filepath.Join(out, "other.012")26+ for _, p := range []string{"book.012", ".secret.012", outsideBook} {27+ if !filepath.IsAbs(p) {28+ p = filepath.Join(dir, p)29+ }30+ data, _ := os.ReadFile(bookFile(t))31+ if err := os.WriteFile(p, data, 0o644); err != nil {32+ t.Fatal(err)33+ }34+ }35+ if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {36+ t.Fatal(err)37+ }38+ files := map[string]string{"sub/data.csv": "Name,Score\nAda,3\nBo,5\n", "notes.txt": "hello"}39+ for name, text := range files {40+ if err := os.WriteFile(filepath.Join(dir, name), []byte(text), 0o644); err != nil {41+ t.Fatal(err)42+ }43+ }44+ if err := os.Symlink(outsideBook, filepath.Join(dir, "link.012")); err != nil {45+ t.Fatal(err)46+ }47+ return dir, outsideBook48+}49+50+func rootsFor(t *testing.T, dirs ...string) []confine.Root {51+ t.Helper()52+ roots, err := NewRoots(dirs)53+ if err != nil {54+ t.Fatal(err)55+ }56+ return roots57+}58+59+func TestResolve(t *testing.T) {60+ dir, outsideBook := folder(t)61+ roots := rootsFor(t, dir)62+ real := roots[0].Dir()63+ for name, want := range map[string]string{64+ "book.012": real + "/book.012",65+ "sub/data.csv": real + "/sub/data.csv",66+ filepath.Join(dir, "book.012"): real + "/book.012",67+ "file://" + filepath.Join(dir, "x.012"): real + "/x.012",68+ "new.012": real + "/new.012",69+ } {70+ if got, err := resolve(roots, name); err != nil || got != filepath.FromSlash(want) {71+ t.Errorf("%s: %s, %v; want %s", name, got, err, want)72+ }73+ }74+ for _, name := range []string{"../x.012", outsideBook, ".secret.012", "link.012", "/etc/passwd", filepath.Join(dir, "..", "x.012")} {75+ if got, err := resolve(roots, name); !errors.Is(err, confine.ErrOutside) {76+ t.Errorf("%s: %s, %v; want it refused", name, got, err)77+ }78+ }79+ // A relative path is looked for in each root in turn.80+ other := filepath.Dir(outsideBook)81+ if got, err := resolve(rootsFor(t, dir, other), "other.012"); err != nil || filepath.Base(got) != "other.012" {82+ t.Errorf("second root: %s, %v", got, err)83+ }84+}85+86+func TestDescribeListsWorkbooks(t *testing.T) {87+ dir, _ := folder(t)88+ cs := connect(t, Options{Roots: rootsFor(t, dir)})89+ var d describeOut90+ call(t, cs, "describe", map[string]any{}, &d)91+ var paths []string92+ for _, f := range d.Workbooks {93+ paths = append(paths, f.Path+" "+f.Format)94+ }95+ slices.Sort(paths)96+ if !slices.Equal(paths, []string{"book.012 012", "sub/data.csv CSV"}) || len(d.Roots) != 1 {97+ t.Errorf("describe without a path: %v in %v", paths, d.Roots)98+ }99+ call(t, cs, "describe", map[string]any{"path": "sub/data.csv"}, &d)100+ if d.Path != "sub/data.csv" || d.Description == nil || d.Sheets[0].Name != "data" || d.Sheets[0].Columns[1] != "Score" {101+ t.Errorf("describe a CSV: %+v", d)102+ }103+ for tl, err := range cs.Tools(context.Background(), nil) {104+ if err != nil {105+ t.Fatal(err)106+ }107+ list, _ := tl.InputSchema.(map[string]any)["required"].([]any)108+ required := slices.Contains(list, any("path"))109+ if tl.Name != "describe" && !required {110+ t.Errorf("%s doesn't require path", tl.Name)111+ }112+ }113+ if res := call(t, cs, "read_range", map[string]any{"path": "link.012", "ref": "A1"}, nil); !strings.Contains(errorText(res), "outside") {114+ t.Errorf("a link leading out: %q", errorText(res))115+ }116+ if res := call(t, cs, "read_range", map[string]any{"path": "notes.txt", "ref": "A1"}, nil); !strings.Contains(errorText(res), "isn't a workbook") {117+ t.Errorf("a text file: %q", errorText(res))118+ }119+ var r RangeRead120+ call(t, cs, "read_range", map[string]any{"path": filepath.Join(dir, "book.012"), "ref": "B2"}, &r)121+ if string(r.Values[0][0]) != "3" {122+ t.Errorf("absolute path: %+v", r)123+ }124+}125+126+func TestCreateWorkbook(t *testing.T) {127+ dir, _ := folder(t)128+ cs := connect(t, Options{Roots: rootsFor(t, dir)})129+ var d describeOut130+ call(t, cs, "create_workbook", map[string]any{"path": "empty.012"}, &d)131+ if d.Path != "empty.012" || len(d.Sheets) != 1 {132+ t.Errorf("empty: %+v", d)133+ }134+ if _, err := headless.Open(filepath.Join(dir, "empty.012"), false); err != nil {135+ t.Errorf("not saved: %v", err)136+ }137+ call(t, cs, "create_workbook", map[string]any{"path": "sub/scores.012", "from": "sub/data.csv"}, &d)138+ if d.Sheets[0].Used != "A1:B3" {139+ t.Errorf("from a CSV: %+v", d.Sheets)140+ }141+ for args, want := range map[[2]string]string{142+ {"book.012", ""}: "exists",143+ {"x.csv", ""}: "ends in .012",144+ {"../x.012", ""}: "outside",145+ {"y.012", "notes.txt"}: "isn't a file 012 imports",146+ } {147+ res := call(t, cs, "create_workbook", map[string]any{"path": args[0], "from": args[1]}, nil)148+ if !strings.Contains(errorText(res), want) {149+ t.Errorf("%v: %q, want %s", args, errorText(res), want)150+ }151+ }152+ res := call(t, cs, "write_cells", map[string]any{"path": "sub/data.csv", "entries": []map[string]string{{"ref": "A1", "input": "x"}}}, nil)153+ if !strings.Contains(errorText(res), "read only") {154+ t.Errorf("writing a CSV: %q", errorText(res))155+ }156+ var ch Changed157+ call(t, cs, "write_cells", map[string]any{"path": "sub/scores.012", "entries": []map[string]string{{"ref": "C1", "input": "Rank"}}}, &ch)158+ if !ch.Saved {159+ t.Errorf("writing the new workbook: %+v", ch)160+ }161+}162+163+func TestPathRequiredWithoutDefault(t *testing.T) {164+ dir, _ := folder(t)165+ cs := connect(t, Options{Roots: rootsFor(t, dir)})166+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: "read_range", Arguments: map[string]any{"ref": "A1"}})167+ if err == nil && !res.IsError {168+ t.Error("read_range without a path")169+ }170+ // With a default, it's optional and names the default.171+ cs = connect(t, Options{Default: filepath.Join(dir, "book.012")})172+ var r RangeRead173+ call(t, cs, "read_range", map[string]any{"ref": "B2"}, &r)174+ if string(r.Values[0][0]) != "3" {175+ t.Errorf("the default: %+v", r)176+ }177+}@@ -0,0 +1,83 @@
1+package mcp2+3+import (4+ "context"5+ "fmt"6+ "os"7+8+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"9+10+ "github.com/FelineStateMachine/012/internal/confine"11+ "github.com/FelineStateMachine/012/internal/headless"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// create_workbook makes a new .012 file in a folder open to the server:16+// empty, or from a file 012 imports (which the other tools only read),17+// or a copy of another workbook. It never replaces a file.18+19+type createIn struct {20+ Path string `json:"path" jsonschema:"the new workbook's file, ending in .012; relative to the folder open to the server, or absolute inside it"`21+ From string `json:"from,omitempty" jsonschema:"a file to make it from: one 012 imports (CSV, XLSX, JSON, SQLite...) or another workbook; empty for an empty workbook"`22+}23+24+func (s *Server) addCreateTool() {25+ tool(s, &sdk.Tool{Name: "create_workbook", Annotations: writes,26+ Description: "Make a new .012 workbook, empty or from a file 012 imports (CSV, XLSX, JSON, SQLite...), which can then be changed; it describes the workbook made. It won't replace a file that exists."},27+ s.createWorkbook)28+}29+30+func (s *Server) createWorkbook(ctx context.Context, req *sdk.CallToolRequest, in createIn) (*sdk.CallToolResult, describeOut, error) {31+ roots := s.roots(ctx, req.Session)32+ path, err := resolve(roots, in.Path)33+ if err != nil {34+ return nil, describeOut{}, err35+ }36+ if own, _ := isWorkbook(path); !own {37+ return nil, describeOut{}, fmt.Errorf("%s: a workbook's file name ends in %s", in.Path, sheet.FileExt)38+ }39+ if _, err := os.Lstat(path); err == nil {40+ return nil, describeOut{}, fmt.Errorf("%s exists: describe it, or name a new file", in.Path)41+ }42+ f, err := headless.Open(path, true)43+ if err != nil {44+ return nil, describeOut{}, err45+ }46+ if in.From != "" {47+ if f.Book, err = s.source(ctx, roots, in.From); err != nil {48+ return nil, describeOut{}, err49+ }50+ }51+ save := f.Save52+ if s.opts.Save != nil {53+ save = func() error { return s.opts.Save(f) }54+ }55+ if err := save(); err != nil {56+ return nil, describeOut{}, err57+ }58+ b := &book{Backend: s.backend(path), path: path, name: displayName(roots, path)}59+ s.used(ctx, b)60+ d := headless.Describe(f.Book)61+ return nil, describeOut{Path: b.name, Description: &d}, nil62+}63+64+// source is the workbook a new one is made from: a file imported, or65+// another workbook's contents.66+func (s *Server) source(ctx context.Context, roots []confine.Root, name string) (*sheet.Workbook, error) {67+ path, err := resolve(roots, name)68+ if err != nil {69+ return nil, err70+ }71+ own, readable := isWorkbook(path)72+ switch {73+ case own:74+ f, err := headless.Open(path, false)75+ if err != nil {76+ return nil, err77+ }78+ return f.Book, nil79+ case readable:80+ return importBook(ctx, path)81+ }82+ return nil, fmt.Errorf("%s isn't a file 012 imports", name)83+}@@ -0,0 +1,99 @@
1+package mcp2+3+import (4+ "io/fs"5+ "os"6+ "path/filepath"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/confine"10+ "github.com/FelineStateMachine/012/internal/fileio"11+)12+13+// describe without a path lists the workbooks open to the server: the14+// default one, then the .012 files and files 012 imports in each root,15+// not looking in hidden folders or node_modules, nor deeper than16+// listDepth, and stopping at maxListedFiles files or maxListedDirs17+// folders looked in, so a root as wide as a home folder answers18+// quickly.19+const (20+ maxListedFiles = 50021+ maxListedDirs = 200022+ listDepth = 423+)24+25+// listedFile is a workbook describe lists.26+type listedFile struct {27+ Path string `json:"path" jsonschema:"what to pass as path"`28+ Format string `json:"format" jsonschema:"012, or the format it's imported from (read only)"`29+ Size int64 `json:"size"`30+ Modified string `json:"modified" jsonschema:"when it last changed, RFC 3339"`31+}32+33+// lister gathers the files of a listing.34+type lister struct {35+ roots []confine.Root36+ skip string // the default workbook, listed first37+ files []listedFile38+ dirs int39+ stopped bool40+}41+42+// listFiles lists the workbooks in roots, and the default one; more is43+// set when there were more than it lists.44+func (s *Server) listFiles(roots []confine.Root) (files []listedFile, more bool) {45+ l := &lister{roots: roots, skip: s.opts.Default}46+ if d := s.opts.Default; d != "" {47+ if info, err := os.Stat(d); err == nil {48+ l.add(d, info)49+ }50+ }51+ for _, r := range roots {52+ if l.stopped {53+ break54+ }55+ _ = filepath.WalkDir(r.Dir(), func(p string, e fs.DirEntry, err error) error { return l.visit(r, p, e, err) })56+ }57+ return l.files, l.stopped58+}59+60+func (l *lister) add(path string, info fs.FileInfo) {61+ format := "012"62+ if own, _ := isWorkbook(path); !own {63+ k, _ := fileio.KindOf(path)64+ format = k.String()65+ }66+ l.files = append(l.files, listedFile{Path: displayName(l.roots, path), Format: format, Size: info.Size(),67+ Modified: info.ModTime().UTC().Format("2006-01-02T15:04:05Z")})68+}69+70+// visit is the walk's step: a folder to look in or not, or a file to71+// list or not.72+func (l *lister) visit(r confine.Root, p string, e fs.DirEntry, err error) error {73+ if err != nil || p == r.Dir() {74+ return nil75+ }76+ if e.IsDir() {77+ rel, _ := filepath.Rel(r.Dir(), p)78+ l.dirs++79+ switch {80+ case l.dirs > maxListedDirs:81+ l.stopped = true82+ return filepath.SkipAll83+ case strings.HasPrefix(e.Name(), ".") || e.Name() == "node_modules" || strings.Count(rel, string(filepath.Separator)) >= listDepth-1:84+ return filepath.SkipDir85+ }86+ return nil87+ }88+ if _, ok := isWorkbook(p); !ok || !e.Type().IsRegular() || strings.HasPrefix(e.Name(), ".") || p == l.skip {89+ return nil90+ }91+ if len(l.files) == maxListedFiles {92+ l.stopped = true93+ return filepath.SkipAll94+ }95+ if info, err := e.Info(); err == nil {96+ l.add(p, info)97+ }98+ return nil99+}@@ -15,44 +15,57 @@ var prompts = []struct {
1515 text string // with {arg} for each argument1616 }{{1717 prompt: &sdk.Prompt{Name: "summarize_workbook", Title: "Summarize the workbook",18- Description: "What each sheet holds and what its numbers say"},19- text: "Call describe, then read the main ranges it points at with read_range (a few hundred rows at most). " +18+ Description: "What each sheet holds and what its numbers say", Arguments: []*sdk.PromptArgument{pathArg}},19+ text: "Call describe on {path}, then read the main ranges it points at with read_range (a few hundred rows at most). " +2020 "Summarize what each sheet holds, how the sheets relate (formulas between them, tables, pivots, charts), and what the numbers say. Don't change anything.",2121 }, {2222 prompt: &sdk.Prompt{Name: "fix_errors", Title: "Fix formula errors",23- Description: "Find the formulas showing errors, explain them and fix them"},24- text: "Call list_errors. For each formula showing an error, read the cells it reads, and explain the cause. " +23+ Description: "Find the formulas showing errors, explain them and fix them", Arguments: []*sdk.PromptArgument{pathArg}},24+ text: "Call list_errors on {path}. For each formula showing an error, read the cells it reads, and explain the cause. " +2525 "Propose a fix, check it with evaluate at the same cell, and show it with write_cells and dry_run. Make the fixes once they're agreed.",2626 }, {2727 prompt: &sdk.Prompt{Name: "add_column", Title: "Add a computed column",2828 Description: "A new column of formulas beside a table of data",29- Arguments: []*sdk.PromptArgument{29+ Arguments: []*sdk.PromptArgument{pathArg,3030 {Name: "range", Description: "the data, e.g. Sales!A1:F99, or a table's name", Required: true},3131 {Name: "column", Description: "what the new column computes, e.g. profit as revenue minus cost", Required: true},3232 }},33- text: "Add a column to {range} that computes {column}. Call describe and read_range to find the header row and the columns involved. " +33+ text: "Add a column to {range} in {path} that computes {column}. Call describe and read_range to find the header row and the columns involved. " +3434 "Write the header, then the formula for the first data row: check it with evaluate at that cell, then write every row's formula with write_cells (dry_run first), relative references moving with the row. " +3535 "Follow the columns' number formats. Finish with list_errors.",3636 }, {3737 prompt: &sdk.Prompt{Name: "chart", Title: "Chart some data",3838 Description: "A chart that answers a question about a range",39- Arguments: []*sdk.PromptArgument{39+ Arguments: []*sdk.PromptArgument{pathArg,4040 {Name: "range", Description: "the data, e.g. Sales!A1:C13", Required: true},4141 {Name: "question", Description: "what the chart should show, e.g. how sales grew by month"},4242 }},43- text: "Read {range} with read_range and pick the chart that best shows {question}: line or area for change over time, column or bar to compare, pie for shares of a whole, scatter for two measures. " +43+ text: "Read {range} in {path} with read_range and pick the chart that best shows {question}: line or area for change over time, column or bar to compare, pie for shares of a whole, scatter for two measures. " +4444 "If the data needs summarizing first, use create_pivot and chart its result. Create it with create_chart, with a title that says what it shows.",4545 }}4646 47+// pathArg is every prompt's workbook; without it the model finds one.48+var pathArg = &sdk.PromptArgument{Name: "path", Description: "the workbook, e.g. budget.012; describe without a path lists them"}49+50+// unset are what a prompt says for the arguments not given.51+var unset = map[string]string{52+ "{question}": "the data's main point",53+ "{path}": "the workbook (describe without a path lists them)",54+}55+4756 func (s *Server) addPrompts() {4857 for _, p := range prompts {4958 text := p.text5059 s.AddPrompt(p.prompt, func(_ context.Context, req *sdk.GetPromptRequest) (*sdk.GetPromptResult, error) {5160 msg := text5261 for k, v := range req.Params.Arguments {53- msg = strings.ReplaceAll(msg, "{"+k+"}", v)62+ if v != "" {63+ msg = strings.ReplaceAll(msg, "{"+k+"}", v)64+ }65+ }66+ for k, v := range unset {67+ msg = strings.ReplaceAll(msg, k, v)5468 }55- msg = strings.ReplaceAll(msg, "{question}", "the data's main point")5669 return &sdk.GetPromptResult{Description: req.Params.Name,5770 Messages: []*sdk.PromptMessage{{Role: "user", Content: &sdk.TextContent{Text: msg}}}}, nil5871 })@@ -2,6 +2,9 @@ package mcp
22 33 import (44 "context"5+ "slices"6+7+ "github.com/google/jsonschema-go/jsonschema"58 69 sdk "github.com/modelcontextprotocol/go-sdk/mcp"710 @@ -9,27 +12,44 @@ import (
912 "github.com/FelineStateMachine/012/internal/sheet"1013 )1114 12-// The tools that read the workbook, or compute on a copy thrown away.15+// The tools that read a workbook, or compute on a copy thrown away.16+17+type describeIn struct{ bookIn }1318 14-type describeIn struct{}19+// describeOut is a workbook described, or with no path the workbooks20+// open to the server.21+type describeOut struct {22+ Path string `json:"path,omitempty" jsonschema:"the workbook described"`23+ *headless.Description24+ Roots []string `json:"roots,omitempty" jsonschema:"without a path: the folders open to the server"`25+ Workbooks []listedFile `json:"workbooks,omitempty" jsonschema:"without a path: the workbooks in them"`26+ More bool `json:"more,omitempty" jsonschema:"there were more workbooks than listed"`27+}1528 1629 type readIn struct {30+ bookIn1731 Ref string `json:"ref" jsonschema:"a cell, range, named range, table or sheet, as formulas write it: Q3!A1:D40, Sales, Sales[Amount], Q3"`1832 Text bool `json:"text,omitempty" jsonschema:"also return each cell as the sheet shows it ($1,200.00, 9/29/2026)"`1933 Max int `json:"max_cells,omitempty" jsonschema:"the most cells to return, whole rows at a time; 10000 when 0"`2034 }2135 36+// RangeRead is headless.Range under another name, which a struct37+// embedding it can have beside its own field Range.38+type RangeRead = headless.Range39+2240 type evaluateIn struct {41+ bookIn2342 Formula string `json:"formula" jsonschema:"the formula, with or without its =: =SUMIF(B:B, \"North\", C:C)"`2443 At string `json:"at,omitempty" jsonschema:"the cell it's computed in, which relative references are relative to; below the shown sheet's data when empty"`2544 }2645 2746 type findIn struct {47+ bookIn2848 Query string `json:"query" jsonschema:"what to look for"`2949 headless.FindOptions3050 }3151 32-type errorsIn struct{}52+type errorsIn struct{ bookIn }3353 3454 // errorsOut lists the formulas showing errors.3555 type errorsOut struct {@@ -42,22 +62,17 @@ var readOnly = &sdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, Op
4262 func ptr[T any](v T) *T { return &v }4363 4464 func (s *Server) addReadTools() {45- tool(s, &sdk.Tool{Name: "describe", Annotations: readOnly,46- Description: "What the workbook holds: each sheet's used range, guessed header row and column names, tables, notebook outputs and linked files, charts, pivot tables, notebook cells, and the named ranges. Call it first."},47- func(ctx context.Context, _ *sdk.CallToolRequest, _ describeIn) (*sdk.CallToolResult, headless.Description, error) {48- var d headless.Description49- err := s.b.View(ctx, func(w *sheet.Workbook) error { d = headless.Describe(w); return nil })50- return nil, d, err51- })52- tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: s.viewMeta(),53- Description: "The values of a range as rows (numbers, strings, booleans, null for blank, dates as ISO 8601, errors as #DIV/0!), with the formulas in it by cell."},65+ tool(s, &sdk.Tool{Name: "describe", Annotations: readOnly, OutputSchema: describeSchema(),66+ Description: "Without a path: the workbooks (.012 files, and files 012 imports) in the folders open to the server. With one: what the workbook holds, each sheet's used range, guessed header row and column names, tables, notebook outputs and linked files, charts, pivot tables, notebook cells, and the named ranges. Call it first."},67+ s.describe)68+ tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: viewMeta("Reading the range", "Read the range"),69+ Description: "The values of a range as rows (numbers, strings, booleans, null for blank, dates as ISO 8601, errors as #DIV/0!), with the formulas in it by cell. Hosts that draw views show it as 012's grid."},5470 s.readRange)5571 tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,5672 Description: "Compute a formula in the workbook without writing it: its value, as the cell would show it, why it's an error, and an array's spilled values."},57- func(ctx context.Context, _ *sdk.CallToolRequest, in evaluateIn) (*sdk.CallToolResult, headless.Evaluated, error) {73+ func(ctx context.Context, req *sdk.CallToolRequest, in evaluateIn) (*sdk.CallToolResult, headless.Evaluated, error) {5874 var out headless.Evaluated59- err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {60- var err error75+ err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) (err error) {6176 out, err = headless.Evaluate(w, in.Formula, in.At)6277 return err6378 })@@ -65,10 +80,9 @@ func (s *Server) addReadTools() {
6580 })6681 tool(s, &sdk.Tool{Name: "find", Annotations: readOnly,6782 Description: "Find cells by what they show (or their formulas' text), as Edit > Find does: the cells, their text and what was typed in them."},68- func(ctx context.Context, _ *sdk.CallToolRequest, in findIn) (*sdk.CallToolResult, headless.Found, error) {83+ func(ctx context.Context, req *sdk.CallToolRequest, in findIn) (*sdk.CallToolResult, headless.Found, error) {6984 var out headless.Found70- err := s.b.View(ctx, func(w *sheet.Workbook) error {71- var err error85+ err := s.view(ctx, req, in.Path, func(_ *book, w *sheet.Workbook) (err error) {7286 out, err = headless.Find(w, in.Query, in.FindOptions)7387 return err7488 })@@ -76,9 +90,9 @@ func (s *Server) addReadTools() {
7690 })7791 tool(s, &sdk.Tool{Name: "list_errors", Annotations: readOnly,7892 Description: "The formulas showing errors after recalculating everything, with what the screen says about each (as 012 recalc lists them), without saving."},79- func(ctx context.Context, _ *sdk.CallToolRequest, _ errorsIn) (*sdk.CallToolResult, errorsOut, error) {93+ func(ctx context.Context, req *sdk.CallToolRequest, in errorsIn) (*sdk.CallToolResult, errorsOut, error) {8094 var out errorsOut81- err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {95+ err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) error {8296 problems, circular := headless.Recalc(w)8397 out = errorsOut{Errors: headless.Problems(problems), Circular: circular}8498 return nil@@ -87,17 +101,79 @@ func (s *Server) addReadTools() {
87101 })88102 }89103 90-func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, headless.Range, error) {91- var out headless.Range92- var res *sdk.CallToolResult93- err := s.b.View(ctx, func(w *sheet.Workbook) error {104+// describeSchema is describeOut's, whose workbook's sheets and names105+// are there only with a path.106+func describeSchema() *jsonschema.Schema {107+ schema, err := jsonschema.For[describeOut](rawSchemas)108+ if err != nil {109+ panic(err)110+ }111+ schema.Required = slices.DeleteFunc(schema.Required, func(p string) bool { return p == "sheets" || p == "names" })112+ return schema113+}114+115+func (s *Server) describe(ctx context.Context, req *sdk.CallToolRequest, in describeIn) (*sdk.CallToolResult, describeOut, error) {116+ if in.Path == "" && s.opts.Default == "" {117+ roots := s.roots(ctx, req.Session)118+ files, more := s.listFiles(roots)119+ return nil, describeOut{Roots: rootDirs(roots), Workbooks: files, More: more}, nil120+ }121+ var out describeOut122+ err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {123+ d := headless.Describe(w)124+ out = describeOut{Path: b.name, Description: &d}125+ return nil126+ })127+ return nil, out, err128+}129+130+func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, RangeRead, error) {131+ var out RangeRead132+ var view *View133+ err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {94134 t, err := headless.Resolve(w, in.Ref)95135 if err != nil {96136 return err97137 }98138 out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})99- res = s.rangeView(req, t)139+ view = rangeView(b, t, out.Rows)100140 return nil101141 })102- return res, out, err142+ if err != nil {143+ return nil, RangeRead{}, err144+ }145+ return viewResult(view), out, nil146+}147+148+// view runs fn on the workbook path names, as it is.149+func (s *Server) view(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*book, *sheet.Workbook) error) error {150+ b, err := s.open(ctx, req, path)151+ if err != nil {152+ return err153+ }154+ return b.View(ctx, func(w *sheet.Workbook) error { return fn(b, w) })155+}156+157+// scratch runs fn on a copy of the workbook path names, thrown away.158+func (s *Server) scratch(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*sheet.Workbook) error) error {159+ b, err := s.open(ctx, req, path)160+ if err != nil {161+ return err162+ }163+ return b.Scratch(ctx, fn)164+}165+166+// open is the workbook path names for req's client, its resources167+// listed from now on.168+func (s *Server) open(ctx context.Context, req *sdk.CallToolRequest, path string) (*book, error) {169+ var ss *sdk.ServerSession170+ if req != nil {171+ ss = req.Session172+ }173+ b, err := s.book(ctx, ss, path)174+ if err != nil {175+ return nil, err176+ }177+ s.used(ctx, b)178+ return b, nil103179 }@@ -14,41 +14,72 @@ import (
1414 "github.com/FelineStateMachine/012/internal/sheet"1515 )1616 17-// Resources are the workbook's data by address, for hosts that let18-// people attach them to a conversation:17+// Resources are workbooks' data by address, for hosts that let people18+// attach them to a conversation:1919 //20-// o12://book/Sheet1!A1:D40 a range, as read_range returns it (JSON)21-// o12://book/table/Sales a table, header row included (JSON)22-// o12://book/notebook/Notes/2 a notebook cell: its source and output20+// o12://budget.012/Sheet1!A1:D40 a range, as read_range returns it (JSON)21+// o12://budget.012/table/Sales a table, header row included (JSON)22+// o12://budget.012/notebook/Notes/2 a notebook cell: its source and output2323 //24-// Sheet names are percent-encoded. Each sheet's used range, each table25-// and each code cell are listed; the templates reach any range. The26-// scheme is o12 because a URI's scheme starts with a letter, and the27-// workbook is the authority, book, so a range's colon is in the path.24+// The workbook is named as tools name it, relative to the folder open25+// to the server (or absolute, o12:///Users/me/budget.012/...), each26+// part of it and the sheet's name percent-encoded. The scheme is o1227+// because a URI's scheme starts with a letter. Each sheet's used range,28+// table and code cell of the workbooks used lately is listed; the29+// templates reach any range of any workbook open to the server.2830 const (29- scheme = "o12://book/"30- tablePrefix = scheme + "table/"31- notebookPrefix = scheme + "notebook/"32- maxListed = 20031+ scheme = "o12://"32+ maxListed = 20033+ maxRecent = 5 // workbooks whose resources are listed34+ tableSeg = "table"35+ notebookSeg = "notebook"3336 )3437 3538 func (s *Server) addResources() {36- s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{sheet}!{+range}", Name: "range", MIMEType: "application/json",37- Description: "A range of a sheet as rows of values, as read_range returns it; the sheet's name percent-encoded"}, s.readResource)38- s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: tablePrefix + "{name}", Name: "table", MIMEType: "application/json",39+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{+book}/{sheet}!{+range}", Name: "range", MIMEType: "application/json",40+ Description: "A range of a sheet as rows of values, as read_range returns it; the workbook's path and the sheet's name percent-encoded"}, s.readResource)41+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{+book}/table/{name}", Name: "table", MIMEType: "application/json",3942 Description: "A named table, header row included"}, s.readResource)40- s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: notebookPrefix + "{sheet}/{cell}", Name: "notebook cell", MIMEType: "text/plain",43+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{+book}/notebook/{sheet}/{cell}", Name: "notebook cell", MIMEType: "text/plain",4144 Description: "A notebook cell's source, and its output as NUON or why it failed"}, s.readResource)4245 }4346 44-// refreshResources lists the workbook's resources as it stands,45-// telling clients when the list changes.47+// used notes that a tool used b, listing its resources from now on.48+func (s *Server) used(ctx context.Context, b *book) {49+ s.mu.Lock()50+ known := false51+ for i, r := range s.recent {52+ if r.path == b.path {53+ s.recent = append(s.recent[:i], s.recent[i+1:]...)54+ known = true55+ break56+ }57+ }58+ s.recent = append(s.recent, b)59+ if len(s.recent) > maxRecent {60+ s.recent = s.recent[len(s.recent)-maxRecent:]61+ }62+ s.mu.Unlock()63+ if !known {64+ s.refreshResources(ctx)65+ }66+}67+68+// refreshResources lists the resources of the workbooks used lately as69+// they stand, telling clients when the list changes.4670 func (s *Server) refreshResources(ctx context.Context) {71+ s.mu.Lock()72+ recent := append([]*book(nil), s.recent...)73+ s.mu.Unlock()4774 var want []sdk.Resource48- _ = s.b.View(ctx, func(w *sheet.Workbook) error {49- want = listResources(w)50- return nil51- })75+ for i := len(recent) - 1; i >= 0; i-- {76+ b := recent[i]77+ _ = b.View(ctx, func(w *sheet.Workbook) error {78+ want = append(want, listResources(w, b.name)...)79+ return nil80+ })81+ }82+ want = want[:min(len(want), maxListed)]5283 s.mu.Lock()5384 defer s.mu.Unlock()5485 keep := map[string]bool{}@@ -71,46 +102,62 @@ func (s *Server) refreshResources(ctx context.Context) {
71102 s.resources = keep72103 }73104 74-func listResources(w *sheet.Workbook) []sdk.Resource {105+// listResources are a workbook's resources: each sheet's used range,106+// table and notebook code cell.107+func listResources(w *sheet.Workbook, name string) []sdk.Resource {75108 var out []sdk.Resource76- add := func(r sdk.Resource) {77- if len(out) < maxListed {78- out = append(out, r)79- }80- }109+ base := bookURI(name)81110 for _, sh := range w.Sheets() {82111 if sh.IsNotebook() {83112 for i, c := range sh.NotebookCells() {84113 if c.Kind == notebook.Code {85- add(sdk.Resource{URI: fmt.Sprintf("%s%s/%d", notebookPrefix, escape(sh.Name()), i+1), Name: fmt.Sprintf("%s cell %d", sh.Name(), i+1),86- MIMEType: "text/plain", Description: firstLine(c.Source)})114+ out = append(out, sdk.Resource{URI: fmt.Sprintf("%s/%s/%s/%d", base, notebookSeg, escape(sh.Name()), i+1),115+ Name: fmt.Sprintf("%s %s cell %d", name, sh.Name(), i+1), MIMEType: "text/plain", Description: firstLine(c.Source)})87116 }88117 }89118 continue90119 }91120 if used, ok := sh.UsedRange(); ok {92- add(sdk.Resource{URI: rangeURI(sh.Name(), used), Name: sh.Name(), Title: sh.Name() + " " + used.String(),93- MIMEType: "application/json", Description: "The used range of " + sh.Name()})121+ out = append(out, sdk.Resource{URI: rangeURI(name, sh.Name(), used), Name: name + " " + sh.Name(), Title: sh.Name() + " " + used.String(),122+ MIMEType: "application/json", Description: "The used range of " + sh.Name() + " in " + name})94123 }95124 for _, t := range sh.Tables() {96- add(sdk.Resource{URI: tablePrefix + escape(t.Name), Name: "table " + t.Name, MIMEType: "application/json",125+ out = append(out, sdk.Resource{URI: base + "/" + tableSeg + "/" + escape(t.Name), Name: name + " table " + t.Name, MIMEType: "application/json",97126 Description: "Table " + t.Name + " on " + sh.Name() + ", " + t.Range.String() + ": " + strings.Join(t.Cols, ", ")})98127 }99128 }100129 return out101130 }102131 103-// rangeURI is the resource of a range of a sheet.104-func rangeURI(sheetName string, r sheet.Rect) string {105- return scheme + escape(sheetName) + "!" + r.String()132+// bookURI is where a workbook's resources start: the scheme and its133+// path, each part percent-encoded.134+func bookURI(name string) string {135+ parts := strings.Split(name, "/")136+ for i, p := range parts {137+ parts[i] = escape(p)138+ }139+ return scheme + strings.Join(parts, "/")140+}141+142+// rangeURI is the resource of a range of a sheet of a workbook.143+func rangeURI(book, sheetName string, r sheet.Rect) string {144+ return bookURI(book) + "/" + escape(sheetName) + "!" + r.String()106145 }107146 108147 func (s *Server) readResource(ctx context.Context, req *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {109148 uri := req.Params.URI149+ name, rest, err := splitURI(uri)150+ if err != nil {151+ return nil, err152+ }153+ b, err := s.book(ctx, req.Session, name)154+ if err != nil {155+ return nil, err156+ }110157 var text, mime string111- err := s.b.View(ctx, func(w *sheet.Workbook) error {158+ err = b.View(ctx, func(w *sheet.Workbook) error {112159 var err error113- text, mime, err = resourceText(w, uri)160+ text, mime, err = resourceText(w, rest)114161 return err115162 })116163 if err != nil {@@ -119,30 +166,52 @@ func (s *Server) readResource(ctx context.Context, req *sdk.ReadResourceRequest)
119166 return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: uri, MIMEType: mime, Text: text}}}, nil120167 }121168 122-// resourceText is what a resource's URI names, and its type.123-func resourceText(w *sheet.Workbook, uri string) (string, string, error) {169+// splitURI is the workbook a resource's URI names, and the rest of it170+// (what's in the workbook) as its last parts: Sheet1!A1:D40,171+// table/Sales or notebook/Notes/2.172+func splitURI(uri string) (book string, rest []string, err error) {173+ parts := strings.Split(strings.TrimPrefix(uri, scheme), "/")174+ n := len(parts)124175 switch {125- case strings.HasPrefix(uri, notebookPrefix):126- return notebookCellText(w, strings.TrimPrefix(uri, notebookPrefix))127- case strings.HasPrefix(uri, tablePrefix):128- name, err := unescape(strings.TrimPrefix(uri, tablePrefix))129- if err != nil {130- return "", "", err131- }132- return rangeJSON(w, name)133- case strings.HasPrefix(uri, scheme):134- rest := strings.TrimPrefix(uri, scheme)135- i := strings.LastIndex(rest, "!")136- if i < 0 {137- return "", "", sdk.ResourceNotFoundError(uri)176+ case !strings.HasPrefix(uri, scheme) || n < 2:177+ return "", nil, sdk.ResourceNotFoundError(uri)178+ case strings.Contains(parts[n-1], "!"):179+ rest = parts[n-1:]180+ case n >= 4 && parts[n-3] == notebookSeg:181+ rest = parts[n-3:]182+ case n >= 3 && parts[n-2] == tableSeg:183+ rest = parts[n-2:]184+ default:185+ return "", nil, sdk.ResourceNotFoundError(uri)186+ }187+ path := parts[:n-len(rest)]188+ for i, p := range path {189+ if path[i], err = unescape(p); err != nil {190+ return "", nil, err138191 }139- name, err := unescape(rest[:i])192+ }193+ return strings.Join(path, "/"), rest, nil194+}195+196+// resourceText is what the rest of a resource's URI names in a197+// workbook, and its type.198+func resourceText(w *sheet.Workbook, rest []string) (string, string, error) {199+ switch {200+ case rest[0] == notebookSeg:201+ return notebookCellText(w, rest[1], rest[2])202+ case rest[0] == tableSeg:203+ name, err := unescape(rest[1])140204 if err != nil {141205 return "", "", err142206 }143- return rangeJSON(w, sheet.QuoteSheet(name)+"!"+rest[i+1:])207+ return rangeJSON(w, name)144208 }145- return "", "", sdk.ResourceNotFoundError(uri)209+ sh, rng, _ := strings.Cut(rest[0], "!")210+ name, err := unescape(sh)211+ if err != nil {212+ return "", "", err213+ }214+ return rangeJSON(w, sheet.QuoteSheet(name)+"!"+rng)146215 }147216 148217 func rangeJSON(w *sheet.Workbook, ref string) (string, string, error) {@@ -155,20 +224,16 @@ func rangeJSON(w *sheet.Workbook, ref string) (string, string, error) {
155224 }156225 157226 // notebookCellText is a cell's source, then its output or why it158-// failed; path is sheet/cell.159-func notebookCellText(w *sheet.Workbook, path string) (string, string, error) {160- i := strings.LastIndex(path, "/")161- if i < 0 {162- return "", "", fmt.Errorf("a notebook cell's resource is %snotebook/cell", notebookPrefix)163- }164- name, err := unescape(path[:i])227+// failed.228+func notebookCellText(w *sheet.Workbook, sheetPart, cell string) (string, string, error) {229+ name, err := unescape(sheetPart)165230 if err != nil {166231 return "", "", err167232 }168- n, err := strconv.Atoi(path[i+1:])233+ n, err := strconv.Atoi(cell)169234 s := w.Lookup(name)170235 if s == nil || !s.IsNotebook() || err != nil || n < 1 || n > len(s.NotebookCells()) {171- return "", "", fmt.Errorf("no cell %s of a notebook %q", path[i+1:], name)236+ return "", "", fmt.Errorf("no cell %s of a notebook %q", cell, name)172237 }173238 c := s.NotebookCells()[n-1]174239 var b strings.Builder@@ -191,7 +256,7 @@ func firstLine(s string) string {
191256 }192257 193258 // escape percent-encodes all but URI's unreserved characters, so a194-// sheet's name fits a template's simple variable.259+// sheet's name or a part of a path fits a template's simple variable.195260 func escape(s string) string {196261 var b strings.Builder197262 for _, c := range []byte(s) {@@ -0,0 +1,77 @@
1+// The roots capability is deprecated (SEP-2577), but every protocol2+// version keeps it for a year after 2026-07-28 and hosts in use share3+// their project folders through it (Claude Code among them), so the4+// server follows a client's roots where the client offers them, and5+// Options.Roots otherwise. Clients of 2026-07-28 and later can't be6+// asked in the middle of a call, so they get Options.Roots.7+//8+//lint:file-ignore SA1019 roots are deprecated, and followed for the hosts that still share them9+10+package mcp11+12+import (13+ "context"14+ "net/url"15+ "path/filepath"16+17+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"18+19+ "github.com/FelineStateMachine/012/internal/confine"20+)21+22+// roots are the folders workbooks may be in for the client of ss: its23+// roots when it shares any, else the server's.24+func (s *Server) roots(ctx context.Context, ss *sdk.ServerSession) []confine.Root {25+ if ss == nil {26+ return s.fallback27+ }28+ s.mu.Lock()29+ cached, ok := s.clientRoots[ss]30+ s.mu.Unlock()31+ if ok {32+ return cached33+ }34+ var roots []confine.Root35+ if p := ss.InitializeParams(); p != nil && p.Capabilities != nil && p.Capabilities.RootsV2 != nil {36+ if res, err := ss.ListRoots(ctx, nil); err == nil {37+ roots = rootsOf(res.Roots)38+ }39+ }40+ if len(roots) == 0 {41+ roots = s.fallback42+ }43+ s.mu.Lock()44+ s.clientRoots[ss] = roots45+ s.mu.Unlock()46+ return roots47+}48+49+// forgetRoots drops the roots kept for a client, which said they50+// changed.51+func (s *Server) forgetRoots(_ context.Context, req *sdk.RootsListChangedRequest) {52+ s.mu.Lock()53+ delete(s.clientRoots, req.Session)54+ s.mu.Unlock()55+}56+57+// serverOptions are the SDK's options for the server: its instructions,58+// its MCP Apps extension, and forgetRoots.59+func (s *Server) serverOptions() *sdk.ServerOptions {60+ return &sdk.ServerOptions{Instructions: instructions, RootsListChangedHandler: s.forgetRoots,61+ Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}}62+}63+64+// rootsOf are the folders of a client's file:// roots that exist.65+func rootsOf(list []*sdk.Root) []confine.Root {66+ var out []confine.Root67+ for _, r := range list {68+ u, err := url.Parse(r.URI)69+ if err != nil || u.Scheme != "file" {70+ continue71+ }72+ if root, err := confine.New(filepath.FromSlash(u.Path)); err == nil {73+ out = append(out, root)74+ }75+ }76+ return out77+}@@ -0,0 +1,37 @@
1+//lint:file-ignore SA1019 roots are deprecated, and followed for the hosts that still share them2+3+package mcp4+5+import (6+ "path/filepath"7+ "strings"8+ "testing"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+)12+13+// TestClientRoots checks the client's roots win over the server's, and14+// follow the client's changes.15+func TestClientRoots(t *testing.T) {16+ dir, outsideBook := folder(t)17+ c := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil)18+ c.AddRoots(&sdk.Root{URI: "file://" + filepath.ToSlash(filepath.Dir(outsideBook))})19+ // Servers ask for roots only of clients of protocols up to 2025-11-25.20+ cs := connectClient(t, Options{Roots: rootsFor(t, dir)}, c, &sdk.ClientSessionOptions{ProtocolVersion: "2025-11-25"})21+ if res := call(t, cs, "describe", map[string]any{"path": filepath.Join(dir, "book.012")}, nil); !strings.Contains(errorText(res), "outside") {22+ t.Errorf("the server's root with the client's given: %q", errorText(res))23+ }24+ var d describeOut25+ call(t, cs, "describe", map[string]any{"path": "other.012"}, &d)26+ if d.Path != "other.012" {27+ t.Errorf("the client's root: %+v", d)28+ }29+ c.RemoveRoots("file://" + filepath.ToSlash(filepath.Dir(outsideBook)))30+ c.AddRoots(&sdk.Root{URI: "file://" + filepath.ToSlash(dir)})31+ for range 100 {32+ if res := call(t, cs, "describe", map[string]any{"path": "book.012"}, &d); !res.IsError {33+ return34+ }35+ }36+ t.Error("the client's new roots weren't followed")37+}@@ -1,30 +1,33 @@
1-// Package mcp is 012 mcp: a Model Context Protocol server on one2-// workbook (see docs/agents/mcp.md). Its tools, resources and prompts3-// are a thin layer over internal/headless, the same code 012 get, set4-// and describe run, so agents' changes go through the workbook's one5-// mutation path (Batch) and the same checks as a person's, and are6-// shown by 012 diff. The workbook stands behind a Backend: a file here,7-// and for live mode a session.1+// Package mcp is 012 mcp: a Model Context Protocol server on the2+// workbooks in the folders open to it (see docs/agents/mcp.md). Its3+// tools, resources and prompts are a thin layer over internal/headless,4+// the same code 012 get, set and describe run, so agents' changes go5+// through the workbook's one mutation path (Batch) and the same checks6+// as a person's, and are shown by 012 diff. Each workbook stands behind7+// a Backend: a file here, and for live mode a session.88 package mcp99 1010 import (1111 "context"1212 "encoding/json"1313 "reflect"14+ "slices"1415 "sync"1516 1617 "github.com/google/jsonschema-go/jsonschema"1718 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1819 20+ "github.com/FelineStateMachine/012/internal/confine"1921 "github.com/FelineStateMachine/012/internal/headless"2022 "github.com/FelineStateMachine/012/internal/sheet"2123 )2224 23-// Options say what the server may do beyond reading and writing cells.25+// Options say where the server's workbooks are and what it may do26+// beyond reading and writing cells.2427 type Options struct {2528 // Version is 012's, which the server reports.2629 Version string27- // ReadOnly leaves out every tool that changes the workbook.30+ // ReadOnly leaves out every tool that changes a workbook.2831 ReadOnly bool2932 // Force lets writes change protected ranges, as 012 set --force.3033 Force bool@@ -33,24 +36,40 @@ type Options struct {
3336 // workbook's cells may not run here (trust, the shell setting), and3437 // may mark it trusted, as the command line's --trust does.3538 Notebooks *headless.NotebookOptions36- MayRun func(*sheet.Workbook) error39+ MayRun func(path string, w *sheet.Workbook) error40+ // Roots are the folders workbooks may be in when the client shares41+ // none of its own (NewRoots makes them).42+ Roots []confine.Root43+ // Default, an absolute path, is the workbook of tools called without44+ // a path, open wherever it is; "" makes the path required.45+ Default string46+ // Prepare, when set, runs on every workbook once opened (answering47+ // JEV functions); Save writes a changed one, nil being File.Save.48+ Prepare func(context.Context, *headless.File) error49+ Save func(*headless.File) error3750 }3851 39-// Server is the MCP server on a backend.52+// Server is the MCP server on the workbooks open to it.4053 type Server struct {4154 *sdk.Server42- b Backend43- opts Options55+ opts Options56+ fallback []confine.Root // Options.Roots4457 45- mu sync.Mutex46- resources map[string]bool // the URIs listed now58+ mu sync.Mutex59+ backends map[string]Backend // by absolute path60+ clientRoots map[*sdk.ServerSession][]confine.Root61+ resources map[string]bool // the URIs listed now62+ recent []*book // the workbooks whose resources are listed, most recent last4763 }4864 4965 // New makes the server with its tools, resources and prompts.50-func New(b Backend, o Options) *Server {51- s := &Server{b: b, opts: o, resources: map[string]bool{}}52- s.Server = sdk.NewServer(&sdk.Implementation{Name: "012", Title: "012 spreadsheet", Version: orDevel(o.Version)},53- &sdk.ServerOptions{Instructions: instructions, Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}})66+func New(o Options) *Server {67+ if o.Default != "" {68+ o.Default = realPath(o.Default)69+ }70+ s := &Server{opts: o, fallback: o.Roots, backends: map[string]Backend{},71+ clientRoots: map[*sdk.ServerSession][]confine.Root{}, resources: map[string]bool{}}72+ s.Server = sdk.NewServer(&sdk.Implementation{Name: "012", Title: "012 spreadsheet", Version: orDevel(o.Version)}, s.serverOptions())5473 s.addReadTools()5574 if !o.ReadOnly {5675 s.addWriteTools()@@ -58,7 +77,11 @@ func New(b Backend, o Options) *Server {
5877 s.addResources()5978 s.addPrompts()6079 s.addViews()61- s.refreshResources(context.Background())80+ if o.Default != "" {81+ if b, err := s.book(context.Background(), nil, ""); err == nil {82+ s.used(context.Background(), b)83+ }84+ }6285 return s6386 }6487 @@ -70,8 +93,8 @@ func orDevel(v string) string {
7093 }7194 7295 // instructions tell the model how to use the server.73-const instructions = `This server works on one 012 spreadsheet workbook (.012 file).74-Call describe first: it lists the sheets, their used ranges, guessed header rows and column names, tables, named ranges, charts and notebooks.96+const instructions = `This server works on 012 spreadsheet workbooks (.012 files) in the folders open to it, and reads the files 012 imports (CSV, XLSX, JSON, SQLite and more).97+Every tool takes the workbook's path. Call describe without one to list the workbooks; describe with a path lists its sheets, their used ranges, guessed header rows and column names, tables, named ranges, charts and notebooks. create_workbook makes a new workbook, empty or from a file 012 imports.7598 References are written as in formulas: B7, A1:C9, Q3!B7, 'Q3 plan'!A1:C9, a named range, a table (Sales, Sales[Amount]) or a sheet name for the whole sheet; without a sheet name, the sheet shown when the file was saved.7699 Inputs are what a person types, in en-US form: 1.5, =SUM(A1:A6), $1,200, 12%, 2026-09-29. Formulas are Google Sheets'.77100 Writes are checked as typing is (formulas must parse, validation rules, protected ranges) and each call is one change, saved at once; pass dry_run to see the change without making it. Call evaluate to try a formula without writing it.`@@ -83,8 +106,20 @@ var rawSchemas = &jsonschema.ForOptions{TypeSchemas: map[reflect.Type]*jsonschem
83106 }}84107 85108 // tool adds a tool whose handler takes In and returns Out, with the86-// output schema inferred as rawSchemas says.109+// output schema inferred as rawSchemas says. Its path is required110+// unless the server has a default workbook, or the tool is describe,111+// which lists the workbooks without one.87112 func tool[In, Out any](s *Server, t *sdk.Tool, h func(context.Context, *sdk.CallToolRequest, In) (*sdk.CallToolResult, Out, error)) {113+ if t.InputSchema == nil {114+ schema, err := jsonschema.For[In](nil)115+ if err != nil {116+ panic(err)117+ }118+ if _, ok := schema.Properties["path"]; ok && s.opts.Default == "" && t.Name != "describe" && !slices.Contains(schema.Required, "path") {119+ schema.Required = append(schema.Required, "path")120+ }121+ t.InputSchema = schema122+ }88123 if t.OutputSchema == nil {89124 schema, err := jsonschema.For[Out](rawSchemas)90125 if err != nil {@@ -50,19 +50,31 @@ func writeBook(t *testing.T, w *sheet.Workbook, path string) {
5050 }5151 }5252 53-// connect serves backend b with options o to a client over the SDK's54-// in-memory transport.55-func connect(t *testing.T, b Backend, o Options) *sdk.ClientSession {53+// connect serves o to a client over the SDK's in-memory transport. A54+// server with a default workbook and no roots is open to its folder.55+func connect(t *testing.T, o Options) *sdk.ClientSession {5656 t.Helper()57+ return connectClient(t, o, sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil), nil)58+}59+60+// connectClient serves o to client c, connecting with co.61+func connectClient(t *testing.T, o Options, c *sdk.Client, co *sdk.ClientSessionOptions) *sdk.ClientSession {62+ t.Helper()63+ if o.Roots == nil && o.Default != "" {64+ roots, err := NewRoots([]string{filepath.Dir(o.Default)})65+ if err != nil {66+ t.Fatal(err)67+ }68+ o.Roots = roots69+ }5770 ctx := context.Background()58- srv := New(b, o)5971 st, ct := sdk.NewInMemoryTransports()60- ss, err := srv.Connect(ctx, st, nil)72+ ss, err := New(o).Connect(ctx, st, nil)6173 if err != nil {6274 t.Fatal(err)6375 }6476 t.Cleanup(func() { ss.Close() })65- cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, ct, nil)77+ cs, err := c.Connect(ctx, ct, co)6678 if err != nil {6779 t.Fatal(err)6880 }@@ -95,7 +107,7 @@ func errorText(res *sdk.CallToolResult) string {
95107 }96108 97109 func TestTools(t *testing.T) {98- cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})110+ cs := connect(t, Options{Default: bookFile(t)})99111 var names []string100112 for tl, err := range cs.Tools(context.Background(), nil) {101113 if err != nil {@@ -112,7 +124,7 @@ func TestTools(t *testing.T) {
112124 if slices.Contains(names, "run_notebook_cell") {113125 t.Error("run_notebook_cell offered without Notebooks")114126 }115- ro := connect(t, &FileBackend{Path: bookFile(t)}, Options{ReadOnly: true})127+ ro := connect(t, Options{Default: bookFile(t), ReadOnly: true})116128 for tl := range ro.Tools(context.Background(), nil) {117129 if !tl.Annotations.ReadOnlyHint {118130 t.Errorf("read-only server offers %s", tl.Name)@@ -121,7 +133,7 @@ func TestTools(t *testing.T) {
121133 }122134 123135 func TestDescribeAndRead(t *testing.T) {124- cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})136+ cs := connect(t, Options{Default: bookFile(t)})125137 var d headless.Description126138 call(t, cs, "describe", map[string]any{}, &d)127139 if len(d.Sheets) != 2 || d.Sheets[0].Header != 1 || !slices.Equal(d.Sheets[0].Columns, []string{"Region", "Units", "Price"}) {@@ -150,7 +162,7 @@ func TestDescribeAndRead(t *testing.T) {
150162 func TestEvaluateLeavesTheFile(t *testing.T) {151163 path := bookFile(t)152164 before, _ := os.ReadFile(path)153- cs := connect(t, &FileBackend{Path: path}, Options{})165+ cs := connect(t, Options{Default: path})154166 var ev headless.Evaluated155167 call(t, cs, "evaluate", map[string]any{"formula": `SUMIF(A2:A4, "North", B2:B4)`}, &ev)156168 if string(ev.Value) != "10" || ev.Cell != "Sheet1!A6" {@@ -179,7 +191,7 @@ func TestEvaluateLeavesTheFile(t *testing.T) {
179191 180192 func TestWrites(t *testing.T) {181193 path := bookFile(t)182- cs := connect(t, &FileBackend{Path: path}, Options{})194+ cs := connect(t, Options{Default: path})183195 before, _ := os.ReadFile(path)184196 var ch Changed185197 call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D1", "input": "Total"}, {"ref": "D2", "input": "=B2*C2"}},@@ -224,7 +236,7 @@ func TestWrites(t *testing.T) {
224236 225237 func TestChartAndPivot(t *testing.T) {226238 path := bookFile(t)227- cs := connect(t, &FileBackend{Path: path}, Options{})239+ cs := connect(t, Options{Default: path})228240 var c chartOut229241 call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "type": "bar", "title": "Units"}, &c)230242 if c.Chart.Number != 1 || c.Chart.Sheet != "Sheet1" || !c.Saved {@@ -247,7 +259,7 @@ func TestChartAndPivot(t *testing.T) {
247259 }248260 249261 func TestResourcesAndPrompts(t *testing.T) {250- cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})262+ cs := connect(t, Options{Default: bookFile(t)})251263 ctx := context.Background()252264 var uris []string253265 for r, err := range cs.Resources(ctx, nil) {@@ -256,14 +268,14 @@ func TestResourcesAndPrompts(t *testing.T) {
256268 }257269 uris = append(uris, r.URI)258270 }259- if !slices.Contains(uris, "o12://book/Sheet1!A1:C4") || !slices.Contains(uris, "o12://book/Q3%20plan!A1") {271+ if !slices.Contains(uris, "o12://book.012/Sheet1!A1:C4") || !slices.Contains(uris, "o12://book.012/Q3%20plan!A1") {260272 t.Errorf("resources: %v", uris)261273 }262274 for uri, want := range map[string]string{263- "o12://book/Sheet1!A1:C4": `"range":"Sheet1!A1:C4"`,264- "o12://book/Q3%20plan!A1": `"formulas":{"A1":"=Sheet1!B2*2"}`,265- "o12://book/Sheet1!B2:B3": `"values":[[3],[5]]`,266- "o12://book/table/Missing": "isn't a cell",275+ "o12://book.012/Sheet1!A1:C4": `"range":"Sheet1!A1:C4"`,276+ "o12://book.012/Q3%20plan!A1": `"formulas":{"A1":"=Sheet1!B2*2"}`,277+ "o12://book.012/Sheet1!B2:B3": `"values":[[3],[5]]`,278+ "o12://book.012/table/Missing": "isn't a cell",267279 } {268280 res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri})269281 got := ""@@ -290,7 +302,7 @@ func TestResourcesAndPrompts(t *testing.T) {
290302 if err != nil {291303 t.Fatal(err)292304 }293- if text := res.Messages[0].Content.(*sdk.TextContent).Text; !strings.Contains(text, "Add a column to Sheet1!A1:C4 that computes revenue") {305+ if text := res.Messages[0].Content.(*sdk.TextContent).Text; !strings.Contains(text, "Add a column to Sheet1!A1:C4 in the workbook (describe without a path lists them) that computes revenue") {294306 t.Errorf("prompt: %s", text)295307 }296308 }@@ -314,8 +326,8 @@ func TestRunNotebookCell(t *testing.T) {
314326 writeBook(t, w, path)315327 nu := &fakeNu{}316328 refuse := true317- cs := connect(t, &FileBackend{Path: path}, Options{Notebooks: &headless.NotebookOptions{Runner: nu},318- MayRun: func(*sheet.Workbook) error {329+ cs := connect(t, Options{Default: path, Notebooks: &headless.NotebookOptions{Runner: nu},330+ MayRun: func(string, *sheet.Workbook) error {319331 if refuse {320332 return errNotTrusted321333 }@@ -330,7 +342,7 @@ func TestRunNotebookCell(t *testing.T) {
330342 if run.State != "ran" || run.Name != "files" || !strings.Contains(run.Output, "a.txt") {331343 t.Errorf("run: %+v", run)332344 }333- res, err := cs.ReadResource(context.Background(), &sdk.ReadResourceParams{URI: "o12://book/notebook/Notes/2"})345+ res, err := cs.ReadResource(context.Background(), &sdk.ReadResourceParams{URI: "o12://nb.012/notebook/Notes/2"})334346 if err != nil || !strings.Contains(res.Contents[0].Text, "a.txt") {335347 t.Errorf("cell resource after the run: %v %+v", err, res)336348 }@@ -0,0 +1,161 @@
1+// Draws the MCP view in Chromium the two ways hosts show it, and checks2+// what a person would see (see views_browser_test.go):3+//4+// - openai: the OpenAI Apps SDK's way. window.openai is set before the5+// page's scripts run, toolOutput and toolResponseMetadata null at6+// first and then set by an openai:set_globals event, as ChatGPT does7+// when the result arrives;8+// notifyIntrinsicHeight resizes the frame.9+// - mcp-apps: the MCP Apps way. The host answers ui/initialize with its10+// context, sends ui/notifications/tool-input and tool-result once the11+// view says it's initialized, and sizes the frame as12+// ui/notifications/size-changed says.13+//14+// Usage: node view.mjs cases.json [screenshot folder]. It exits 0 when15+// every check passes, 3 when there's no browser to run, 1 otherwise.16+import { readFileSync } from "node:fs";17+import { createRequire } from "node:module";18+import { join } from "node:path";19+20+const require = createRequire(import.meta.url);21+const { chromium } = require("playwright");22+const [, , casesFile, shots] = process.argv;23+const cases = JSON.parse(readFileSync(casesFile, "utf8"));24+const failures = [];25+const check = (ok, what) => { if (!ok) failures.push(what); };26+27+let browser;28+try {29+ browser = await chromium.launch();30+} catch (e) {31+ console.error(String(e).split("\n")[0]);32+ process.exit(3);33+}34+35+// js is v as a script's literal, safe inside a script element.36+const js = (v) => JSON.stringify(v).replace(/</g, "\\u003c");37+38+// openaiDoc is the view's page with window.openai set first.39+function openaiDoc(page, args, theme) {40+ const boot = `<script>41+window.openai={theme:${js(theme)},displayMode:"inline",maxHeight:520,locale:"en-US",42+ toolInput:${js(args)},toolOutput:null,toolResponseMetadata:null,widgetState:null,43+ setWidgetState:function(){return Promise.resolve()},callTool:function(){return Promise.reject(new Error("no tools here"))},44+ notifyIntrinsicHeight:function(h){parent.postMessage({harness:"height",height:h},"*")}};45+</script>`;46+ return page.replace("<head>", "<head>" + boot);47+}48+49+// hostPage is the page around the frame: it relays the frame's size and,50+// as an MCP Apps host, speaks the extension's messages.51+function hostPage(mode, doc, c, theme) {52+ const hc = { theme, displayMode: "inline", containerDimensions: { maxHeight: 520 }, locale: "en-US", platform: "web" };53+ return `<!doctype html><html><body style="margin:24px;background:${theme === "dark" ? "#111" : "#eee"}">54+<iframe id="f" sandbox="allow-scripts" style="width:720px;height:150px;border:1px solid #888;display:block"></iframe>55+<script>56+var f=document.getElementById("f"),seen=[];window.seen=seen;57+window.addEventListener("message",function(e){58+ if(e.source!==f.contentWindow)return;var m=e.data;seen.push(m);59+ if(m.harness==="height"){f.style.height=m.height+"px";return}60+ if(${js(mode)}!=="mcp-apps"||!m||m.jsonrpc!=="2.0")return;61+ function post(x){f.contentWindow.postMessage(Object.assign({jsonrpc:"2.0"},x),"*")}62+ if(m.method==="ui/initialize")post({id:m.id,result:{protocolVersion:"2026-01-26",hostInfo:{name:"harness",version:"1"},hostCapabilities:{},hostContext:${js(hc)}}});63+ if(m.method==="ui/notifications/initialized"){post({method:"ui/notifications/tool-input",params:{arguments:${js(c.args)}}});64+ post({method:"ui/notifications/tool-result",params:${js(c.result)}})}65+ if(m.method==="ui/notifications/size-changed")f.style.height=m.params.height+"px";66+});67+f.srcdoc=${js(doc)};68+</script></body></html>`;69+}70+71+async function run(mode, name, c, theme) {72+ const page = await browser.newPage({ viewport: { width: 800, height: 700 } });73+ const errors = [];74+ page.on("pageerror", (e) => errors.push(String(e)));75+ const doc = mode === "openai" ? openaiDoc(cases.page, c.args, theme) : cases.page;76+ await page.setContent(hostPage(mode, doc, c, theme));77+ const frame = page.frameLocator("#f");78+ if (mode === "openai") {79+ await frame.locator("#o12-view").waitFor({ state: "attached" });80+ await page.waitForTimeout(100);81+ await frame.locator("body").evaluate((_, r) => {82+ window.openai.toolOutput = r.structuredContent;83+ window.openai.toolResponseMetadata = r._meta;84+ const globals = { toolOutput: r.structuredContent, toolResponseMetadata: r._meta };85+ window.dispatchEvent(new CustomEvent("openai:set_globals", { detail: { globals } }));86+ }, c.result);87+ }88+ const what = `${mode} ${name}`;89+ try {90+ await frame.locator("#o12-view > *").first().waitFor({ timeout: 5000 });91+ } catch {92+ failures.push(`${what}: nothing drawn`);93+ await page.close();94+ return;95+ }96+ await page.waitForTimeout(300);97+ const v = c.result._meta["o12/view"];98+ const state = () => frame.locator("body").evaluate(() => {99+ const sh = document.querySelector("#o12-view .sheet");100+ return {101+ book: document.querySelector(".panel .book").textContent,102+ name: document.getElementById("o12-name").textContent,103+ input: document.getElementById("o12-input").textContent,104+ where: document.getElementById("o12-context").textContent,105+ theme: document.documentElement.className,106+ height: Math.ceil(document.documentElement.getBoundingClientRect().height),107+ scroll: sh ? { client: sh.clientHeight, full: sh.scrollHeight } : null,108+ };109+ });110+ let s = await state();111+ const frameHeight = await page.locator("#f").evaluate((f) => f.getBoundingClientRect().height);112+ check(s.book === v.title && s.where === v.where, `${what}: panel says ${s.book} / ${s.where}`);113+ check(s.theme === theme, `${what}: theme ${s.theme}, want ${theme}`);114+ check(Math.abs(frameHeight - s.height - 2) <= 2, `${what}: frame ${frameHeight}px for ${s.height}px of view`);115+ check(s.height <= 520, `${what}: ${s.height}px, more than the host's room`);116+ check(errors.length === 0, `${what}: script errors ${errors}`);117+ if (c.grid) {118+ check(s.name === "A1" && s.input === c.grid.a1, `${what}: pointer on ${s.name} showing ${s.input}`);119+ await frame.locator(`td[data-a="${c.grid.cell}"]`).click();120+ s = await state();121+ check(s.name === c.grid.cell && s.input === c.grid.input, `${what}: clicking ${c.grid.cell} shows ${s.name} ${s.input}`);122+ await page.keyboard.press("ArrowDown");123+ s = await state();124+ check(s.name === c.grid.below, `${what}: the arrow key moved to ${s.name}`);125+ check(s.scroll && s.scroll.full > s.scroll.client, `${what}: the grid doesn't scroll (${JSON.stringify(s.scroll)})`);126+ const header = await frame.locator("#o12-view .sheet").evaluate((sh) => {127+ sh.scrollTop = 400;128+ const th = sh.querySelector("thead th"), top = sh.getBoundingClientRect().top;129+ return { scrolled: sh.scrollTop, headerAt: Math.round(th.getBoundingClientRect().top - top) };130+ });131+ check(header.scrolled > 0 && header.headerAt === 0, `${what}: scrolled ${header.scrolled}, column header ${header.headerAt}px down`);132+ await frame.locator("#o12-view .sheet").evaluate((sh) => { sh.scrollTop = 0; });133+ const style = await frame.locator(`td[data-a="${c.grid.cell}"]`).evaluate((td) => getComputedStyle(td).textAlign);134+ check(style === "right", `${what}: a number is aligned ${style}`);135+ } else {136+ check(await frame.locator("#o12-view svg").count() === 1, `${what}: no chart drawn`);137+ check(s.name === v.name, `${what}: name box ${s.name}`);138+ }139+ if (mode === "mcp-apps") {140+ const seen = await page.evaluate(() => window.seen.map((m) => m.method || (m.id ? "response" : m.harness)));141+ check(seen[0] === "ui/initialize" && seen.includes("ui/notifications/initialized") && seen.includes("ui/notifications/size-changed"),142+ `${what}: messages ${seen}`);143+ }144+ if (shots) {145+ await page.screenshot({ path: join(shots, `${mode}-${name}-${theme}.png`) });146+ }147+ await page.close();148+}149+150+for (const mode of ["openai", "mcp-apps"]) {151+ for (const [name, c] of Object.entries(cases.cases)) {152+ for (const theme of ["light", "dark"]) {153+ await run(mode, name, c, theme);154+ }155+ }156+}157+await browser.close();158+if (failures.length) {159+ console.error(failures.join("\n"));160+ process.exit(1);161+}@@ -10,110 +10,124 @@ import (
1010 "github.com/FelineStateMachine/012/internal/sheet"1111 )1212 13-// Views are the MCP Apps extension (io.modelcontextprotocol/ui, spec14-// 2026-01-26): read_range and create_chart name one UI resource,15-// ui://012/view, which hosts that support the extension draw in a16-// sandboxed frame beside the result. The resource is the HTML export's17-// page with nothing in it yet (its fonts, palettes and pointer script);18-// each result carries the fragment to show in its _meta, under viewKey,19-// only for clients that declared the extension. The page asks the host20-// for its theme, shows the fragment when the result arrives, and says21-// how tall it is.13+// Views are the range read_range read and the chart create_chart made,14+// drawn as 012 draws them in a sandboxed frame beside the result, by15+// hosts that implement either of the two ways of doing that:16+//17+// - MCP Apps (the io.modelcontextprotocol/ui extension, spec18+// 2026-01-26): the tool's _meta.ui.resourceUri names ui://012/view,19+// text/html;profile=mcp-app, and the page speaks the extension's20+// messages over postMessage (ui/initialize, the tool's result, the21+// host's theme and room, the page's size).22+// - The OpenAI Apps SDK (ChatGPT, Codex): the tool's23+// _meta["openai/outputTemplate"] names ui://012/view.skybridge, the24+// same page as text/html+skybridge, which reads window.openai25+// (toolResponseMetadata, theme, maxHeight, displayMode) instead.26+//27+// The page is the HTML export's with nothing in it yet (fonts,28+// palettes, the pointer's script). What it draws is in the result's29+// _meta under viewKey, for every client, since both kinds of hosts hand30+// a result's _meta to the view and neither shows it to the model: its31+// title, where it is and the grid's or chart's HTML. structuredContent32+// stays the tool's result, what the model reads.2233 const (23- uiExtension = "io.modelcontextprotocol/ui"24- uiMIME = "text/html;profile=mcp-app"25- viewURI = "ui://012/view"26- viewKey = "o12/view"34+ uiExtension = "io.modelcontextprotocol/ui"35+ uiMIME = "text/html;profile=mcp-app"36+ skybridgeMIME = "text/html+skybridge"37+ viewURI = "ui://012/view"38+ viewKey = "o12/view"39+ skybridgeURI = "ui://012/view.skybridge"2740 // maxViewRows bounds the rows a range's view draws.28- maxViewRows = 50041+ maxViewRows = 2002942 )3043 31-func (s *Server) addViews() {32- meta := sdk.Meta{"ui": map[string]any{"prefersBorder": true, "csp": map[string]any{}}}33- s.AddResource(&sdk.Resource{URI: viewURI, Name: "012 view", Title: s.b.Name() + " in 012", MIMEType: uiMIME, Meta: meta,34- Description: "The range or chart a tool returned, drawn as 012 draws it"},35- func(context.Context, *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {36- page := fileio.HTMLDocument(s.b.Name(), fileio.HTMLPanel(s.b.Name(), "", "")+`<div id="o12-view"></div>`, viewScript)37- return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: viewURI, MIMEType: uiMIME, Text: page, Meta: meta}}}, nil38- })44+// View is what a view draws of a result, in its _meta under viewKey:45+// the workbook's name, what the name box shows before a cell is46+// pointed at (a chart's data), the range or the chart's kind, sheet and47+// data, and the grid or chart as the HTML export draws it.48+type View struct {49+ Title string `json:"title"`50+ Name string `json:"name,omitempty"`51+ Where string `json:"where"`52+ HTML string `json:"html"`3953 }4054 41-// viewMeta is what a tool drawn in the view says of it: the resource,42-// under the key the spec names and the one its first drafts named.43-func (s *Server) viewMeta() sdk.Meta {44- return sdk.Meta{"ui": map[string]any{"resourceUri": viewURI}, "ui/resourceUri": viewURI}55+// viewResourceMeta says how hosts should frame the view, in both56+// dialects: a border, and no network (an empty content security57+// policy's lists).58+var viewResourceMeta = sdk.Meta{59+ "ui": map[string]any{"prefersBorder": true, "csp": map[string]any{}},60+ "openai/widgetPrefersBorder": true,61+ "openai/widgetCSP": map[string]any{"connect_domains": []string{}, "resource_domains": []string{}},62+ "openai/widgetDescription": "The range or chart the tool returned, drawn as 012's grid: the model need not repeat it.",4563 }4664 47-// wantsViews reports whether the client declared the extension.48-func wantsViews(req *sdk.CallToolRequest) bool {49- if req == nil || req.Session == nil {50- return false51- }52- p := req.Session.InitializeParams()53- if p == nil || p.Capabilities == nil {54- return false65+func (s *Server) addViews() {66+ for _, r := range []struct{ uri, mime string }{{viewURI, uiMIME}, {skybridgeURI, skybridgeMIME}} {67+ s.AddResource(&sdk.Resource{URI: r.uri, Name: "012 view", Title: "012", MIMEType: r.mime, Meta: viewResourceMeta,68+ Description: "The range or chart a tool returned, drawn as 012 draws it"},69+ func(context.Context, *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {70+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: r.uri, MIMEType: r.mime, Text: viewPage(), Meta: viewResourceMeta}}}, nil71+ })5572 }56- _, ok := p.Capabilities.Extensions[uiExtension]57- return ok5873 }5974 60-// viewResult is a result carrying a fragment of the view.61-func viewResult(title, where, html string) *sdk.CallToolResult {62- return &sdk.CallToolResult{Meta: sdk.Meta{viewKey: map[string]any{"title": title, "where": where, "html": html}}}75+// viewPage is the view's page: the panel, an empty place for the view76+// and the scripts.77+func viewPage() string {78+ return fileio.HTMLDocument("012", "<style>"+viewCSS+"</style>"+fileio.HTMLPanel("012", "", "")+`<div id="o12-view"></div>`, viewScript)79+}80+81+// viewMeta is what a tool drawn in the view says of it: the resource82+// under MCP Apps' key and its first drafts' flat one, and the Apps83+// SDK's template and the status lines it shows while the tool runs.84+// The view calls no tools, so openai/widgetAccessible stays false.85+func viewMeta(invoking, invoked string) sdk.Meta {86+ return sdk.Meta{"ui": map[string]any{"resourceUri": viewURI}, "ui/resourceUri": viewURI,87+ "openai/outputTemplate": skybridgeURI, "openai/toolInvocation/invoking": invoking, "openai/toolInvocation/invoked": invoked}6388 }6489 65-// rangeView draws what read_range read, at most maxViewRows rows.66-func (s *Server) rangeView(req *sdk.CallToolRequest, t headless.Target) *sdk.CallToolResult {67- if !wantsViews(req) {90+// viewResult is a result carrying v in its _meta, nil without one.91+func viewResult(v *View) *sdk.CallToolResult {92+ if v == nil {6893 return nil6994 }95+ return &sdk.CallToolResult{Meta: sdk.Meta{viewKey: v}}96+}97+98+// rangeView draws the rows read_range returned of t, at most99+// maxViewRows.100+func rangeView(b *book, t headless.Target, rows int) *View {70101 r := t.Range71102 if t.Whole {72- r, _ = t.Sheet.UsedRange()103+ var ok bool104+ if r, ok = t.Sheet.UsedRange(); !ok {105+ r = sheet.Rect{}106+ }73107 }74- r.To.Row = min(r.To.Row, r.From.Row+maxViewRows-1)108+ r.To.Row = min(r.To.Row, r.From.Row+max(rows, 1)-1, r.From.Row+maxViewRows-1)75109 snap := fileio.Snap(t.Sheet, r, t.Sheet.Name())76110 grid, _ := fileio.HTMLGrid(snap)77- return viewResult(s.b.Name(), sheet.Qualified(t.Sheet.Name(), snap.Range), grid)111+ return &View{Title: b.Name(), Where: sheet.Qualified(t.Sheet.Name(), snap.Range), HTML: grid}78112 }79113 80-// chartView draws the chart create_chart made.81-func (s *Server) chartView(req *sdk.CallToolRequest, w *sheet.Workbook, made headless.ChartMade) *sdk.CallToolResult {82- if !wantsViews(req) {83- return nil84- }114+// chartView draws the chart create_chart made in b.115+func chartView(b *book, w *sheet.Workbook, made headless.ChartMade) *View {85116 c, ok := fileio.ChartSnap(w.Lookup(made.Sheet), made.Number-1)86117 if !ok {87118 return nil88119 }89- return viewResult(made.Title, made.Sheet+" "+c.Data.String(), fileio.HTMLChart(c))120+ data := sheet.Qualified(made.Sheet, c.Data)121+ return &View{Title: b.Name(), Name: c.Data.String(), Where: c.Type.Title() + " chart of " + data, HTML: fileio.HTMLChart(c)}90122 }91123 92-// viewScript is the view's side of the extension's messages, over93-// postMessage with the host: ui/initialize, then the host's theme, the94-// tool's result and teardown, and the page's height as it changes.95-const viewScript = `(function(){96-var next=1,host=window.parent;97-function send(m){host.postMessage(Object.assign({jsonrpc:"2.0"},m),"*")}98-function theme(t){var r=document.documentElement;r.classList.remove("dark","light");if(t==="dark"||t==="light")r.classList.add(t)}99-function size(){send({method:"ui/notifications/size-changed",params:{width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight}})}100-function show(res){101- var v=res&&res._meta&&res._meta["o12/view"];if(!v)return;102- document.getElementById("o12-view").innerHTML=v.html;103- var c=document.getElementById("o12-context");if(c)c.textContent=v.where;104- var first=document.querySelector("#o12-view td[data-a]");if(first&&window.o12point)window.o12point(first);105- size();106-}107-window.addEventListener("message",function(e){108- var m=e.data;if(!m||m.jsonrpc!=="2.0")return;109- if(m.id===1&&m.result){var hc=m.result.hostContext||{};theme(hc.theme);send({method:"ui/notifications/initialized",params:{}});return}110- switch(m.method){111- case "ui/notifications/tool-result":show(m.params);break;112- case "ui/notifications/host-context-changed":if(m.params)theme(m.params.theme);break;113- case "ui/resource-teardown":send({id:m.id,result:{}});break;114- }115-});116-send({id:next++,method:"ui/initialize",params:{protocolVersion:"2026-01-26",capabilities:{},clientInfo:{name:"012",version:"1"},appCapabilities:{availableDisplayModes:["inline","fullscreen"]}}});117-new ResizeObserver(size).observe(document.body);118-})();124+// viewCSS fits the export's page to a frame: the panel on top, the125+// grid scrolling in what room the host gives, its headers sticking to126+// the grid's edges.127+const viewCSS = `html,body{overflow:hidden}128+.o12{width:auto;min-width:0}129+#o12-view{overflow-x:auto}130+#o12-view .sheet{overflow:auto;max-height:480px;overscroll-behavior:contain}131+#o12-view .grid thead th{top:0}132+#o12-view:empty::before{content:"Waiting for the tool's result";display:block;color:var(--muted);padding:0 9px;height:21px}119133 `@@ -0,0 +1,102 @@
1+package mcp2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "os"8+ "os/exec"9+ "path/filepath"10+ "strings"11+ "testing"12+13+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// TestViewInBrowser draws the view as the OpenAI Apps SDK's hosts and19+// MCP Apps hosts do, in Chromium through Playwright (testdata/view.mjs),20+// and checks the grid, the pointer and formula bar, scrolling, the21+// theme and the frame's height. It needs Node and the playwright22+// package, found through PLAYWRIGHT_DIR (a folder whose node_modules23+// holds it) or NODE_PATH, and is skipped without them.24+// VIEW_SHOTS names a folder for screenshots of each case.25+func TestViewInBrowser(t *testing.T) {26+ if testing.Short() {27+ t.Skip("-short")28+ }29+ nodePath := playwrightPath(t)30+ w := sheet.NewBook()31+ s := w.Sheet(0)32+ s.Set(sheet.Addr{}, "Month")33+ s.Set(sheet.Addr{Col: 1}, "Revenue")34+ s.Set(sheet.Addr{Col: 2}, "Cost")35+ s.Set(sheet.Addr{Col: 3}, "Profit")36+ for r := 1; r <= 60; r++ {37+ s.Set(sheet.Addr{Row: r}, fmt.Sprintf("Week %d", r))38+ s.Set(sheet.Addr{Col: 1, Row: r}, fmt.Sprintf("$%d,%03d", 10+r%7, r*37%1000))39+ s.Set(sheet.Addr{Col: 2, Row: r}, fmt.Sprintf("$%d,%03d", 6+r%3, r*53%1000))40+ s.Set(sheet.Addr{Col: 3, Row: r}, fmt.Sprintf("=B%d-C%d", r+1, r+1))41+ }42+ path := filepath.Join(t.TempDir(), "weeks.012")43+ writeBook(t, w, path)44+ cs := connect(t, Options{Default: path})45+ result := func(name string, args map[string]any) *sdk.CallToolResult {46+ return call(t, cs, name, args, nil)47+ }48+ gridArgs := map[string]any{"ref": "A1:D61"}49+ chartArgs := map[string]any{"data": "A1:C9", "type": "line", "title": "Revenue and cost"}50+ cases := map[string]any{51+ "page": viewPage(),52+ "cases": map[string]any{53+ "grid": map[string]any{"args": gridArgs, "result": result("read_range", gridArgs), "grid": map[string]string{"a1": "Month", "cell": "D2", "input": "=B2-C2", "below": "D3"}},54+ "chart": map[string]any{"args": chartArgs, "result": result("create_chart", chartArgs)},55+ },56+ }57+ data, err := json.Marshal(cases)58+ if err != nil {59+ t.Fatal(err)60+ }61+ file := filepath.Join(t.TempDir(), "cases.json")62+ if err := os.WriteFile(file, data, 0o644); err != nil {63+ t.Fatal(err)64+ }65+ args := []string{filepath.Join("testdata", "view.mjs"), file}66+ if shots := os.Getenv("VIEW_SHOTS"); shots != "" {67+ args = append(args, shots)68+ }69+ cmd := exec.Command("node", args...)70+ cmd.Env = append(os.Environ(), "NODE_PATH="+nodePath)71+ out, err := cmd.CombinedOutput()72+ var exit *exec.ExitError73+ switch {74+ case errors.As(err, &exit) && exit.ExitCode() == 3:75+ t.Skipf("no browser for Playwright: %s", out)76+ case err != nil:77+ t.Fatalf("%v\n%s", err, out)78+ }79+}80+81+// playwrightPath is the NODE_PATH that finds the playwright package,82+// skipping the test when there's none.83+func playwrightPath(t *testing.T) string {84+ t.Helper()85+ if _, err := exec.LookPath("node"); err != nil {86+ t.Skip("no node")87+ }88+ var dirs []string89+ if d := os.Getenv("PLAYWRIGHT_DIR"); d != "" {90+ dirs = append(dirs, filepath.Join(d, "node_modules"), d)91+ }92+ if d := os.Getenv("NODE_PATH"); d != "" {93+ dirs = append(dirs, d)94+ }95+ nodePath := strings.Join(dirs, string(os.PathListSeparator))96+ cmd := exec.Command("node", "-e", "require('playwright')")97+ cmd.Env = append(os.Environ(), "NODE_PATH="+nodePath)98+ if err := cmd.Run(); err != nil {99+ t.Skip("no playwright package: set PLAYWRIGHT_DIR to a folder whose node_modules holds it")100+ }101+ return nodePath102+}@@ -2,42 +2,47 @@ package mcp
22 33 import (44 "context"5+ "encoding/json"6+ "fmt"7+ "path/filepath"58 "strings"69 "testing"710 811 sdk "github.com/modelcontextprotocol/go-sdk/mcp"12+13+ "github.com/FelineStateMachine/012/internal/sheet"914 )1015 1116 // connectApps is connect with a client that declares the MCP Apps1217 // extension.13-func connectApps(t *testing.T, b Backend) *sdk.ClientSession {18+func connectApps(t *testing.T, o Options) *sdk.ClientSession {1419 t.Helper()15- ctx := context.Background()16- st, ct := sdk.NewInMemoryTransports()17- ss, err := New(b, Options{}).Connect(ctx, st, nil)18- if err != nil {19- t.Fatal(err)20- }21- t.Cleanup(func() { ss.Close() })2220 caps := &sdk.ClientCapabilities{}2321 caps.AddExtension(uiExtension, map[string]any{"mimeTypes": []string{uiMIME}})24- cs, err := sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}).Connect(ctx, ct, nil)25- if err != nil {22+ return connectClient(t, o, sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}), nil)23+}24+25+// resultView is the view in a result's _meta.26+func resultView(t *testing.T, res *sdk.CallToolResult) View {27+ t.Helper()28+ var out View29+ data, _ := json.Marshal(res.Meta[viewKey])30+ if err := json.Unmarshal(data, &out); err != nil {2631 t.Fatal(err)2732 }28- t.Cleanup(func() { cs.Close() })29- return cs33+ return out3034 }3135 32-func viewHTML(res *sdk.CallToolResult) string {33- v, _ := res.Meta[viewKey].(map[string]any)34- html, _ := v["html"].(string)35- return html36+// forModel is what of a result the model reads: its structured content37+// and its text.38+func forModel(t *testing.T, res *sdk.CallToolResult) string {39+ t.Helper()40+ data, _ := json.Marshal(res.StructuredContent)41+ return string(data) + res.Content[0].(*sdk.TextContent).Text3642 }3743 38-func TestViews(t *testing.T) {39- path := bookFile(t)40- cs := connectApps(t, &FileBackend{Path: path})44+func TestViewResources(t *testing.T) {45+ cs := connectApps(t, Options{Default: bookFile(t)})4146 ctx := context.Background()4247 for tl, err := range cs.Tools(ctx, nil) {4348 if err != nil {@@ -45,30 +50,78 @@ func TestViews(t *testing.T) {
4550 }4651 ui, _ := tl.Meta["ui"].(map[string]any)4752 drawn := tl.Name == "read_range" || tl.Name == "create_chart"48- if (ui["resourceUri"] == viewURI) != drawn {53+ if (ui["resourceUri"] == viewURI) != drawn || (tl.Meta["openai/outputTemplate"] == skybridgeURI) != drawn {4954 t.Errorf("%s: _meta %v", tl.Name, tl.Meta)5055 }5156 }52- res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: viewURI})53- if err != nil {54- t.Fatal(err)57+ for uri, mime := range map[string]string{viewURI: uiMIME, skybridgeURI: skybridgeMIME} {58+ res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri})59+ if err != nil {60+ t.Fatal(err)61+ }62+ page := res.Contents[0]63+ if page.MIMEType != mime || !strings.Contains(page.Text, `"ui/initialize"`) || !strings.Contains(page.Text, "openai:set_globals") ||64+ !strings.Contains(page.Text, "@font-face") || !strings.Contains(page.Text, `<div id="o12-view"></div>`) {65+ t.Errorf("%s: %s %.200s", uri, page.MIMEType, page.Text)66+ }67+ if ui, _ := page.Meta["ui"].(map[string]any); ui["prefersBorder"] != true || page.Meta["openai/widgetPrefersBorder"] != true {68+ t.Errorf("%s: _meta %v", uri, page.Meta)69+ }70+ }71+}72+73+// TestViewsForEveryClient checks the view is in the result's _meta74+// whether or not the client declared the extension (the Apps SDK's75+// hosts don't), and left out of the structured content and text models76+// read.77+func TestViewsForEveryClient(t *testing.T) {78+ path := bookFile(t)79+ for name, cs := range map[string]*sdk.ClientSession{80+ "apps": connectApps(t, Options{Default: path}),81+ "plain": connect(t, Options{Default: path}),82+ } {83+ got := call(t, cs, "read_range", map[string]any{"ref": "A1:C4"}, nil)84+ v := resultView(t, got)85+ if !strings.Contains(v.HTML, `<table class="grid">`) || !strings.Contains(v.HTML, `data-a="A2"`) || v.Where != "Sheet1!A1:C4" || v.Title != "book" {86+ t.Errorf("%s: read_range view: %+v", name, v)87+ }88+ if text := forModel(t, got); strings.Contains(text, "html") || strings.Contains(text, "<table") || !strings.Contains(text, `"range":"Sheet1!A1:C4"`) {89+ t.Errorf("%s: what the model reads: %s", name, text)90+ }91+ got = call(t, cs, "read_range", map[string]any{"ref": "A1:C4", "max_cells": 6}, nil)92+ if v := resultView(t, got); v.Where != "Sheet1!A1:C2" {93+ t.Errorf("%s: the view draws %s, not the rows read", name, v.Where)94+ }5595 }56- page := res.Contents[0]57- if page.MIMEType != uiMIME || !strings.Contains(page.Text, `"ui/initialize"`) || !strings.Contains(page.Text, "@font-face") ||58- !strings.Contains(page.Text, `<div id="o12-view"></div>`) {59- t.Errorf("view resource: %s %.200s", page.MIMEType, page.Text)96+ cs := connect(t, Options{Default: path})97+ got := call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "title": "Units"}, nil)98+ if v := resultView(t, got); !strings.Contains(v.HTML, `<figure class="chart solo"`) || !strings.Contains(v.HTML, "<svg") || v.Title != "book" || v.Name != "A1:B4" || v.Where != "Column chart of Sheet1!A1:B4" {99+ t.Errorf("create_chart view: %.300s", v.HTML)60100 }61- got := call(t, cs, "read_range", map[string]any{"ref": "A1:C4"}, nil)62- if html := viewHTML(got); !strings.Contains(html, `<table class="grid">`) || !strings.Contains(html, `data-a="A2"`) {63- t.Errorf("read_range view: %.300s", html)101+ if text := forModel(t, got); strings.Contains(text, "<svg") || !strings.Contains(text, `"saved":true`) {102+ t.Errorf("create_chart, what the model reads: %s", text)103+ }104+}105+106+// TestViewLeavesTheModelSmall checks what the model reads of a 60-row107+// read stays a few kilobytes while the view gets the grid.108+func TestViewLeavesTheModelSmall(t *testing.T) {109+ w := sheet.NewBook()110+ s := w.Sheet(0)111+ for r := range 60 {112+ s.Set(sheet.Addr{Row: r}, fmt.Sprintf("Week %d", r+1))113+ s.Set(sheet.Addr{Col: 1, Row: r}, fmt.Sprintf("$%d,%03d", 10+r%7, r*37%1000))114+ s.Set(sheet.Addr{Col: 2, Row: r}, fmt.Sprintf("$%d,%03d", 6+r%3, r*53%1000))115+ s.Set(sheet.Addr{Col: 3, Row: r}, fmt.Sprintf("=B%d-C%d", r+1, r+1))64116 }65- got = call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "title": "Units"}, nil)66- if html := viewHTML(got); !strings.Contains(html, `<figure class="chart solo"`) || !strings.Contains(html, "<svg") {67- t.Errorf("create_chart view: %.300s", html)117+ path := filepath.Join(t.TempDir(), "weeks.012")118+ writeBook(t, w, path)119+ got := call(t, connect(t, Options{Default: path}), "read_range", map[string]any{"ref": "A1:D60"}, nil)120+ structured, _ := json.Marshal(got.StructuredContent)121+ if len(structured) > 5000 || strings.Contains(string(structured), "<") {122+ t.Errorf("structuredContent of 60 rows: %d bytes", len(structured))68123 }69- // A client without the extension gets no view.70- plain := connect(t, &FileBackend{Path: path}, Options{})71- if got := call(t, plain, "read_range", map[string]any{"ref": "A1"}, nil); got.Meta[viewKey] != nil {72- t.Error("a view for a client without the extension")124+ if v := resultView(t, got); strings.Count(v.HTML, "<tr") != 61 {125+ t.Errorf("the view has %d rows", strings.Count(v.HTML, "<tr"))73126 }74127 }@@ -0,0 +1,82 @@
1+package mcp2+3+// viewScript is the view's side of both hosts' protocols. With4+// window.openai (the Apps SDK) it draws toolResponseMetadata's view, takes the5+// theme, maxHeight and displayMode from it and from openai:set_globals6+// events, and reports its height with notifyIntrinsicHeight. Otherwise7+// it speaks MCP Apps over postMessage: ui/initialize with its info and8+// display modes, then ui/notifications/initialized; it draws the9+// view in the _meta of ui/notifications/tool-result, follows the10+// hostContext's theme, containerDimensions and displayMode as they11+// change, answers ping and ui/resource-teardown, and sends12+// ui/notifications/size-changed as its content's size changes,13+// measured as the extension's SDK measures it.14+//15+// The grid scrolls inside the room it's given: inline, at most 48016+// pixels, less when the host's maximum height less the panel's is17+// smaller, so a view in a conversation stays a glance; the whole frame18+// when the host fixes its height or shows it full screen. A smaller19+// range takes only its own height.20+const viewScript = `(function(){21+var doc=document.documentElement,view=document.getElementById("o12-view"),oai=window.openai;22+var room=0,fill=false,shown=null,next=1,lastW=0,lastH=0,sizing=false;23+function theme(t){doc.classList.remove("dark","light");if(t==="dark"||t==="light")doc.classList.add(t)}24+function send(m){window.parent.postMessage(Object.assign({jsonrpc:"2.0"},m),"*")}25+function fit(){26+ var sh=view.querySelector(".sheet"),p=document.querySelector(".panel");if(!sh)return;27+ var top=p?p.offsetHeight:0,h=fill?window.innerHeight-top:Math.min(room>0?room-top:480,480);28+ sh.style.maxHeight=Math.max(h,84)+"px";29+}30+function measure(){31+ var old=doc.style.height;doc.style.height="max-content";32+ var h=Math.ceil(doc.getBoundingClientRect().height);doc.style.height=old;return h;33+}34+function size(){35+ if(sizing)return;sizing=true;36+ requestAnimationFrame(function(){37+ sizing=false;var w=Math.ceil(window.innerWidth),h=measure();38+ if(w===lastW&&h===lastH)return;lastW=w;lastH=h;39+ if(oai){if(typeof oai.notifyIntrinsicHeight==="function")oai.notifyIntrinsicHeight(h)}40+ else send({method:"ui/notifications/size-changed",params:{width:w,height:h}});41+ });42+}43+function show(v){44+ if(!v||typeof v.html!=="string"||v===shown)return;45+ shown=v;view.innerHTML=v.html;46+ var b=document.querySelector(".panel .book"),c=document.getElementById("o12-context"),n=document.getElementById("o12-name");47+ if(b)b.textContent=v.title||"";if(c)c.textContent=v.where||"";if(n)n.textContent=v.name||"";48+ var first=view.querySelector("td[data-a]");if(first&&window.o12point)window.o12point(first);49+ fit();size();50+}51+function viewOf(meta){return meta&&meta["o12/view"]}52+function context(hc){53+ if(!hc)return;if(hc.theme)theme(hc.theme);54+ var d=hc.containerDimensions;55+ if(d){fill=typeof d.height==="number";room=typeof d.maxHeight==="number"?d.maxHeight:0}56+ if(hc.displayMode)fill=fill||hc.displayMode==="fullscreen";57+ fit();size();58+}59+function globals(g){60+ if(!g)return;if(g.theme)theme(g.theme);61+ if(typeof g.maxHeight==="number")room=g.maxHeight;62+ if(g.displayMode)fill=g.displayMode==="fullscreen";63+ if(g.toolResponseMetadata)show(viewOf(g.toolResponseMetadata));64+ fit();size();65+}66+window.addEventListener("message",function(e){67+ var m=e.data;if(!m||m.jsonrpc!=="2.0"||oai)return;68+ if(m.id===1&&(m.result||m.error)){if(m.result)context(m.result.hostContext);send({method:"ui/notifications/initialized",params:{}});return}69+ switch(m.method){70+ case "ui/notifications/tool-result":show(viewOf(m.params&&m.params._meta));break;71+ case "ui/notifications/host-context-changed":context(m.params);break;72+ case "ui/resource-teardown":case "ping":send({id:m.id,result:{}});break;73+ }74+});75+window.addEventListener("openai:set_globals",function(e){if(e.detail)globals(e.detail.globals)});76+window.addEventListener("resize",function(){fit();size()});77+new ResizeObserver(size).observe(document.body);78+if(oai)globals(oai);79+else send({id:next++,method:"ui/initialize",params:{protocolVersion:"2026-01-26",appInfo:{name:"012",version:"1"},appCapabilities:{availableDisplayModes:["inline","fullscreen"]}}});80+size();81+})();82+`@@ -9,21 +9,24 @@ import (
99 "github.com/FelineStateMachine/012/internal/sheet"1010 )1111 12-// The tools that change the workbook. Each is one call to the backend's12+// The tools that change a workbook. Each is one call to the backend's1313 // Change, so one undo step and one save, and returns what changed as1414 // 012 diff lists it; dry_run returns it without keeping it.1515 1616 type writeIn struct {17+ bookIn1718 Entries []headless.Entry `json:"entries" jsonschema:"the cells to set, in order: each a ref (one cell) and its input, typed as a person types it in en-US form; an empty input clears the cell"`1819 DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`1920 }2021 2122 type opsIn struct {23+ bookIn2224 Operations []headless.Operation `json:"operations" jsonschema:"the operations, made in order as one change"`2325 DryRun bool `json:"dry_run,omitempty" jsonschema:"return the change without making it"`2426 }2527 2628 type sortIn struct {29+ bookIn2730 Ref string `json:"ref" jsonschema:"the range to sort"`2831 Keys []headless.SortKey `json:"keys" jsonschema:"the columns to sort by, first first"`2932 Header bool `json:"header,omitempty" jsonschema:"the first row is a header and stays in place"`@@ -31,6 +34,7 @@ type sortIn struct {
3134 }3235 3336 type filterIn struct {37+ bookIn3438 Ref string `json:"ref" jsonschema:"the range to filter, its first row the header"`3539 Columns []headless.FilterColumn `json:"columns,omitempty" jsonschema:"each filtered column's criteria"`3640 Remove bool `json:"remove,omitempty" jsonschema:"take the sheet's filter off instead"`@@ -38,16 +42,19 @@ type filterIn struct {
3842 }3943 4044 type chartIn struct {45+ bookIn4146 headless.ChartSpec4247 DryRun bool `json:"dry_run,omitempty"`4348 }4449 4550 type pivotIn struct {51+ bookIn4652 headless.PivotSpec4753 DryRun bool `json:"dry_run,omitempty"`4854 }4955 5056 type runCellIn struct {57+ bookIn5158 Notebook string `json:"notebook" jsonschema:"the notebook tab's name"`5259 Cell int `json:"cell" jsonschema:"the cell's number, 1 for the first, as describe lists them"`5360 }@@ -76,45 +83,46 @@ func (s *Server) addWriteTools() {
7683 set := headless.SetOptions{Force: s.opts.Force}7784 tool(s, &sdk.Tool{Name: "write_cells", Annotations: writes,7885 Description: "Type entries into cells as one change, as 012 set does: each checked as typing it is (a formula must parse, validation rules, protected ranges), stopping at the first a cell can't take."},79- func(ctx context.Context, _ *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {86+ func(ctx context.Context, req *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {8087 var warnings []string81- return s.change(ctx, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {88+ return s.change(ctx, req, in.Path, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {8289 warnings, err = headless.Set(w, in.Entries, set)8390 return err8491 })8592 })8693 tool(s, &sdk.Tool{Name: "apply_operations", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},8794 Description: "Make several operations as one change: set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name, sort."},88- func(ctx context.Context, _ *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {95+ func(ctx context.Context, req *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {8996 var warnings []string90- return s.change(ctx, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {97+ return s.change(ctx, req, in.Path, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {9198 warnings, err = headless.Apply(w, in.Operations, set)9299 return err93100 })94101 })95102 tool(s, &sdk.Tool{Name: "sort", Annotations: writes, Description: "Sort a range's rows by columns, as Data > Sort range does."},96- func(ctx context.Context, _ *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {97- return s.change(ctx, "sort", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })103+ func(ctx context.Context, req *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {104+ return s.change(ctx, req, in.Path, "sort", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })98105 })99106 tool(s, &sdk.Tool{Name: "filter", Annotations: writes,100107 Description: "Put a filter on a range, hiding the rows its columns' conditions or values leave out, as Data > Create a filter does; or remove the sheet's filter."},101- func(ctx context.Context, _ *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {102- return s.change(ctx, "filter", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })108+ func(ctx context.Context, req *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {109+ return s.change(ctx, req, in.Path, "filter", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })103110 })104- tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: s.viewMeta(),111+ tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: viewMeta("Adding the chart", "Added the chart"),105112 Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},106113 s.createChart)107114 tool(s, &sdk.Tool{Name: "create_pivot", Annotations: writes,108115 Description: "Add a pivot table on a new sheet, summarizing a range whose first row names its columns."},109- func(ctx context.Context, _ *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {116+ func(ctx context.Context, req *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {110117 var out pivotOut111- _, changed, err := s.change(ctx, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {118+ _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {112119 out.Sheet, err = headless.AddPivot(w, in.PivotSpec)113120 return err114121 })115122 out.Changed = changed116123 return nil, out, err117124 })125+ s.addCreateTool()118126 if s.opts.Notebooks != nil {119127 tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},120128 Description: "Run a notebook tab's code cell with nushell, as Run does on the screen: its output replaces the one kept and goes on to the sheet it was sent to."},@@ -124,23 +132,34 @@ func (s *Server) addWriteTools() {
124132 125133 func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in chartIn) (*sdk.CallToolResult, chartOut, error) {126134 var out chartOut127- var res *sdk.CallToolResult128- _, changed, err := s.change(ctx, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {135+ var view *View136+ b, err := s.open(ctx, req, in.Path)137+ if err != nil {138+ return nil, out, err139+ }140+ _, changed, err := s.change(ctx, req, in.Path, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {129141 if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {130142 return err131143 }132- res = s.chartView(req, w, out.Chart)144+ view = chartView(b, w, out.Chart)133145 return nil134146 })147+ if err != nil {148+ return nil, chartOut{}, err149+ }135150 out.Changed = changed136- return res, out, err151+ return viewResult(view), out, nil137152 }138153 139-func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {154+func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {140155 var out headless.CellRun141- _, _, err := s.change(ctx, "run notebook cell", false, nil, func(w *sheet.Workbook) error {156+ b, err := s.open(ctx, req, in.Path)157+ if err != nil {158+ return nil, out, err159+ }160+ _, _, err = s.change(ctx, req, in.Path, "run notebook cell", false, nil, func(w *sheet.Workbook) error {142161 if s.opts.MayRun != nil {143- if err := s.opts.MayRun(w); err != nil {162+ if err := s.opts.MayRun(b.path, w); err != nil {144163 return err145164 }146165 }@@ -151,10 +170,14 @@ func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCell
151170 return nil, out, err152171 }153172 154-// change makes fn a change of the backend's and says what it changed;155-// warnings, when set, are fn's.156-func (s *Server) change(ctx context.Context, label string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {157- changes, err := s.b.Change(ctx, label, dryRun, fn)173+// change makes fn a change of the workbook path names and says what it174+// changed; warnings, when set, are fn's.175+func (s *Server) change(ctx context.Context, req *sdk.CallToolRequest, path, label string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {176+ b, err := s.open(ctx, req, path)177+ if err != nil {178+ return nil, Changed{}, err179+ }180+ changes, err := b.Change(ctx, label, dryRun, fn)158181 if err != nil {159182 return nil, Changed{}, err160183 }@@ -7,12 +7,14 @@ import (
77 )88 99 // The cells' dependency graph: a cell reading $sales depends on the cell10-// named sales. A name belongs to the first code cell giving it; a later11-// one giving the same name can't run until it's renamed. Cells run in12-// the order asked, each after the cells it reads among those running,13-// and a cell reading itself, directly or through others, doesn't run.10+// giving sales (Vars). A cell's name belongs to the first code cell11+// giving it; a later one giving the same name can't run until it's12+// renamed. Cells run in the order asked, each after the cells it reads13+// among those running, and a cell reading itself, directly or through14+// others, doesn't run.1415 15-// Names maps each name to the index of the first code cell giving it.16+// Names maps each cell's name to the index of the first code cell17+// giving it.1618 func Names(cells []Cell) map[string]int {1719 out := map[string]int{}1820 for i, c := range cells {@@ -25,6 +27,21 @@ func Names(cells []Cell) map[string]int {
2527 return out2628 }2729 30+// Vars maps each variable the cells give others to the index of the31+// cell giving it: a cell's name to that cell (Names), and any other32+// name a statement assigns to the first code cell assigning it.33+func Vars(cells []Cell) map[string]int {34+ out := Names(cells)35+ for i, c := range cells {36+ for _, n := range c.Parse().Assigned() {37+ if _, ok := out[n]; !ok {38+ out[n] = i39+ }40+ }41+ }42+ return out43+}44+2845 // Taken says why cell i can't use its name: an earlier cell gives it2946 // (names ignore case there, as formulas' nu.name do), or "".3047 func Taken(cells []Cell, i int) string {@@ -40,13 +57,33 @@ func Taken(cells []Cell, i int) string {
4057 return ""4158 }4259 60+// ref is a variable a cell reads from another: its name, and the index61+// of the cell giving it.62+type ref struct {63+ name string64+ cell int65+}66+67+// refs are the variables cell i reads from other cells, in the order it68+// names them; vars is Vars(cells). A name only it assigns, read before69+// it does, is none.70+func refs(cells []Cell, vars map[string]int, i int) []ref {71+ var out []ref72+ for _, n := range cells[i].Parse().Refs() {73+ if j, ok := vars[n]; ok && j != i {74+ out = append(out, ref{n, j})75+ }76+ }77+ return out78+}79+4380 // Reads returns the indices of the cells cell i reads, in the order it44-// names them.45-func Reads(cells []Cell, names map[string]int, i int) []int {81+// names them; vars is Vars(cells).82+func Reads(cells []Cell, vars map[string]int, i int) []int {4683 var out []int47- for _, n := range Refs(cells[i].Pipeline()) {48- if j, ok := names[n]; ok && j != i && !slices.Contains(out, j) {49- out = append(out, j)84+ for _, r := range refs(cells, vars, i) {85+ if !slices.Contains(out, r.cell) {86+ out = append(out, r.cell)5087 }5188 }5289 return out@@ -56,7 +93,7 @@ func Reads(cells []Cell, names map[string]int, i int) []int {
5693 // among them, in want's order otherwise, and the cells left out for5794 // reading themselves, directly or through others.5895 func Order(cells []Cell, want []int) (order, cycle []int) {59- names := Names(cells)96+ vars := Vars(cells)6097 in := map[int]bool{}6198 for _, i := range want {6299 if cells[i].Kind == Code {@@ -70,7 +107,7 @@ func Order(cells []Cell, want []int) (order, cycle []int) {
70107 if !in[i] || done[i] {71108 continue72109 }73- ready := !slices.ContainsFunc(Reads(cells, names, i), func(j int) bool { return in[j] && !done[j] })110+ ready := !slices.ContainsFunc(Reads(cells, vars, i), func(j int) bool { return in[j] && !done[j] })74111 if ready {75112 order, done[i], progress = append(order, i), true, true76113 }@@ -90,12 +127,12 @@ func Order(cells []Cell, want []int) (order, cycle []int) {
90127 // Dependents returns the cells that read cell i, directly or through91128 // others, in the notebook's order.92129 func Dependents(cells []Cell, i int) []int {93- names := Names(cells)130+ vars := Vars(cells)94131 want := map[int]bool{i: true}95132 for changed := true; changed; {96133 changed = false97134 for j := range cells {98- if !want[j] && cells[j].Kind == Code && slices.ContainsFunc(Reads(cells, names, j), func(k int) bool { return want[k] }) {135+ if !want[j] && cells[j].Kind == Code && slices.ContainsFunc(Reads(cells, vars, j), func(k int) bool { return want[k] }) {99136 want[j], changed = true, true100137 }101138 }@@ -109,21 +146,35 @@ func Dependents(cells []Cell, i int) []int {
109146 return out110147 }111148 149+// Readable reports whether o, the output of cell c, holds the variable150+// name: c's output when c is named so, else a variable it assigns,151+// which a file doesn't keep.152+func Readable(c Cell, name string, o *Output) bool {153+ switch {154+ case o == nil || o.NUON == nil:155+ return false156+ case c.Name() == name:157+ return true158+ }159+ _, ok := o.Vars[name]160+ return ok161+}162+112163 // Inputs returns the cells cell i reads, directly or through others,113-// that have no output, in the notebook's order: what has to run before114-// it can.164+// whose outputs don't hold what it reads (Readable), in the notebook's165+// order: what has to run before it can.115166 func Inputs(cells []Cell, i int, output func(id int) *Output) []int {116- names := Names(cells)167+ vars := Vars(cells)117168 need := map[int]bool{}118169 var visit func(j int)119170 visit = func(j int) {120- for _, k := range Reads(cells, names, j) {121- if need[k] || k == i {171+ for _, r := range refs(cells, vars, j) {172+ if need[r.cell] || r.cell == i {122173 continue123174 }124- if o := output(cells[k].ID); o == nil || o.NUON == nil {125- need[k] = true126- visit(k)175+ if c := cells[r.cell]; !Readable(c, r.name, output(c.ID)) {176+ need[r.cell] = true177+ visit(r.cell)127178 }128179 }129180 }@@ -141,7 +192,7 @@ func Inputs(cells []Cell, i int, output func(id int) *Output) []int {
141192 // them now would give: their source changed since, or an output they142193 // read changed or is stale itself.143194 func Stale(cells []Cell, output func(id int) *Output) map[int]bool {144- names := Names(cells)195+ vars := Vars(cells)145196 all := make([]int, len(cells))146197 for i := range all {147198 all[i] = i@@ -155,18 +206,17 @@ func Stale(cells []Cell, output func(id int) *Output) map[int]bool {
155206 continue156207 }157208 s := o.Source != c.Source158- for _, name := range Refs(c.Pipeline()) {159- j, ok := names[name]209+ read := map[string]bool{}210+ for _, r := range refs(cells, vars, i) {211+ read[r.name] = true160212 seq := 0161- if ok {162- if d := output(cells[j].ID); d != nil {163- seq = d.Seq164- }165- s = s || stale[cells[j].ID]166- }167- if read, had := o.Reads[name]; had || ok {168- s = s || read != seq213+ if d := output(cells[r.cell].ID); d != nil {214+ seq = d.Seq169215 }216+ s = s || stale[cells[r.cell].ID] || o.Reads[r.name] != seq217+ }218+ for name := range o.Reads {219+ s = s || !read[name] // it read a cell it no longer does170220 }171221 if s {172222 stale[c.ID] = true@@ -179,20 +229,17 @@ func Stale(cells []Cell, output func(id int) *Output) map[int]bool {
179229 // have left: made from the source as it is, having read the outputs as180230 // they are, so none is stale until something changes.181231 func Settle(cells []Cell, outputs map[int]*Output) {182- names := Names(cells)183- for _, c := range cells {232+ vars := Vars(cells)233+ for i, c := range cells {184234 o := outputs[c.ID]185235 if o == nil {186236 continue187237 }188238 o.Source, o.Reads = c.Source, map[string]int{}189- for _, name := range Refs(c.Pipeline()) {190- if j, ok := names[name]; ok {191- if d := outputs[cells[j].ID]; d != nil {192- o.Reads[name] = d.Seq193- } else {194- o.Reads[name] = 0195- }239+ for _, r := range refs(cells, vars, i) {240+ o.Reads[r.name] = 0241+ if d := outputs[cells[r.cell].ID]; d != nil {242+ o.Reads[r.name] = d.Seq196243 }197244 }198245 }@@ -0,0 +1,56 @@
1+package notebook2+3+import "fmt"4+5+// Run is what running a code cell takes that the notebook knows: what6+// nu runs, and the variables it reads from other cells. The screen and7+// the headless runner each add what only they know: linked files, the8+// selection and ranges of sheets.9+type Run struct {10+ // Command is what nu runs (Source.Command), and Exports the11+ // variables it hands back beside the output.12+ Command string13+ Exports []string14+ // Stream is what nu runs to stream the cell (Source.StreamCommand).15+ Stream string16+ // Tables are the other cells' outputs and variables it reads, by17+ // name, as NUON, and Reads the Seqs of the outputs they came from.18+ Tables map[string][]byte19+ Reads map[string]int20+ // Others are the names it reads that no cell gives: linked files,21+ // or nothing nu will say so of.22+ Others []string23+ // Ranges are the ranges of sheets it reads, and Selection whether it24+ // reads $selection.25+ Ranges []SheetRef26+ Selection bool27+}28+29+// Prepare works out what running cell i takes, reading the outputs of30+// the cells it reads; the error says why it can't run: what it reads31+// hasn't run or failed.32+func Prepare(cells []Cell, i int, output func(id int) *Output) (Run, error) {33+ src := cells[i].Parse()34+ cmd, exports, ranges := src.Command()35+ run := Run{Command: cmd, Exports: exports, Stream: src.StreamCommand(), Tables: map[string][]byte{}, Reads: map[string]int{}, Ranges: ranges, Selection: src.ReadsSelection()}36+ vars := Vars(cells)37+ for _, name := range src.Refs() {38+ j, ok := vars[name]39+ if !ok || j == i {40+ run.Others = append(run.Others, name)41+ continue42+ }43+ o := output(cells[j].ID)44+ switch {45+ case o.Failed():46+ return run, fmt.Errorf("it reads $%s, which failed", name)47+ case o == nil || o.Unsaved || !Readable(cells[j], name, o):48+ return run, fmt.Errorf("it reads $%s, which hasn't run", name)49+ }50+ run.Tables[name], run.Reads[name] = o.NUON, o.Seq51+ if cells[j].Name() != name {52+ run.Tables[name] = o.Vars[name]53+ }54+ }55+ return run, nil56+}@@ -54,41 +54,37 @@ type Cell struct {
5454 Source string5555 }5656 57-// Name is the name the cell gives its output, from a source of the form58-// `name = pipeline`, or "".59-func (c Cell) Name() string {57+// Parse reads the cell's source; a note cell's has nothing to read.58+func (c Cell) Parse() Source {6059 if c.Kind != Code {61- return ""60+ return Source{Text: c.Source}6261 }63- name, _ := SplitName(c.Source)64- return name62+ return Parse(c.Source)6563 }6664 67-// Pipeline is what runs: the source without its name.68-func (c Cell) Pipeline() string {69- _, p := SplitName(c.Source)70- return p71-}72-73-// SplitName splits `name = pipeline` into the name and the pipeline; a74-// source that doesn't start so has no name. `==` isn't a name's `=`.75-func SplitName(src string) (name, pipeline string) {76- head, rest, ok := strings.Cut(src, "=")77- head = strings.TrimSpace(head)78- if !ok || strings.HasPrefix(rest, "=") || ValidName(head) != nil {79- return "", src80- }81- return head, strings.TrimLeft(rest, " \t")82-}65+// Name is the name the cell gives its output: what its last statement66+// assigns (`name = pipeline`), or "".67+func (c Cell) Name() string { return c.Parse().Name() }8368 84-// WithName is src given the name name, replacing any name it has; ""85-// takes its name away.69+// WithName is src given the name name, as the name its last statement70+// assigns, replacing any it has; "" takes its name away.8671 func WithName(src, name string) string {87- _, p := SplitName(src)88- if name == "" {89- return p72+ head := ""73+ if name != "" {74+ head = name + " = "75+ }76+ p := Parse(src)77+ if len(p.Stmts) == 0 {78+ if strings.TrimSpace(src) == "" {79+ return head80+ }81+ return src + "\n" + head82+ }83+ last := p.Stmts[len(p.Stmts)-1]84+ if last.Name != "" {85+ return src[:last.From] + head + src[last.Body:]9086 }91- return name + " = " + p87+ return src[:last.From] + head + src[last.From:]9288 }9389 9490 // Reserved are nushell's own variables and the notebook's: $selection@@ -141,6 +137,10 @@ type Output struct {
141137 // Seq identifies the output among the workbook's, for telling142138 // whether what a cell read has changed since.143139 Seq int140+ // Vars are the values, as NUON, of the variables the cell assigns141+ // other than its output, which later cells read by name. A file142+ // doesn't keep them: a cell reading one runs its cell first.143+ Vars map[string][]byte144144 // Took is how long the run took.145145 Took time.Duration146146 // Source is the cell's source when it ran.@@ -5,59 +5,196 @@ import (
55 "testing"66 )77 8-func TestSplitName(t *testing.T) {9- for _, tc := range []struct{ src, name, pipe string }{10- {"sales = open sales.csv", "sales", "open sales.csv"},11- {"big=\n$sales | first", "big", "\n$sales | first"},12- {"ls | where name == x", "", "ls | where name == x"},13- {"a == b", "", "a == b"},14- {"in = ls", "", "in = ls"},15- {"9x = ls", "", "9x = ls"},16- {"let x = 1", "", "let x = 1"},17- {"__s = 1", "", "__s = 1"},8+func TestName(t *testing.T) {9+ for _, tc := range []struct{ src, name string }{10+ {"sales = open sales.csv", "sales"},11+ {"big=\n$sales | first", "big"},12+ {"ls | where name == x", ""},13+ {"a == b", ""},14+ {"a =~ b", ""},15+ {"in = ls", ""},16+ {"9x = ls", ""},17+ {"let x = 1", ""},18+ {"__s = 1", ""},19+ {"files = ls\n$files | length", ""},20+ {"files = ls\nn = $files | length", "n"},21+ {"n = ls\n| where size > 1kb", "n"},22+ {"n = ls |\n where size > 1kb", "n"},23+ {"n = ls\n# keep the big ones\n| where size > 1kb", "n"},24+ {"n = ls\n\n| length", ""},25+ {"n = ls; $n | length", ""},26+ {"n = [\n1\n2\n]", "n"},27+ {"n = ls # all of them", "n"},28+ {"# n = ls", ""},1829 } {19- name, p := SplitName(tc.src)20- if name != tc.name || p != tc.pipe {21- t.Errorf("SplitName(%q) = %q, %q", tc.src, name, p)30+ if got := (Cell{Source: tc.src}).Name(); got != tc.name {31+ t.Errorf("Name(%q) = %q, want %q", tc.src, got, tc.name)2232 }2333 }24- if got := WithName("ls", "files"); got != "files = ls" {25- t.Errorf("WithName %q", got)26- }27- if got := WithName("files = ls", ""); got != "ls" {28- t.Errorf("WithName without %q", got)29- }3034 if (Cell{Kind: Note, Source: "x = y"}).Name() != "" {3135 t.Error("a note cell has a name")3236 }3337 }3438 35-func TestRefs(t *testing.T) {36- got := Refs("$r1 | where x == $in.a and $nu.home | $r_2.x | $selection | $sheet.A1")37- if !slices.Equal(got, []string{"r1", "r_2"}) {39+func TestWithName(t *testing.T) {40+ for _, tc := range []struct{ src, name, want string }{41+ {"ls", "files", "files = ls"},42+ {"files = ls", "", "ls"},43+ {"files = ls", "all", "all = ls"},44+ {"files = ls\n$files | length", "n", "files = ls\nn = $files | length"},45+ {"files = ls\nn = $files | length", "", "files = ls\n$files | length"},46+ {"", "x", "x = "},47+ {"# just a note", "x", "# just a note\nx = "},48+ } {49+ if got := WithName(tc.src, tc.name); got != tc.want {50+ t.Errorf("WithName(%q, %q) = %q, want %q", tc.src, tc.name, got, tc.want)51+ }52+ }53+}54+55+// The owner's cell: files is the cell's own, read by its second line, not56+// a cell it reads; the cell has no name, and hands files to later cells.57+func TestOwnersCell(t *testing.T) {58+ src := "files = ls | where type == file\n$files | where size > 1kb | sort-by size --reverse"59+ p := Parse(src)60+ if p.Name() != "" || len(p.Refs()) != 0 || !slices.Equal(p.Assigned(), []string{"files"}) {61+ t.Errorf("name %q refs %v assigned %v", p.Name(), p.Refs(), p.Assigned())62+ }63+ cmd, exports, _ := p.Command()64+ want := "let files = ls | where type == file\n{__out: ($files | where size > 1kb | sort-by size --reverse), files: $files}"65+ if cmd != want || !slices.Equal(exports, []string{"files"}) {66+ t.Errorf("command %q exports %v", cmd, exports)67+ }68+ if got := p.StreamCommand(); got != "let files = ls | where type == file\n$files | where size > 1kb | sort-by size --reverse" {69+ t.Errorf("stream command %q", got)70+ }71+ cs := cells(src, "$files | length")72+ order, cycle := Order(cs, []int{0, 1})73+ if !slices.Equal(order, []int{0, 1}) || len(cycle) != 0 {74+ t.Errorf("order %v cycle %v", order, cycle)75+ }76+ run, err := Prepare(cs, 0, func(int) *Output { return nil })77+ if err != nil || run.Command != want || len(run.Tables) != 0 {78+ t.Errorf("prepare %+v %v", run, err)79+ }80+ // The second cell reads files from the first's run.81+ outs := map[int]*Output{1: {NUON: []byte("[]"), Seq: 4, Vars: map[string][]byte{"files": []byte("[[name]; [a]]")}}}82+ out := func(id int) *Output { return outs[id] }83+ run, err = Prepare(cs, 1, out)84+ if err != nil || string(run.Tables["files"]) != "[[name]; [a]]" || run.Reads["files"] != 4 {85+ t.Errorf("prepare the reader %+v %v", run, err)86+ }87+ // An output read from a file keeps no variables: the first cell runs88+ // before the second.89+ outs[1].Vars = nil90+ if got := Inputs(cs, 1, out); !slices.Equal(got, []int{0}) {91+ t.Errorf("inputs %v", got)92+ }93+ if _, err := Prepare(cs, 1, out); err == nil {94+ t.Error("prepared a cell reading a variable its cell didn't hand back")95+ }96+}97+98+func TestComments(t *testing.T) {99+ src := "# x = ls\n# $y | $sheet.A1:B2 | $selection\nls # $z\n| where name == \"a#b\" # $w\n| where name != 'c # $v'\n| where name != r#'d # $u'#\n| append [1#2]\n| append $\"($q # $t\n)\""100+ p := Parse(src)101+ if p.Name() != "" || !slices.Equal(p.Refs(), []string{"q"}) || p.ReadsSelection() || len(p.Ranges) != 0 {102+ t.Errorf("name %q refs %v selection %v ranges %v", p.Name(), p.Refs(), p.ReadsSelection(), p.Ranges)103+ }104+ var comments []string105+ for _, c := range p.Comments {106+ comments = append(comments, src[c[0]:c[1]])107+ }108+ if !slices.Equal(comments, []string{"# x = ls", "# $y | $sheet.A1:B2 | $selection", "# $z", "# $w", "# $t"}) {109+ t.Errorf("comments %q", comments)110+ }111+ if len(p.Stmts) != 1 {112+ t.Errorf("statements %+v", p.Stmts)113+ }114+ cmd, _, _ := p.Command()115+ if len(cmd) != len(src) || cmd[:8] != " " {116+ t.Errorf("command %q", cmd)117+ }118+ // Inside brackets a comment may follow a , or a :.119+ if got := Parse("[1,#x\n2]").Comments; len(got) != 1 {120+ t.Errorf("comments in a list %v", got)121+ }122+ if got := Parse("echo x,#y").Comments; len(got) != 0 {123+ t.Errorf("a comment in a word %v", got)124+ }125+}126+127+func TestStatements(t *testing.T) {128+ src := "a = ls\nb = $a | length; c = $b + 1\nlet d = 2\n$c + $d + $e"129+ p := Parse(src)130+ if !slices.Equal(p.Assigned(), []string{"a", "b", "c"}) || !slices.Equal(p.Refs(), []string{"e"}) {131+ t.Errorf("assigned %v refs %v", p.Assigned(), p.Refs())132+ }133+ cmd, exports, _ := p.Command()134+ want := "let a = ls\nlet b = $a | length; let c = $b + 1\nlet d = 2\n{__out: ($c + $d + $e), a: $a, b: $b, c: $c}"135+ if cmd != want || !slices.Equal(exports, []string{"a", "b", "c"}) {136+ t.Errorf("command %q exports %v", cmd, exports)137+ }138+ // The last statement's name is the output: it's not exported apart.139+ cmd, exports, _ = Parse("x = 1\nx = $x + 1").Command()140+ if cmd != "let x = 1\n$x + 1" || len(exports) != 0 {141+ t.Errorf("command %q exports %v", cmd, exports)142+ }143+ // $x on the line assigning x reads the x from before.144+ if got := Parse("x = $x | first").Refs(); !slices.Equal(got, []string{"x"}) {38145 t.Errorf("refs %v", got)39146 }40- if !ReadsSelection("$selection | math sum") || ReadsSelection("$selections") {41- t.Error("ReadsSelection")147+}148+149+// A variable of the cell's own shadows another cell's name: the cell150+// doesn't read that cell.151+func TestLocalShadows(t *testing.T) {152+ cs := cells("sales = open sales.csv", "sales = [[a]; [1]]\n$sales | length", "n = $sales | length")153+ if got := Reads(cs, Vars(cs), 1); len(got) != 0 {154+ t.Errorf("the shadowing cell reads %v", got)155+ }156+ if got := Reads(cs, Vars(cs), 2); !slices.Equal(got, []int{0}) {157+ t.Errorf("the third cell reads %v", got)158+ }159+ if got := Dependents(cs, 0); !slices.Equal(got, []int{2}) {160+ t.Errorf("dependents %v", got)161+ }162+ if Vars(cs)["sales"] != 0 {163+ t.Errorf("sales is the first cell's, as its name: %v", Vars(cs))164+ }165+ // A cell reading another's name before assigning it reads that cell.166+ cs = cells("sales = ls", "big = $sales | first\nsales = 1\n$sales")167+ if got := Reads(cs, Vars(cs), 1); !slices.Equal(got, []int{0}) {168+ t.Errorf("reads %v", got)42169 }43170 }44171 45-func TestBind(t *testing.T) {46- got, refs := Bind("$sheet.A1:C9 | append $sheet.'Q1 data'!B2:B5 | append $sheet.A1:C9.name | $x$sheet.A1 | $sheet.")47- want := "$__sheet1 | append $__sheet2 | append $__sheet1.name | $x$sheet.A1 | $sheet."172+func TestRanges(t *testing.T) {173+ src := "$sheet.A1:C9 | append $sheet.'Q1 data'!B2:B5 | append $sheet.A1:C9.name | $x$sheet.A1 | $sheet. | append \"$sheet.A1\""174+ p := Parse(src)175+ var spans []string176+ for _, s := range p.Ranges {177+ spans = append(spans, src[s[0]:s[1]])178+ }179+ if !slices.Equal(spans, []string{"$sheet.A1:C9", "$sheet.'Q1 data'!B2:B5", "$sheet.A1:C9"}) {180+ t.Errorf("ranges %q", spans)181+ }182+ got, _, refs := p.Command()183+ want := "$__sheet1 | append $__sheet2 | append $__sheet1.name | $x$sheet.A1 | $sheet. | append \"$sheet.A1\""48184 if got != want {49- t.Errorf("Bind = %q", got)185+ t.Errorf("command = %q", got)50186 }51187 if len(refs) != 2 || refs[0].Ref != "A1:C9" || refs[1].Ref != "'Q1 data'!B2:B5" || refs[1].Var != "__sheet2" {52188 t.Errorf("refs %+v", refs)53189 }54- src := "$sheet.A1:C9 | append $sheet.'Q1 data'!B2:B5 | append $sheet.A1:C9.name | $x$sheet.A1 | $sheet."55- var spans []string56- for _, s := range RangeSpans(src) {57- spans = append(spans, src[s[0]:s[1]])190+ if !slices.Equal(p.Refs(), []string{"x"}) {191+ t.Errorf("refs %v", p.Refs())58192 }59- if !slices.Equal(spans, []string{"$sheet.A1:C9", "$sheet.'Q1 data'!B2:B5", "$sheet.A1:C9"}) {60- t.Errorf("RangeSpans %q", spans)193+ if !Parse("$selection | math sum").ReadsSelection() || Parse("$selections").ReadsSelection() {194+ t.Error("ReadsSelection")195+ }196+ if got := Parse("$r1 | where x == $in.a and $nu.home | $r_2.x | $selection | $sheet.A1").Refs(); !slices.Equal(got, []string{"r1", "r_2"}) {197+ t.Errorf("refs %v", got)61198 }62199 }63200 @@ -95,7 +232,7 @@ func TestStale(t *testing.T) {
95232 out := func(id int) *Output { return outs[id] }96233 run := func(i int, seq int) {97234 o := &Output{NUON: []byte("x"), Seq: seq, Count: seq, Source: cs[i].Source, Reads: map[string]int{}}98- for _, n := range Refs(cs[i].Pipeline()) {235+ for _, n := range cs[i].Parse().Refs() {99236 if j, ok := Names(cs)[n]; ok && outs[cs[j].ID] != nil {100237 o.Reads[n] = outs[cs[j].ID].Seq101238 }@@ -1,17 +1,14 @@
11 package notebook22 3-import (4- "slices"5- "strconv"6- "strings"7-)3+import "strings"84 95 // What a pipeline reads. A code cell reads another's output as $name,106 // the selection on a sheet as $selection, and a range of a sheet as117 // $sheet.A1:C9 (or $sheet.Sales!A1:C9, $sheet.'Q1 data'!A1:B5). Each128 // reaches nu as a variable holding a table, read from a NUON file, never139 // as text spliced into the pipeline: a $sheet range is renamed to a14-// variable of its own ($__sheet1) before the pipeline runs.10+// variable of its own ($__sheet1) before the pipeline runs11+// (Source.Command).1512 1613 // Selection is the variable that holds the selection.1714 const Selection = "selection"@@ -19,43 +16,6 @@ const Selection = "selection"
1916 // sheetVar starts a range of a sheet.2017 const sheetVar = "$sheet."2118 22-// Refs returns the names a pipeline reads as $name, in the order it23-// first names them, leaving out nushell's variables and the notebook's24-// own ($selection, $sheet).25-func Refs(pipeline string) []string {26- var out []string27- for i := 0; i < len(pipeline); i++ {28- if pipeline[i] != '$' {29- continue30- }31- j := i + 132- for j < len(pipeline) && isWord(pipeline[j]) {33- j++34- }35- name := pipeline[i+1 : j]36- if name != "" && ValidName(name) == nil && !slices.Contains(out, name) {37- out = append(out, name)38- }39- i = j - 140- }41- return out42-}43-44-// ReadsSelection reports whether a pipeline reads $selection.45-func ReadsSelection(pipeline string) bool {46- for i := 0; ; {47- k := strings.Index(pipeline[i:], "$"+Selection)48- if k < 0 {49- return false50- }51- end := i + k + 1 + len(Selection)52- if end == len(pipeline) || !isWord(pipeline[end]) {53- return true54- }55- i = end56- }57-}58-5919 // SheetRef is a range of a sheet a pipeline reads, $sheet.A1:C9.6020 type SheetRef struct {6121 // Ref is the range as written after $sheet.: "A1:C9", "Sales!A1:C9".@@ -64,63 +24,6 @@ type SheetRef struct {
6424 Var string6525 }6626 67-// Bind renames each range of a sheet the pipeline reads ($sheet.A1:C9)68-// to a variable of its own, returning the pipeline to run and the ranges69-// in the order they appear, the same range read twice once.70-func Bind(pipeline string) (string, []SheetRef) {71- var b strings.Builder72- var refs []SheetRef73- rest := pipeline74- for {75- k := strings.Index(rest, sheetVar)76- if k < 0 || k > 0 && isWord(rest[k-1]) {77- if k < 0 {78- b.WriteString(rest)79- return b.String(), refs80- }81- b.WriteString(rest[:k+len(sheetVar)])82- rest = rest[k+len(sheetVar):]83- continue84- }85- b.WriteString(rest[:k])86- rest = rest[k+len(sheetVar):]87- n := refLen(rest)88- if n == 0 {89- b.WriteString(sheetVar)90- continue91- }92- ref := rest[:n]93- rest = rest[n:]94- i := slices.IndexFunc(refs, func(r SheetRef) bool { return r.Ref == ref })95- if i < 0 {96- refs = append(refs, SheetRef{Ref: ref, Var: "__sheet" + strconv.Itoa(len(refs)+1)})97- i = len(refs) - 198- }99- b.WriteString("$" + refs[i].Var)100- }101-}102-103-// RangeSpans are where a pipeline reads ranges of sheets, by byte104-// offsets: each $sheet.A1:C9 whole, as Bind renames them.105-func RangeSpans(pipeline string) [][2]int {106- var out [][2]int107- for i := 0; ; {108- k := strings.Index(pipeline[i:], sheetVar)109- if k < 0 {110- return out111- }112- k += i113- i = k + len(sheetVar)114- if k > 0 && isWord(pipeline[k-1]) {115- continue116- }117- if n := refLen(pipeline[i:]); n > 0 {118- i += n119- out = append(out, [2]int{k, i})120- }121- }122-}123-12427 // refLen is how long the range at the start of s is: a sheet's name,12528 // quoted in ” if it needs to be, then ! and cells, or cells alone. A12629 // dot ends it, so a cell path may follow: $sheet.A1:C9.name.@@ -0,0 +1,297 @@
1+package notebook2+3+import (4+ "slices"5+ "strings"6+)7+8+// How 012 reads a code cell's source, as nu does: its comments and9+// strings apart from its code, and its statements. A statement is a10+// line, or a part of one between `;`, at the top level (outside11+// brackets); a pipeline goes on over the next line when that line12+// starts with `|` (comment lines between allowed, a blank line not), or13+// when its own line ends in `|` or in an assignment's `=`. A statement14+// `name = pipeline` assigns name, as nu's `let name = pipeline` does.15+16+// Source is a code cell's source as 012 reads it.17+type Source struct {18+ Text string19+ // Stmts are its statements, in order.20+ Stmts []Stmt21+ // Comments are its comments' bytes, # to the end of the line, and22+ // Strings its strings', quotes included.23+ Comments, Strings [][2]int24+ // Ranges are where it reads ranges of sheets, each $sheet.A1:C925+ // whole.26+ Ranges [][2]int27+ // code is Text with its comments blanked, and view is code with its28+ // strings blanked too, but for the code interpolated into them:29+ // where names are read.30+ code, view []byte31+}32+33+// Stmt is a statement of a source, by byte offsets into it.34+type Stmt struct {35+ // From and To are its bytes, without the spaces and comments around.36+ From, To int37+ // Body is where what it runs starts: after `name =`, or From.38+ Body int39+ // Name is what `name = pipeline` assigns, or "".40+ Name string41+ // Local is the variable `let name = ...` or `mut name = ...` binds,42+ // which only the cell's later statements read, or "".43+ Local string44+}45+46+// Parse reads src.47+func Parse(src string) Source {48+ s := &scanner{src: src, view: []byte(src)}49+ s.code(0, 0)50+ code := []byte(src)51+ for _, c := range s.comments {52+ blank(code, c[0], c[1])53+ }54+ return Source{Text: src, Stmts: statements(src, s.view), Comments: s.comments, Strings: s.strs, Ranges: s.ranges, code: code, view: s.view}55+}56+57+// Name is what the source's last statement assigns: the cell's output's58+// name, or "".59+func (s Source) Name() string {60+ if len(s.Stmts) == 0 {61+ return ""62+ }63+ return s.Stmts[len(s.Stmts)-1].Name64+}65+66+// Assigned are the names the statements assign, in the order they67+// first do.68+func (s Source) Assigned() []string {69+ var out []string70+ for _, st := range s.Stmts {71+ if st.Name != "" && !slices.Contains(out, st.Name) {72+ out = append(out, st.Name)73+ }74+ }75+ return out76+}77+78+// Refs are the names the source reads as $name from outside it, in the79+// order it first names them: not a name an earlier statement assigns or80+// binds with let, nor nushell's variables and the notebook's own81+// ($selection, $sheet), nor one in a comment or a string.82+func (s Source) Refs() []string {83+ var out []string84+ v := s.view85+ for i := 0; i < len(v); i++ {86+ if v[i] != '$' {87+ continue88+ }89+ j := i + 190+ for j < len(v) && isWord(v[j]) {91+ j++92+ }93+ name := string(v[i+1 : j])94+ if name != "" && ValidName(name) == nil && !slices.Contains(out, name) && !s.local(name, i) {95+ out = append(out, name)96+ }97+ i = j - 198+ }99+ return out100+}101+102+// local reports whether name, read at offset at, is a variable of the103+// cell's own: a statement before assigns or binds it.104+func (s Source) local(name string, at int) bool {105+ return slices.ContainsFunc(s.Stmts, func(st Stmt) bool {106+ return st.To <= at && (st.Name == name || st.Local == name)107+ })108+}109+110+// ReadsSelection reports whether the source reads $selection.111+func (s Source) ReadsSelection() bool {112+ v, want := string(s.view), "$"+Selection113+ for i := 0; ; {114+ k := strings.Index(v[i:], want)115+ if k < 0 {116+ return false117+ }118+ end := i + k + len(want)119+ if end == len(v) || !isWord(v[end]) {120+ return true121+ }122+ i = end123+ }124+}125+126+// Heads are the bytes of each assignment's `name =`, up to what it runs.127+func (s Source) Heads() [][2]int {128+ var out [][2]int129+ for _, st := range s.Stmts {130+ if st.Name != "" {131+ out = append(out, [2]int{st.From, st.Body})132+ }133+ }134+ return out135+}136+137+// scanner tells a source's comments and strings from its code.138+type scanner struct {139+ src string140+ view []byte141+ comments, strs, ranges [][2]int142+}143+144+// code reads code from i up to the bracket close that ends it (0 for145+// the end of the source), returning where it stopped.146+func (s *scanner) code(i int, close byte) int {147+ for i < len(s.src) {148+ c := s.src[i]149+ switch {150+ case close != 0 && c == close:151+ return i152+ case c == '(' || c == '[' || c == '{':153+ i = s.code(i+1, closer(c))154+ if i < len(s.src) {155+ i++156+ }157+ case c == '#' && s.tokenStart(i, close != 0):158+ i = s.comment(i)159+ case c == '"' || c == '\'' || c == '`':160+ i = s.str(i, i+1, c)161+ case c == '$':162+ i = s.dollar(i)163+ case c == 'r' && s.tokenStart(i, close != 0) && rawHashes(s.src[i+1:]) > 0:164+ i = s.raw(i)165+ default:166+ i++167+ }168+ }169+ return i170+}171+172+func closer(c byte) byte {173+ switch c {174+ case '(':175+ return ')'176+ case '[':177+ return ']'178+ }179+ return '}'180+}181+182+// tokenStart reports whether a token may start at i: after a space, a183+// pipe, a ; or an opening bracket, and inside brackets after a , or a184+// : too, where nu reads a # as a comment.185+func (s *scanner) tokenStart(i int, inside bool) bool {186+ if i == 0 {187+ return true188+ }189+ p := s.src[i-1]190+ return strings.IndexByte(" \t\r\n|;([{", p) >= 0 || inside && (p == ',' || p == ':')191+}192+193+func (s *scanner) comment(i int) int {194+ j := i195+ for j < len(s.src) && s.src[j] != '\n' {196+ j++197+ }198+ s.comments = append(s.comments, [2]int{i, j})199+ blank(s.view, i, j)200+ return j201+}202+203+// str reads a quoted string opened at start, its text from i: a double204+// quoted one has escapes, a single quoted or backquoted one none.205+func (s *scanner) str(start, i int, q byte) int {206+ for i < len(s.src) && s.src[i] != q {207+ if q == '"' && s.src[i] == '\\' {208+ i++209+ }210+ i++211+ }212+ i = min(i+1, len(s.src))213+ s.strs = append(s.strs, [2]int{start, i})214+ blank(s.view, start, i)215+ return i216+}217+218+// dollar reads what starts with $ at i: an interpolated string, a range219+// of a sheet, or a variable.220+func (s *scanner) dollar(i int) int {221+ rest := s.src[i+1:]222+ switch {223+ case strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, "'"):224+ return s.interpolated(i)225+ case strings.HasPrefix(s.src[i:], sheetVar) && (i == 0 || !isWord(s.src[i-1])):226+ from := i + len(sheetVar)227+ if n := refLen(s.src[from:]); n > 0 {228+ s.ranges = append(s.ranges, [2]int{i, from + n})229+ blank(s.view, from, from+n) // its quotes and brackets aren't code230+ return from + n231+ }232+ }233+ return i + 1234+}235+236+// interpolated reads $"..." or $'...' at i: text, blanked, and the code237+// in its parentheses, read as code.238+func (s *scanner) interpolated(i int) int {239+ q := s.src[i+1]240+ j := i + 2241+ blank(s.view, i, j)242+ for j < len(s.src) && s.src[j] != q {243+ switch {244+ case q == '"' && s.src[j] == '\\':245+ blank(s.view, j, min(j+2, len(s.src)))246+ j += 2247+ case s.src[j] == '(':248+ j = s.code(j+1, ')')249+ if j < len(s.src) {250+ j++251+ }252+ default:253+ blank(s.view, j, j+1)254+ j++255+ }256+ }257+ j = min(j+1, len(s.src))258+ blank(s.view, j-1, j)259+ s.strs = append(s.strs, [2]int{i, j})260+ return j261+}262+263+// rawHashes is how many # open a raw string at the start of s, r#'...'#264+// without its r, or 0.265+func rawHashes(s string) int {266+ n := 0267+ for n < len(s) && s[n] == '#' {268+ n++269+ }270+ if n == 0 || n == len(s) || s[n] != '\'' {271+ return 0272+ }273+ return n274+}275+276+// raw reads a raw string at i, to its ' and as many # as opened it.277+func (s *scanner) raw(i int) int {278+ n := rawHashes(s.src[i+1:])279+ end := "'" + strings.Repeat("#", n)280+ body := i + 2 + n281+ j := len(s.src)282+ if k := strings.Index(s.src[body:], end); k >= 0 {283+ j = body + k + len(end)284+ }285+ s.strs = append(s.strs, [2]int{i, j})286+ blank(s.view, i, j)287+ return j288+}289+290+// blank makes b[from:to] spaces, but for its line breaks.291+func blank(b []byte, from, to int) {292+ for i := from; i < to && i < len(b); i++ {293+ if b[i] != '\n' {294+ b[i] = ' '295+ }296+ }297+}@@ -0,0 +1,193 @@
1+package notebook2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+)8+9+// statements splits a source into its statements, reading view, the10+// source with its comments and strings blanked.11+func statements(src string, view []byte) []Stmt {12+ var out []Stmt13+ depth, from := 0, 014+ for i := 0; i <= len(view); i++ {15+ if i < len(view) {16+ switch c := view[i]; {17+ case c == '(' || c == '[' || c == '{':18+ depth++19+ continue20+ case c == ')' || c == ']' || c == '}':21+ depth = max(depth-1, 0)22+ continue23+ case c != ';' && c != '\n', depth > 0, c == '\n' && continues(src, view, from, i):24+ continue25+ }26+ }27+ if st, ok := statement(view, from, i); ok {28+ out = append(out, st)29+ }30+ from = i + 131+ }32+ return out33+}34+35+// continues reports whether the statement from from goes on past the36+// line break at i: its line ends in | or in an assignment's =, or the37+// next line, past comment lines, starts with |.38+func continues(src string, view []byte, from, i int) bool {39+ before := strings.TrimRight(string(view[from:i]), " \t\r")40+ if strings.HasSuffix(before, "|") || strings.HasSuffix(before, "=") {41+ return true42+ }43+ for j := i + 1; j < len(view); {44+ end := j45+ for end < len(view) && view[end] != '\n' {46+ end++47+ }48+ line := strings.TrimSpace(string(view[j:end]))49+ switch {50+ case strings.HasPrefix(line, "|"):51+ return true52+ case line != "" || strings.TrimSpace(src[j:end]) == "":53+ return false // code, or a blank line54+ }55+ j = end + 1 // a comment line56+ }57+ return false58+}59+60+// statement is the statement in view[from:to], if there's one.61+func statement(view []byte, from, to int) (Stmt, bool) {62+ for from < to && isSpace(view[from]) {63+ from++64+ }65+ for to > from && isSpace(view[to-1]) {66+ to--67+ }68+ if from == to {69+ return Stmt{}, false70+ }71+ st := Stmt{From: from, To: to, Body: from}72+ name, rest := word(view, from, to)73+ rest = skipSpaces(view, rest, to, " \t")74+ switch {75+ case rest < to && view[rest] == '=' && ValidName(name) == nil && !opAt(view, rest+1, to):76+ st.Name, st.Body = name, skipSpaces(view, rest+1, to, " \t\r\n")77+ case name == "let" || name == "mut" || name == "const":78+ st.Local, _ = word(view, skipSpaces(view, rest, to, " \t"), to)79+ }80+ return st, true81+}82+83+// opAt reports whether an = ending at i is part of an operator: ==, =~.84+func opAt(view []byte, i, to int) bool { return i < to && (view[i] == '=' || view[i] == '~') }85+86+// word is the word of letters, digits and _ at from, and where it ends.87+func word(view []byte, from, to int) (string, int) {88+ i := from89+ for i < to && isWord(view[i]) {90+ i++91+ }92+ return string(view[from:i]), i93+}94+95+func skipSpaces(view []byte, i, to int, spaces string) int {96+ for i < to && strings.IndexByte(spaces, view[i]) >= 0 {97+ i++98+ }99+ return i100+}101+102+func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' }103+104+// OutputVar is the name of the variable that holds a cell's output in105+// the record a run hands back when it also hands back variables106+// (nushell.ExecVars).107+const OutputVar = "__out"108+109+// Command is what nu runs for the source: each statement but the last110+// that assigns a name as `let name = ...`, the last without its `name =`111+// (its value is the output), the ranges of sheets renamed to variables112+// of their own ($__sheet1), and the comments blanked, so what's left113+// keeps its place. When statements before the last assign names the114+// last doesn't, those are exports, and the command ends in a record of115+// the output (as __out) and each of them.116+func (s Source) Command() (cmd string, exports []string, ranges []SheetRef) {117+ return s.command(true)118+}119+120+// StreamCommand is what nu runs for the source as a stream: Command121+// without the record, so the last statement's values stream as they122+// come, and the names the statements before it assign stay the run's.123+func (s Source) StreamCommand() string {124+ cmd, _, _ := s.command(false)125+ return cmd126+}127+128+func (s Source) command(record bool) (cmd string, exports []string, ranges []SheetRef) {129+ var edits []edit130+ for _, r := range s.Ranges {131+ ref := s.Text[r[0]+len(sheetVar) : r[1]]132+ i := slices.IndexFunc(ranges, func(x SheetRef) bool { return x.Ref == ref })133+ if i < 0 {134+ ranges = append(ranges, SheetRef{Ref: ref, Var: "__sheet" + strconv.Itoa(len(ranges)+1)})135+ i = len(ranges) - 1136+ }137+ edits = append(edits, edit{r[0], r[1], "$" + ranges[i].Var})138+ }139+ if len(s.Stmts) == 0 {140+ return apply(s.code, edits), nil, ranges141+ }142+ last := s.Stmts[len(s.Stmts)-1]143+ for _, st := range s.Stmts[:len(s.Stmts)-1] {144+ if st.Name == "" {145+ continue146+ }147+ edits = append(edits, edit{st.From, st.Body, "let " + st.Name + " = "})148+ if st.Name != last.Name && !slices.Contains(exports, st.Name) {149+ exports = append(exports, st.Name)150+ }151+ }152+ if last.Name != "" {153+ edits = append(edits, edit{last.From, last.Body, ""})154+ }155+ if !record {156+ exports = nil157+ }158+ if len(exports) > 0 {159+ var rec strings.Builder160+ rec.WriteString(")")161+ for _, e := range exports {162+ rec.WriteString(", " + e + ": $" + e)163+ }164+ rec.WriteString("}")165+ edits = append(edits, edit{last.Body, last.Body, "{" + OutputVar + ": ("}, edit{last.To, last.To, rec.String()})166+ }167+ return apply(s.code, edits), exports, ranges168+}169+170+// edit replaces b[from:to] with text.171+type edit struct {172+ from, to int173+ text string174+}175+176+// apply makes edits to b, which don't overlap.177+func apply(b []byte, edits []edit) string {178+ slices.SortStableFunc(edits, func(x, y edit) int {179+ if x.from != y.from {180+ return x.from - y.from181+ }182+ return x.to - y.to183+ })184+ var out strings.Builder185+ at := 0186+ for _, e := range edits {187+ out.Write(b[at:e.from])188+ out.WriteString(e.text)189+ at = e.to190+ }191+ out.Write(b[at:])192+ return out.String()193+}@@ -10,6 +10,7 @@ package nushell
1010 import (1111 "bytes"1212 "context"13+ "encoding/base64"1314 "errors"1415 "fmt"1516 "io"@@ -33,6 +34,10 @@ type Job struct {
3334 Tables map[string][]byte3435 // Config runs nu with the user's config files rather than without.3536 Config bool37+ // Vars are the variables the run hands back beside its output: the38+ // command ends in a record of its output, as __out, and each of39+ // them, and the run prints each as a line of its own (ExecVars).40+ Vars []string3641 // IDE asks nu about the script rather than running it: the flags3742 // that ask (--ide-ast, or --ide-complete 14), nu reading the script3843 // from a file named after them, without config files or the@@ -77,10 +82,43 @@ func Script(job Job, names []string) string {
7782 for i, name := range names {7883 fmt.Fprintf(&b, "let %s = (open --raw $env.NU012_TABLE_%d | from nuon)\n", name, i)7984 }80- b.WriteString("do {\n" + job.Command + "\n} | to nuon\n")85+ b.WriteString("do {\n" + job.Command + "\n} | ")86+ if len(job.Vars) > 0 {87+ b.WriteString(`items {|k, v| $"($k) ($v | to nuon | encode base64)" } | str join "\n"` + "\n")88+ } else {89+ b.WriteString("to nuon\n")90+ }8191 return b.String()8292 }8393 94+// outVar names the output among a run's variables.95+const outVar = "__out"96+97+// ExecVars runs job as Exec does, returning its output and, when it98+// hands back variables (Job.Vars), their values, each as NUON. They're99+// the last lines it printed, each a name and its NUON as base64, so100+// what the pipeline printed itself before them doesn't mix in.101+func ExecVars(ctx context.Context, r Runner, job Job, timeout time.Duration, maxBytes int) ([]byte, map[string][]byte, error) {102+ out, err := Exec(ctx, r, job, timeout, maxBytes)103+ if err != nil || len(job.Vars) == 0 {104+ return out, nil, err105+ }106+ lines := strings.Split(string(out), "\n")107+ vars := map[string][]byte{}108+ for _, line := range lines[max(len(lines)-len(job.Vars)-1, 0):] {109+ name, enc, _ := strings.Cut(strings.TrimSpace(line), " ")110+ if data, err := base64.StdEncoding.DecodeString(enc); err == nil && (name == outVar || slices.Contains(job.Vars, name)) {111+ vars[name] = data112+ }113+ }114+ nuon, ok := vars[outVar]115+ if !ok || len(vars) != len(job.Vars)+1 {116+ return nil, nil, errors.New("the run didn't hand back its variables")117+ }118+ delete(vars, outVar)119+ return nuon, vars, nil120+}121+84122 // ErrTooLarge is a run that printed more than it may keep.85123 var ErrTooLarge = errors.New("the output is too large to keep")86124 @@ -53,6 +53,42 @@ func TestExec(t *testing.T) {
5353 }5454 }5555 56+// A run handing back variables prints a line for each after what the57+// pipeline printed itself.58+func TestExecVars(t *testing.T) {59+ job := Job{Command: "x", Vars: []string{"files"}}60+ f := &fake{out: "printed\n__out WzEsIDJd\nfiles WzNd\n"}61+ out, vars, err := ExecVars(context.Background(), f, job, time.Second, 0)62+ if err != nil || string(out) != "[1, 2]" || string(vars["files"]) != "[3]" || len(vars) != 1 {63+ t.Errorf("%q %q %v", out, vars, err)64+ }65+ if !strings.Contains(f.script, `items {|k, v|`) || strings.Contains(f.script, "| to nuon\n") {66+ t.Errorf("script %s", f.script)67+ }68+ if _, _, err := ExecVars(context.Background(), &fake{out: "[1, 2]"}, job, time.Second, 0); err == nil {69+ t.Error("no variables came back, and no error")70+ }71+ out, vars, err = ExecVars(context.Background(), &fake{out: "[1, 2]"}, Job{Command: "x"}, time.Second, 0)72+ if err != nil || string(out) != "[1, 2]" || vars != nil {73+ t.Errorf("without variables %q %v %v", out, vars, err)74+ }75+}76+77+// The real nu hands back a cell's variables beside its output.78+func TestNuVars(t *testing.T) {79+ if _, err := exec.LookPath("nu"); err != nil {80+ t.Skip("nu isn't installed")81+ }82+ job := Job{83+ Command: "let files = [[name, size]; [a, 2kb], [b, 10b]]\n{__out: ($files | where size > 1kb | get name), files: ($files | length)}",84+ Vars: []string{"files"},85+ }86+ out, vars, err := ExecVars(context.Background(), Nu{}, job, 30*time.Second, 0)87+ if err != nil || string(out) != "[a]" || string(vars["files"]) != "2" {88+ t.Errorf("%q %q %v", out, vars, err)89+ }90+}91+5692 func TestMessage(t *testing.T) {5793 stderr := "Error: nu::shell::column_not_found\n\n × Cannot find column 'xyz'\n ╭─[source:1:6]\n"5894 if got := Message(stderr, errors.New("exit status 1")); got != "Cannot find column 'xyz'" {@@ -167,8 +167,11 @@ func (w *Workbook) convertOld(old []oldRegion) {
167167 for _, o := range old {168168 fr := o.fr169169 src := fr.Command170- if fr.Input != "" {170+ switch {171+ case fr.Input != "":171172 src = "$sheet." + quoteRef(fr.Input) + " | do {\n" + src + "\n}"173+ case len(notebook.Parse(src).Stmts) > 1: // one statement, so the name is its output's174+ src = "do {\n" + src + "\n}"172175 }173176 nb.regions.cells = append(nb.regions.cells, notebook.Cell{ID: w.NewCellID(), Source: fr.Name + " = " + src})174177 at, _ := ParseAddr(fr.At)@@ -267,7 +267,8 @@ func TestOldNotebookConverted(t *testing.T) {
267267 {"name": "Shell 1", "notebook": true, "cells": {}, "regions": [268268 {"name": "r1", "command": "ls", "at": "A1", "rows": 3, "cols": 2},269269 {"name": "big", "command": "$r1 | where size > 1kb", "at": "A6", "reads": ["r1"]},270- {"name": "sel", "command": "$in | math sum", "at": "A9", "input": "Data 1!A1:A4"}270+ {"name": "sel", "command": "$in | math sum", "at": "A9", "input": "Data 1!A1:A4"},271+ {"name": "two", "command": "let x = 1\n$x + 1", "at": "A12"}271272 ]},272273 {"name": "Data 1", "cells": {"B1": "=SUM(nu.big)"}}273274 ]}`@@ -280,7 +281,7 @@ func TestOldNotebookConverted(t *testing.T) {
280281 t.Fatalf("notebook %v at %d", nb, w.Index(nb))281282 }282283 cells := nb.NotebookCells()283- want := []string{"r1 = ls", "big = $r1 | where size > 1kb", "sel = $sheet.'Data 1'!A1:A4 | do {\n$in | math sum\n}"}284+ want := []string{"r1 = ls", "big = $r1 | where size > 1kb", "sel = $sheet.'Data 1'!A1:A4 | do {\n$in | math sum\n}", "two = do {\nlet x = 1\n$x + 1\n}"}284285 for i, c := range cells {285286 if c.Source != want[i] {286287 t.Errorf("cell %d: %q, want %q", i+1, c.Source, want[i])@@ -5,6 +5,7 @@ import (
55 "context"66 "errors"77 "fmt"8+ "slices"89 "time"910 1011 tea "charm.land/bubbletea/v2"@@ -20,26 +21,22 @@ import (
2021 // ($selection) and ranges of sheets ($sheet.A1:C9), each handed to nu as2122 // NUON, never spliced into the pipeline.2223 23-// jobFor is what running cell c takes: the outputs, linked files and24-// ranges it reads, as NUON.25-func (m *Model) jobFor(s *sheet.Sheet, c notebook.Cell, run *nbRun) (nushell.Job, error) {26- pipeline, ranges := notebook.Bind(c.Pipeline())27- job := nushell.Job{Command: pipeline, Tables: map[string][]byte{}, Config: m.configBool("nu-config", false)}28- run.reads = map[string]int{}24+// jobFor is what running cell c takes: the outputs, variables, linked25+// files and ranges it reads, as NUON. As a stream, the cell hands back26+// no variables but its output (notebook.Source.StreamCommand).27+func (m *Model) jobFor(s *sheet.Sheet, c notebook.Cell, run *nbRun, stream bool) (nushell.Job, error) {2928 cells := s.NotebookCells()30- names := notebook.Names(cells)31- for _, name := range notebook.Refs(pipeline) {32- if j, ok := names[name]; ok {33- o := m.book().Output(cells[j].ID)34- switch {35- case o == nil || o.Unsaved:36- return job, fmt.Errorf("it reads $%s, which hasn't run", name)37- case o.Failed():38- return job, fmt.Errorf("it reads $%s, which failed", name)39- }40- job.Tables[name], run.reads[name] = o.NUON, o.Seq41- continue42- }29+ i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID })30+ p, err := notebook.Prepare(cells, i, m.book().Output)31+ job := nushell.Job{Command: p.Command, Vars: p.Exports, Tables: p.Tables, Config: m.configBool("nu-config", false)}32+ if stream {33+ job.Command, job.Vars = p.Stream, nil34+ }35+ run.reads = p.Reads36+ if err != nil {37+ return job, err38+ }39+ for _, name := range p.Others {4340 if t, r, ok := m.book().Region(name); ok && r.Linked() && r.Name == name {4441 data, err := regionNUON(t, r)4542 if err != nil {@@ -48,12 +45,12 @@ func (m *Model) jobFor(s *sheet.Sheet, c notebook.Cell, run *nbRun) (nushell.Job
4845 job.Tables[name] = data4946 }5047 }51- if notebook.ReadsSelection(pipeline) {48+ if p.Selection {5249 if err := m.bindSelection(&job, run); err != nil {5350 return job, err5451 }5552 }56- for _, ref := range ranges {53+ for _, ref := range p.Ranges {5754 t, rng, err := m.rangeRef(ref.Ref)5855 if err != nil {5956 return job, err@@ -1,6 +1,7 @@
11 package ui22 33 import (4+ "slices"45 "strings"56 67 "github.com/FelineStateMachine/012/internal/sheet"@@ -63,8 +64,14 @@ func (m *Model) syncLang(s *sheet.Sheet, v *nbview.View) {
6364 func (m *Model) nbWords(s *sheet.Sheet) []nbview.Word {6465 var out []nbview.Word6566 for _, c := range s.NotebookCells() {66- if n := c.Name(); n != "" {67- out = append(out, nbview.Word{Text: "$" + n, Desc: "a cell's output"})67+ name := c.Name()68+ if name != "" {69+ out = append(out, nbview.Word{Text: "$" + name, Desc: "a cell's output"})70+ }71+ for _, n := range c.Parse().Assigned() {72+ if n != name && !slices.ContainsFunc(out, func(w nbview.Word) bool { return w.Text == "$"+n }) {73+ out = append(out, nbview.Word{Text: "$" + n, Desc: "a cell's variable"})74+ }6875 }6976 }7077 for _, t := range m.book().Sheets() {@@ -107,6 +107,7 @@ type (
107107 nbDoneMsg struct {108108 run *nbRun109109 nuon []byte110+ vars map[string][]byte // the variables it handed back110111 err error111112 }112113 // nbWordsMsg brings nu's command names.@@ -294,7 +295,7 @@ func (m *Model) nextCell() tea.Cmd {
294295 }295296 m.stopStream(q.id) // run once, it no longer follows296297 run := &nbRun{nbQueued: q, source: c.Source, start: time.Now()}297- job, err := m.jobFor(q.s, c, run)298+ job, err := m.jobFor(q.s, c, run, false)298299 if err != nil {299300 m.failCell(q, c, err.Error())300301 continue@@ -336,13 +337,13 @@ func (m *Model) startCell(run *nbRun, job nushell.Job) tea.Cmd {
336337 exec := func() tea.Msg {337338 defer cancel()338339 span := parent.Start("nu", slog.Int("tables", len(job.Tables)))339- out, err := nushell.Exec(ctx, runner, job, timeout, maxOutput)340+ out, vars, err := nushell.ExecVars(ctx, runner, job, timeout, maxOutput)340341 if err != nil {341342 span.Fail(err)342343 } else {343344 span.End(slog.Int("bytes", len(out)))344345 }345- return nbDoneMsg{run: run, nuon: out, err: err}346+ return nbDoneMsg{run: run, nuon: out, vars: vars, err: err}346347 }347348 return tea.Batch(m.roomOwned(exec), m.tickCell(run.gen))348349 }@@ -388,7 +389,7 @@ func (m *Model) finishCell(msg nbDoneMsg) tea.Cmd {
388389 }389390 m.nb.runs.queue = nil390391 } else {391- o.NUON = msg.nuon392+ o.NUON, o.Vars = msg.nuon, msg.vars392393 if o.NUON == nil {393394 o.NUON = []byte{}394395 }@@ -0,0 +1,112 @@
1+package ui2+3+import (4+ "encoding/base64"5+ "slices"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+)11+12+// ownersCell is a cell of two statements: files is its own variable,13+// read by its second line.14+const ownersCell = "files = ls | where type == file\n$files | where size > 1kb | sort-by size --reverse"15+16+// varsOut is what nu prints for a run handing back variables: a line17+// for the output and each variable, its NUON as base64.18+func varsOut(out string, vars ...string) string {19+ enc := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }20+ lines := []string{"__out " + enc(out)}21+ for i := 0; i+1 < len(vars); i += 2 {22+ lines = append(lines, vars[i]+" "+enc(vars[i+1]))23+ }24+ return strings.Join(lines, "\n") + "\n"25+}26+27+// nuCommand is what nu runs for src.28+func nuCommand(src string) string {29+ cmd, _, _ := notebook.Parse(src).Command()30+ return cmd31+}32+33+// The owner's cell runs as a script: files is the cell's own, not a cell34+// it reads, its output is its last line's, it has no name, and a later35+// cell reads $files from its run.36+func TestNotebookStatements(t *testing.T) {37+ big := "[[name, size]; [c.txt, 5kb], [a.txt, 2kb]]"38+ m, nu := notebookModel(t, map[string]string{39+ nuCommand(ownersCell): varsOut(big, "files", lsOut),40+ "$files | length": "3",41+ })42+ write(t, m, ownersCell)43+ write(t, m, "$files | length")44+ press(t, m, "<ctrl+enter>") // the second runs the first first45+ if got := nu.ran(); len(got) != 2 || !strings.HasPrefix(got[0], "let files = ls") {46+ t.Fatalf("ran %q", got)47+ }48+ if !slices.Equal(nu.jobs[0].Vars, []string{"files"}) || len(nu.jobs[0].Tables) != 0 {49+ t.Errorf("the first job %+v", nu.jobs[0])50+ }51+ if got := string(nu.jobs[1].Tables["files"]); got != lsOut {52+ t.Errorf("$files reached the second cell as %q", got)53+ }54+ s := screen(m)55+ if strings.Contains(s, "failed") || strings.Contains(s, "─ files ─") || !strings.Contains(s, "c.txt") {56+ t.Errorf("screen:\n%s", s)57+ }58+ cs := m.sheet.NotebookCells()59+ if o := m.book().Output(cs[0].ID); o == nil || string(o.NUON) != big || string(o.Vars["files"]) != lsOut {60+ t.Errorf("output %+v", o)61+ }62+ press(t, m, "<up>", "<up>", "<up>")63+ if i, _ := m.nbView().Selected(); i != 0 {64+ t.Fatalf("selected %d", i)65+ }66+ if got := line(m, contextLine); !strings.Contains(got, "sets $files") || strings.Contains(got, "reads") {67+ t.Errorf("context line %q", got)68+ }69+ // Running it again makes the reader stale.70+ press(t, m, "<ctrl+enter>")71+ if st := m.cellState(m.sheet, cs[1].ID); !st.Stale {72+ t.Error("the reader isn't stale")73+ }74+ // Naming the cell names its last statement.75+ press(t, m, "n")76+ send(m, pasteMsg("big"))77+ press(t, m, "<enter>")78+ if got := m.sheet.NotebookCells()[0].Source; got != "files = ls | where type == file\nbig = $files | where size > 1kb | sort-by size --reverse" {79+ t.Errorf("named %q", got)80+ }81+}82+83+// Comments are nu's: a commented-out assignment names nothing and a84+// commented-out $name reads nothing.85+func TestNotebookComments(t *testing.T) {86+ src := "# files = ls\nls # | where name == $nope"87+ m, nu := notebookModel(t, map[string]string{nuCommand(src): lsOut})88+ write(t, m, src)89+ press(t, m, "<ctrl+enter>")90+ if len(nu.jobs) != 1 || len(nu.jobs[0].Tables) != 0 || strings.Contains(nu.jobs[0].Command, "#") {91+ t.Fatalf("ran %+v", nu.jobs)92+ }93+ if strings.Contains(screen(m), "failed") || m.sheet.NotebookCells()[0].Name() != "" {94+ t.Errorf("screen:\n%s", screen(m))95+ }96+}97+98+// A cell of several statements streams its last one's values, its own99+// variables bound first and none handed back.100+func TestNotebookStreamStatements(t *testing.T) {101+ streamPolls = nil102+ m, _ := notebookModel(t, nil)103+ nu := newStreamNu()104+ m.SetShellRunner(nu)105+ write(t, m, "log = open app.log\n$log | lines")106+ press(t, m, "f")107+ defer m.stopCells()108+ script := <-nu.scripts109+ if !strings.Contains(script, "let log = open app.log\n$log | lines") || strings.Contains(script, "__out") {110+ t.Errorf("stream script %s", script)111+ }112+}@@ -77,7 +77,7 @@ func (m *Model) startStream(q nbQueued) tea.Cmd {
7777 return nil7878 }7979 run := &nbRun{nbQueued: q, source: c.Source, start: time.Now()}80- job, err := m.jobFor(q.s, c, run)80+ job, err := m.jobFor(q.s, c, run, true)8181 if err != nil {8282 m.failCell(q, c, err.Error())8383 return nil@@ -319,22 +319,31 @@ func (v *View) Head() (name, text string) {
319319 if v.onOut {320320 return name, v.outputSummary(c)321321 }322+ src := c.Parse()322323 var reads []string323- for _, r := range notebook.Refs(c.Pipeline()) {324+ for _, r := range src.Refs() {324325 reads = append(reads, "$"+r)325326 }326- if notebook.ReadsSelection(c.Pipeline()) {327+ if src.ReadsSelection() {327328 reads = append(reads, "$selection")328329 }329- if _, refs := notebook.Bind(c.Pipeline()); len(refs) > 0 {330- for _, r := range refs {331- reads = append(reads, "$sheet."+r.Ref)332- }330+ _, _, ranges := src.Command()331+ for _, r := range ranges {332+ reads = append(reads, "$sheet."+r.Ref)333333 }334334 text = "a nushell pipeline"335335 if len(reads) > 0 {336336 text = "reads " + strings.Join(reads, ", ")337337 }338+ var sets []string339+ for _, a := range src.Assigned() {340+ if a != c.Name() {341+ sets = append(sets, "$"+a)342+ }343+ }344+ if len(sets) > 0 {345+ text += "; sets " + strings.Join(sets, ", ")346+ }338347 if n := c.Name(); n != "" {339348 text += "; read as $" + n + ", nu." + n + " in formulas"340349 }@@ -16,28 +16,36 @@ import (
1616 1717 // prepared is the text nu is asked about: a prelude declaring the1818 // notebook's variables, then the source masked, starting at byte at.19+// Masking keeps every byte of the source in its place, so what nu says20+// of the text is where it says it in the source.1921 type prepared struct {20- text string21- at int22- head int // the bytes of the source that are its `name =`22+ text string23+ at int24+ heads [][2]int // the bytes of the source that are its `name =`s2325 }2426 2527 // prepare masks src and declares what it reads: `sales = ls` is asked2628 // about as ` ls`, $sheet.A1:C9 as $__s1_______, and the prelude27-// declares $__s1, $selection, $sheet and names, each `let x: any = []`28-// so nu takes it for whatever it holds.29+// declares $__s1, $selection, $sheet, names and the names the cell's30+// lines assign for the lines after them, each `let x: any = []` so nu31+// takes it for whatever it holds.2932 func prepare(src string, names []string) prepared {3033 b := []byte(src)31- name, pipeline := notebook.SplitName(src)32- head := 033- if name != "" {34- head = len(src) - len(pipeline)35- for i := range head {34+ parsed := notebook.Parse(src)35+ heads := parsed.Heads()36+ for _, h := range heads {37+ for i := h[0]; i < h[1]; i++ {3638 if b[i] != '\n' {3739 b[i] = ' '3840 }3941 }4042 }43+ names = slices.Clone(names)44+ for k, st := range parsed.Stmts {45+ if st.Name != "" && k < len(parsed.Stmts)-1 { // assigned for the lines after46+ names = append(names, st.Name)47+ }48+ }4149 var pre strings.Builder4250 declared := map[string]bool{}4351 declare := func(name, value string) {@@ -46,9 +54,9 @@ func prepare(src string, names []string) prepared {
4654 pre.WriteString("let " + name + ": any = " + value + "\n")4755 }4856 }49- for i, s := range notebook.RangeSpans(src) {57+ for i, s := range parsed.Ranges {5058 v := "__s" + strconv.Itoa(i+1)51- if s[0] < head || len(v)+1 > s[1]-s[0] {59+ if len(v)+1 > s[1]-s[0] {5260 continue5361 }5462 v += strings.Repeat("_", s[1]-s[0]-len(v)-1)@@ -62,22 +70,28 @@ func prepare(src string, names []string) prepared {
6270 declare(nm, "[]")6371 }6472 }65- return prepared{text: pre.String() + string(b), at: pre.Len(), head: head}73+ return prepared{text: pre.String() + string(b), at: pre.Len(), heads: heads}74+}75+76+// inHead reports whether src[from:to] is part of an assignment's head.77+func (p prepared) inHead(from, to int) bool {78+ return slices.ContainsFunc(p.heads, func(h [2]int) bool { return from < h[1] && to > h[0] })6679 }6780 6881 // spans are src's syntax roles from nu's shapes of p's text: the69-// tokenizer's for the cell's name and comments, which nu doesn't70-// report, and a number's unit (the kb of 1kb) as part of the number.82+// tokenizer's for the names the cell assigns and its comments, which nu83+// doesn't report, and a number's unit (the kb of 1kb) as part of the84+// number.7185 func (p prepared) spans(src string, shapes []nushell.Shape) []Span {7286 var out []Span7387 for _, s := range (Tokens{}).Highlight(context.Background(), src) {74- if s.To <= p.head || s.Kind == theme.SyntaxComment {88+ if p.inHead(s.From, s.To) || s.Kind == theme.SyntaxComment {7589 out = append(out, s)7690 }7791 }7892 for i, s := range shapes {7993 from, to := s.From-p.at, s.To-p.at80- if from < p.head || to > len(src) || from >= to {94+ if from < 0 || to > len(src) || from >= to || p.inHead(from, to) {8195 continue8296 }8397 kind, ok := shapeKind(s.Shape, src[from:to])@@ -0,0 +1,59 @@
1+package nbview2+3+import (4+ "context"5+ "fmt"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+)11+12+// A cell of several statements: each name assigned shows as a variable,13+// a comment's text as a comment, whatever it holds, and # in a string14+// or a raw string is the string's.15+func TestHighlightsStatements(t *testing.T) {16+ src := "files = ls # all\n# big = $files\nbig = $files | where name != \"a#b\" | append r#'c # d'#"17+ var got []string18+ for _, s := range (Tokens{}).Highlight(context.Background(), src) {19+ got = append(got, fmt.Sprintf("%s:%d", src[s.From:s.To], s.Kind))20+ }21+ want := fmt.Sprint([]string{"files:2", "=:5", "ls:0", "# all:6", "# big = $files:6", "big:2", "=:5", "$files:2", "|:5", "where:0",22+ "!=:5", `"a#b":1`, "|:5", "append:0", "r#'c # d'#:1"})23+ if fmt.Sprint(got) != want {24+ t.Errorf("spans %v\nwant %v", got, want)25+ }26+}27+28+// nu is asked about a cell of several statements with each `name =`29+// blanked, every byte in its place, and the names its lines assign for30+// the lines after declared.31+func TestPrepareStatements(t *testing.T) {32+ src := "files = ls # x = 1\nbig = $files | first"33+ p := prepare(src, []string{"sales"})34+ if body := p.text[p.at:]; body != " ls # x = 1\n $files | first" {35+ t.Errorf("masked %q", body)36+ }37+ pre := p.text[:p.at]38+ if !strings.Contains(pre, "let files: any = []\n") || strings.Contains(pre, "let big") || strings.Contains(pre, "let x") {39+ t.Errorf("prelude %q", pre)40+ }41+}42+43+// The context line says what a cell of several statements reads and44+// sets: not its own variables.45+func TestHeadStatements(t *testing.T) {46+ h := newHost("files = ls | where type == file\n$files | where size > 1kb | sort-by size --reverse", "$files | length")47+ v := newView(h, 60, 12)48+ v.Select(0, false)49+ if name, text := v.Head(); name != "cell 1" || text != "a nushell pipeline; sets $files" {50+ t.Errorf("head %q %q", name, text)51+ }52+ v.Select(1, false)53+ if _, text := v.Head(); text != "reads $files" {54+ t.Errorf("head of the reader %q", text)55+ }56+ if (notebook.Cell{Source: h.cells[0].Source}).Name() != "" {57+ t.Error("the cell is named")58+ }59+}@@ -71,12 +71,30 @@ var operators = []string{"and", "or", "not", "xor", "in", "not-in", "like", "not
7171 7272 // Highlight implements Highlighter.7373 func (Tokens) Highlight(_ context.Context, src string) []Span {74- t := tokenizer{src: src, command: true}75- if name, _ := notebook.SplitName(src); name != "" {76- t.i = strings.Index(src, name) + len(name)77- t.add(t.i-len(name), theme.SyntaxVariable) // the cell's name, as $name reads it74+ t := tokenizer{src: src, command: true, whole: map[int]Span{}}75+ p := notebook.Parse(src)76+ for _, st := range p.Stmts {77+ if st.Name != "" { // a name assigned, as $name reads it78+ t.whole[st.From] = Span{st.From, st.From + len(st.Name), theme.SyntaxVariable}79+ }80+ }81+ for _, c := range p.Comments {82+ t.whole[c[0]] = Span{c[0], c[1], theme.SyntaxComment}83+ }84+ for _, s := range p.Strings {85+ if src[s[0]] == 'r' { // a raw string, r#'...'#86+ t.whole[s[0]] = Span{s[0], s[1], theme.SyntaxString}87+ }7888 }7989 for t.i < len(t.src) {90+ if s, ok := t.whole[t.i]; ok {91+ t.out = append(t.out, s)92+ t.i = s.To93+ if s.Kind != theme.SyntaxComment {94+ t.command = false95+ }96+ continue97+ }8098 if !t.punct() {8199 t.word()82100 }@@ -91,6 +109,9 @@ type tokenizer struct {
91109 i int92110 command bool93111 out []Span112+ // whole are the tokens the source's reading knows (notebook.Parse),113+ // by where they start: names assigned, comments, raw strings.114+ whole map[int]Span94115 }95116 96117 func (t *tokenizer) add(from int, k theme.Syntax) { t.out = append(t.out, Span{from, t.i, k}) }@@ -109,9 +130,6 @@ func (t *tokenizer) punct() bool {
109130 t.command = true110131 case c == ' ' || c == '\t' || c == '\r' || c == ')' || c == '}' || c == '[' || c == ']' || c == ',':111132 t.i++112- case c == '#' && (i == 0 || isSpace(src[i-1])):113- t.i = lineEnd(src, i)114- t.add(start, theme.SyntaxComment)115133 case c == '"' || c == '\'' || c == '`':116134 t.i = quoted(src, i)117135 t.add(start, theme.SyntaxString)@@ -176,13 +194,6 @@ func wordEnd(src string, i int) int {
176194 return i177195 }178196 179-func lineEnd(src string, i int) int {180- for i < len(src) && src[i] != '\n' {181- i++182- }183- return i184-}185-186197 // quoted is where a string starting at i ends: its closing quote, a187198 // double-quoted string's escapes skipped, or the end.188199 func quoted(src string, i int) int {