2929from dacli .asciidoc_parser import AsciidocStructureParser
3030from dacli .file_handler import FileReadError , FileSystemHandler , FileWriteError
3131from dacli .markdown_parser import MarkdownStructureParser
32- from dacli .index_builder import build_index
32+ from dacli .index_builder import build_index , count_descendants , find_root_for_file
33+ from dacli .models import DocumentRoot , detect_document_role
34+ from dacli .root_config import RootConfigError , resolve_roots
3335from dacli .services import (
3436 compute_hash ,
3537 get_project_metadata ,
@@ -140,11 +142,12 @@ def _get_section_append_line(
140142 "el" : "elements" ,
141143 "val" : "validate" ,
142144 "lv" : "sections-at-level" ,
145+ "ns" : "namespaces" ,
143146}
144147
145148# Command groups for organized help output (story-based ordering)
146149COMMAND_GROUPS = {
147- "Discover" : ["structure" , "metadata" ],
150+ "Discover" : ["namespaces" , " structure" , "metadata" ],
148151 "Find" : ["search" , "sections-at-level" ],
149152 "Read" : ["section" , "elements" ],
150153 "Validate" : ["validate" ],
@@ -265,14 +268,31 @@ class CliContext:
265268
266269 def __init__ (
267270 self ,
268- docs_root : Path ,
269- output_format : str ,
270- pretty : bool ,
271+ roots : list [ DocumentRoot ] | Path | None = None ,
272+ output_format : str = "text" ,
273+ pretty : bool = False ,
271274 verbose : bool = False ,
272275 respect_gitignore : bool = True ,
273276 include_hidden : bool = False ,
277+ * ,
278+ docs_root : Path | None = None ,
274279 ):
275- self .docs_root = docs_root
280+ # Backward compat: accept docs_root kwarg or Path as first arg
281+ if isinstance (roots , Path ):
282+ docs_root = roots
283+ roots = None
284+ if roots is None :
285+ if docs_root is None :
286+ docs_root = Path .cwd ()
287+ self .roots = [DocumentRoot (
288+ name = docs_root .name ,
289+ path = docs_root .resolve (),
290+ mode = "workspace" ,
291+ )]
292+ else :
293+ self .roots = roots
294+ # Backward compat: first root is the primary docs_root
295+ self .docs_root = self .roots [0 ].path
276296 self .output_format = output_format
277297 self .pretty = pretty
278298 self .verbose = verbose
@@ -286,15 +306,11 @@ def __init__(
286306
287307 self .index = StructureIndex ()
288308 self .file_handler = FileSystemHandler ()
289- self .asciidoc_parser = AsciidocStructureParser (base_path = docs_root )
290- self .markdown_parser = MarkdownStructureParser (base_path = docs_root )
291309
292- # Build index
310+ # Build index from all roots
293311 build_index (
294- docs_root ,
312+ self . roots ,
295313 self .index ,
296- self .asciidoc_parser ,
297- self .markdown_parser ,
298314 respect_gitignore = respect_gitignore ,
299315 include_hidden = include_hidden ,
300316 )
@@ -353,8 +369,23 @@ def _format_as_text(data: dict, indent: int = 0) -> str:
353369@click .option (
354370 "--docs-root" ,
355371 type = click .Path (exists = True , file_okay = False , dir_okay = True , path_type = Path ),
356- default = Path .cwd (),
357- help = "Documentation root directory (default: current directory)" ,
372+ default = None ,
373+ help = "Documentation root directory (default: current directory). "
374+ "Cannot be combined with --workspace/--reference." ,
375+ )
376+ @click .option (
377+ "--workspace" ,
378+ multiple = True ,
379+ metavar = "name=X,path=Y[,type=Z]" ,
380+ help = "Read-write documentation root (repeatable). "
381+ "Required keys: name, path. Optional: type." ,
382+ )
383+ @click .option (
384+ "--reference" ,
385+ multiple = True ,
386+ metavar = "name=X,path=Y[,type=Z]" ,
387+ help = "Read-only documentation root (repeatable). "
388+ "Required keys: name, path. Optional: type." ,
358389)
359390@click .option (
360391 "--format" ,
@@ -392,7 +423,9 @@ def _format_as_text(data: dict, indent: int = 0) -> str:
392423@click .pass_context
393424def cli (
394425 ctx ,
395- docs_root : Path ,
426+ docs_root : Path | None ,
427+ workspace : tuple [str , ...],
428+ reference : tuple [str , ...],
396429 output_format : str ,
397430 pretty : bool ,
398431 verbose : bool ,
@@ -404,8 +437,17 @@ def cli(
404437 Access documentation structure, content, and metadata from the command line.
405438 Designed for LLM integration via bash/shell commands.
406439 """
440+ try :
441+ roots = resolve_roots (
442+ workspaces = list (workspace ) if workspace else None ,
443+ references = list (reference ) if reference else None ,
444+ docs_root = docs_root ,
445+ )
446+ except RootConfigError as e :
447+ raise click .UsageError (str (e )) from e
448+
407449 ctx .obj = CliContext (
408- docs_root ,
450+ roots ,
409451 output_format ,
410452 pretty ,
411453 verbose ,
@@ -414,6 +456,53 @@ def cli(
414456 )
415457
416458
459+ @cli .command (
460+ epilog = """
461+ Examples:
462+ dacli namespaces # List all documentation sources
463+ dacli --format json ns # JSON output using alias
464+ """
465+ )
466+ @pass_context
467+ def namespaces (ctx : CliContext ):
468+ """List available documentation namespaces (ADR-014).
469+
470+ Shows all documentation roots with their access mode (workspace/reference),
471+ framework type, and the documents they contain with roles and formats.
472+ """
473+ ns_list = []
474+ for root in ctx .roots :
475+ root_docs = []
476+ for doc in ctx .index ._documents :
477+ try :
478+ doc .file_path .resolve ().relative_to (root .path )
479+ except ValueError :
480+ continue
481+ ext = doc .file_path .suffix .lower ()
482+ doc_format = "asciidoc" if ext in (".adoc" , ".asciidoc" ) else "markdown"
483+ section_count = len (doc .sections )
484+ for s in doc .sections :
485+ section_count += count_descendants (s )
486+ root_docs .append ({
487+ "slug" : doc .file_path .stem .lower (),
488+ "role" : detect_document_role (doc .file_path ),
489+ "format" : doc_format ,
490+ "sections" : section_count ,
491+ })
492+ ns_list .append ({
493+ "name" : root .name ,
494+ "type" : root .doc_type ,
495+ "mode" : root .mode ,
496+ "documents" : root_docs ,
497+ })
498+
499+ result = {
500+ "namespaces" : ns_list ,
501+ "total_namespaces" : len (ns_list ),
502+ }
503+ click .echo (format_output (ctx , result ))
504+
505+
417506@cli .command (
418507 epilog = """
419508Examples:
@@ -452,7 +541,10 @@ def section(ctx: CliContext, path: str):
452541 normalized_path = path .lstrip ("/" )
453542
454543 # Check for path format issues (Issue #198)
455- corrected_path , had_extra_colons = ctx .index .normalize_path (normalized_path )
544+ multi_root = len (ctx .roots ) > 1
545+ corrected_path , had_extra_colons = ctx .index .normalize_path (
546+ normalized_path , multi_root = multi_root ,
547+ )
456548
457549 section_obj = ctx .index .get_section (normalized_path )
458550 if section_obj is None :
@@ -728,7 +820,7 @@ def metadata(ctx: CliContext, path: str | None):
728820@pass_context
729821def validate (ctx : CliContext ):
730822 """Validate the document structure."""
731- result = service_validate_structure (ctx .index , ctx .docs_root )
823+ result = service_validate_structure (ctx .index , [ r . path for r in ctx .roots ] )
732824 click .echo (format_output (ctx , result ))
733825
734826 if not result ["valid" ]:
@@ -755,6 +847,19 @@ def update(
755847 if not processed_content .strip ():
756848 click .echo ("Warning: Section content will be cleared." , err = True )
757849
850+ # ADR-014: Check access mode before write
851+ section_obj = ctx .index .get_section (path )
852+ if section_obj is not None :
853+ root = find_root_for_file (ctx .roots , section_obj .source_location .file )
854+ if root is not None and root .mode == "reference" :
855+ result = {
856+ "success" : False ,
857+ "error" : f"Cannot modify '{ path } ': source is mounted as "
858+ f"read-only reference (namespace '{ root .name } ')." ,
859+ }
860+ click .echo (format_output (ctx , result ))
861+ sys .exit (EXIT_WRITE_ERROR )
862+
758863 result = service_update_section (
759864 index = ctx .index ,
760865 file_handler = ctx .file_handler ,
@@ -794,6 +899,17 @@ def insert(ctx: CliContext, path: str, position: str, content: str):
794899 click .echo (format_output (ctx , result ))
795900 sys .exit (EXIT_PATH_NOT_FOUND )
796901
902+ # ADR-014: Check access mode before write
903+ root = find_root_for_file (ctx .roots , section_obj .source_location .file )
904+ if root is not None and root .mode == "reference" :
905+ result = {
906+ "success" : False ,
907+ "error" : f"Cannot modify '{ normalized_path } ': source is mounted as "
908+ f"read-only reference (namespace '{ root .name } ')." ,
909+ }
910+ click .echo (format_output (ctx , result ))
911+ sys .exit (EXIT_WRITE_ERROR )
912+
797913 file_path = section_obj .source_location .file
798914 start_line = section_obj .source_location .line
799915 # Note: end_line is computed dynamically by _get_section_append_line for 'after'/'append'
0 commit comments