personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""sol config — show and rewrite the embedded journal path in the wrapper."""
5
6from __future__ import annotations
7
8import argparse
9import os
10import subprocess
11import sys
12from dataclasses import dataclass
13from enum import Enum
14from pathlib import Path
15
16from think.install_guard import (
17 alias_path,
18 parse_wrapper,
19 render_wrapper,
20 validate_journal_path_for_wrapper,
21 wrapper_lock,
22 write_wrapper_atomic,
23)
24from think.service import service_is_installed, service_is_running
25from think.utils import (
26 SolstoneNotConfigured,
27 get_journal_info,
28 get_project_root,
29 journal_is_active,
30)
31
32MERGE_INSTRUCTIONS = "\n".join(
33 [
34 "sol config: --merge is not handled here.",
35 "use 'sol call journal merge <source> --dry-run' to preview the merge.",
36 "use 'sol call journal merge <source>' to perform the merge.",
37 ]
38)
39
40
41class RequestedAction(Enum):
42 MOVE = "move"
43 SWITCH = "switch"
44 MERGE = "merge"
45 FORCE = "force"
46
47
48class Action(Enum):
49 PROCEED = "proceed"
50 MOVE = "move"
51 SWITCH = "switch"
52 MERGE = "merge"
53 NOOP = "noop"
54 REFUSE = "refuse"
55
56
57@dataclass(frozen=True)
58class JournalChange:
59 current_path: Path
60 target_path: Path
61 paths_equal: bool
62 current_active: bool
63 target_active: bool
64 current_exists: bool
65 target_exists: bool
66 target_parent_exists: bool
67 current_device: int | None
68 target_parent_device: int | None
69 same_filesystem: bool | None
70 service_installed: bool
71 service_running: bool
72 action: RequestedAction | None
73 yes: bool
74 dry_run: bool
75 sol_bin: str
76 alias: Path
77
78
79@dataclass(frozen=True)
80class Decision:
81 action: Action
82 exit_code: int
83 message: str | None = None
84 plan_only: bool = False
85
86
87def _read_wrapper_status() -> tuple[str, str | None]:
88 alias = alias_path()
89 if not alias.exists() and not alias.is_symlink():
90 return "absent", None
91 if alias.is_symlink():
92 return "legacy-symlink", None
93
94 try:
95 content = alias.read_text(encoding="utf-8")
96 except OSError:
97 return "foreign", None
98
99 parsed = parse_wrapper(content)
100 if parsed is None:
101 return "foreign", None
102 return "managed", parsed["journal"]
103
104
105def _wrapper_refusal(alias: Path) -> str:
106 return (
107 "sol config: refused: "
108 f"{alias} is not a managed wrapper (run 'sol setup' from the solstone "
109 "source checkout to install the wrapper first)"
110 )
111
112
113def _state_label(active: bool) -> str:
114 return "active" if active else "not active"
115
116
117def _valid_flags(change: JournalChange) -> str:
118 if change.current_active and not change.target_active:
119 return "--move, --switch"
120 return "--switch, --merge, --force"
121
122
123def _refusal_message(change: JournalChange) -> str:
124 return (
125 "sol config: refused: "
126 f"current is {_state_label(change.current_active)} and target is "
127 f"{_state_label(change.target_active)}; valid flags: {_valid_flags(change)}"
128 )
129
130
131def _move_target_exists_message(change: JournalChange) -> str:
132 return f"sol config: refused: move target already exists: {change.target_path}"
133
134
135def _move_missing_current_message(change: JournalChange) -> str:
136 return f"sol config: refused: move source does not exist: {change.current_path}"
137
138
139def _move_missing_parent_message(change: JournalChange) -> str:
140 return f"sol config: refused: move target parent does not exist: {change.target_path.parent}"
141
142
143def _move_cross_filesystem_message(change: JournalChange) -> str:
144 return (
145 "sol config: refused: cannot move across filesystems "
146 f"(current device={change.current_device}, target parent device={change.target_parent_device}); "
147 "use 'sol call journal merge <source>' instead"
148 )
149
150
151def _move_requires_inactive_target_message(change: JournalChange) -> str:
152 return (
153 "sol config: refused: "
154 f"--move requires a not active target; current is {_state_label(change.current_active)} "
155 f"and target is {_state_label(change.target_active)}; valid flags: --switch, --merge, --force"
156 )
157
158
159def _plan_closer(change: JournalChange) -> str:
160 if change.dry_run:
161 return "dry-run: yes; nothing will be changed"
162 return "re-run with --yes to proceed"
163
164
165def _service_summary(change: JournalChange, decision: Decision) -> str:
166 if decision.action is Action.MOVE:
167 if not change.service_installed:
168 return "service: not installed; will move and rewrite wrapper"
169 if not change.service_running:
170 return "service: installed but not running; will move and rewrite wrapper"
171 return (
172 "service: installed and running; will stop, move, rewrite wrapper, restart"
173 )
174
175 if not change.service_installed:
176 return "service: not installed; will rewrite wrapper"
177 if not change.service_running:
178 return "service: installed but not running; will rewrite wrapper"
179 return "service: installed and running; will rewrite wrapper, restart"
180
181
182def render_plan(change: JournalChange, decision: Decision) -> str:
183 lines = [
184 "sol config journal - plan summary",
185 "",
186 f"current: {change.current_path} ({_state_label(change.current_active)})",
187 f"target: {change.target_path} ({_state_label(change.target_active)})",
188 f"action: {decision.action.value}",
189 _service_summary(change, decision),
190 ]
191
192 if decision.action is Action.MOVE:
193 filesystem = "same device" if change.same_filesystem else "different devices"
194 lines.append(f"filesystem: {filesystem}")
195
196 if decision.action is Action.SWITCH:
197 lines.extend(
198 [
199 "",
200 "current journal is left intact. "
201 f"to re-adopt it later: sol config journal {change.current_path} --switch --yes",
202 ]
203 )
204
205 lines.extend(["", _plan_closer(change)])
206 return "\n".join(lines)
207
208
209def _rewrite_wrapper(change: JournalChange) -> str | None:
210 try:
211 current_content = change.alias.read_text(encoding="utf-8")
212 except OSError as exc:
213 print(
214 f"sol config: refused: cannot read {change.alias}: {exc}",
215 file=sys.stderr,
216 )
217 return None
218
219 current = parse_wrapper(current_content)
220 if current is None:
221 print(_wrapper_refusal(change.alias), file=sys.stderr)
222 return None
223
224 target_str = str(change.target_path)
225 if current["journal"] == target_str:
226 return current["sol_bin"]
227
228 new_content = render_wrapper(target_str, current["sol_bin"])
229 write_wrapper_atomic(change.alias, new_content)
230 return current["sol_bin"]
231
232
233def _service_command(sol_bin: str, subcommand: str) -> subprocess.CompletedProcess:
234 return subprocess.run(
235 [sol_bin, "service", subcommand],
236 check=False,
237 capture_output=True,
238 text=True,
239 )
240
241
242def _maybe_restart_current_service(change: JournalChange) -> None:
243 if not change.service_running:
244 return
245 try:
246 _service_command(change.sol_bin, "start")
247 except FileNotFoundError as exc:
248 print(
249 f"sol config: rollback warning: could not restart service ({exc})",
250 file=sys.stderr,
251 )
252
253
254def _run_switch(change: JournalChange) -> int:
255 try:
256 change.target_path.mkdir(parents=True, exist_ok=True)
257 except OSError as exc:
258 print(
259 f"sol config: refused: cannot create {change.target_path}: {exc}",
260 file=sys.stderr,
261 )
262 return 1
263
264 with wrapper_lock():
265 try:
266 restart_sol = _rewrite_wrapper(change)
267 except OSError as exc:
268 print(
269 f"sol config: refused: cannot rewrite {change.alias}: {exc}",
270 file=sys.stderr,
271 )
272 return 1
273
274 if restart_sol is None:
275 return 1
276
277 if not change.service_installed:
278 print("service not installed; wrapper updated.")
279 return 0
280
281 if not change.service_running:
282 print("service installed but not running; wrapper updated.")
283 return 0
284
285 try:
286 result = subprocess.run(
287 [restart_sol, "service", "restart", "--if-installed"],
288 check=False,
289 )
290 except FileNotFoundError as exc:
291 print(
292 f"sol config: wrapper rewritten to {change.target_path} but service restart could not run ({exc}); restart manually",
293 file=sys.stderr,
294 )
295 return 2
296
297 if result.returncode != 0:
298 print(
299 "sol config: wrapper rewritten to "
300 f"{change.target_path} but 'sol service restart --if-installed' exited "
301 f"{result.returncode}; investigate and restart manually",
302 file=sys.stderr,
303 )
304 return 2
305
306 print("wrapper updated; service restarted.")
307 return 0
308
309
310def _run_move(change: JournalChange) -> int:
311 current = change.current_path
312 target = change.target_path
313
314 if not change.target_parent_exists:
315 print(_move_missing_parent_message(change), file=sys.stderr)
316 return 1
317 if not current.exists():
318 print(_move_missing_current_message(change), file=sys.stderr)
319 return 1
320 if target.exists() or target.is_symlink():
321 print(_move_target_exists_message(change), file=sys.stderr)
322 return 1
323 if change.same_filesystem is False:
324 print(_move_cross_filesystem_message(change), file=sys.stderr)
325 return 1
326
327 if change.service_running:
328 try:
329 stop_result = _service_command(change.sol_bin, "stop")
330 except FileNotFoundError as exc:
331 print(
332 f"sol config: could not stop service before move ({exc})",
333 file=sys.stderr,
334 )
335 return 2
336 if stop_result.returncode != 0:
337 print(
338 "sol config: could not stop service before move",
339 file=sys.stderr,
340 )
341 return 2
342
343 try:
344 os.rename(current, target)
345 except OSError as exc:
346 _maybe_restart_current_service(change)
347 print(f"sol config: move failed: {exc}", file=sys.stderr)
348 return 1
349
350 with wrapper_lock():
351 try:
352 restart_sol = _rewrite_wrapper(change)
353 except OSError as exc:
354 rollback_ok = True
355 try:
356 if target.exists():
357 os.rename(target, current)
358 except OSError as rollback_exc:
359 rollback_ok = False
360 print(
361 f"sol config: rollback failed after wrapper write error: {rollback_exc}",
362 file=sys.stderr,
363 )
364 _maybe_restart_current_service(change)
365 message = f"sol config: move failed during wrapper update: {exc}"
366 if rollback_ok:
367 message += "; restored original journal"
368 print(message, file=sys.stderr)
369 return 2
370
371 if restart_sol is None:
372 try:
373 os.rename(target, current)
374 except OSError as rollback_exc:
375 print(
376 f"sol config: rollback failed after wrapper validation error: {rollback_exc}",
377 file=sys.stderr,
378 )
379 _maybe_restart_current_service(change)
380 return 1
381
382 if not change.service_installed:
383 print("service not installed; journal moved; wrapper updated.")
384 return 0
385
386 if not change.service_running:
387 print("service installed but not running; journal moved; wrapper updated.")
388 return 0
389
390 try:
391 start_result = _service_command(restart_sol, "start")
392 except FileNotFoundError:
393 print(
394 f"wrapper updated to {target} but service start failed; restart manually",
395 file=sys.stderr,
396 )
397 return 2
398
399 if start_result.returncode != 0:
400 print(
401 f"wrapper updated to {target} but service start failed; restart manually",
402 file=sys.stderr,
403 )
404 return 2
405
406 print("journal moved; wrapper updated; service restarted.")
407 return 0
408
409
410def _run_noop(change: JournalChange, _decision: Decision) -> int:
411 print(f"sol config: journal already set to {change.target_path}")
412 return 0
413
414
415def _refuse(decision: Decision) -> int:
416 if decision.message:
417 print(decision.message, file=sys.stderr)
418 return decision.exit_code
419
420
421def decide(change: JournalChange) -> Decision:
422 if change.action is RequestedAction.MERGE:
423 return Decision(Action.MERGE, 1, MERGE_INSTRUCTIONS)
424
425 if change.paths_equal:
426 return Decision(Action.NOOP, 0)
427
428 if change.action is None:
429 if not change.current_active and not change.target_active:
430 return Decision(Action.PROCEED, 0)
431 return Decision(Action.REFUSE, 1, _refusal_message(change))
432
433 if change.action is RequestedAction.FORCE:
434 return Decision(Action.SWITCH, 0)
435
436 if change.action is RequestedAction.MOVE:
437 if not change.target_parent_exists:
438 return Decision(Action.REFUSE, 1, _move_missing_parent_message(change))
439 if not change.current_exists:
440 return Decision(Action.REFUSE, 1, _move_missing_current_message(change))
441 if change.target_exists:
442 return Decision(Action.REFUSE, 1, _move_target_exists_message(change))
443 if change.target_active:
444 return Decision(
445 Action.REFUSE, 1, _move_requires_inactive_target_message(change)
446 )
447 if change.same_filesystem is False:
448 return Decision(Action.REFUSE, 1, _move_cross_filesystem_message(change))
449 if change.dry_run:
450 return Decision(Action.MOVE, 0, plan_only=True)
451 if not change.yes:
452 return Decision(Action.MOVE, 1, plan_only=True)
453 return Decision(Action.MOVE, 0)
454
455 if change.action is RequestedAction.SWITCH:
456 if change.dry_run:
457 return Decision(Action.SWITCH, 0, plan_only=True)
458 if not change.yes:
459 return Decision(Action.SWITCH, 1, plan_only=True)
460 return Decision(Action.SWITCH, 0)
461
462 return Decision(Action.REFUSE, 1, _refusal_message(change))
463
464
465def execute(change: JournalChange, decision: Decision) -> int:
466 if change.action is RequestedAction.FORCE:
467 print(
468 "sol config: warning: --force bypasses confirmation and target activity checks",
469 file=sys.stderr,
470 )
471
472 if decision.action is Action.MERGE:
473 print(decision.message or MERGE_INSTRUCTIONS)
474 return decision.exit_code
475 if decision.action is Action.REFUSE:
476 return _refuse(decision)
477 if decision.action is Action.NOOP:
478 return _run_noop(change, decision)
479 if decision.plan_only:
480 print(render_plan(change, decision))
481 return decision.exit_code
482 if decision.action in {Action.PROCEED, Action.SWITCH}:
483 return _run_switch(change)
484 if decision.action is Action.MOVE:
485 return _run_move(change)
486 return 1
487
488
489def build_change(
490 args: argparse.Namespace, *, alias_path: Path, sol_bin: str, current_path: Path
491) -> JournalChange:
492 target_path = Path(args.path).expanduser().resolve()
493 current_path = current_path.expanduser().resolve()
494 current_exists = current_path.exists()
495 target_exists = target_path.exists() or target_path.is_symlink()
496 target_parent_exists = target_path.parent.exists()
497 current_device = None
498 target_parent_device = None
499 same_filesystem = None
500 if current_exists:
501 try:
502 current_device = os.stat(current_path).st_dev
503 except OSError:
504 current_device = None
505 if target_parent_exists:
506 try:
507 target_parent_device = os.stat(target_path.parent).st_dev
508 except OSError:
509 target_parent_device = None
510 if current_device is not None and target_parent_device is not None:
511 same_filesystem = current_device == target_parent_device
512
513 installed = service_is_installed()
514 running = service_is_running() if installed else False
515
516 return JournalChange(
517 current_path=current_path,
518 target_path=target_path,
519 paths_equal=current_path == target_path,
520 current_active=journal_is_active(current_path),
521 target_active=journal_is_active(target_path),
522 current_exists=current_exists,
523 target_exists=target_exists,
524 target_parent_exists=target_parent_exists,
525 current_device=current_device,
526 target_parent_device=target_parent_device,
527 same_filesystem=same_filesystem,
528 service_installed=installed,
529 service_running=running,
530 action=args.action,
531 yes=args.yes,
532 dry_run=args.dry_run,
533 sol_bin=sol_bin,
534 alias=alias_path,
535 )
536
537
538def cmd_show() -> int:
539 wrapper_status, embedded_journal = _read_wrapper_status()
540
541 try:
542 path, info_source = get_journal_info()
543 except SolstoneNotConfigured as exc:
544 print(f"sol config: {exc}", file=sys.stderr)
545 return 1
546
547 if info_source == "env":
548 if (
549 embedded_journal is not None
550 and os.environ.get("SOLSTONE_JOURNAL") == embedded_journal
551 ):
552 user_source = "wrapper-embedded"
553 else:
554 user_source = "caller-override"
555 elif info_source == "config":
556 user_source = "user config (~/.config/solstone/config.toml)"
557 elif info_source == "default":
558 user_source = "built-in default (~/Documents/journal)"
559 else: # "source"
560 user_source = "source-tree fallback"
561
562 print(f"path: {path}")
563 print(f"source: {user_source}")
564 print(f"wrapper-status: {wrapper_status}")
565 return 0
566
567
568def cmd_journal(
569 target_path: str,
570 *,
571 action: RequestedAction | None = None,
572 yes: bool = False,
573 dry_run: bool = False,
574) -> int:
575 target = Path(target_path).expanduser().resolve()
576 target_str = str(target)
577
578 try:
579 validate_journal_path_for_wrapper(target_str)
580 except ValueError as exc:
581 print(f"sol config: refused: {exc}", file=sys.stderr)
582 return 1
583
584 project_root = Path(get_project_root())
585 is_source_checkout = (project_root / "pyproject.toml").exists() and (
586 project_root / ".git"
587 ).exists()
588 source_tree_journal = (project_root / "journal").resolve()
589 if target == source_tree_journal and not is_source_checkout:
590 print(
591 "sol config: refused: "
592 f"{target_str} is the source-tree fallback path but this is not a "
593 "source checkout",
594 file=sys.stderr,
595 )
596 return 1
597
598 if action is RequestedAction.MOVE and not target.parent.exists():
599 print(
600 f"sol config: refused: move target parent does not exist: {target.parent}",
601 file=sys.stderr,
602 )
603 return 1
604
605 alias = alias_path()
606 if not alias.exists() or alias.is_symlink():
607 print(_wrapper_refusal(alias), file=sys.stderr)
608 return 1
609
610 try:
611 content = alias.read_text(encoding="utf-8")
612 except OSError as exc:
613 print(f"sol config: refused: cannot read {alias}: {exc}", file=sys.stderr)
614 return 1
615
616 parsed = parse_wrapper(content)
617 if parsed is None:
618 print(_wrapper_refusal(alias), file=sys.stderr)
619 return 1
620
621 args = argparse.Namespace(
622 path=target_str,
623 action=action,
624 yes=yes,
625 dry_run=dry_run,
626 )
627 change = build_change(
628 args,
629 alias_path=alias,
630 sol_bin=parsed["sol_bin"],
631 current_path=Path(parsed["journal"]),
632 )
633 decision = decide(change)
634 return execute(change, decision)
635
636
637def main() -> int:
638 parser = argparse.ArgumentParser(prog="sol config")
639 subparsers = parser.add_subparsers(dest="cmd", required=True)
640 subparsers.add_parser("show", help="show the configured journal path and source")
641 journal_parser = subparsers.add_parser(
642 "journal",
643 help="rewrite the wrapper's embedded journal path",
644 )
645 journal_parser.add_argument(
646 "path", help="absolute path to the new journal directory"
647 )
648 action_group = journal_parser.add_mutually_exclusive_group()
649 action_group.add_argument(
650 "--move",
651 dest="action",
652 action="store_const",
653 const=RequestedAction.MOVE,
654 )
655 action_group.add_argument(
656 "--switch",
657 dest="action",
658 action="store_const",
659 const=RequestedAction.SWITCH,
660 )
661 action_group.add_argument(
662 "--merge",
663 dest="action",
664 action="store_const",
665 const=RequestedAction.MERGE,
666 )
667 action_group.add_argument(
668 "--force",
669 dest="action",
670 action="store_const",
671 const=RequestedAction.FORCE,
672 )
673 confirm_group = journal_parser.add_mutually_exclusive_group()
674 confirm_group.add_argument("--yes", action="store_true")
675 confirm_group.add_argument("--dry-run", action="store_true")
676 args = parser.parse_args()
677
678 if args.cmd == "show":
679 return cmd_show()
680 if args.cmd == "journal":
681 return cmd_journal(
682 args.path,
683 action=args.action,
684 yes=args.yes,
685 dry_run=args.dry_run,
686 )
687 return 1
688
689
690if __name__ == "__main__":
691 sys.exit(main())