tabtree-mcp 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -12
- package/package.json +1 -1
- package/tabtree-mcp.mjs +151 -38
package/README.md
CHANGED
|
@@ -7,17 +7,22 @@ Let Claude read your TabTree library and build new maps for you.
|
|
|
7
7
|
> "Draw our database schema as a board."
|
|
8
8
|
> "Add the risks we just discussed to my Q3 plan."
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
**You need TabTree itself.** This package is the bridge, not the app. It works
|
|
11
|
+
with both editions of TabTree, and you pick the mode with ONE environment variable:
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
| Edition | What Claude talks to | Set |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| **TabTree** (subscription — your maps live in your account) | your account, through TabTree's API | `TABTREE_API_KEY` — the key from ⚙️ Settings → Account → **Claude connector** |
|
|
16
|
+
| **TabTree Classic** (the local file, bought once) | the 🛟 backup folder on your disk | `TABTREE_DIR` — that folder's path |
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
In account mode a new map appears in TabTree within a minute, on every device you
|
|
19
|
+
are signed in on. In Classic mode it appears as a file in your backup folder, and
|
|
20
|
+
you pull it in from *My maps* → **🛟** → **"Bring maps back from this folder…"**.
|
|
21
|
+
|
|
22
|
+
In both modes the connector **only ever creates new maps.** It cannot overwrite a
|
|
23
|
+
map, cannot delete one, and never touches your browser storage. With a folder,
|
|
24
|
+
nothing is uploaded anywhere; with an account, it talks only to TabTree's own
|
|
25
|
+
API for that account.
|
|
21
26
|
|
|
22
27
|
Changes to a map you already have are no exception. Claude never writes to it:
|
|
23
28
|
it files a **proposal**, TabTree shows it as a banner on that map, and you tick
|
|
@@ -27,9 +32,16 @@ the changes you want. ⌘Z undoes them afterwards, like anything else you do.
|
|
|
27
32
|
|
|
28
33
|
## Before you start
|
|
29
34
|
|
|
30
|
-
**1.
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
**1. Get your key, or your folder.** TabTree (subscription): open the app →
|
|
36
|
+
⚙️ Settings → Account → **Claude connector** → *Create a connector key*. It is
|
|
37
|
+
shown once — copy it. TabTree Classic: open TabTree → *My maps* → 🛟 and pick a
|
|
38
|
+
folder; that folder is what the connector reads and writes.
|
|
39
|
+
|
|
40
|
+
Quickest test, account mode:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
TABTREE_API_KEY=tt_live_… npx tabtree-mcp
|
|
44
|
+
```
|
|
33
45
|
|
|
34
46
|
**2. Node.js 18+ — for the npm and Claude Code routes.** Claude Desktop ships
|
|
35
47
|
its own Node, so the `.mcpb` install below needs nothing. For the other routes,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabtree-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"mcpName": "app.tabtree/tabtree",
|
|
5
5
|
"description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards. Local files, no account, nothing uploaded.",
|
|
6
6
|
"repository": {
|
package/tabtree-mcp.mjs
CHANGED
|
@@ -22,6 +22,27 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSy
|
|
|
22
22
|
import { join, basename } from "node:path";
|
|
23
23
|
|
|
24
24
|
const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// DEUX SOURCES, UN CONNECTEUR (2026-09-08). Le dossier 🛟 sert TabTree Classic (le fichier
|
|
27
|
+
// local) ; le CLOUD sert l'abonné TabTree SaaS. La source est choisie par la présence d'une
|
|
28
|
+
// clé personnelle `TABTREE_API_KEY` (fabriquée dans ⚙️ Account → Claude connector) : les six
|
|
29
|
+
// outils ne changent pas d'un mot de contrat, seule la tuyauterie de lecture/écriture change.
|
|
30
|
+
// `TABTREE_API_URL` n'est là que pour une préversion — la valeur par défaut est la production.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
|
|
33
|
+
const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
|
|
34
|
+
const CLOUD = !!API_KEY;
|
|
35
|
+
async function cloudCall(action, body){
|
|
36
|
+
let r;
|
|
37
|
+
try{
|
|
38
|
+
r = await fetch(API_URL, { method:"POST",
|
|
39
|
+
headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
|
|
40
|
+
body: JSON.stringify(Object.assign({ action }, body || {})) });
|
|
41
|
+
}catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
|
|
42
|
+
let j = null; try{ j = await r.json(); }catch(e){}
|
|
43
|
+
if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
|
|
44
|
+
return j;
|
|
45
|
+
}
|
|
25
46
|
const MANIFEST = "bibliotheque.json";
|
|
26
47
|
// Boîte aux lettres des propositions. Le connecteur reste EN AJOUT SEUL : il ne modifie
|
|
27
48
|
// jamais un .tabtree, il dépose ici un fichier que l'app lit, montre, et n'applique que si
|
|
@@ -33,7 +54,8 @@ const PROP_DIR = "Propositions";
|
|
|
33
54
|
// Utilitaires
|
|
34
55
|
// ---------------------------------------------------------------------------
|
|
35
56
|
function dirOk(){
|
|
36
|
-
if(
|
|
57
|
+
if(CLOUD) return; // la source est le compte, pas un dossier
|
|
58
|
+
if(!DIR) throw new Error("The connector has no source. For TabTree (subscription), set TABTREE_API_KEY to the connector key from ⚙️ Settings → Account → Claude connector. For TabTree Classic (the local file), set TABTREE_DIR to your 🛟 backup folder. (The older name YGMIND_DIR still works.)");
|
|
37
59
|
if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
|
|
38
60
|
}
|
|
39
61
|
// Même règle de nommage que l'app (bkSafeName) : la restauration retire le
|
|
@@ -116,7 +138,9 @@ const ART_IDS_MCP = [
|
|
|
116
138
|
"laptop", "phone", "mail", "folder", "book", "chart",
|
|
117
139
|
"money", "calendar", "building", "car", "globe", "rocket",
|
|
118
140
|
"house", "pin", "plane", "coffee", "plant", "sun",
|
|
119
|
-
"camera", "music", "lock", "key", "health", "gift"
|
|
141
|
+
"camera", "music", "lock", "key", "health", "gift",
|
|
142
|
+
"manager", "client", "expert", "assistant", "coach", "learner",
|
|
143
|
+
"developer", "seller", "support", "speaker", "freelancer", "group"
|
|
120
144
|
];
|
|
121
145
|
const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
122
146
|
const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
|
|
@@ -267,7 +291,11 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
267
291
|
a0: (e.kind==="arc" && Number.isFinite(+e.a0)) ? +e.a0 : null,
|
|
268
292
|
a1: (e.kind==="arc" && Number.isFinite(+e.a1)) ? +e.a1 : null,
|
|
269
293
|
ir: (e.kind==="arc" && Number.isFinite(+e.ir)) ? +e.ir : null,
|
|
270
|
-
size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null
|
|
294
|
+
size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null,
|
|
295
|
+
// L'image sans octets sur un élément de board (2026-09-03) : même frontière que
|
|
296
|
+
// pour un nœud — un id inventé retombe sur « aucune image », jamais sur du balisage.
|
|
297
|
+
// Pas sur un secteur, un gradin ou un chevron : leur silhouette n'a pas de « haut ».
|
|
298
|
+
pic: (e.pic != null && !["arc","trap","chev"].includes(kind)) ? okPic(String(e.pic)) : "" };
|
|
271
299
|
});
|
|
272
300
|
const byId = new Map();
|
|
273
301
|
for(const e of els){
|
|
@@ -332,10 +360,11 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
332
360
|
for(const e of els){
|
|
333
361
|
if(e.kind==="sticky"){
|
|
334
362
|
refOf.set(e.lid, "st:" + stickies.length);
|
|
335
|
-
stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined });
|
|
363
|
+
stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined, pic:e.pic||undefined });
|
|
336
364
|
} else {
|
|
337
365
|
refOf.set(e.lid, "sh:" + shapes.length);
|
|
338
366
|
shapes.push({ kind:e.kind, x:e.x, y:e.y, w:e.w, h:e.h, color:e.kind==="text" ? "#ffffff" : e.color, text:e.text, size:e.size||undefined,
|
|
367
|
+
pic: e.pic||undefined,
|
|
339
368
|
tk: e.tk != null ? e.tk : undefined,
|
|
340
369
|
a0: e.a0 != null ? e.a0 : undefined,
|
|
341
370
|
a1: e.a1 != null ? e.a1 : undefined,
|
|
@@ -452,6 +481,36 @@ function scanLibrary(){
|
|
|
452
481
|
out.sort((a,b)=>b.mtime.localeCompare(a.mtime));
|
|
453
482
|
return out;
|
|
454
483
|
}
|
|
484
|
+
// La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
|
|
485
|
+
// les six outils n'ont rien à savoir de la source. `file` porte la clé de la carte (map_key),
|
|
486
|
+
// que `propose_changes` renvoie telle quelle — pas de suffixe « __id » à analyser ici.
|
|
487
|
+
async function cloudLibrary(){
|
|
488
|
+
const j = await cloudCall("docs", {});
|
|
489
|
+
const folders = new Map((j.folders||[]).filter(f=>f && f.folder_key).map(f=>[f.folder_key, f]));
|
|
490
|
+
const pathOf = (k)=>{
|
|
491
|
+
const out = [], seen = new Set();
|
|
492
|
+
let f = folders.get(k);
|
|
493
|
+
while(f && !seen.has(f.folder_key) && out.length <= 16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? folders.get(f.parent_key) : null; }
|
|
494
|
+
return out.length ? out.join(" / ") : null;
|
|
495
|
+
};
|
|
496
|
+
const out = [];
|
|
497
|
+
for(const r of (j.maps||[])){
|
|
498
|
+
const doc = r && r.doc;
|
|
499
|
+
if(!doc || !doc.root) continue;
|
|
500
|
+
out.push({
|
|
501
|
+
file: r.map_key,
|
|
502
|
+
name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
|
|
503
|
+
folder: pathOf(r.folder_key),
|
|
504
|
+
kind: doc.board ? "board" : "map",
|
|
505
|
+
nodes: countNodes(doc.root),
|
|
506
|
+
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
507
|
+
mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
|
|
508
|
+
doc
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
async function loadLibrary(){ return CLOUD ? cloudLibrary() : scanLibrary(); }
|
|
455
514
|
function collectTexts(doc){
|
|
456
515
|
const texts = [];
|
|
457
516
|
(function walk(n){ if(n.text) texts.push(n.text); if(n.note) texts.push(n.note); (n.children||[]).forEach(walk); })(doc.root);
|
|
@@ -486,9 +545,9 @@ function boardToText(doc){
|
|
|
486
545
|
if((doc.draws||[]).length) lines.push(`Pencil strokes: ${doc.draws.length}`);
|
|
487
546
|
return lines.join("\n");
|
|
488
547
|
}
|
|
489
|
-
function findMap(file){
|
|
548
|
+
async function findMap(file){
|
|
490
549
|
checkRelPath(file);
|
|
491
|
-
const all =
|
|
550
|
+
const all = await loadLibrary();
|
|
492
551
|
const hit = all.find(m=>m.file===file) || all.find(m=>m.file.endsWith(file)) || all.find(m=>m.name===file);
|
|
493
552
|
if(!hit) throw new Error("No such map: " + file + ". Use list_maps to see what is available.");
|
|
494
553
|
return hit;
|
|
@@ -497,7 +556,20 @@ function findMap(file){
|
|
|
497
556
|
// ---------------------------------------------------------------------------
|
|
498
557
|
// Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
|
|
499
558
|
// ---------------------------------------------------------------------------
|
|
500
|
-
function writeDoc(name, doc, folder){
|
|
559
|
+
async function writeDoc(name, doc, folder){
|
|
560
|
+
if(CLOUD){
|
|
561
|
+
// Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il existe déjà,
|
|
562
|
+
// sinon la carte arrive à la racine et le texte de réponse le dit (« no silent caps »).
|
|
563
|
+
let folder_key = null;
|
|
564
|
+
if(folder){
|
|
565
|
+
const j = await cloudCall("list", {});
|
|
566
|
+
const hit = (j.folders||[]).find(f=>f && String(f.name||"").trim().toLowerCase() === String(folder).trim().toLowerCase());
|
|
567
|
+
if(hit) folder_key = hit.folder_key;
|
|
568
|
+
}
|
|
569
|
+
const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
|
|
570
|
+
const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
|
|
571
|
+
return "cloud:" + r.map_key + (folder && !folder_key ? " (folder “" + folder + "” does not exist in this account — the map is at the root)" : "");
|
|
572
|
+
}
|
|
501
573
|
dirOk();
|
|
502
574
|
let target = DIR;
|
|
503
575
|
if(folder){
|
|
@@ -527,7 +599,7 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
|
527
599
|
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
528
600
|
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
529
601
|
// C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
|
|
530
|
-
const PROP_OPS = ["add","rename","note","check","delete","move","pic"];
|
|
602
|
+
const PROP_OPS = ["add","rename","note","check","delete","move","pic","persona"];
|
|
531
603
|
|
|
532
604
|
// Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
|
|
533
605
|
// délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
|
|
@@ -541,6 +613,24 @@ function buildProposalOps(mapDoc, changes){
|
|
|
541
613
|
const where = "change #" + (i+1);
|
|
542
614
|
const op = String((c && c.op) || "").trim();
|
|
543
615
|
if(!PROP_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${PROP_OPS.join(", ")}.`);
|
|
616
|
+
if(op === "persona"){
|
|
617
|
+
// `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
|
|
618
|
+
// nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
|
|
619
|
+
// « ma cartographie du travail » dépose ici l'objet de l'entretien, l'app propose de
|
|
620
|
+
// l'appliquer d'un clic. Contrôle SUPERFICIEL, et c'est une décision : `okPersona`, la
|
|
621
|
+
// frontière de confiance de l'app (soixante lignes de bornes), juge à l'application et
|
|
622
|
+
// nomme son refus dans le panneau. La dupliquer ici serait une seconde règle à figer
|
|
623
|
+
// caractère par caractère comme `okPic`. La raison qui fait valider les autres ops en
|
|
624
|
+
// profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
|
|
625
|
+
// s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
|
|
626
|
+
const P = c.persona;
|
|
627
|
+
if(!P || typeof P !== "object" || !Array.isArray(P.modules) || !P.modules.length)
|
|
628
|
+
throw new Error(`${where}: \`persona\` must be the interview object — { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }.`);
|
|
629
|
+
const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
|
|
630
|
+
if(n < 3) throw new Error(`${where}: the persona has ${n} process(es) — a work map needs at least three. Finish the interview first.`);
|
|
631
|
+
if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
|
|
632
|
+
return { op, persona: P, procs: n };
|
|
633
|
+
}
|
|
544
634
|
const target = String(c.target == null ? "" : c.target);
|
|
545
635
|
const r = propFind(mapDoc.root, target);
|
|
546
636
|
if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the node the change applies to.`);
|
|
@@ -595,17 +685,24 @@ function buildProposalOps(mapDoc, changes){
|
|
|
595
685
|
}
|
|
596
686
|
function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
|
|
597
687
|
|
|
598
|
-
function writeProposal(map, note, ops){
|
|
688
|
+
async function writeProposal(map, note, ops){
|
|
689
|
+
const payload = (id)=>({
|
|
690
|
+
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
|
|
691
|
+
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
692
|
+
});
|
|
693
|
+
if(CLOUD){
|
|
694
|
+
// La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
|
|
695
|
+
// Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
|
|
696
|
+
const r = await cloudCall("propose", { map_key: map.file, payload: payload(map.file) });
|
|
697
|
+
return "cloud:" + (r.id || "proposal");
|
|
698
|
+
}
|
|
599
699
|
dirOk();
|
|
600
700
|
const target = join(DIR, PROP_DIR);
|
|
601
701
|
if(!existsSync(target)) mkdirSync(target);
|
|
602
702
|
const id = mapIdOf(map.file);
|
|
603
703
|
if(!id) throw new Error("This map's file has no id suffix (“…__map123.tabtree”), so the app cannot match a proposal to it. Open it once in TabTree with the backup folder turned on, then try again.");
|
|
604
704
|
const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
|
|
605
|
-
writeFileSync(join(target, file), JSON.stringify(
|
|
606
|
-
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: basename(map.file), mapName: map.name,
|
|
607
|
-
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
608
|
-
}, null, 2), "utf8");
|
|
705
|
+
writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
|
|
609
706
|
return PROP_DIR + "/" + file;
|
|
610
707
|
}
|
|
611
708
|
|
|
@@ -619,6 +716,8 @@ function writeProposal(map, note, ops){
|
|
|
619
716
|
// Tout répond « ✅ » et rien n'apparaît. Donner le nom du dossier rend l'écart
|
|
620
717
|
// visible en une seconde, sans que personne ait à soupçonner quoi que ce soit.
|
|
621
718
|
function importHint(){
|
|
719
|
+
if(CLOUD) return "Saved to the user's TabTree account. It appears in TabTree within a minute (the app polls "
|
|
720
|
+
+ "its account while open), or on the next sign-in on any device. Nothing is overwritten.";
|
|
622
721
|
return "Written to the folder “" + basename(DIR) + "”.\n"
|
|
623
722
|
+ "To see it in TabTree: My maps → 🛟 → “Bring maps back from this folder…”. "
|
|
624
723
|
+ "(Or press ⌘K and type “bring my maps back”.) Nothing is overwritten.\n"
|
|
@@ -674,6 +773,7 @@ const TOOLS = [
|
|
|
674
773
|
x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
|
|
675
774
|
color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
|
|
676
775
|
size:{ type:"number", description:"Text size 9-72 (defaults to 15)" },
|
|
776
|
+
pic:{ type:"string", description:"Optional picture drawn INSIDE the element, no file needed — \"a:<id>\" for a built-in illustration (roles: manager, client, expert, assistant, coach, learner, developer, seller, support, speaker, freelancer, group — plus person, team, idea, target, rocket…) or \"e:<emoji>\". Not on arc/trap/chev." },
|
|
677
777
|
tk:{ type:"number", description:"trap only — small-side/large-side ratio, 0..0.95 (0 = triangle); NEGATIVE puts the narrow side at the bottom (funnel tier)" },
|
|
678
778
|
a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
|
|
679
779
|
a1:{ type:"number", description:"arc only — end angle in degrees (a1 > a0; a full circle is a0=0, a1=360)" },
|
|
@@ -696,34 +796,35 @@ const TOOLS = [
|
|
|
696
796
|
},
|
|
697
797
|
{
|
|
698
798
|
name: "propose_changes",
|
|
699
|
-
description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node. Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
|
|
799
|
+
description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
|
|
700
800
|
inputSchema: { type:"object", properties:{
|
|
701
801
|
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
702
802
|
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
703
803
|
changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
|
|
704
|
-
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it" },
|
|
705
|
-
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous" },
|
|
804
|
+
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","persona"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) from an interview — no target" },
|
|
805
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
|
|
806
|
+
persona:{ type:"object", description:"persona: the interview object exactly as the skill returns it — who, role, mission, tools, meetings, opener, modules[{name, procs[{name,h,after,freq,rep,data,stakes,lever,step,why}]}], trajectory, signs, parking. TabTree rebuilds the canvas AND its companion sheet from it." },
|
|
706
807
|
markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
|
|
707
808
|
text:{ type:"string", description:"rename: the new text" },
|
|
708
809
|
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
709
810
|
value:{ type:"boolean", description:"check: true to tick, false to untick" },
|
|
710
811
|
col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
|
|
711
812
|
pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." }
|
|
712
|
-
}, required:["op"
|
|
813
|
+
}, required:["op"], additionalProperties:false } }
|
|
713
814
|
}, required:["file","changes"], additionalProperties:false }
|
|
714
815
|
}
|
|
715
816
|
];
|
|
716
817
|
|
|
717
818
|
const HANDLERS = {
|
|
718
|
-
list_maps(){
|
|
719
|
-
const maps =
|
|
720
|
-
if(!maps.length) return "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
|
|
819
|
+
async list_maps(){
|
|
820
|
+
const maps = await loadLibrary();
|
|
821
|
+
if(!maps.length) return CLOUD ? "This TabTree account has no maps yet." : "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
|
|
721
822
|
return maps.map(m=>
|
|
722
823
|
`• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
|
|
723
824
|
).join("\n");
|
|
724
825
|
},
|
|
725
|
-
read_map(args){
|
|
726
|
-
const m = findMap(args.file);
|
|
826
|
+
async read_map(args){
|
|
827
|
+
const m = await findMap(args.file);
|
|
727
828
|
const head = `${m.name} [${m.kind}] — file: ${m.file}\n`;
|
|
728
829
|
if(m.kind === "board") return head + boardToText(m.doc);
|
|
729
830
|
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
@@ -731,11 +832,11 @@ const HANDLERS = {
|
|
|
731
832
|
return head + nodeToMarkdown(m.doc.root, 0)
|
|
732
833
|
+ "\n\n(To change this map, use propose_changes with `file: \"" + m.file + "\"`. Name a node by the exact text above, or \"Ancestor > Node\" when it appears twice. Nothing is applied until the user approves it in TabTree.)";
|
|
733
834
|
},
|
|
734
|
-
search_maps(args){
|
|
835
|
+
async search_maps(args){
|
|
735
836
|
const q = String(args.query||"").toLowerCase();
|
|
736
837
|
if(!q) throw new Error("`query` is empty.");
|
|
737
838
|
const hits = [];
|
|
738
|
-
for(const m of
|
|
839
|
+
for(const m of await loadLibrary()){
|
|
739
840
|
const found = [];
|
|
740
841
|
if(m.name.toLowerCase().includes(q)) found.push("(map name)");
|
|
741
842
|
for(const t of collectTexts(m.doc)){
|
|
@@ -746,7 +847,7 @@ const HANDLERS = {
|
|
|
746
847
|
}
|
|
747
848
|
return hits.length ? hits.join("\n") : "Nothing found for \"" + args.query + "\".";
|
|
748
849
|
},
|
|
749
|
-
create_mindmap(args){
|
|
850
|
+
async create_mindmap(args){
|
|
750
851
|
// Sans cette garde, un appel où « markdown » manque donnait String(undefined) =
|
|
751
852
|
// la chaîne "undefined" : une carte à un seul nœud, écrite sur le disque sans erreur.
|
|
752
853
|
if(typeof args.markdown !== "string" || !args.markdown.trim())
|
|
@@ -764,12 +865,12 @@ const HANDLERS = {
|
|
|
764
865
|
const doc = { v:1, root, images:[], stickies:[] };
|
|
765
866
|
const st = okStyleMcp(args.style);
|
|
766
867
|
if(st) doc.style = st;
|
|
767
|
-
const file = writeDoc(args.name || root.text, doc, args.folder);
|
|
868
|
+
const file = await writeDoc(args.name || root.text, doc, args.folder);
|
|
768
869
|
return `✅ Map created (${countNodes(root)} nodes): ${file}\n${importHint()}`;
|
|
769
870
|
},
|
|
770
|
-
create_board(args){
|
|
871
|
+
async create_board(args){
|
|
771
872
|
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
772
|
-
const file = writeDoc(args.name || "Board", doc, args.folder);
|
|
873
|
+
const file = await writeDoc(args.name || "Board", doc, args.folder);
|
|
773
874
|
const n = doc.shapes.length + doc.stickies.length;
|
|
774
875
|
// Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
|
|
775
876
|
// peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
|
|
@@ -781,12 +882,23 @@ const HANDLERS = {
|
|
|
781
882
|
: "";
|
|
782
883
|
return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
|
|
783
884
|
},
|
|
784
|
-
propose_changes(args){
|
|
785
|
-
const m = findMap(args.file);
|
|
786
|
-
|
|
885
|
+
async propose_changes(args){
|
|
886
|
+
const m = await findMap(args.file);
|
|
887
|
+
// Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
|
|
888
|
+
const allPersona = Array.isArray(args.changes) && args.changes.length > 0 && args.changes.every(c=>c && c.op === "persona");
|
|
889
|
+
if(m.kind === "board" && !allPersona)
|
|
787
890
|
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
891
|
+
if(allPersona && m.kind !== "board")
|
|
892
|
+
throw new Error("A persona re-casts a WORK MAP canvas, which is a board. “" + m.name + "” is a mind map — pick the person's work map from list_maps (its name starts with “Work map —” or “Cartographie —”).");
|
|
788
893
|
const ops = buildProposalOps(m.doc, args.changes);
|
|
789
|
-
const file = writeProposal(m, args.note, ops);
|
|
894
|
+
const file = await writeProposal(m, args.note, ops);
|
|
895
|
+
if(allPersona){
|
|
896
|
+
return `📮 Interview filed for “${m.name}” — ${ops[0].procs} processes: ${file}\n`
|
|
897
|
+
+ "NOTHING has been changed yet. The canvas file is untouched.\n"
|
|
898
|
+
+ (CLOUD ? "TabTree shows a banner on that work map within a minute (app open, signed in): " : "TabTree shows a banner on that work map within a few seconds (backup folder connected, app open): ") + "“Your Claude interview is ready — re-cast this work map?”. "
|
|
899
|
+
+ "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
|
|
900
|
+
+ "Tell the user to look at TabTree — do not claim the map has been re-cast.";
|
|
901
|
+
}
|
|
790
902
|
const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
|
|
791
903
|
const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
|
|
792
904
|
return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
|
|
@@ -794,7 +906,7 @@ const HANDLERS = {
|
|
|
794
906
|
+ (drops ? `, ${drops} node(s) to remove` : "")
|
|
795
907
|
+ `: ${file}\n`
|
|
796
908
|
+ "NOTHING has been changed yet. The map file is untouched.\n"
|
|
797
|
-
+ "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). "
|
|
909
|
+
+ (CLOUD ? "TabTree shows a banner on that map within a minute (the app open and signed in). " : "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). ")
|
|
798
910
|
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
799
911
|
+ "Tell the user to look at TabTree — do not claim the map has been updated.";
|
|
800
912
|
}
|
|
@@ -818,7 +930,7 @@ function handle(msg){
|
|
|
818
930
|
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
819
931
|
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
820
932
|
// autotest les compare désormais toutes les quatre.
|
|
821
|
-
serverInfo: { name: "tabtree", version: "1.
|
|
933
|
+
serverInfo: { name: "tabtree", version: "1.2.0" }
|
|
822
934
|
});
|
|
823
935
|
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
824
936
|
// notification : pas de réponse
|
|
@@ -830,12 +942,13 @@ function handle(msg){
|
|
|
830
942
|
const name = params && params.name;
|
|
831
943
|
const fn = HANDLERS[name];
|
|
832
944
|
if(!fn){ replyErr(id, -32602, "Outil inconnu : " + name); return; }
|
|
833
|
-
|
|
834
|
-
|
|
945
|
+
// Les gestionnaires sont ASYNCHRONES depuis la source cloud (2026-09-08) : un `fn` qui
|
|
946
|
+
// lève ou qui rejette rend le même message d'erreur — Claude n'a pas à distinguer les deux.
|
|
947
|
+
Promise.resolve().then(()=>fn((params && params.arguments) || {})).then(text=>{
|
|
835
948
|
reply(id, { content: [{ type:"text", text }] });
|
|
836
|
-
}
|
|
949
|
+
}, e=>{
|
|
837
950
|
reply(id, { content: [{ type:"text", text: "Erreur : " + (e && e.message || e) }], isError: true });
|
|
838
|
-
}
|
|
951
|
+
});
|
|
839
952
|
} else if(id !== undefined){
|
|
840
953
|
replyErr(id, -32601, "Méthode non supportée : " + method);
|
|
841
954
|
}
|