@@ -337,18 +337,26 @@ def _apply_env_vars(merged: dict[str, Any], provenance: dict[str, Any]) -> None:
337337 _set_nested (provenance , path , f"env { env_key } " )
338338
339339
340- def _load_scoped (project_root : Path | None ) -> Config :
340+ def _load_scoped (project_root : Path | None , * , persist : bool = True ) -> Config :
341341 """Run the five-step scoped config resolution pipeline.
342342
343343 Steps: Ingest → Guard → Merge → Env → Validate.
344344 Returns a fully-validated Config with source_scopes populated.
345+
346+ When ``persist`` is False the pipeline is read-only: it merges the same
347+ user/project/local scopes but performs no disk writes (no share-dir/lock
348+ creation, no migration, no default seeding, no auto-gitignore) and skips the
349+ project-trust read (which itself creates the trust-lock file). Trust only
350+ gates project *hooks*, which no read-only consumer renders or executes, so
351+ skipping it leaves the merged config faithful for those callers.
345352 """
346353 from pythinker_code .utils .gitignore import ensure_gitignored
347354
348355 # ── INGEST ────────────────────────────────────────────────────────────
349- default_user_file = get_config_file ().expanduser ().resolve (strict = False )
356+ # When read-only, do not let get_config_file() create the share dir.
357+ default_user_file = get_config_file (create = persist ).expanduser ().resolve (strict = False )
350358 # Trigger JSON→TOML migration if needed (existing logic)
351- if not default_user_file .exists ():
359+ if persist and not default_user_file .exists ():
352360 migration_error = _migrate_json_config_to_toml ()
353361 if migration_error is not None :
354362 raise ConfigError (
@@ -371,7 +379,7 @@ def _read_toml(path: Path) -> dict[str, Any]:
371379 # If the user config file still doesn't exist after migration (e.g. corrupt JSON
372380 # was backed up but no TOML was written), seed it with defaults so subsequent
373381 # runs have a concrete starting point — matching the legacy single-file behaviour.
374- if not user_file .exists ():
382+ if persist and not user_file .exists ():
375383 default_cfg = get_default_config ()
376384 logger .debug ("No config file found, creating default config: {config}" , config = default_cfg )
377385 save_config (default_cfg , user_file )
@@ -384,11 +392,18 @@ def _read_toml(path: Path) -> dict[str, Any]:
384392 stripped_hook_files : list [str ] = []
385393
386394 if project_root is not None :
387- from pythinker_code .project_trust import is_project_trusted
388-
389- project_trusted = is_project_trusted (project_root )
390395 project_file = project_root / ".pythinker" / "config.toml"
391396 local_file = project_root / ".pythinker" / "config.local.toml"
397+ if persist :
398+ from pythinker_code .project_trust import is_project_trusted
399+
400+ project_trusted = is_project_trusted (project_root )
401+ else :
402+ # Read-only callers skip the trust read — it creates the trust-lock
403+ # file under the share dir. Trust only gates project hooks, which a
404+ # read-only consumer never executes, so read every scope and leave any
405+ # hooks merged-but-inert rather than touching disk.
406+ project_trusted = True
392407 if project_trusted :
393408 project_dict = _read_toml (project_file )
394409 local_dict = _read_toml (local_file )
@@ -464,12 +479,14 @@ def _read_toml(path: Path) -> dict[str, Any]:
464479 config .source_scopes ["project" ] = project_file .resolve (strict = False )
465480 if local_file is not None and local_file .exists ():
466481 config .source_scopes ["local" ] = local_file .resolve (strict = False )
467- # Auto-gitignore local config so it is never accidentally committed
468- ensure_gitignored (
469- project_root , # type: ignore[arg-type]
470- ".pythinker/config.local.toml" ,
471- comment = "Added by pythinker" ,
472- )
482+ # Auto-gitignore local config so it is never accidentally committed.
483+ # Skip this write for read-only callers.
484+ if persist :
485+ ensure_gitignored (
486+ project_root , # type: ignore[arg-type]
487+ ".pythinker/config.local.toml" ,
488+ comment = "Added by pythinker" ,
489+ )
473490
474491 return config
475492
@@ -1235,7 +1252,7 @@ def get_default_config() -> Config:
12351252 )
12361253
12371254
1238- def load_config (config_file : Path | None = None ) -> Config :
1255+ def load_config (config_file : Path | None = None , * , persist : bool = True ) -> Config :
12391256 """Load configuration, resolving up to three scopes when no explicit file is given.
12401257
12411258 When *config_file* is None (the default), the scoped pipeline runs:
@@ -1245,10 +1262,16 @@ def load_config(config_file: Path | None = None) -> Config:
12451262 When *config_file* is given explicitly (e.g. via --config), that single
12461263 file is loaded directly with no scope resolution — preserving the legacy
12471264 behaviour used by tests and the CLI --config flag.
1265+
1266+ Pass ``persist=False`` for read-only callers (e.g. ``pythinker system-prompt``)
1267+ that must resolve the merged scoped config without any disk side effects:
1268+ no share-dir creation, no default-config seeding, no JSON→TOML migration,
1269+ no project-trust lock, and no auto-gitignore. Only the scoped (``config_file
1270+ is None``) branch honours the flag; an explicit path is already read-or-seed.
12481271 """
12491272 if config_file is None :
12501273 project_root = find_project_root (Path .cwd ())
1251- return _load_scoped (project_root )
1274+ return _load_scoped (project_root , persist = persist )
12521275
12531276 # ── Explicit path: legacy single-file load (unchanged) ────────────────
12541277 default_config_file = get_config_file ().expanduser ().resolve (strict = False )
0 commit comments