012 describe lists sheets, used ranges, guessed header rows, tables, regions, charts, pivots, notebook cells and names, as text or JSON/NUON. set, recalc, describe, merge-driver and version take --format json|nuon and export --report, with schemas in docs/reference/json.md. set --dry-run prints the change as 012 diff would. 012 agent --install-skill writes the embedded SKILL.md, as 012 nu --install-module does. docs/agents is the section agents' readers look in.
- commit
- 2c3ad01363ed9b9b081309789ca52de2ffe77a22
- parent
- a173bc865019eeeb7300c1f78ba14cb8ba989d14
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.0 of 3, Jev's estimate
| M | ROADMAP.md | 5 | +- |
| A | cmd/ | 106 | +++++++++++++ |
| A | cmd/ | 45 | ++++++ |
| A | cmd/ | 49 | ++++++ |
| M | cmd/ | 31 | ++++- |
| M | cmd/ | 32 | +++- |
| M | cmd/ | 9 | +- |
| A | cmd/ | 76 | +++++++++ |
| M | cmd/ | 79 | +++++++++- |
| A | cmd/ | 91 | +++++++++++ |
| M | cmd/ | 25 | +++- |
| M | docs/ | 3 | +- |
| A | docs/ | 89 | +++++++++++ |
| A | docs/ | 8 | + |
| M | docs/ | 2 | +- |
| M | docs/ | 22 | ++- |
| M | docs/ | 1 | + |
| M | docs/ | 2 | +- |
| M | docs/ | 17 | +- |
| A | docs/ | 133 | +++++++++++++++++ |
| A | internal/ | 241 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 86 | +++++++++++ |
| A | internal/ | 107 | +++++++++++++ |
| A | internal/ | 33 | ++++ |
| A | internal/ | 108 | +++++++++++++ |
@@ -76,7 +76,6 @@ section 3's shared editing with an agent as one participant.
7676 7777 | Step | Result | Size |7878 |---|---|---|79-| `012 describe` (sheets, used ranges, headers, names, tables, regions, charts), `--format json` on every command, `012 set --dry-run` printing the change as a diff, an agents page in the docs and a Claude Code skill installed by 012 | Agents with a shell use 012 well | S |8079 | `012 mcp` over stdio on a workbook file: tools (describe, read, write, apply operations, evaluate without writing, find, sort, filter, chart, pivot, run a notebook cell), resources (`012://book/Sheet1!A1:D40`, tables, notebook cells), prompts | Any MCP host works with workbooks | M |8180 | MCP Apps views: a sheet or chart rendered as an interactive view inside the host, from the HTML export | Workbooks shown in the chat | S to M |8281 | 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 |@@ -161,6 +160,10 @@ stream above rather than a design of their own.
161160 - Web pages in 012's look: a sheet, a range or a chart as one self-contained `.html` file, charts as SVG, from File > Download and `012 export`: [Files](docs/files/README.md#web-pages)162161 - A crash keeps unsaved work for recovery, restores the terminal and writes a report, locally and in `012 serve`: [Saving](docs/files/saving.md#if-012-crashes)163162 163+**Agents**164+165+- `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)166+164167 **Upkeep**165168 166169 - `make check` before every push; demo tapes (`make demos`); annotated version tags with release notes (v0.2.0 onward; v0.1.0 remains on the Go module proxy): [Testing](docs/contributing/testing.md)@@ -0,0 +1,106 @@
1+package main2+3+import (4+ "bytes"5+ _ "embed"6+ "errors"7+ "fmt"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "strings"12+)13+14+// 012 agent --skill and 012 agent --install-skill: the Claude Code15+// skill that teaches agents with a shell to use 012 (see16+// docs/agents/README.md), installed as 012 nu --install-module installs17+// the nushell module.18+19+//go:embed skill/SKILL.md20+var skillSource string21+22+const agentUsage = "usage: 012 agent --skill | --install-skill [--force] [dir]"23+24+// skillHome is where Claude Code looks for a person's own skills; tests25+// replace it.26+var skillHome = func() (string, error) {27+ home, err := os.UserHomeDir()28+ if err != nil {29+ return "", fmt.Errorf("finding your home directory: %w; name the folder instead: 012 agent --install-skill path/to/skills/012", err)30+ }31+ return filepath.Join(home, ".claude", "skills", "012"), nil32+}33+34+// runAgent is 012 agent --skill (print the skill) or --install-skill35+// [--force] [dir].36+func runAgent(args []string, e env) error {37+ a, err := parseArgs(args, nil, []string{"skill", "install-skill", "force", "help"})38+ switch {39+ case err != nil:40+ return usageError(err.Error(), agentUsage)41+ case a.has("help"):42+ fmt.Fprintln(e.stdout, agentUsage)43+ return nil44+ case a.has("skill") && !a.has("install-skill") && len(a.pos) == 0:45+ _, err := fmt.Fprint(e.stdout, skillSource)46+ return err47+ case !a.has("install-skill") || a.has("skill") || len(a.pos) > 1:48+ return usageError("", agentUsage)49+ }50+ path, err := skillPath(a.pos)51+ if err != nil {52+ return err53+ }54+ wrote, err := installSkill(path, a.has("force"))55+ if err != nil {56+ return err57+ }58+ if wrote {59+ fmt.Fprintln(e.stdout, "Wrote", path)60+ } else {61+ fmt.Fprintln(e.stdout, path, "is up to date")62+ }63+ fmt.Fprintln(e.stdout, "Claude Code finds it in the next session; /skills lists it.")64+ return nil65+}66+67+// skillPath is the SKILL.md to write: in the folder given (a file named68+// there, if it ends in .md), or in ~/.claude/skills/012.69+func skillPath(given []string) (string, error) {70+ dir := ""71+ if len(given) == 1 {72+ p, err := filepath.Abs(given[0])73+ if err != nil {74+ return "", err75+ }76+ if strings.EqualFold(filepath.Ext(p), ".md") {77+ return p, nil78+ }79+ dir = p80+ } else {81+ var err error82+ if dir, err = skillHome(); err != nil {83+ return "", err84+ }85+ }86+ return filepath.Join(dir, "SKILL.md"), nil87+}88+89+// installSkill writes the skill to path, creating its folder. A file90+// already there is replaced only with force, unless it's this skill;91+// wrote is false then.92+func installSkill(path string, force bool) (wrote bool, err error) {93+ old, err := os.ReadFile(path)94+ switch {95+ case err == nil && bytes.Equal(old, []byte(skillSource)):96+ return false, nil97+ case err == nil && !force:98+ return false, fmt.Errorf("%s exists and isn't this 012's skill; --force replaces it", path)99+ case err != nil && !errors.Is(err, fs.ErrNotExist):100+ return false, err101+ }102+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {103+ return false, err104+ }105+ return true, os.WriteFile(path, []byte(skillSource), 0o644)106+}@@ -0,0 +1,45 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+func TestAgentSkill(t *testing.T) {11+ e, out, _ := scriptEnv(t)12+ if code, err := status(e, "agent", "--skill"); code != 0 || !strings.HasPrefix(out.String(), "---\nname: 012\n") {13+ t.Fatalf("--skill: %d %v %.40q", code, err, out)14+ }15+ home := filepath.Join(t.TempDir(), "skills", "012")16+ old := skillHome17+ skillHome = func() (string, error) { return home, nil }18+ t.Cleanup(func() { skillHome = old })19+20+ out.Reset()21+ if code, err := status(e, "agent", "--install-skill"); code != 0 || !strings.Contains(out.String(), "Wrote "+filepath.Join(home, "SKILL.md")) {22+ t.Fatalf("install: %d %v %q", code, err, out)23+ }24+ out.Reset()25+ if status(e, "agent", "--install-skill"); !strings.Contains(out.String(), "is up to date") {26+ t.Errorf("again: %q", out)27+ }28+ os.WriteFile(filepath.Join(home, "SKILL.md"), []byte("mine"), 0o644)29+ if _, err := status(e, "agent", "--install-skill"); err == nil || !strings.Contains(err.Error(), "--force replaces it") {30+ t.Errorf("over another file: %v", err)31+ }32+ if code, err := status(e, "agent", "--install-skill", "--force"); code != 0 {33+ t.Error(err)34+ }35+ dir := filepath.Join(t.TempDir(), "project", ".claude", "skills", "012")36+ if code, err := status(e, "agent", "--install-skill", dir); code != 0 {37+ t.Fatal(err)38+ }39+ if data, _ := os.ReadFile(filepath.Join(dir, "SKILL.md")); string(data) != skillSource {40+ t.Errorf("project skill not written")41+ }42+ if code, _ := status(e, "agent"); code != 2 {43+ t.Errorf("agent alone: status %d", code)44+ }45+}@@ -0,0 +1,49 @@
1+package main2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/012/internal/headless"7+)8+9+// 012 describe: what a workbook holds, for agents and people deciding10+// what to read (see docs/agents/README.md).11+12+const describeUsage = "usage: 012 describe file.012 [--format text|json|nuon] [--notebooks] [--jev] [--trust]"13+14+// runDescribe is 012 describe: its sheets, used ranges, guessed header15+// rows, names, tables, regions, notebooks and charts.16+func runDescribe(args []string, e env) error {17+ a, err := parseArgs(args, []string{"format"}, append([]string{"help"}, evalFlags...))18+ switch {19+ case err != nil:20+ return usageError(err.Error(), describeUsage)21+ case a.has("help"):22+ fmt.Fprintln(e.stdout, describeUsage)23+ return nil24+ case len(a.pos) != 1:25+ return usageError("", describeUsage)26+ }27+ format, err := resultFormat(a)28+ if err != nil {29+ return usageError(err.Error(), describeUsage)30+ }31+ f, err := headless.Open(a.pos[0], false)32+ if err != nil {33+ return err34+ }35+ failed := evaluate(f, a, e)36+ if err := failed.fatal(); err != nil {37+ return err38+ }39+ d := headless.Describe(f.Book)40+ if format == "text" {41+ err = headless.WriteDescription(e.stdout, d)42+ } else {43+ err = headless.Encode(e.stdout, format, d)44+ }45+ if err != nil {46+ return err47+ }48+ return failed.err()49+}@@ -19,7 +19,7 @@ import (
1919 const (2020 diffUsage = "usage: 012 diff a.012 b.012 [--format text|json|nuon] [--color auto|always|never]\n" +2121 " 012 diff --textconv file.012"22- mergeUsage = "usage: 012 merge-driver base.012 ours.012 theirs.012 [path]"22+ mergeUsage = "usage: 012 merge-driver base.012 ours.012 theirs.012 [path] [--format text|json|nuon]"2323 )2424 2525 // runDiff is 012 diff: exit status 0 when the workbooks are the same, 1@@ -156,10 +156,13 @@ func textconv(path string, w io.Writer) error {
156156 // as a conflict to resolve; on trouble it leaves ours as it was and157157 // exits 2.158158 func runMergeDriver(args []string, e env) error {159- a, err := parseArgs(args, nil, []string{"help"})159+ a, err := parseArgs(args, []string{"format"}, []string{"help"})160+ format, ferr := resultFormat(a)160161 switch {161162 case err != nil:162163 return usageError(err.Error(), mergeUsage)164+ case ferr != nil:165+ return usageError(ferr.Error(), mergeUsage)163166 case a.has("help"):164167 fmt.Fprintln(e.stdout, mergeUsage)165168 return nil@@ -188,6 +191,11 @@ func runMergeDriver(args []string, e env) error {
188191 return &exitError{code: 2, err: err}189192 }190193 }194+ if format != "text" {195+ if err := headless.Encode(e.stdout, format, mergeResult(conflicts)); err != nil {196+ return &exitError{code: 2, err: err}197+ }198+ }191199 if len(conflicts) == 0 {192200 return nil193201 }@@ -207,3 +215,22 @@ func fileMode(path string) fs.FileMode {
207215 }208216 return st.Mode().Perm()209217 }218+219+// mergeConflict is a conflict as merge-driver --format json lists it.220+type mergeConflict struct {221+ Where string `json:"where"`222+ Field string `json:"field"`223+ Base string `json:"base"`224+ Ours string `json:"ours"`225+ Theirs string `json:"theirs"`226+ What string `json:"what"`227+}228+229+// mergeResult is what merge-driver --format json writes.230+func mergeResult(conflicts []diff.Conflict) map[string]any {231+ out := []mergeConflict{}232+ for _, c := range conflicts {233+ out = append(out, mergeConflict{c.Where, c.Field, c.Base, c.Ours, c.Theirs, c.What})234+ }235+ return map[string]any{"conflicts": out}236+}@@ -13,13 +13,13 @@ import (
1313 "github.com/FelineStateMachine/012/internal/sheet"1414 )1515 16-const exportUsage = "usage: 012 export file.012 out.csv|tsv|xlsx|json|nuon|sqlite|html [ref] [--format kind] [--table name] [--chart n|title] [--notebooks] [--jev] [--trust]"16+const exportUsage = "usage: 012 export file.012 out.csv|tsv|xlsx|json|nuon|sqlite|html [ref] [--format kind] [--table name] [--chart n|title] [--report json|nuon] [--notebooks] [--jev] [--trust]"1717 1818 // runExport is 012 export: a sheet, a range or (to formats holding1919 // several sheets) the whole workbook written as File > Download writes2020 // it.2121 func runExport(args []string, e env) error {22- a, err := parseArgs(args, []string{"format", "table", "chart"}, append([]string{"help"}, evalFlags...))22+ a, err := parseArgs(args, []string{"format", "table", "chart", "report"}, append([]string{"help"}, evalFlags...))2323 switch {2424 case err != nil:2525 return usageError(err.Error(), exportUsage)@@ -34,6 +34,10 @@ func runExport(args []string, e env) error {
3434 if err != nil {3535 return usageError(err.Error(), exportUsage)3636 }37+ report := strings.ToLower(a.flags["report"])38+ if a.has("report") && report != "json" && report != "nuon" {39+ return usageError("--report "+report+": json or nuon", exportUsage)40+ }3741 if a.has("chart") && k != fileio.HTML {3842 return usageError("--chart writes a web page: name an .html file", exportUsage)3943 }@@ -57,15 +61,29 @@ func runExport(args []string, e env) error {
5761 return err5862 }5963 if a.has("chart") {60- return exportChart(out, t, a.flags["chart"], failed)64+ if err := exportChart(out, t, a.flags["chart"]); err != nil {65+ return err66+ }67+ return exportReport(e, report, headless.ExportResult{File: out, Format: "html", Notes: []string{}}, failed)6168 }6269 snap := exportSnap(t, k, ref == "")6370 res, err := fileio.Export(context.Background(), out, k, snap, fileio.ExportOptions{Table: a.flags["table"]})6471 if err != nil {6572 return fmt.Errorf("%s: %w", out, err)6673 }67- for _, n := range res.Notes {68- fmt.Fprintln(e.stderr, "012: note: "+n)74+ return exportReport(e, report, headless.ExportResult{File: out, Format: strings.ToLower(k.String()), Rows: res.Rows,75+ Notes: append([]string{}, res.Notes...)}, failed)76+}77+78+// exportReport says what was written: its notes on standard error, or79+// with --report the whole result on standard output.80+func exportReport(e env, report string, res headless.ExportResult, failed evalFailures) error {81+ if report == "" {82+ for _, n := range res.Notes {83+ fmt.Fprintln(e.stderr, "012: note: "+n)84+ }85+ } else if err := headless.Encode(e.stdout, report, res); err != nil {86+ return err6987 }7088 return failed.err()7189 }@@ -125,7 +143,7 @@ func same(a, b string) bool {
125143 126144 // exportChart is 012 export --chart: one chart of the target's sheet as127145 // a web page of its own, found by its number (1 is the first) or title.128-func exportChart(out string, t headless.Target, which string, failed evalFailures) error {146+func exportChart(out string, t headless.Target, which string) error {129147 i, err := headless.FindChart(t.Sheet, which)130148 if err != nil {131149 return err@@ -138,5 +156,5 @@ func exportChart(out string, t headless.Target, which string, failed evalFailure
138156 if err != nil {139157 return fmt.Errorf("%s: %w", out, err)140158 }141- return failed.err()159+ return nil142160 }@@ -80,8 +80,9 @@ func usage() error {
8080 " 012 nu [flags] [file]: the workbook's nushell notebook (see docs/nushell/notebooks.md)\n" +8181 " 012 nu --module | --install-module [--force] [path]: the nushell module with sheet (see docs/nushell/README.md)\n" +8282 " 012 serve [flags] [dir]: serve sheets in dir over SSH (see docs/terminal/ssh.md)\n" +83- " 012 get|set|recalc|export file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +83+ " 012 get|set|recalc|export|describe file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +8484 " 012 diff a.012 b.012, 012 merge-driver base ours theirs: compare and merge workbooks (see docs/files/git.md)\n" +85+ " 012 agent --skill | --install-skill [--force] [dir]: the Claude Code skill (see docs/agents/README.md)\n" +8586 " 012 config [path|edit|default|themes|set-key|delete-key]\n" +8687 " 012 version")8788 }@@ -90,8 +91,8 @@ func usage() error {
9091 // opens as ./name.9192 var subcommands = map[string]func([]string, env) error{9293 "config": runConfig, "serve": runServe,93- "get": runGet, "set": runSet, "recalc": runRecalc, "export": runExport,94- "diff": runDiff, "merge-driver": runMergeDriver,94+ "get": runGet, "set": runSet, "recalc": runRecalc, "export": runExport, "describe": runDescribe,95+ "diff": runDiff, "merge-driver": runMergeDriver, "agent": runAgent,9596 }9697 9798 func run(args []string, e env) error {@@ -99,7 +100,7 @@ func run(args []string, e env) error {
99100 return subcommands[args[0]](args[1:], e)100101 }101102 if len(args) > 0 && (args[0] == "version" || args[0] == "--version") {102- return runVersion(e)103+ return runVersion(args[1:], e)103104 }104105 if len(args) > 0 && args[0] == "nu" && isModuleCommand(args[1:]) {105106 return runNuModule(args[1:], e)@@ -0,0 +1,76 @@
1+package main2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/012/internal/headless"11+)12+13+// TestSetDryRun prints the change as 012 diff would and leaves the14+// file as it was.15+func TestSetDryRun(t *testing.T) {16+ e, out, _ := scriptEnv(t)17+ path := filepath.Join(t.TempDir(), "book.012")18+ status(e, "set", path, "A1", "1")19+ before, _ := os.ReadFile(path)20+ out.Reset()21+ if code, err := status(e, "set", path, "A1", "2", "B1", "=A1*2", "--dry-run"); code != 0 {22+ t.Fatal(err)23+ }24+ if got := out.String(); !strings.Contains(got, "Sheet1!A1 input 1 → 2") || !strings.Contains(got, "Sheet1!B1 input + =A1*2") {25+ t.Errorf("dry run printed %q", got)26+ }27+ if after, _ := os.ReadFile(path); string(after) != string(before) {28+ t.Errorf("dry run saved")29+ }30+ out.Reset()31+ if code, err := status(e, "set", path, "A1", "3", "--format", "json"); code != 0 {32+ t.Fatal(err)33+ }34+ var res headless.SetResult35+ if err := json.Unmarshal(out.Bytes(), &res); err != nil {36+ t.Fatalf("%v: %s", err, out)37+ }38+ if !res.Saved || len(res.Changes) == 0 || res.Changes[0].Item != "A1" || string(res.Changes[0].New) != `"3"` {39+ t.Errorf("set result %+v", res)40+ }41+ // A new file's dry run lists what it would hold, and makes no file.42+ fresh := filepath.Join(t.TempDir(), "new.012")43+ out.Reset()44+ status(e, "set", fresh, "A1", "x", "--dry-run", "--format", "nuon")45+ if _, err := os.Stat(fresh); err == nil || !strings.Contains(out.String(), "saved: false") {46+ t.Errorf("dry run on a new file: %s", out)47+ }48+}49+50+// TestResultFormats checks the other commands' --format json results.51+func TestResultFormats(t *testing.T) {52+ e, out, _ := scriptEnv(t)53+ dir := t.TempDir()54+ path := filepath.Join(dir, "book.012")55+ status(e, "set", path, "A1", "Item", "B1", "Price", "A2", "Pen", "B2", "=1/0")56+ for _, c := range []struct {57+ args []string58+ code int59+ want string60+ }{61+ {[]string{"recalc", path, "--format", "json"}, 1, `"value": "#DIV/0!"`},62+ {[]string{"describe", path, "--format", "json"}, 0, `"columns": [`},63+ {[]string{"describe", path}, 0, "Sheet1 (shown) A1:B2, 2 rows x 2 columns, 4 cells, 1 formula (1 showing errors)"},64+ {[]string{"export", path, filepath.Join(dir, "out.csv"), "--report", "json"}, 0, `"format": "csv"`},65+ {[]string{"version", "--format", "json"}, 0, `"go": "go`},66+ } {67+ out.Reset()68+ code, err := status(e, c.args...)69+ if code != c.code || !strings.Contains(out.String(), c.want) {70+ t.Errorf("%v: %d %v %s, want %q", c.args, code, err, out, c.want)71+ }72+ }73+ if code, _ := status(e, "describe", path, "--format", "csv"); code != 2 {74+ t.Errorf("describe --format csv: status %d", code)75+ }76+}@@ -7,6 +7,7 @@ import (
77 "slices"88 "strings"99 10+ "github.com/FelineStateMachine/012/internal/diff"1011 "github.com/FelineStateMachine/012/internal/headless"1112 "github.com/FelineStateMachine/012/internal/notebook"1213 )@@ -17,8 +18,8 @@ import (
1718 1819 const (1920 getUsage = "usage: 012 get file.012 [ref] [--format text|csv|tsv|json|nuon] [--input] [--no-header] [--notebooks] [--jev] [--trust]"20- setUsage = "usage: 012 set file.012 ref input [ref input ...] [--force]"21- recalcUsage = "usage: 012 recalc file.012 [--notebooks] [--jev] [--trust]"21+ setUsage = "usage: 012 set file.012 ref input [ref input ...] [--force] [--dry-run] [--format text|json|nuon]"22+ recalcUsage = "usage: 012 recalc file.012 [--format text|json|nuon] [--notebooks] [--jev] [--trust]"2223 )2324 2425 // evalFlags are the flags that let a command run a workbook's notebook@@ -63,9 +64,10 @@ func runGet(args []string, e env) error {
6364 return failed.err()6465 }6566 66-// runSet is 012 set: type entries into cells and save.67+// runSet is 012 set: type entries into cells and save, or with68+// --dry-run print what would change, as 012 diff would, and not save.6769 func runSet(args []string, e env) error {68- a, err := parseArgs(args, nil, []string{"force", "help"})70+ a, err := parseArgs(args, []string{"format"}, []string{"force", "dry-run", "help"})6971 switch {7072 case err != nil:7173 return usageError(err.Error(), setUsage)@@ -75,6 +77,10 @@ func runSet(args []string, e env) error {
7577 case len(a.pos) < 3 || len(a.pos)%2 == 0:7678 return usageError("", setUsage)7779 }80+ format, err := resultFormat(a)81+ if err != nil {82+ return usageError(err.Error(), setUsage)83+ }7884 f, err := headless.Open(a.pos[0], true)7985 if err != nil {8086 return err@@ -87,16 +93,55 @@ func runSet(args []string, e env) error {
8793 if err != nil {8894 return fmt.Errorf("%w; %s is unchanged", err, f.Path)8995 }90- for _, w := range warnings {91- fmt.Fprintln(e.stderr, "012: warning: "+w)96+ res := headless.SetResult{File: f.Path, Warnings: warnings, Changes: []headless.Change{}}97+ if format == "text" {98+ for _, w := range warnings {99+ fmt.Fprintln(e.stderr, "012: warning: "+w)100+ }101+ }102+ if format != "text" || a.has("dry-run") {103+ changes, err := f.Changes()104+ if err != nil {105+ return err106+ }107+ if format == "text" {108+ color, _ := useColor("", e)109+ return diff.WriteChanges(e.stdout, changes, "text", color)110+ }111+ res.Changes = headless.Changes(changes)112+ }113+ if !a.has("dry-run") {114+ if err := save(f, e); err != nil {115+ return err116+ }117+ res.Saved = true118+ }119+ if format == "text" {120+ return nil121+ }122+ if res.Warnings == nil {123+ res.Warnings = []string{}124+ }125+ return headless.Encode(e.stdout, format, res)126+}127+128+// resultFormat is --format's form for a command's result: text, json129+// or nuon.130+func resultFormat(a cliArgs) (string, error) {131+ switch f := strings.ToLower(a.flags["format"]); f {132+ case "", "text":133+ return "text", nil134+ case "json", "nuon":135+ return f, nil136+ default:137+ return "", errors.New("--format " + f + ": text, json or nuon")92138 }93- return save(f, e)94139 }95140 96141 // runRecalc is 012 recalc: recalculate, save, and list the cells whose97142 // formulas show errors, exiting 1 when there are any.98143 func runRecalc(args []string, e env) error {99- a, err := parseArgs(args, nil, append([]string{"help"}, evalFlags...))144+ a, err := parseArgs(args, []string{"format"}, append([]string{"help"}, evalFlags...))100145 switch {101146 case err != nil:102147 return usageError(err.Error(), recalcUsage)@@ -106,6 +151,10 @@ func runRecalc(args []string, e env) error {
106151 case len(a.pos) != 1:107152 return usageError("", recalcUsage)108153 }154+ format, err := resultFormat(a)155+ if err != nil {156+ return usageError(err.Error(), recalcUsage)157+ }109158 f, err := headless.Open(a.pos[0], false)110159 if err != nil {111160 return err@@ -118,6 +167,14 @@ func runRecalc(args []string, e env) error {
118167 if err := save(f, e); err != nil {119168 return err120169 }170+ if format != "text" {171+ res := headless.RecalcResult{File: f.Path, Errors: headless.Problems(problems), Circular: circular,172+ NotebookFailures: append([]string{}, failed.cells...)}173+ if err := headless.Encode(e.stdout, format, res); err != nil {174+ return err175+ }176+ return problemStatus(problems, circular, failed)177+ }121178 return reportProblems(e.stdout, problems, circular, failed)122179 }123180 @@ -149,6 +206,12 @@ func reportProblems(w io.Writer, problems []headless.Problem, circular bool, fai
149206 if circular {150207 fmt.Fprintln(w, "circular reference: a formula reads its own cell, directly or through others")151208 }209+ return problemStatus(problems, circular, failed)210+}211+212+// problemStatus is recalc's exit status: 1 when cells show errors or a213+// notebook cell failed.214+func problemStatus(problems []headless.Problem, circular bool, failed evalFailures) error {152215 if len(problems) > 0 || circular {153216 return &exitError{code: 1, err: fmt.Errorf("%d %s showing errors", len(problems), plural(len(problems), "cell", "cells"))}154217 }@@ -0,0 +1,91 @@
1+---2+name: 0123+description: Read, change, check and share 012 spreadsheet workbooks (.012 files) from the shell with the 012 command. Use when a task involves a .012 file, or when a spreadsheet, table of numbers or chart should be built, inspected, recalculated or exported (CSV, XLSX, JSON, NUON, SQLite, HTML).4+---5+6+# Working with 012 workbooks7+8+012 is a terminal spreadsheet. Its `.012` files are workbooks of sheets,9+formulas, tables, charts and nushell notebooks. Work on them with the10+`012` command, never by editing the file's JSON: `012 set` checks every11+entry as the screen does and saves atomically, and its changes show in12+`012 diff` and undo like a person's.13+14+## Look before you read15+16+```sh17+012 describe book.012 # sheets, used ranges, header rows, tables, names, charts, notebooks18+012 describe book.012 --format json # the same as JSON19+```20+21+Read what describe points at rather than whole sheets.22+23+## Read24+25+```sh26+012 get book.012 B7 # one cell, as shown: $1,200.0027+012 get book.012 B7 --input # what was typed: =SUM(B2:B6)28+012 get book.012 'Q3 plan'!A1:F20 --format json # records named by the first row29+012 get book.012 Sales --format json # a table or named range by name30+012 get book.012 'Sales[Amount]' --format json # one column of a table31+```32+33+References are written as in formulas: `B7`, `A1:C9`, `A:A`, `Q3!B7`,34+`'Q3 plan'!A1:C9`, a named range, a table (`Sales`, `Sales[Amount]`) or a35+sheet name for the whole sheet. Without a sheet, the sheet shown when the36+file was saved is meant. `--no-header` names JSON columns by letter.37+38+## Change39+40+Preview first, then write:41+42+```sh43+012 set book.012 B7 '=SUM(B2:B6)' B8 1200 --dry-run # prints the change as 012 diff would; saves nothing44+012 set book.012 B7 '=SUM(B2:B6)' B8 1200 # types the entries, as one change, and saves45+012 set book.012 B7 '=SUM(B2:B6)' --format json # {file, saved, changes, warnings}46+```47+48+- Inputs are what a person types, in en-US form whatever the workbook's49+ locale: `1.5`, `=ROUND(A1, 2)`, `$1,200` (sets a currency format),50+ `12%`, `2026-09-29`. An empty input clears a cell.51+- Formulas are Google Sheets' (`SUM`, `XLOOKUP`, `FILTER`, `LET`,52+ `LAMBDA`, structured references like `Sales[Amount]`).53+- `set` refuses, and changes nothing, on the first entry a cell can't54+ take: a formula that doesn't parse, a validation rule, a protected55+ range (`--force` overrides only that), or a cell of a spill, pivot56+ table or notebook output. Read the message; it names the cell.57+- An input starting with `--` goes after `--`: `012 set book.012 -- A1 --`.58+59+## Check60+61+```sh62+012 recalc book.012 # recalculates, saves, lists cells showing errors; exit 1 if any63+012 recalc book.012 --format json # {file, errors: [{cell, value, why}], circular, notebook_failures}64+012 diff old.012 new.012 --format json65+```66+67+Run `012 recalc` after changing formulas and fix what it lists.68+69+## Share70+71+```sh72+012 export book.012 out.xlsx # every sheet, with formulas and formats73+012 export book.012 out.csv 'Q3'!A1:F20 # values as shown74+012 export book.012 report.html # a web page in 012's look, charts as SVG75+012 export book.012 chart.html --chart 1 # one chart alone, by number or title76+```77+78+## Safety79+80+Nothing runs a program or reaches the network unless a flag asks:81+`--notebooks` runs the workbook's nushell notebook cells (and82+`--trust` a workbook saved on another computer), `--jev` asks the JEV83+model service. Don't add them unless the user wants that.84+85+## Exit status86+87+0 done; 1 an error on standard error (or, for `recalc`, cells showing88+errors; for `diff`, the workbooks differ); 2 the command was used89+wrongly, with its usage. Every command's `--help` prints its usage.90+91+The full reference: https://github.com/FelineStateMachine/012/blob/main/docs/agents/README.md@@ -4,6 +4,8 @@ import (
44 "fmt"55 "runtime"66 "runtime/debug"7+8+ "github.com/FelineStateMachine/012/internal/headless"79 )810 911 // version is the release this binary was built as, set by make dist@@ -37,7 +39,26 @@ func buildVersion() string {
3739 return v3840 }3941 40-func runVersion(e env) error {41- _, err := fmt.Fprintf(e.stdout, "012 %s %s %s/%s\n", buildVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH)42+// versionResult is what 012 version --format json writes.43+type versionResult struct {44+ Version string `json:"version"`45+ Go string `json:"go"`46+ OS string `json:"os"`47+ Arch string `json:"arch"`48+}49+50+func runVersion(args []string, e env) error {51+ a, err := parseArgs(args, []string{"format"}, nil)52+ if err != nil || len(a.pos) > 0 {53+ return usageError("", "usage: 012 version [--format text|json|nuon]")54+ }55+ format, err := resultFormat(a)56+ if err != nil {57+ return usageError(err.Error(), "usage: 012 version [--format text|json|nuon]")58+ }59+ if format != "text" {60+ return headless.Encode(e.stdout, format, versionResult{buildVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH})61+ }62+ _, err = fmt.Fprintf(e.stdout, "012 %s %s %s/%s\n", buildVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH)4263 return err4364 }@@ -13,7 +13,8 @@ sidebar_position: 1
1313 | [Files](files/README.md) | Import and download, Excel files, saving, the `.012` format, scripts, diff and merge in git |1414 | [The terminal](terminal/README.md) | Terminal features, themes, serving over SSH |1515 | [Nushell](nushell/README.md) | 012 in a pipeline, nushell notebooks, types, a cookbook |16-| [Reference](reference/README.md) | Keys, functions, configuration, the macro API, the command line |16+| [Agents](agents/README.md) | Agents working on workbooks: the commands and their JSON, the Claude Code skill |17+| [Reference](reference/README.md) | Keys, functions, configuration, the macro API, the command line, JSON output |1718 | [Contributing](contributing/README.md) | Architecture, extending, the UX bar, testing, limits, observability |1819 1920 The [roadmap](../ROADMAP.md) says what's ahead. The docs are Markdown that@@ -0,0 +1,89 @@
1+---2+title: "Agents"3+sidebar_position: 14+---5+6+# Agents7+8+Coding agents and assistants work on `.012` workbooks through the same9+operations people use: every change is typed into cells as the screen10+types it, checked by the same rules, made as one undoable step and11+shown by [`012 diff`](../files/git.md#012-diff) like anyone's. Nothing12+agents do runs a program or reaches the network unless they were13+started with the flags that allow it, as for scripts.14+15+| Page | For |16+|---|---|17+| This page | Agents with a shell: the commands, their JSON, and the Claude Code skill |18+| [JSON output](../reference/json.md) | The schemas of what commands write with `--format json` |19+20+## With a shell21+22+Agents that can run commands use the ones [scripts](../files/scripts.md)23+use, in this order:24+25+```sh26+012 describe book.012 --format json # what's where27+012 get book.012 Sales --format json # read what describe pointed at28+012 set book.012 D2 '=B2*C2' D3 '=B3*C3' --dry-run # preview a change as a diff29+012 set book.012 D2 '=B2*C2' D3 '=B3*C3' # make it: one change, saved atomically30+012 recalc book.012 --format json # check no formula shows an error31+012 export book.012 report.html # share it as a web page32+```33+34+`012 describe` lists each sheet with its used range, the row that looks35+like its header and the column names in it, frozen panes, filters,36+tables, notebook outputs and linked files sent to it, and charts37+(numbered, as `012 export --chart` names them); pivot tables with their38+source; notebook tabs with their cells and whether each ran; and the39+workbook's named ranges:40+41+```42+Sales (shown) A1:F9, 9 rows x 6 columns, 54 cells, 9 formulas43+ header row 1: Date, Region, Units, Price, Total, Share44+ frozen 1 row, 0 columns45+ table Orders A1:F9 Date, Region, Units, Price, Total, Share46+ chart 1 Units by date column of A1:C9 at H247+Notebook (notebook) 2 cells48+ 1 code files = ls (ran)49+ 2 code $files | where size > 1kb (not run)50+names51+ Rate Sales!B1252+```53+54+A header row is guessed: the first row with contents, when every cell55+in it is text and the rows below hold numbers, dates, booleans or56+formulas, or when it's bold. `--format json` gives the same as the57+[schema](../reference/json.md#describe).58+59+`012 set --dry-run` checks the entries as `set` does (a formula that60+doesn't parse, a validation rule, a protected range stop it the same61+way) and prints what would change, as `012 diff` would, without saving;62+`--format json` gives the changes as records instead. Every command's63+flags are in [Command line](../reference/command-line.md).64+65+## The Claude Code skill66+67+012 carries a [Claude Code skill](https://docs.claude.com/en/docs/claude-code/skills):68+a `SKILL.md` that tells Claude when to reach for `012` and how to use69+it well (describe first, preview with `--dry-run`, `recalc` after70+changing formulas, never edit the file's JSON by hand).71+72+```sh73+012 agent --install-skill # into ~/.claude/skills/012, for every project74+012 agent --install-skill .claude/skills/012 # into this project, to commit with it75+012 agent --skill # print it76+```77+78+Installing over a file that isn't this 012's skill asks for `--force`;79+installing the same skill again says it's up to date. A newer 012 may80+carry a newer skill: install it again after upgrading.81+82+## Trust83+84+Agents get no more than scripts do. A workbook's85+[notebook](../nushell/notebooks.md) cells run only with `--notebooks`,86+and then only when the workbook was saved on this computer or87+`--trust` says so; [JEV functions](../formulas/jev.md) ask the network88+only with `--jev`. Protected ranges refuse changes unless `--force` is89+given. Leave these flags to the person asking.@@ -6,7 +6,8 @@ sidebar_position: 6
66 # Scripts77 88 Four commands read and change a `.012` workbook without the screen, for9-shell scripts, cron jobs, Makefiles and nushell pipelines:9+shell scripts, cron jobs, Makefiles, nushell pipelines and10+[agents](../agents/README.md), which also have `012 describe`:1011 1112 ```sh1213 012 get budget.012 B7 # a cell's value, as the sheet shows it@@ -97,6 +98,18 @@ An entry a rule only marks invalid is set, with a warning on standard
9798 error. An input that starts with `--` (a flag's shape) comes after `--`:9899 `012 set budget.012 -- A1 --`.99100 101+`--dry-run` checks the entries the same way but saves nothing, and102+prints what would change as [`012 diff`](git.md#012-diff) would:103+104+```105+$ 012 set budget.012 B7 '=SUM(A1:A6)' B8 1200 --dry-run106+Sheet1!B7 input "=SUM(A1:A5)" → "=SUM(A1:A6)"107+Sheet1!B8 input + 1200108+```109+110+`--format json` writes the result, the changes as records, instead111+([JSON output](../reference/json.md#set)).112+100113 ## recalc101114 102115 `012 recalc file` recomputes every formula, saves the file (leaving it@@ -109,7 +122,8 @@ Q3!D2 #N/A JEV functions need --jev and an API key to be answered
109122 ```110123 111124 It exits 1 when any cell shows an error or a formula reads its own cell,112-so `012 recalc budget.012 && deploy` stops on a broken sheet.125+so `012 recalc budget.012 && deploy` stops on a broken sheet. `--format126+json` writes the list as [JSON](../reference/json.md#recalc).113127 114128 ## export115129 @@ -159,5 +173,5 @@ things need more than the file:
159173 network, and show `#N/A` here unless `--jev` asks it, with the API key160174 from the environment, the credential store or `jev-api-key-command`.161175 162-`get`, `recalc` and `export` take these flags; `set` computes values only163-to check entries.176+`get`, `recalc`, `export` and `describe` take these flags; `set`177+computes values only to check entries.@@ -12,3 +12,4 @@ sidebar_position: 1
1212 | [Configuration](config.md) | The config file, `012 config` and every option, generated from the registry |1313 | [Macro scripting API](macro-api.md) | The functions and command ids macros can use |1414 | [Command line](command-line.md) | Every command, its flags and exit statuses |15+| [JSON output](json.md) | The schemas of what commands write with `--format json` |@@ -25,18 +25,20 @@ The flags are the options' flags, listed in [Configuration](config.md#options).
2525 | Command | Does |2626 |---|---|2727 | `012 get file.012 [ref]` | Writes a cell's value, or a range or sheet as a table: [Scripts](../files/scripts.md#get) |28-| `012 set file.012 ref input [ref input ...]` | Types entries into cells and saves: [Scripts](../files/scripts.md#set) |28+| `012 set file.012 ref input [ref input ...]` | Types entries into cells and saves, or with `--dry-run` prints the change: [Scripts](../files/scripts.md#set) |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) |31+| `012 describe file.012` | Lists the sheets and what's on them: [Agents](../agents/README.md#with-a-shell) |3132 | `012 diff a.012 b.012` | Lists what changed, cell by cell: [Diff and merge in git](../files/git.md#012-diff) |3233 | `012 diff --textconv file.012` | Writes a workbook as lines, for git's textconv: [Diff and merge in git](../files/git.md#git) |3334 | `012 merge-driver base ours theirs [path]` | Merges theirs into ours, for git: [Diff and merge in git](../files/git.md#merging) |3435 | `012 nu --module`, `012 nu --install-module [--force] [path]` | Prints or installs the nushell module: [Nushell](../nushell/README.md#install-the-sheet-command) |36+| `012 agent --skill`, `012 agent --install-skill [--force] [dir]` | Prints or installs the Claude Code skill: [Agents](../agents/README.md#the-claude-code-skill) |3537 | `012 config [path\|edit\|default\|themes\|set-key\|delete-key]` | The settings: [Configuration](config.md#commands) |36-| `012 version` | Prints the version |38+| `012 version [--format text\|json\|nuon]` | Prints the version |3739 38-`get`, `set`, `recalc`, `export`, `diff` and `merge-driver` take their39-flags anywhere after the command's name, and `--help` prints their40+`get`, `set`, `recalc`, `export`, `describe`, `diff`, `merge-driver`41+and `agent` take their flags anywhere after the command's name, and `--help` prints their4042 usage. `--` ends the flags, so what follows is taken as it is4143 (`012 set book.012 -- A1 --`), and an argument such as `-5` is never a4244 flag.@@ -47,11 +49,14 @@ flag.
4749 | `--input` | `get` | What was typed (a formula) rather than its value |4850 | `--no-header` | `get` | JSON and NUON columns named by their letters, the first row a record |4951 | `--force` | `set` | Sets cells in protected ranges |52+| `--dry-run` | `set` | Prints what would change, as `012 diff` would, and saves nothing |53+| `--format text\|json\|nuon` | `set`, `recalc`, `describe`, `merge-driver`, `version` | The result as text, or as [JSON or NUON](json.md) |54+| `--report json\|nuon` | `export` | Writes what was exported as [JSON or NUON](json.md#export) |5055 | `--format kind`, `--table name` | `export` | The format when the file's extension doesn't say; SQLite's table |5156 | `--chart n\|title` | `export` | One chart of the sheet as a web page, by its number or title |52-| `--notebooks` | `get`, `recalc`, `export` | Runs the notebooks' cells first, when the file is trusted here |57+| `--notebooks` | `get`, `recalc`, `export`, `describe` | Runs the notebooks' cells first, when the file is trusted here |5358 | `--trust` | with `--notebooks` | Runs the cells of a file saved on another computer |54-| `--jev` | `get`, `recalc`, `export` | Asks JEV for the JEV functions' answers first |59+| `--jev` | `get`, `recalc`, `export`, `describe` | Asks JEV for the JEV functions' answers first |5560 | `--format text\|json\|nuon` | `diff` | The form of the changes; text by default |5661 | `--color auto\|always\|never` | `diff` | Colors the text; auto colors on a terminal or git's pager unless `NO_COLOR` is set |5762 @@ -0,0 +1,133 @@
1+---2+title: "JSON output"3+sidebar_position: 74+---5+6+# JSON output7+8+Commands without the screen write their results as JSON with9+`--format json`, or as NUON (the same values, as nushell reads them)10+with `--format nuon`; `012 export` writes to a file, so its result11+comes with `--report json`. The schemas below are stable: a later 01212+may add fields, but never renames or removes one, or changes its type.13+Errors are not JSON: they go to standard error as text, with the exit14+statuses in [Command line](command-line.md#exit-status).15+16+## get17+18+`012 get file ref --format json` writes a cell's value alone (a number,19+a string, `true`, `null` for a blank cell), or a range as a list of20+records named by its first row (`--no-header`: by column letters), as21+[Scripts](../files/scripts.md#get) describes. Dates are strings in ISO22+8601, and in NUON dates, file sizes and durations keep their types.23+24+## set25+26+```json27+{28+ "file": "book.012",29+ "saved": true,30+ "changes": [31+ {"kind": "cell", "sheet": "Sheet1", "item": "B7", "field": "input", "old": "12", "new": "=SUM(B1:B6)"}32+ ],33+ "warnings": ["Sheet1!C2: Enter a number between 1 and 10"]34+}35+```36+37+| Field | Is |38+|---|---|39+| `file` | The workbook's path, as given |40+| `saved` | Whether the file was written: false with `--dry-run` |41+| `changes` | What changed, as [`diff`](#diff) lists it |42+| `warnings` | Entries a validation rule marks invalid but lets in |43+44+## recalc45+46+```json47+{48+ "file": "book.012",49+ "errors": [50+ {"cell": "Q3!B7", "sheet": "Q3", "addr": "B7", "value": "#DIV/0!", "why": "Division by zero in B6/C6", "jev": false}51+ ],52+ "circular": false,53+ "notebook_failures": []54+}55+```56+57+`errors` are the formulas showing errors, sheet by sheet and row by58+row; `why` is what the screen's context line says, or `""`; `jev` is59+set for a JEV function's error that `--jev` would answer. `circular` is60+set when a formula reads its own cell. `notebook_failures` are the61+lines `--notebooks` reported for cells that failed. The exit status is62+1 when there are errors, as without `--format`.63+64+## describe65+66+```json67+{68+ "sheets": [69+ {70+ "name": "Sales", "kind": "sheet", "shown": true, "hidden": false,71+ "used": "A1:F9", "rows": 9, "cols": 6, "cells": 54, "formulas": 9, "errors": 0,72+ "header_row": 1, "columns": ["Date", "Region", "Units", "Price", "Total", "Share"],73+ "frozen": [1, 0], "filter": "",74+ "tables": [{"name": "Orders", "range": "A1:F9", "columns": ["Date", "Region", "Units", "Price", "Total", "Share"]}],75+ "regions": [{"name": "files", "kind": "output", "range": "H1:J12"}],76+ "charts": [{"number": 1, "title": "Units by date", "type": "column", "data": "A1:C9", "at": "H2"}]77+ }78+ ],79+ "names": [{"name": "Rate", "range": "Sales!B12"}]80+}81+```82+83+| Field | Is |84+|---|---|85+| `kind` | `sheet`, `notebook` or `pivot` |86+| `shown`, `hidden` | The sheet shown when the file was saved; a hidden tab |87+| `used` | A1 to the last cell with contents, `""` for an empty sheet; `rows` and `cols` its size |88+| `cells`, `formulas`, `errors` | Cells with contents, formulas, and formulas showing errors; `formulas` and `errors` are -1 on sheets of over a million cells, which aren't counted |89+| `header_row`, `columns` | The row guessed to name the columns (1 for the first; 0 and `[]` when none) and its names, `""` for a blank cell |90+| `frozen` | Frozen rows and columns |91+| `filter` | The filtered range, or `""` |92+| `tables` | Named tables, header row included in `range` |93+| `regions` | Notebook outputs (`kind` `output`) and linked files (`linked`, with `file`) sent to the sheet, with the range their rows fill |94+| `charts` | Each chart's `number` (for `012 export --chart`), title (its type's name when it has none), `type`, `data` range and `at`, the cell under its top-left corner |95+| `pivot` | On a pivot table, `{"source": "Sales!A1:F9"}` |96+| `notebook_cells` | On a notebook, each cell: `number`, `kind` (`code` or `note`), `name` (its output's), `source`, and for code `state` (`ran`, `failed`, `not run`) and `error` |97+| `names` | Named ranges, `range` `#REF!` when its cells were deleted |98+99+## diff100+101+`012 diff a b --format json` writes a list of changes:102+103+| Field | Is |104+|---|---|105+| `kind` | `cell`, `sheet`, `region`, `table`, `layout` (another field of a sheet: widths, rules, charts), `name`, `macro`, `notebook` or `workbook` (its locale, its arithmetic) |106+| `sheet` | The sheet, by its name in the second workbook where it has one |107+| `item` | The cell, region, name, macro or field that changed |108+| `field` | What about it: for a cell `input`, `value`, `format` or `note`; for a sheet `added`, `removed`, `renamed` or `moved` |109+| `old`, `new` | Its values, `null` where it wasn't or isn't there |110+111+See [Diff and merge in git](../files/git.md#012-diff).112+113+## export114+115+`012 export file out --report json`:116+117+```json118+{"file": "out.csv", "format": "csv", "rows": 9, "notes": ["1 formula saved as values"]}119+```120+121+## merge-driver122+123+`012 merge-driver base ours theirs --format json` writes124+`{"conflicts": [...]}`, each with `where`, `field`, and the `base`,125+`ours` and `theirs` versions as text (`""` where it isn't there), and126+`what` for a conflict about a whole sheet. The merge and exit status127+are as without it.128+129+## version130+131+```json132+{"version": "v0.9.0", "go": "go1.27.1", "os": "darwin", "arch": "arm64"}133+```@@ -0,0 +1,241 @@
1+package headless2+3+import (4+ "github.com/FelineStateMachine/012/internal/notebook"5+ "github.com/FelineStateMachine/012/internal/sheet"6+)7+8+// Description is what a workbook holds, for 012 describe and the MCP9+// describe tool: enough for agents (or people) to know which10+// references to read and write without reading every cell. Its JSON11+// form is a stable schema (docs/reference/json.md): fields are only12+// added, never renamed or removed.13+type Description struct {14+ Sheets []SheetDescription `json:"sheets"`15+ Names []NameDescription `json:"names"`16+}17+18+// SheetDescription is one tab: a sheet or a notebook.19+type SheetDescription struct {20+ Name string `json:"name"`21+ Kind string `json:"kind"` // "sheet", "notebook" or "pivot"22+ Shown bool `json:"shown"`23+ Hidden bool `json:"hidden"`24+ // Used is the range from A1 to the last cell with contents, "" when25+ // the sheet is empty; Rows and Cols are its size.26+ Used string `json:"used"`27+ Rows int `json:"rows"`28+ Cols int `json:"cols"`29+ Cells int `json:"cells"`30+ Formulas int `json:"formulas"` // -1 on sheets too large to count them31+ Errors int `json:"errors"` // formulas showing errors; -1 when not counted32+ // Header is the row guessed to name the used range's columns, and33+ // Columns its names; 0 and none when no row looks like one.34+ Header int `json:"header_row"`35+ Columns []string `json:"columns"`36+ Frozen [2]int `json:"frozen"` // rows, columns37+ Filter string `json:"filter"` // the filtered range, or ""38+ Tables []TableDescription `json:"tables"`39+ Regions []RegionDescription `json:"regions"`40+ Charts []ChartDescription `json:"charts"`41+ Pivot *PivotDescription `json:"pivot,omitempty"`42+ Notebook []NotebookCell `json:"notebook_cells,omitempty"`43+}44+45+// NameDescription is a named range.46+type NameDescription struct {47+ Name string `json:"name"`48+ Range string `json:"range"` // with its sheet: Q3!B2:B9, or #REF!49+}50+51+// TableDescription is a named table.52+type TableDescription struct {53+ Name string `json:"name"`54+ Range string `json:"range"` // header row included55+ Columns []string `json:"columns"`56+}57+58+// RegionDescription is a linked file or a notebook cell's output sent to59+// the sheet.60+type RegionDescription struct {61+ Name string `json:"name"`62+ Kind string `json:"kind"` // "output" or "linked"63+ Range string `json:"range"`64+ File string `json:"file,omitempty"` // a linked region's file65+}66+67+// ChartDescription is a chart: Number is what 012 export --chart and68+// the MCP tools call it.69+type ChartDescription struct {70+ Number int `json:"number"`71+ Title string `json:"title"`72+ Type string `json:"type"`73+ Data string `json:"data"`74+ At string `json:"at"`75+}76+77+// PivotDescription is where a pivot table's data comes from.78+type PivotDescription struct {79+ Source string `json:"source"` // Sheet!A1:D9980+}81+82+// NotebookCell is one cell of a notebook tab.83+type NotebookCell struct {84+ Number int `json:"number"`85+ Kind string `json:"kind"` // "code" or "note"86+ Name string `json:"name,omitempty"`87+ Source string `json:"source"`88+ // State is "ran", "failed" or "not run"; Error says why it failed.89+ State string `json:"state,omitempty"`90+ Error string `json:"error,omitempty"`91+}92+93+// maxCounted is the most cells Describe counts formulas and errors on.94+const maxCounted = 1_000_00095+96+// Describe describes the workbook as it is.97+func Describe(w *sheet.Workbook) Description {98+ d := Description{Sheets: []SheetDescription{}, Names: []NameDescription{}}99+ for i, s := range w.Sheets() {100+ d.Sheets = append(d.Sheets, describeSheet(s, i == w.Active()))101+ }102+ for _, n := range w.Names() {103+ r := "#REF!"104+ if !n.Gone() {105+ r = sheet.Qualified(n.Sheet.Name(), n.Range)106+ }107+ d.Names = append(d.Names, NameDescription{Name: n.Name, Range: r})108+ }109+ return d110+}111+112+func describeSheet(s *sheet.Sheet, shown bool) SheetDescription {113+ d := SheetDescription{Name: s.Name(), Kind: "sheet", Shown: shown, Hidden: s.Hidden(), Cells: s.Len(),114+ Tables: []TableDescription{}, Regions: []RegionDescription{}, Charts: []ChartDescription{}, Columns: []string{}}115+ if used, ok := s.UsedRange(); ok {116+ d.Used, d.Rows, d.Cols = used.String(), used.To.Row+1, used.To.Col+1117+ d.Header, d.Columns = guessHeader(s, used)118+ }119+ d.Formulas, d.Errors = countFormulas(s)120+ d.Frozen[0], d.Frozen[1] = s.Frozen()121+ if f := s.Filter(); f != nil {122+ d.Filter = f.Range.String()123+ }124+ for _, t := range s.Tables() {125+ d.Tables = append(d.Tables, TableDescription{Name: t.Name, Range: t.Range.String(), Columns: append([]string{}, t.Cols...)})126+ }127+ for _, r := range s.Regions() {128+ d.Regions = append(d.Regions, describeRegion(s, r))129+ }130+ for i, c := range s.Charts() {131+ d.Charts = append(d.Charts, ChartDescription{Number: i + 1, Title: ChartTitle(c), Type: c.Type.String(),132+ Data: c.Data.String(), At: c.At.String()})133+ }134+ if p, ok := s.Pivot(); ok {135+ d.Kind, d.Pivot = "pivot", &PivotDescription{Source: sheet.Qualified(p.Source, p.Range)}136+ }137+ if s.IsNotebook() {138+ d.Kind, d.Notebook = "notebook", describeNotebook(s)139+ }140+ return d141+}142+143+// countFormulas counts the sheet's formulas and those showing errors,144+// or returns -1 for both past maxCounted cells.145+func countFormulas(s *sheet.Sheet) (formulas, errors int) {146+ if s.Len() > maxCounted {147+ return -1, -1148+ }149+ for _, a := range s.Addrs() {150+ if c := s.Cell(a); c != nil && c.IsFormula() {151+ formulas++152+ if s.Value(a).Kind == sheet.Error {153+ errors++154+ }155+ }156+ }157+ return formulas, errors158+}159+160+func describeRegion(s *sheet.Sheet, r sheet.Region) RegionDescription {161+ d := RegionDescription{Name: r.Name, Kind: "output", Range: r.At.String()}162+ if rng, ok := s.RegionTable(r.Name); ok {163+ d.Range = rng.String()164+ }165+ if r.Linked() {166+ d.Kind, d.File = "linked", r.File.Path167+ }168+ return d169+}170+171+func describeNotebook(s *sheet.Sheet) []NotebookCell {172+ out := []NotebookCell{}173+ for i, c := range s.NotebookCells() {174+ nc := NotebookCell{Number: i + 1, Kind: "code", Name: c.Name(), Source: c.Source}175+ if c.Kind != notebook.Code {176+ nc.Kind = "note"177+ out = append(out, nc)178+ continue179+ }180+ switch o := s.Book().Output(c.ID); {181+ case o == nil || o.Unsaved:182+ nc.State = "not run"183+ case o.Failed():184+ nc.State, nc.Error = "failed", o.Err185+ default:186+ nc.State = "ran"187+ }188+ out = append(out, nc)189+ }190+ return out191+}192+193+// headerLook is how many rows below a header guessHeader reads.194+const headerLook = 5195+196+// guessHeader finds the row naming the used range's columns: its first197+// row with contents, when every cell there is text and the rows below198+// hold something other than text, or the row is bold. It returns the199+// row's number (1 for the first) and the names, or 0.200+func guessHeader(s *sheet.Sheet, used sheet.Rect) (int, []string) {201+ first, ok := s.FilledBounds(used)202+ if !ok {203+ return 0, []string{}204+ }205+ row := first.From.Row206+ names := make([]string, 0, used.To.Col-used.From.Col+1)207+ bold, n := true, 0208+ for col := used.From.Col; col <= used.To.Col; col++ {209+ a := sheet.Addr{Col: col, Row: row}210+ v := s.Value(a)211+ switch v.Kind {212+ case sheet.Empty:213+ names = append(names, "")214+ continue215+ case sheet.Text:216+ default:217+ return 0, []string{}218+ }219+ names = append(names, v.Str)220+ bold = bold && s.CellStyle(a).Bold221+ n++222+ }223+ if n == 0 || row == used.To.Row || !bold && !typedBelow(s, used, row) {224+ return 0, []string{}225+ }226+ return row + 1, names227+}228+229+// typedBelow reports whether the rows below row hold a number, a date,230+// a boolean or a formula.231+func typedBelow(s *sheet.Sheet, used sheet.Rect, row int) bool {232+ for r := row + 1; r <= min(row+headerLook, used.To.Row); r++ {233+ for col := used.From.Col; col <= used.To.Col; col++ {234+ a := sheet.Addr{Col: col, Row: r}235+ if k := s.Value(a).Kind; k != sheet.Empty && k != sheet.Text {236+ return true237+ }238+ }239+ }240+ return false241+}@@ -0,0 +1,86 @@
1+package headless2+3+import (4+ "bytes"5+ "encoding/json"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// TestDescribeFixture describes the workbook of every feature the13+// release fixtures keep.14+func TestDescribeFixture(t *testing.T) {15+ f, err := Open("../sheet/testdata/fixtures/new/workbook.012", false)16+ if err != nil {17+ t.Fatal(err)18+ }19+ d := Describe(f.Book)20+ var text bytes.Buffer21+ if err := WriteDescription(&text, d); err != nil {22+ t.Fatal(err)23+ }24+ for _, want := range []string{25+ "Sales (shown) A1:J13, 13 rows x 10 columns",26+ " header row 1: Date, Region, Units, Price, Total",27+ " chart 1 Units by date column of A1:C9 at K2",28+ "Lists (hidden)",29+ " table Targets D1:E3 Region, Target",30+ " pivot table of Sales!A1:E9",31+ " output files A1:B3",32+ " linked app F2 following logs/app.csv",33+ "Notebook (notebook) 5 cells",34+ " 4 code $files | nope (failed: Command `nope` not found)",35+ " Units Sales!C2:C9",36+ } {37+ if !strings.Contains(text.String(), want) {38+ t.Errorf("text lacks %q:\n%s", want, text.String())39+ }40+ }41+ var js bytes.Buffer42+ if err := Encode(&js, "json", d); err != nil {43+ t.Fatal(err)44+ }45+ var back Description46+ if err := json.Unmarshal(js.Bytes(), &back); err != nil {47+ t.Fatal(err)48+ }49+ if len(back.Sheets) != len(d.Sheets) || back.Sheets[0].Charts[2].Type != "pie" || back.Sheets[4].Notebook[1].Name != "files" {50+ t.Errorf("JSON round trip lost something: %+v", back.Sheets[0])51+ }52+ var nu bytes.Buffer53+ if err := Encode(&nu, "nuon", d); err != nil || !strings.HasPrefix(nu.String(), "{sheets: [") {54+ t.Errorf("NUON: %v %.40s", err, nu.String())55+ }56+}57+58+// TestGuessHeader takes a first row of text over typed rows, or a bold59+// one, as a header, and not a table of text.60+func TestGuessHeader(t *testing.T) {61+ for _, c := range []struct {62+ rows [][]string63+ bold bool64+ want int65+ }{66+ {[][]string{{"Item", "Price"}, {"Apple", "1.5"}}, false, 1},67+ {[][]string{{"Item", "Kind"}, {"Apple", "fruit"}}, false, 0},68+ {[][]string{{"Item", "Kind"}, {"Apple", "fruit"}}, true, 1},69+ {[][]string{{"2024", "2025"}, {"1", "2"}}, false, 0},70+ {[][]string{{"Only", "row"}}, true, 0},71+ } {72+ s := sheet.New()73+ for r, row := range c.rows {74+ for col, in := range row {75+ s.Set(sheet.Addr{Col: col, Row: r}, in)76+ }77+ }78+ if c.bold {79+ s.SetStyle(sheet.Rect{To: sheet.Addr{Col: 1}}, func(st *sheet.Style) { st.Bold = true })80+ }81+ used, _ := s.UsedRange()82+ if got, _ := guessHeader(s, used); got != c.want {83+ t.Errorf("%v bold %v: header %d, want %d", c.rows, c.bold, got, c.want)84+ }85+ }86+}@@ -0,0 +1,107 @@
1+package headless2+3+import (4+ "fmt"5+ "io"6+ "strings"7+)8+9+// WriteDescription writes d as text: a line for each sheet saying what10+// it holds, with what's on it indented below, then the named ranges.11+func WriteDescription(w io.Writer, d Description) error {12+ var b strings.Builder13+ for _, s := range d.Sheets {14+ describeSheetText(&b, s)15+ }16+ if len(d.Names) > 0 {17+ b.WriteString("names\n")18+ for _, n := range d.Names {19+ fmt.Fprintf(&b, " %s %s\n", n.Name, n.Range)20+ }21+ }22+ _, err := io.WriteString(w, b.String())23+ return err24+}25+26+func describeSheetText(b *strings.Builder, s SheetDescription) {27+ var tags []string28+ if s.Shown {29+ tags = append(tags, "shown")30+ }31+ if s.Hidden {32+ tags = append(tags, "hidden")33+ }34+ if s.Kind != "sheet" {35+ tags = append(tags, s.Kind)36+ }37+ b.WriteString(s.Name)38+ if len(tags) > 0 {39+ b.WriteString(" (" + strings.Join(tags, ", ") + ")")40+ }41+ switch {42+ case s.Kind == "notebook":43+ fmt.Fprintf(b, " %d %s\n", len(s.Notebook), plural(len(s.Notebook), "cell", "cells"))44+ case s.Used == "":45+ b.WriteString(" empty\n")46+ default:47+ fmt.Fprintf(b, " %s, %d %s x %d %s, %d %s%s\n", s.Used, s.Rows, plural(s.Rows, "row", "rows"), s.Cols,48+ plural(s.Cols, "column", "columns"), s.Cells, plural(s.Cells, "cell", "cells"), formulaCount(s))49+ }50+ describeParts(b, s)51+}52+53+// formulaCount says how many formulas there are and how many show54+// errors.55+func formulaCount(s SheetDescription) string {56+ if s.Formulas <= 0 {57+ return ""58+ }59+ out := fmt.Sprintf(", %d %s", s.Formulas, plural(s.Formulas, "formula", "formulas"))60+ if s.Errors > 0 {61+ out += fmt.Sprintf(" (%d showing errors)", s.Errors)62+ }63+ return out64+}65+66+// describeParts writes what's on a sheet, a line each.67+func describeParts(b *strings.Builder, s SheetDescription) {68+ if s.Header > 0 {69+ fmt.Fprintf(b, " header row %d: %s\n", s.Header, strings.Join(s.Columns, ", "))70+ }71+ if s.Frozen != [2]int{} {72+ fmt.Fprintf(b, " frozen %d %s, %d %s\n", s.Frozen[0], plural(s.Frozen[0], "row", "rows"), s.Frozen[1], plural(s.Frozen[1], "column", "columns"))73+ }74+ if s.Filter != "" {75+ fmt.Fprintf(b, " filter %s\n", s.Filter)76+ }77+ if s.Pivot != nil {78+ fmt.Fprintf(b, " pivot table of %s\n", s.Pivot.Source)79+ }80+ for _, t := range s.Tables {81+ fmt.Fprintf(b, " table %s %s %s\n", t.Name, t.Range, strings.Join(t.Columns, ", "))82+ }83+ for _, r := range s.Regions {84+ if r.Kind == "linked" {85+ fmt.Fprintf(b, " linked %s %s following %s\n", r.Name, r.Range, r.File)86+ } else {87+ fmt.Fprintf(b, " output %s %s\n", r.Name, r.Range)88+ }89+ }90+ for _, c := range s.Charts {91+ fmt.Fprintf(b, " chart %d %s %s of %s at %s\n", c.Number, c.Title, c.Type, c.Data, c.At)92+ }93+ for _, c := range s.Notebook {94+ line := []rune(strings.SplitN(c.Source, "\n", 2)[0])95+ if len(line) > 60 {96+ line = append(line[:57], []rune("...")...)97+ }98+ state := ""99+ switch {100+ case c.Error != "":101+ state = " (failed: " + c.Error + ")"102+ case c.State != "":103+ state = " (" + c.State + ")"104+ }105+ fmt.Fprintf(b, " %d %s %s%s\n", c.Number, c.Kind, string(line), state)106+ }107+}@@ -0,0 +1,33 @@
1+package headless2+3+import (4+ "encoding/json"5+ "fmt"6+ "io"7+8+ "github.com/FelineStateMachine/012/internal/nuon"9+)10+11+// Encode writes v, a value of this package's result types, as JSON12+// (indented, one value) or NUON, the forms --format json and nuon ask13+// for. NUON is the JSON read as nushell reads it, so both carry the14+// same schema (docs/reference/json.md).15+func Encode(w io.Writer, format string, v any) error {16+ data, err := json.MarshalIndent(v, "", " ")17+ if err != nil {18+ return err19+ }20+ switch format {21+ case "json":22+ case "nuon":23+ val, err := nuon.Parse(data)24+ if err != nil {25+ return err26+ }27+ data = nuon.Append(nil, val)28+ default:29+ return fmt.Errorf("can't write %s", format)30+ }31+ _, err = w.Write(append(data, '\n'))32+ return err33+}@@ -0,0 +1,108 @@
1+package headless2+3+import (4+ "bytes"5+ "encoding/json"6+ "os"7+8+ "github.com/FelineStateMachine/012/internal/diff"9+ "github.com/FelineStateMachine/012/internal/nuon"10+)11+12+// The results commands write with --format json or nuon, and the MCP13+// tools return. Each is a stable schema (docs/reference/json.md): fields14+// are only added, never renamed or removed.15+16+// Change is one change as 012 diff --format json lists it: what kind17+// of thing changed (cell, sheet, region, name, macro or a layout18+// field), on which sheet, which item, which field of it, and its old19+// and new values, null where it wasn't or isn't there.20+type Change struct {21+ Kind string `json:"kind"`22+ Sheet string `json:"sheet"`23+ Item string `json:"item"`24+ Field string `json:"field"`25+ Old json.RawMessage `json:"old"`26+ New json.RawMessage `json:"new"`27+}28+29+// Changes are diff's changes as records.30+func Changes(changes []diff.Change) []Change {31+ out := make([]Change, 0, len(changes))32+ for _, c := range changes {33+ out = append(out, Change{Kind: c.Kind, Sheet: c.Sheet, Item: c.Item, Field: c.Field,34+ Old: nuon.AppendJSON(nil, c.Old), New: nuon.AppendJSON(nil, c.New)})35+ }36+ return out37+}38+39+// SetResult is what 012 set writes: the file, whether it was saved (not40+// with --dry-run), what changed, and the warnings of entries a rule41+// marks invalid.42+type SetResult struct {43+ File string `json:"file"`44+ Saved bool `json:"saved"`45+ Changes []Change `json:"changes"`46+ Warnings []string `json:"warnings"`47+}48+49+// ProblemRecord is a formula showing an error.50+type ProblemRecord struct {51+ Cell string `json:"cell"` // with its sheet: Q3!B752+ Sheet string `json:"sheet"`53+ Addr string `json:"addr"`54+ Value string `json:"value"` // #DIV/0!55+ Why string `json:"why"`56+ JEV bool `json:"jev"` // a JEV function's, answered with --jev57+}58+59+// Problems are problems as records.60+func Problems(ps []Problem) []ProblemRecord {61+ out := make([]ProblemRecord, 0, len(ps))62+ for _, p := range ps {63+ out = append(out, ProblemRecord{Cell: p.At(), Sheet: p.Sheet, Addr: p.Addr.String(), Value: p.Value, Why: p.Why, JEV: p.JEV})64+ }65+ return out66+}67+68+// RecalcResult is what 012 recalc writes.69+type RecalcResult struct {70+ File string `json:"file"`71+ Errors []ProblemRecord `json:"errors"`72+ Circular bool `json:"circular"`73+ // NotebookFailures are the notebook cells --notebooks couldn't run.74+ NotebookFailures []string `json:"notebook_failures"`75+}76+77+// ExportResult is what 012 export --report writes.78+type ExportResult struct {79+ File string `json:"file"`80+ Format string `json:"format"`81+ Rows int `json:"rows"`82+ Notes []string `json:"notes"`83+}84+85+// Changes compares the workbook as it is with its file as it was86+// opened (nothing, for a new file), before it's saved.87+func (f *File) Changes() ([]diff.Change, error) {88+ var old []byte89+ if !f.New {90+ var err error91+ if old, err = os.ReadFile(f.Path); err != nil {92+ return nil, err93+ }94+ }95+ var now bytes.Buffer96+ if err := f.Book.Write(&now); err != nil {97+ return nil, err98+ }99+ a, err := diff.Read(old)100+ if err != nil {101+ return nil, err102+ }103+ b, err := diff.Read(now.Bytes())104+ if err != nil {105+ return nil, err106+ }107+ return diff.Compare(a, b), nil108+}