tftio-kb 2.5.4

Personal knowledge base — typed AST with org-mode as projection, SQLite-backed
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
//! CLI entrypoint for the kb knowledge base tool.
//!
//! Follows the `todoer` / `silent-critic` pattern: `ToolSpec`, agent
//! surface, metadata routing, and domain command dispatch all live on the
//! library side; `main.rs` is a thin binary stub.

use std::io::Read;

use serde_json::json;
use tftio_lib::{
    AgentCapability, AgentSurfaceSpec, CommandSelector, FlagSelector, JsonOutput, LicenseType,
    ToolSpec, error::print_error, map_standard_command, render_response, run_cli_no_doctor_from,
    workspace_tool,
};

use clap::{Parser, Subcommand};
use std::path::PathBuf;
pub use tftio_lib::MetaCommand;

use crate::markdown;
use crate::org_meta;
use crate::parser;
use crate::prompt;
use crate::storage;
use tftio_org::ast::{Block, Document, Tag, Title};

// ── Agent surface ──────────────────────────────────────────────────────

const SEARCH_COMMAND: CommandSelector = CommandSelector::new(&["search"]);
const GET_COMMAND: CommandSelector = CommandSelector::new(&["get"]);
const CREATE_COMMAND: CommandSelector = CommandSelector::new(&["create"]);
const UPDATE_COMMAND: CommandSelector = CommandSelector::new(&["update"]);
const DELETE_COMMAND: CommandSelector = CommandSelector::new(&["delete"]);
const RECENT_COMMAND: CommandSelector = CommandSelector::new(&["recent"]);
const LIST_BY_TAG_COMMAND: CommandSelector = CommandSelector::new(&["list-by-tag"]);
const LINKS_COMMAND: CommandSelector = CommandSelector::new(&["links"]);
const ORPHANS_COMMAND: CommandSelector = CommandSelector::new(&["orphans"]);
const HUBS_COMMAND: CommandSelector = CommandSelector::new(&["hubs"]);
const BROKEN_COMMAND: CommandSelector = CommandSelector::new(&["broken"]);
const PROMPT_RENDER_COMMAND: CommandSelector = CommandSelector::new(&["prompt", "render"]);
const PROMPT_LIST_COMMAND: CommandSelector = CommandSelector::new(&["prompt", "list"]);
const PROMPT_SHOW_COMMAND: CommandSelector = CommandSelector::new(&["prompt", "show"]);

const SEARCH_JSON_FLAG: FlagSelector = FlagSelector::new(&["search"], "json");
const GET_JSON_FLAG: FlagSelector = FlagSelector::new(&["get"], "json");
const CREATE_ID_FLAG: FlagSelector = FlagSelector::new(&["create"], "id");
const CREATE_TAG_FLAG: FlagSelector = FlagSelector::new(&["create"], "tag");
const CREATE_MARKDOWN_FLAG: FlagSelector = FlagSelector::new(&["create"], "markdown");
const CREATE_JSON_FLAG: FlagSelector = FlagSelector::new(&["create"], "json");
const UPDATE_TAG_FLAG: FlagSelector = FlagSelector::new(&["update"], "tag");
const UPDATE_MARKDOWN_FLAG: FlagSelector = FlagSelector::new(&["update"], "markdown");
const UPDATE_JSON_FLAG: FlagSelector = FlagSelector::new(&["update"], "json");
const RECENT_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["recent"], "limit");
const RECENT_JSON_FLAG: FlagSelector = FlagSelector::new(&["recent"], "json");
const LIST_BY_TAG_JSON_FLAG: FlagSelector = FlagSelector::new(&["list-by-tag"], "json");
const LINKS_JSON_FLAG: FlagSelector = FlagSelector::new(&["links"], "json");
const ORPHANS_JSON_FLAG: FlagSelector = FlagSelector::new(&["orphans"], "json");
const HUBS_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["hubs"], "limit");
const HUBS_JSON_FLAG: FlagSelector = FlagSelector::new(&["hubs"], "json");
const BROKEN_JSON_FLAG: FlagSelector = FlagSelector::new(&["broken"], "json");
const PROMPT_LIST_JSON_FLAG: FlagSelector = FlagSelector::new(&["prompt", "list"], "json");
const PROMPT_SHOW_JSON_FLAG: FlagSelector = FlagSelector::new(&["prompt", "show"], "json");
const GLOBAL_DB_FLAG: FlagSelector = FlagSelector::new(&[], "db");

const SEARCH_CAPABILITY: AgentCapability = AgentCapability::new(
    "search",
    "Full-text search across knowledge base nodes using FTS5 query syntax",
    &[SEARCH_COMMAND],
    &[SEARCH_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use(
    "the user wants to find knowledge-base nodes by keyword, phrase, or FTS5 query expression",
)
.with_when_not_to_use(
    "the user already knows the exact node id (use get instead), \
     or wants nodes by tag rather than content (use list-by-tag instead)",
)
.with_output("a JSON array of {id, title} summaries ranked by FTS5 relevance");

const GET_CAPABILITY: AgentCapability = AgentCapability::new(
    "get",
    "Retrieve a single knowledge-base node by id, returning the full document, \
     derived title, tag list, and storage timestamps",
    &[GET_COMMAND],
    &[GET_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user references a specific node id and wants to see its full contents")
.with_when_not_to_use(
    "the user wants to browse or discover nodes (use search, recent, or list-by-tag instead)",
)
.with_output("a JSON node view: id, title, tags, document AST, createdAt, updatedAt");

const CREATE_CAPABILITY: AgentCapability = AgentCapability::new(
    "create",
    "Create a new knowledge-base node. The document body is read from stdin as org-mode text \
     or as a JSON kb AST Document; with --markdown it is read as GitHub-flavored markdown and \
     converted via pandoc. An id may be supplied via --id; otherwise a UUIDv4 is minted. \
     Tags supplied via --tag are merged with any tags parsed from the document body.",
    &[CREATE_COMMAND],
    &[
        CREATE_ID_FLAG,
        CREATE_TAG_FLAG,
        CREATE_MARKDOWN_FLAG,
        CREATE_JSON_FLAG,
        GLOBAL_DB_FLAG,
    ],
)
.with_when_to_use(
    "the user wants to persist a new piece of knowledge — a note, a transcript, a design doc, \
     or any org-mode text — into the knowledge base",
)
.with_when_not_to_use(
    "the node already exists and should be modified (use update instead), \
     or the id supplied via --id is already taken (the command will fail with a conflict error)",
)
.with_output(
    "a JSON node view of the created node, including the server-assigned or caller-supplied id",
);

const UPDATE_CAPABILITY: AgentCapability = AgentCapability::new(
    "update",
    "Replace an existing node's document. The new body is read from stdin as org-mode text \
     or as a JSON kb AST Document; with --markdown it is read as GitHub-flavored markdown and \
     converted via pandoc. Tags supplied via --tag are merged with any tags parsed \
     from the new body.",
    &[UPDATE_COMMAND],
    &[
        UPDATE_TAG_FLAG,
        UPDATE_MARKDOWN_FLAG,
        UPDATE_JSON_FLAG,
        GLOBAL_DB_FLAG,
    ],
)
.with_when_to_use("the user wants to replace the contents of an existing knowledge-base node")
.with_when_not_to_use(
    "the node does not yet exist (use create instead), \
     or the id is unknown (the command will fail with 'no node with id')",
)
.with_output("a JSON node view of the updated node with the refreshed updatedAt timestamp");

const DELETE_CAPABILITY: AgentCapability = AgentCapability::new(
    "delete",
    "Permanently delete a knowledge-base node by id. This cascades to node tags and links. \
     The prior document is preserved in the audit log.",
    &[DELETE_COMMAND],
    &[GLOBAL_DB_FLAG],
)
.with_when_to_use("the user explicitly asks to delete a specific node from the knowledge base")
.with_when_not_to_use(
    "the user is unsure about deletion, or the node id is unknown \
     (the command will report 'no node with id' rather than silently succeeding)",
)
.with_output("plain-text confirmation 'deleted <id>' on success");

const RECENT_CAPABILITY: AgentCapability = AgentCapability::new(
    "recent",
    "List the most recently updated knowledge-base nodes, newest first. \
     Defaults to 50 nodes; use --limit to adjust.",
    &[RECENT_COMMAND],
    &[RECENT_LIMIT_FLAG, RECENT_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to see what was recently added or changed in the knowledge base")
.with_when_not_to_use(
    "the user wants nodes matching a specific topic (use search) or tag (use list-by-tag)",
)
.with_output("a JSON array of {id, title} summaries ordered by updatedAt descending");

const LIST_BY_TAG_CAPABILITY: AgentCapability = AgentCapability::new(
    "list-by-tag",
    "List knowledge-base node summaries for all nodes carrying a given tag. \
     The tag is supplied without surrounding colons (e.g. 'rust' not ':rust:').",
    &[LIST_BY_TAG_COMMAND],
    &[LIST_BY_TAG_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to enumerate all nodes under a specific tag")
.with_when_not_to_use(
    "the user wants full-text search across node bodies (use search instead), \
     or wants to browse by recency (use recent instead)",
)
.with_output("a JSON array of {id, title} summaries for nodes carrying the tag");

const LINKS_CAPABILITY: AgentCapability = AgentCapability::new(
    "links",
    "Show the forward (outgoing) and back (incoming) links for a node under the unified \
     link graph. Both `[[id:UUID]]` id-links and `[[name]]` bracket name-links coexist \
     in a single physical table discriminated by a `link_type` column ('id' | 'name'). \
     Each row reports `{source_id, link_type, target_id, target_slug}`. For id-link rows \
     `target_id` is always non-null and `target_slug` is null; for name-link rows \
     `target_slug` is always non-null and `target_id` is null when the bracket reference \
     is broken (no node carries that `#+name:` slug yet).",
    &[LINKS_COMMAND],
    &[LINKS_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use(
    "the user wants to see what a node references (by either id or name) and what references it back",
)
.with_when_not_to_use(
    "the user wants global metrics like hubs / orphans / broken (use those verbs instead)",
)
.with_output(
    "a JSON object {outgoing: [...], incoming: [...]} of unified link rows including \
     link_type for the node",
);

const ORPHANS_CAPABILITY: AgentCapability = AgentCapability::new(
    "orphans",
    "List orphan nodes — nodes that nothing else points at under either link_type. \
     A node is an orphan when no `links` row of any link_type ('id' or 'name') has a \
     resolved `target_id` equal to its id.",
    &[ORPHANS_COMMAND],
    &[ORPHANS_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to find isolated nodes in the knowledge base graph")
.with_when_not_to_use(
    "the user wants nodes by tag (use list-by-tag), by recency (use recent), \
     or by full-text content (use search)",
)
.with_output(
    "a JSON array of {id, title} summaries for nodes with zero incoming links of any link_type",
);

const HUBS_CAPABILITY: AgentCapability = AgentCapability::new(
    "hubs",
    "List the most-linked nodes ranked by total in-degree across both link_types \
     (id-links plus resolved name-links) in the unified graph. Defaults to top 20.",
    &[HUBS_COMMAND],
    &[HUBS_LIMIT_FLAG, HUBS_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to see which nodes act as graph hubs in the knowledge base")
.with_when_not_to_use(
    "the user wants the full link table rather than ranked summaries (use links instead)",
)
.with_output("a JSON array of {id, title, in_degree} entries ordered by in-degree descending");

const BROKEN_CAPABILITY: AgentCapability = AgentCapability::new(
    "broken",
    "List broken bracket references in the unified link graph: rows with link_type='name' \
     whose `target_id` is NULL because no node carries the referenced `#+name:` slug. \
     Id-links cannot be broken by construction — the schema-level CHECK enforces a \
     non-null target_id for link_type='id'.",
    &[BROKEN_COMMAND],
    &[BROKEN_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to find dangling bracket references in the knowledge base")
.with_when_not_to_use("the user wants to inspect a single node's links (use links instead)")
.with_output(
    "a JSON array of {source_id, link_type, target_id, target_slug} rows for unresolved \
     bracket references",
);

const PROMPT_RENDER_CAPABILITY: AgentCapability = AgentCapability::new(
    "prompt-render",
    "Render a MiniJinja template against the local kb corpus and write the rendered text \
     to stdout. Templates have access to a documented query surface — `recent`, `orphans`, \
     `hubs`, `tag_frequency` as eagerly bound corpus snapshots, and `by_tag`, `search`, \
     `all_nodes`, `get`, `links`, `link_distance` as callables. Built-in templates ship \
     embedded with the crate and are overridable by files at \
     `$XDG_CONFIG_HOME/kb/prompts/<name>.j2`. kb assembles; the user executes — kb does not \
     pipe the rendered text into any LLM, that is the caller's job.",
    &[PROMPT_RENDER_COMMAND],
    &[GLOBAL_DB_FLAG],
)
.with_when_to_use(
    "the user wants to assemble an LLM prompt from kb contents using a named template",
)
.with_when_not_to_use(
    "the user wants to inspect available templates (use prompt list) or view a template's \
     source (use prompt show)",
)
.with_output("the rendered template text on stdout — no envelope; pipe it directly to an LLM");

const PROMPT_LIST_CAPABILITY: AgentCapability = AgentCapability::new(
    "prompt-list",
    "List every template available to `kb prompt`, merging built-in templates embedded with \
     the crate with user overrides found under `$XDG_CONFIG_HOME/kb/prompts/<name>.j2`. \
     User overrides win on name collision.",
    &[PROMPT_LIST_COMMAND],
    &[PROMPT_LIST_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to enumerate the prompt templates kb can render")
.with_when_not_to_use("the user already knows the template name and wants to render or inspect it")
.with_output("a JSON array of {name, source, path} entries, sorted by name");

const PROMPT_SHOW_CAPABILITY: AgentCapability = AgentCapability::new(
    "prompt-show",
    "Print the raw source of the named template — whichever wins resolution between the \
     user override and the built-in. Useful for inspecting what a template will do before \
     rendering it.",
    &[PROMPT_SHOW_COMMAND],
    &[PROMPT_SHOW_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to read a template's body without rendering it")
.with_when_not_to_use("the user wants the rendered output (use prompt render)")
.with_output("the template body as plain text; with --json, a {name, source, path, body} envelope");

const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[
    SEARCH_CAPABILITY,
    GET_CAPABILITY,
    CREATE_CAPABILITY,
    UPDATE_CAPABILITY,
    DELETE_CAPABILITY,
    RECENT_CAPABILITY,
    LIST_BY_TAG_CAPABILITY,
    LINKS_CAPABILITY,
    ORPHANS_CAPABILITY,
    HUBS_CAPABILITY,
    BROKEN_CAPABILITY,
    PROMPT_RENDER_CAPABILITY,
    PROMPT_LIST_CAPABILITY,
    PROMPT_SHOW_CAPABILITY,
]);

const TOOL_SPEC: ToolSpec = workspace_tool(
    "kb",
    "kb",
    env!("CARGO_PKG_VERSION"),
    LicenseType::MIT,
    true,
    false,
)
.with_agent_surface(&AGENT_SURFACE);

// ── Entrypoint ─────────────────────────────────────────────────────────

/// Return the process exit code for the kb CLI.
#[must_use]
pub fn main_exit_code() -> i32 {
    let env = process_env();
    run_cli_no_doctor_from::<Cli, _, _, _>(
        &TOOL_SPEC,
        &env,
        std::env::args_os(),
        metadata_command,
        |cli| Ok(run(cli)),
    )
}

/// Read process-edge environment values once at the binary edge.
#[allow(
    clippy::disallowed_methods,
    reason = "agent token / HOME read once at the process edge (REPO_INVARIANTS.md #5)"
)]
fn process_env() -> tftio_lib::ProcessEnv {
    tftio_lib::ProcessEnv {
        agent: tftio_lib::AgentModeContext::from_tokens(
            std::env::var(tftio_lib::AGENT_TOKEN_ENV).ok(),
            std::env::var(tftio_lib::AGENT_TOKEN_EXPECTED_ENV).ok(),
        ),
        home: std::env::var_os("HOME").map(std::path::PathBuf::from),
    }
}

/// Map a parsed CLI to a shared metadata command, if one was requested.
#[must_use]
pub fn metadata_command(cli: &Cli) -> Option<tftio_lib::StandardCommand> {
    match &cli.command {
        Command::Meta { command } => Some(map_standard_command(command, JsonOutput::Text)),
        _ => None,
    }
}

// ── Domain dispatch ────────────────────────────────────────────────────

/// Dispatch a parsed non-metadata CLI to its handler and return an exit code.
#[must_use]
pub fn run(cli: Cli) -> i32 {
    match cli.command {
        Command::Meta { .. } => unreachable!("meta commands are routed before run"),
        Command::Search { query, json } => run_search(&cli.db, &query, JsonOutput::from_flag(json)),
        Command::Get { id, json } => run_get(&cli.db, &id, JsonOutput::from_flag(json)),
        Command::Create {
            id,
            tags,
            markdown,
            json,
        } => run_create(
            &cli.db,
            id.as_deref(),
            &tags,
            markdown,
            JsonOutput::from_flag(json),
        ),
        Command::Update {
            id,
            tags,
            markdown,
            json,
        } => run_update(&cli.db, &id, &tags, markdown, JsonOutput::from_flag(json)),
        Command::Delete { id } => run_delete(&cli.db, &id),
        Command::Recent { limit, json } => run_recent(&cli.db, limit, JsonOutput::from_flag(json)),
        Command::ListByTag { tag, json } => {
            run_list_by_tag(&cli.db, &tag, JsonOutput::from_flag(json))
        }
        Command::Links { id, json } => run_links(&cli.db, &id, JsonOutput::from_flag(json)),
        Command::Orphans { json } => run_orphans(&cli.db, JsonOutput::from_flag(json)),
        Command::Hubs { limit, json } => run_hubs(&cli.db, limit, JsonOutput::from_flag(json)),
        Command::Broken { json } => run_broken(&cli.db, JsonOutput::from_flag(json)),
        Command::Prompt { command } => run_prompt(&cli.db, command),
    }
}

fn run_prompt(db_path: &std::path::Path, command: PromptCommand) -> i32 {
    match command {
        PromptCommand::Render { name } => run_prompt_render(db_path, &name),
        PromptCommand::List { json } => run_prompt_list(JsonOutput::from_flag(json)),
        PromptCommand::Show { name, json } => run_prompt_show(&name, JsonOutput::from_flag(json)),
    }
}

fn run_prompt_render(db_path: &std::path::Path, name: &str) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("kb: {e:#}");
            return 1;
        }
    };
    match prompt::render_prompt(&conn, name) {
        Ok(text) => {
            // Render is a write-through to stdout — no envelope. The
            // user pipes it to an LLM. Always include a trailing
            // newline for shell ergonomics.
            if text.ends_with('\n') {
                print!("{text}");
            } else {
                println!("{text}");
            }
            0
        }
        Err(e) => {
            eprintln!("kb: prompt render failed: {e}");
            1
        }
    }
}

fn run_prompt_list(json: JsonOutput) -> i32 {
    let summaries = prompt::list_templates();
    let payload = serde_json::Value::Array(
        summaries
            .iter()
            .map(|t| {
                json!({
                    "name": t.name,
                    "source": t.source,
                    "path": t.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
                })
            })
            .collect(),
    );
    let text = summaries
        .iter()
        .map(|t| {
            t.path.as_ref().map_or_else(
                || format!("{}\t{}", t.name, t.source),
                |p| format!("{}\t{}\t{}", t.name, t.source, p.display()),
            )
        })
        .collect::<Vec<_>>()
        .join("\n");
    println!("{}", render_response("prompt-list", json, payload, text));
    0
}

fn run_prompt_show(name: &str, json: JsonOutput) -> i32 {
    let resolved = match prompt::resolve_template_source(name) {
        Ok(r) => r,
        Err(e) => return print_error("prompt-show", json, &e.to_string()),
    };
    let payload = json!({
        "name": resolved.name,
        "source": resolved.source,
        "path": resolved.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
        "body": resolved.body,
    });
    println!(
        "{}",
        render_response("prompt-show", json, payload, resolved.body)
    );
    0
}

/// kb — personal knowledge base CLI.
#[derive(Debug, Parser)]
#[command(name = "kb")]
pub struct Cli {
    /// Path to the `SQLite` database file. Defaults to
    /// `$HOME/.local/share/kb/kb.db`; overridable via `KB_DB_PATH`.
    #[arg(long, env = "KB_DB_PATH", default_value_os_t = crate::storage::default_db_path())]
    pub db: PathBuf,
    /// The subcommand to run.
    #[command(subcommand)]
    pub command: Command,
}

/// Subcommands for the kb knowledge base tool.
#[derive(Debug, Subcommand)]
pub enum Command {
    /// Shared metadata commands (version, license, completions).
    Meta {
        /// The shared metadata subcommand to run.
        #[command(subcommand)]
        command: MetaCommand,
    },
    /// Full-text search across nodes.
    Search {
        /// FTS5 query string.
        query: String,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// Retrieve a node by id.
    Get {
        /// Node id.
        id: String,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// Create a new node. Body is read from stdin as org text or JSON Document.
    Create {
        /// Optional caller-provided id. Defaults to a server-minted `UUIDv4`.
        #[arg(long)]
        id: Option<String>,
        /// Tags to attach to the node (merged with any tags in the body).
        #[arg(long = "tag")]
        tags: Vec<String>,
        /// Treat stdin as GitHub-flavored markdown, converting it via pandoc.
        #[arg(long)]
        markdown: bool,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// Update an existing node. Body is read from stdin as org text or JSON Document.
    Update {
        /// Node id to update.
        id: String,
        /// Tags to attach to the node (merged with any tags in the body).
        #[arg(long = "tag")]
        tags: Vec<String>,
        /// Treat stdin as GitHub-flavored markdown, converting it via pandoc.
        #[arg(long)]
        markdown: bool,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// Delete a node by id.
    Delete {
        /// Node id to delete.
        id: String,
    },
    /// List most recently updated nodes.
    Recent {
        /// Maximum number of nodes to return.
        #[arg(long, default_value = "50")]
        limit: usize,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// List nodes carrying a given tag.
    ListByTag {
        /// Tag name (without surrounding colons).
        tag: String,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// Show forward and back `[[name]]` references for a node.
    Links {
        /// Node id.
        id: String,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// List orphan nodes — those nothing else `[[name]]`-references.
    Orphans {
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// List the most-linked nodes ranked by `[[name]]` in-degree.
    Hubs {
        /// Maximum number of hubs to return.
        #[arg(long, default_value = "20")]
        limit: usize,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// List broken `[[name]]` references (no matching `#+name:` slug).
    Broken {
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// Run a `MiniJinja` template against the local kb corpus.
    Prompt {
        /// The `kb prompt` sub-verb to run.
        #[command(subcommand)]
        command: PromptCommand,
    },
}

/// Sub-verbs for `kb prompt`.
#[derive(Debug, Subcommand)]
pub enum PromptCommand {
    /// Render the named template against the live corpus and write the
    /// result to stdout.
    Render {
        /// Template name (no extension).
        name: String,
    },
    /// List available templates — built-ins plus user overrides at
    /// `$XDG_CONFIG_HOME/kb/prompts/<name>.j2`.
    List {
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
    /// Print the raw template source for the named template.
    Show {
        /// Template name (no extension).
        name: String,
        /// Emit JSON envelope.
        #[arg(long)]
        json: bool,
    },
}

// ── Helpers ────────────────────────────────────────────────────────────

fn open_or_die(db_path: &std::path::Path) -> Result<rusqlite::Connection, crate::error::KbError> {
    let conn = storage::open_db(&db_path.to_string_lossy())?;
    storage::init_db(&conn)?;
    Ok(conn)
}

fn read_stdin() -> Result<String, i32> {
    let mut buf = String::new();
    match std::io::stdin().read_to_string(&mut buf) {
        Ok(_) => Ok(buf),
        Err(e) => {
            eprintln!("kb: failed to read stdin: {e}");
            Err(1)
        }
    }
}

/// Failure modes of parsing a stdin body into a [`Document`].
#[derive(Debug, thiserror::Error)]
enum BodyParseError {
    /// The body looked like JSON but did not deserialize into a Document.
    #[error("invalid JSON Document: {0}")]
    Json(#[from] serde_json::Error),

    /// The body was treated as org text but the parser rejected it.
    #[error("{0}")]
    Org(#[from] parser::ParseError),

    /// `--markdown` was set and pandoc-backed conversion failed.
    #[error("{0}")]
    Markdown(#[from] markdown::MarkdownError),
}

/// Parse a stdin body into a [`Document`]. With `markdown`, the body is
/// converted from GitHub-flavored markdown via pandoc; otherwise it is
/// parsed as a JSON Document (when it starts with `{`) or as org text.
fn parse_input_body(raw: &str, markdown: bool) -> Result<Document, BodyParseError> {
    if markdown {
        Ok(markdown::markdown_to_document(raw)?)
    } else {
        parse_body(raw)
    }
}

/// Parse stdin into a [`Document`], trying JSON first (when it looks like
/// JSON) then org.
fn parse_body(raw: &str) -> Result<Document, BodyParseError> {
    if raw.trim().starts_with('{') {
        Ok(serde_json::from_str::<Document>(raw)?)
    } else {
        Ok(parser::parse_document(raw)?)
    }
}

/// Merge CLI-supplied tags into the first heading. If the document has no
/// heading, prepend a level-1 heading with an empty title and the CLI tags.
fn merge_cli_tags(doc: &mut Document, cli_tags: &[String]) {
    if cli_tags.is_empty() {
        return;
    }
    let new_tags: Vec<Tag> = cli_tags.iter().map(|t| Tag(t.clone())).collect();

    for block in &mut doc.blocks {
        if let Block::Heading { tags, .. } = block {
            tags.extend(new_tags);
            return;
        }
    }

    // No heading exists — prepend one
    doc.blocks.insert(
        0,
        Block::Heading {
            level: 1,
            title: Title(String::new()),
            tags: new_tags,
            children: vec![],
        },
    );
}

fn node_view_json(id: &str, nf: &storage::NodeFullData) -> serde_json::Value {
    json!({
        "id": id,
        "title": nf.title,
        "tags": nf.tags.iter().map(|t| &t.0).collect::<Vec<_>>(),
        "document": nf.document,
        "createdAt": nf.created_at,
        "updatedAt": nf.updated_at,
    })
}

fn node_view_text(id: &str, title: &str, tags: &[Tag]) -> String {
    let tag_str: Vec<String> = tags.iter().map(|t| format!(":{}", t.0)).collect();
    format!("{id}  {title}  {}\n", tag_str.join(""))
}

fn summaries_json(pairs: &[(String, String)]) -> serde_json::Value {
    let arr: Vec<serde_json::Value> = pairs
        .iter()
        .map(|(id, title)| json!({"id": id, "title": title}))
        .collect();
    serde_json::Value::Array(arr)
}

fn summaries_text(pairs: &[(String, String)]) -> String {
    pairs
        .iter()
        .map(|(id, title)| format!("{id}  {title}"))
        .collect::<Vec<_>>()
        .join("\n")
}

// ── Command handlers ───────────────────────────────────────────────────

fn run_search(db_path: &std::path::Path, query: &str, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("search", json, &e.to_string()),
    };
    let ids = match storage::search_fts(&conn, query) {
        Ok(ids) => ids,
        Err(e) => return print_error("search", json, &e.to_string()),
    };
    let pairs = match storage::fetch_titles(&conn, &ids) {
        Ok(p) => p,
        Err(e) => return print_error("search", json, &e.to_string()),
    };
    println!(
        "{}",
        render_response(
            "search",
            json,
            summaries_json(&pairs),
            summaries_text(&pairs)
        )
    );
    0
}

fn run_get(db_path: &std::path::Path, id: &str, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("get", json, &e.to_string()),
    };
    let nf = match storage::get_node_full(&conn, id) {
        Ok(Some(nf)) => nf,
        Ok(None) => return print_error("get", json, &format!("no node with id: {id}")),
        Err(e) => return print_error("get", json, &e.to_string()),
    };
    let payload = node_view_json(id, &nf);
    let text = org_meta::render_with_metadata(id, &nf.created_at, &nf.updated_at, &nf.document);
    println!("{}", render_response("get", json, payload, text));
    0
}

fn run_create(
    db_path: &std::path::Path,
    id: Option<&str>,
    tags: &[String],
    markdown: bool,
    json: JsonOutput,
) -> i32 {
    let raw = match read_stdin() {
        Ok(r) => r,
        Err(code) => return code,
    };
    let parsed = parse_input_body(&raw, markdown);
    let mut doc = match parsed {
        Ok(d) => d,
        Err(e) => return print_error("create", json, &e.to_string()),
    };
    // Strip any kb metadata drawer so it never enters the stored body.
    // create ignores the drawer's :ID:; the node id comes from --id or
    // a freshly minted UUID.
    let _ = org_meta::hydrate(&mut doc);
    merge_cli_tags(&mut doc, tags);

    let nid = id.map_or_else(|| uuid::Uuid::new_v4().to_string(), String::from);
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("create", json, &e.to_string()),
    };
    if let Err(e) = storage::insert_node(&conn, &nid, &doc) {
        return print_error("create", json, &e.to_string());
    }
    let nf = match storage::get_node_full(&conn, &nid) {
        Ok(Some(nf)) => nf,
        Ok(None) => return print_error("create", json, "node created but disappeared"),
        Err(e) => return print_error("create", json, &e.to_string()),
    };
    let payload = node_view_json(&nid, &nf);
    let text = node_view_text(&nid, &nf.title, &nf.tags);
    println!("{}", render_response("create", json, payload, text));
    0
}

fn run_update(
    db_path: &std::path::Path,
    id: &str,
    tags: &[String],
    markdown: bool,
    json: JsonOutput,
) -> i32 {
    let raw = match read_stdin() {
        Ok(r) => r,
        Err(code) => return code,
    };
    let parsed = parse_input_body(&raw, markdown);
    let mut doc = match parsed {
        Ok(d) => d,
        Err(e) => return print_error("update", json, &e.to_string()),
    };
    // Hydrate the kb metadata drawer out of the body. On the round-trip
    // path the drawer's :ID: must name the node being updated; a mismatch
    // signals an imported file applied to the wrong node.
    if let Some(drawer_id) = org_meta::hydrate(&mut doc)
        && drawer_id != id
    {
        return print_error(
            "update",
            json,
            &format!("document :ID: {drawer_id} does not match target node id {id}"),
        );
    }
    merge_cli_tags(&mut doc, tags);

    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("update", json, &e.to_string()),
    };
    match storage::update_node(&conn, id, &doc) {
        Ok(true) => {}
        Ok(false) => return print_error("update", json, &format!("no node with id: {id}")),
        Err(e) => return print_error("update", json, &e.to_string()),
    }
    let nf = match storage::get_node_full(&conn, id) {
        Ok(Some(nf)) => nf,
        Ok(None) => return print_error("update", json, "node updated but disappeared"),
        Err(e) => return print_error("update", json, &e.to_string()),
    };
    let payload = node_view_json(id, &nf);
    let text = node_view_text(id, &nf.title, &nf.tags);
    println!("{}", render_response("update", json, payload, text));
    0
}

fn run_delete(db_path: &std::path::Path, id: &str) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("kb: {e:#}");
            return 1;
        }
    };
    match storage::delete_node(&conn, id) {
        Ok(true) => {
            println!("deleted {id}");
            0
        }
        Ok(false) => {
            eprintln!("kb: no node with id: {id}");
            1
        }
        Err(e) => {
            eprintln!("kb: delete failed: {e}");
            1
        }
    }
}

fn run_recent(db_path: &std::path::Path, limit: usize, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("recent", json, &e.to_string()),
    };
    let rows = match storage::list_recent(&conn, limit) {
        Ok(r) => r,
        Err(e) => return print_error("recent", json, &e.to_string()),
    };
    let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
    let pairs = match storage::fetch_titles(&conn, &ids) {
        Ok(p) => p,
        Err(e) => return print_error("recent", json, &e.to_string()),
    };
    println!(
        "{}",
        render_response(
            "recent",
            json,
            summaries_json(&pairs),
            summaries_text(&pairs)
        )
    );
    0
}

/// Serialize one row of the unified link graph. `link_type` is always
/// present; `target_id` and `target_slug` are NULL where they don't
/// apply or where a name-link is broken.
fn link_row_json(row: &storage::LinkRow) -> serde_json::Value {
    json!({
        "source_id": row.source_id,
        "link_type": row.link_type,
        "target_id": row.target_id,
        "target_slug": row.target_slug,
    })
}

/// Render one row of the unified link graph for human display, surfacing
/// the `link_type` discriminator so id-links and name-links are
/// distinguishable in plain text output.
fn link_row_text(row: &storage::LinkRow) -> String {
    match (row.link_type.as_str(), &row.target_id, &row.target_slug) {
        ("id", Some(tgt), _) => format!("{}  [id]  -> {}", row.source_id, tgt),
        ("name", Some(tgt), Some(slug)) => {
            format!("{}  [name]  [[{slug}]]  -> {tgt}", row.source_id)
        }
        ("name", None, Some(slug)) => {
            format!("{}  [name]  [[{slug}]]  -> (broken)", row.source_id)
        }
        _ => format!(
            "{}  [{}]  target_id={:?} target_slug={:?}",
            row.source_id, row.link_type, row.target_id, row.target_slug
        ),
    }
}

fn run_links(db_path: &std::path::Path, id: &str, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("links", json, &e.to_string()),
    };
    let nb = match storage::get_links(&conn, id) {
        Ok(n) => n,
        Err(e) => return print_error("links", json, &e.to_string()),
    };
    let payload = json!({
        "outgoing": nb.outgoing.iter().map(link_row_json).collect::<Vec<_>>(),
        "incoming": nb.incoming.iter().map(link_row_json).collect::<Vec<_>>(),
    });
    let mut text_lines = Vec::new();
    text_lines.push("outgoing:".to_string());
    for row in &nb.outgoing {
        text_lines.push(format!("  {}", link_row_text(row)));
    }
    text_lines.push("incoming:".to_string());
    for row in &nb.incoming {
        text_lines.push(format!("  {}", link_row_text(row)));
    }
    println!(
        "{}",
        render_response("links", json, payload, text_lines.join("\n"))
    );
    0
}

fn run_orphans(db_path: &std::path::Path, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("orphans", json, &e.to_string()),
    };
    let rows = match storage::list_orphans(&conn) {
        Ok(r) => r,
        Err(e) => return print_error("orphans", json, &e.to_string()),
    };
    let pairs: Vec<(String, String)> = rows
        .iter()
        .map(|r| (r.id.0.clone(), r.title.0.clone()))
        .collect();
    println!(
        "{}",
        render_response(
            "orphans",
            json,
            summaries_json(&pairs),
            summaries_text(&pairs)
        )
    );
    0
}

fn run_hubs(db_path: &std::path::Path, limit: usize, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("hubs", json, &e.to_string()),
    };
    let hubs = match storage::list_hubs(&conn, limit) {
        Ok(h) => h,
        Err(e) => return print_error("hubs", json, &e.to_string()),
    };
    let payload = serde_json::Value::Array(
        hubs.iter()
            .map(|h| json!({"id": h.id, "title": h.title, "in_degree": h.in_degree}))
            .collect(),
    );
    let text = hubs
        .iter()
        .map(|h| format!("{}\t{}\t{}", h.in_degree, h.id, h.title))
        .collect::<Vec<_>>()
        .join("\n");
    println!("{}", render_response("hubs", json, payload, text));
    0
}

fn run_broken(db_path: &std::path::Path, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("broken", json, &e.to_string()),
    };
    let rows = match storage::list_broken_links(&conn) {
        Ok(r) => r,
        Err(e) => return print_error("broken", json, &e.to_string()),
    };
    let payload = serde_json::Value::Array(rows.iter().map(link_row_json).collect::<Vec<_>>());
    let text = rows
        .iter()
        .map(link_row_text)
        .collect::<Vec<_>>()
        .join("\n");
    println!("{}", render_response("broken", json, payload, text));
    0
}

fn run_list_by_tag(db_path: &std::path::Path, tag: &str, json: JsonOutput) -> i32 {
    let conn = match open_or_die(db_path) {
        Ok(c) => c,
        Err(e) => return print_error("list-by-tag", json, &e.to_string()),
    };
    let rows = match storage::list_by_tag(&conn, tag) {
        Ok(r) => r,
        Err(e) => return print_error("list-by-tag", json, &e.to_string()),
    };
    let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
    let pairs = match storage::fetch_titles(&conn, &ids) {
        Ok(p) => p,
        Err(e) => return print_error("list-by-tag", json, &e.to_string()),
    };
    println!(
        "{}",
        render_response(
            "list-by-tag",
            json,
            summaries_json(&pairs),
            summaries_text(&pairs)
        )
    );
    0
}