open-knowledge-studio 0.2.3__tar.gz → 0.2.4__tar.gz

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.
Files changed (52) hide show
  1. {open_knowledge_studio-0.2.3/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.4}/PKG-INFO +11 -4
  2. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/README.md +10 -3
  3. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/pre-compact.sh +11 -1
  4. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/session-start.sh +7 -1
  5. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/cli.py +86 -21
  6. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/config.py +49 -11
  7. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/recall.py +2 -0
  8. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/store.py +9 -22
  9. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4/open_knowledge_studio.egg-info}/PKG-INFO +11 -4
  10. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/pyproject.toml +1 -1
  11. open_knowledge_studio-0.2.4/tests/test_init.py +90 -0
  12. open_knowledge_studio-0.2.3/tests/test_init.py +0 -50
  13. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/LICENSE +0 -0
  14. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/MANIFEST.in +0 -0
  15. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/__init__.py +0 -0
  16. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/frontmatter-schema.md +0 -0
  17. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/learning-schema.json +0 -0
  18. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/raw-evidence-schema.md +0 -0
  19. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +0 -0
  20. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh +0 -0
  21. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh +0 -0
  22. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/rules/raw-intake.md +0 -0
  23. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/rules/wiki-writing.md +0 -0
  24. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/settings.json +0 -0
  25. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/archive/SKILL.md +0 -0
  26. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/compile/SKILL.md +0 -0
  27. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/ingest/SKILL.md +0 -0
  28. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/lint/SKILL.md +0 -0
  29. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/promote/SKILL.md +0 -0
  30. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/query/SKILL.md +0 -0
  31. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/start/SKILL.md +0 -0
  32. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/status/SKILL.md +0 -0
  33. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/handlers.json +0 -0
  34. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/input-sources.json +0 -0
  35. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/raw-tools.example.json +0 -0
  36. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/anti-pattern.md +0 -0
  37. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/concept.md +0 -0
  38. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/draft.md +0 -0
  39. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/strategy.md +0 -0
  40. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/distiller.py +0 -0
  41. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/health.py +0 -0
  42. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/knowledge_studio/metrics.py +0 -0
  43. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/SOURCES.txt +0 -0
  44. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/dependency_links.txt +0 -0
  45. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/entry_points.txt +0 -0
  46. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/requires.txt +0 -0
  47. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/top_level.txt +0 -0
  48. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/setup.cfg +0 -0
  49. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/setup.py +0 -0
  50. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/tests/test_distiller.py +0 -0
  51. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/tests/test_health.py +0 -0
  52. {open_knowledge_studio-0.2.3 → open_knowledge_studio-0.2.4}/tests/test_recall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-knowledge-studio
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: File-based knowledge engineering CLI for Claude Code
5
5
  Author: open-agent-power
6
6
  License: MIT
@@ -42,16 +42,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
42
42
  ## Install
43
43
 
44
44
  ```bash
45
- pip install open-knowledge-studio
45
+ pipx install open-knowledge-studio && pipx ensurepath
46
46
  ```
47
47
 
48
+ We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
49
+ PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
50
+ PyPI, add `--pip-args="-i https://pypi.org/simple"`.
51
+
48
52
  Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
49
53
  separate, heavier package that you can pull in on demand:
50
54
 
51
55
  ```bash
52
- pip install "open-knowledge-studio[connector]"
56
+ pipx inject open-knowledge-studio oks-connector
53
57
  ```
54
58
 
59
+ (pipx apps share one venv without a standalone pip executable — `pipx inject` is how
60
+ you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
61
+
55
62
  ## What you get
56
63
 
57
64
  - **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
@@ -67,7 +74,7 @@ orchestrate the pipeline around it.
67
74
  ## Quick start
68
75
 
69
76
  ```bash
70
- pip install open-knowledge-studio # 1. install the CLI
77
+ pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
71
78
  oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
72
79
  cd my-knowledge-base
73
80
  oks status # 3. use it
@@ -8,16 +8,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- pip install open-knowledge-studio
11
+ pipx install open-knowledge-studio && pipx ensurepath
12
12
  ```
13
13
 
14
+ We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
15
+ PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
16
+ PyPI, add `--pip-args="-i https://pypi.org/simple"`.
17
+
14
18
  Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
15
19
  separate, heavier package that you can pull in on demand:
16
20
 
17
21
  ```bash
18
- pip install "open-knowledge-studio[connector]"
22
+ pipx inject open-knowledge-studio oks-connector
19
23
  ```
20
24
 
25
+ (pipx apps share one venv without a standalone pip executable — `pipx inject` is how
26
+ you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
27
+
21
28
  ## What you get
22
29
 
23
30
  - **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
@@ -33,7 +40,7 @@ orchestrate the pipeline around it.
33
40
  ## Quick start
34
41
 
35
42
  ```bash
36
- pip install open-knowledge-studio # 1. install the CLI
43
+ pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
37
44
  oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
38
45
  cd my-knowledge-base
39
46
  oks status # 3. use it
@@ -4,7 +4,17 @@
4
4
 
5
5
  set -euo pipefail
6
6
 
7
- REPO_ROOT="${OKS_ROOT:-$(pwd)}"
7
+ REPO_ROOT="${OKS_ROOT:-}"
8
+ if [ -z "$REPO_ROOT" ]; then
9
+ REPO_ROOT="$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.oks/config.json'))).get('knowledge_base_path',''))" 2>/dev/null || true)"
10
+ fi
11
+ if [ -z "$REPO_ROOT" ]; then
12
+ REPO_ROOT="$(pwd)"
13
+ fi
14
+
15
+ # Only snapshot inside a real knowledge base — never litter other dirs.
16
+ [ -d "$REPO_ROOT/wiki" ] || exit 0
17
+
8
18
  SNAPSHOT_DIR="$REPO_ROOT/.oks/snapshots"
9
19
  mkdir -p "$SNAPSHOT_DIR"
10
20
 
@@ -4,7 +4,13 @@
4
4
 
5
5
  set -euo pipefail
6
6
 
7
- REPO_ROOT="${OKS_ROOT:-$(pwd)}"
7
+ REPO_ROOT="${OKS_ROOT:-}"
8
+ if [ -z "$REPO_ROOT" ]; then
9
+ REPO_ROOT="$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.oks/config.json'))).get('knowledge_base_path',''))" 2>/dev/null || true)"
10
+ fi
11
+ if [ -z "$REPO_ROOT" ]; then
12
+ REPO_ROOT="$(pwd)"
13
+ fi
8
14
 
9
15
  if [ ! -d "$REPO_ROOT/wiki" ]; then
10
16
  exit 0
@@ -475,7 +475,11 @@ def config_init(
475
475
  """Initialize global config at ~/.oks/config.json."""
476
476
  from knowledge_studio.config import init_config
477
477
 
478
- path = init_config(kb_path)
478
+ try:
479
+ path = init_config(kb_path)
480
+ except ValueError as e:
481
+ console.print(f"[red]{e}[/red]")
482
+ raise typer.Exit(1)
479
483
  console.print(f"[green]Config created:[/green] {path}")
480
484
 
481
485
  from knowledge_studio.config import load_config
@@ -526,7 +530,15 @@ def config_set(
526
530
  target[k] = {}
527
531
  target = target[k]
528
532
 
529
- if value.lower() in ("true", "false"):
533
+ if key == "knowledge_base_path":
534
+ resolved = Path(value).expanduser().resolve()
535
+ if not resolved.is_dir():
536
+ console.print(
537
+ f"[yellow]Warning:[/yellow] directory does not exist: {resolved}"
538
+ )
539
+ value = str(resolved)
540
+ target[keys[-1]] = value
541
+ elif value.lower() in ("true", "false"):
530
542
  target[keys[-1]] = value.lower() == "true"
531
543
  elif value.isdigit():
532
544
  target[keys[-1]] = int(value)
@@ -607,18 +619,17 @@ def _materialize_assets(root: Path, base: Path, is_packaged: bool, overwrite: bo
607
619
  if not src.is_dir():
608
620
  continue
609
621
  dest = root / dest_name
610
- if dest.exists():
611
- if not overwrite:
612
- continue
613
- shutil.rmtree(dest)
614
- shutil.copytree(src, dest)
622
+ if dest.exists() and not overwrite:
623
+ continue
624
+ # Merge-copy: refresh bundled files in place, keep user-owned files.
625
+ shutil.copytree(src, dest, dirs_exist_ok=True)
615
626
  done.append(dest_name)
616
627
  return done
617
628
 
618
629
 
619
630
  @app.command()
620
631
  def init(
621
- path: str = typer.Argument(".", help="Target directory for the new knowledge instance"),
632
+ path: str = typer.Argument(..., help="Target directory for the new knowledge instance"),
622
633
  set_default: bool = typer.Option(
623
634
  True, "--set-default/--no-set-default",
624
635
  help="Register this folder as the active KB in ~/.oks/config.json",
@@ -630,6 +641,10 @@ def init(
630
641
  False, "--upgrade",
631
642
  help="Re-copy bundled assets (skills/templates/_meta/settings), overwriting them; your memory (wiki/drafts/profiles) is untouched",
632
643
  ),
644
+ force: bool = typer.Option(
645
+ False, "--force",
646
+ help="Scaffold into a non-empty directory that is not already a knowledge base",
647
+ ),
633
648
  ):
634
649
  """Scaffold a new knowledge INSTANCE folder (e.g. your personal artboy-knowledge-studio).
635
650
 
@@ -639,6 +654,26 @@ def init(
639
654
  from anywhere.
640
655
  """
641
656
  root = Path(path).expanduser().resolve()
657
+
658
+ # Refuse to scaffold into an existing non-empty directory that is not
659
+ # already a KB (missing wiki/) — protects arbitrary folders from being
660
+ # hijacked. Re-running on an existing KB is idempotent and allowed.
661
+ if (
662
+ root.is_dir()
663
+ and any(root.iterdir())
664
+ and not (root / "wiki").is_dir()
665
+ and not force
666
+ ):
667
+ console.print(
668
+ f"[red]Refusing to scaffold into non-empty directory:[/red] {root}\n"
669
+ f"It does not look like a knowledge base (no wiki/). Init would create:\n"
670
+ + "\n".join(f" - {d}/" for d in _INSTANCE_DIRS)
671
+ + "\n - .claude/ templates/ _meta/ settings/ (bundled assets)"
672
+ + "\n - .gitignore"
673
+ + "\n\nRe-run with [bold]--force[/bold] to proceed anyway."
674
+ )
675
+ raise typer.Exit(1)
676
+
642
677
  root.mkdir(parents=True, exist_ok=True)
643
678
 
644
679
  for d in _INSTANCE_DIRS:
@@ -693,7 +728,10 @@ def init(
693
728
 
694
729
  # ── Optional editor hooks (opt-in auto-recall) ───────────────────
695
730
 
696
- _RECALL_HOOK_CMD = ".claude/hooks/user-prompt-recall.sh"
731
+ # Hook commands are written as absolute paths (see hook_install). Old
732
+ # installs wired the relative path below; matching is done by script name
733
+ # so both forms are recognized.
734
+ _RECALL_HOOK_SCRIPT_NAME = "user-prompt-recall.sh"
697
735
  _RECALL_HOOK_SCRIPTS = ("user-prompt-recall.py", "user-prompt-recall.sh")
698
736
  _HOOK_EDITORS = {
699
737
  "claude": ".claude/settings.json",
@@ -709,9 +747,16 @@ def _instance_root(path: str | None) -> Path:
709
747
 
710
748
 
711
749
  def _ensure_recall_scripts(root: Path) -> list[str]:
712
- """Copy the recall hook scripts into <root>/.claude/hooks/ if missing."""
750
+ """Copy/refresh the recall hook scripts in <root>/.claude/hooks/.
751
+
752
+ The .sh wrapper gets the current interpreter baked into its OKS_PYTHON
753
+ fallback. If an existing .sh lacks the current bake (fresh copy still on
754
+ `python3`, or baked against a stale interpreter), it is re-copied from
755
+ the asset source and re-baked. The .py engine is only copied if missing.
756
+ """
713
757
  import shutil
714
758
  import stat
759
+ import sys
715
760
 
716
761
  hooks_dir = root / ".claude" / "hooks"
717
762
  hooks_dir.mkdir(parents=True, exist_ok=True)
@@ -721,20 +766,27 @@ def _ensure_recall_scripts(root: Path) -> list[str]:
721
766
  if base is not None:
722
767
  src_dir = base / ("claude/hooks" if is_packaged else ".claude/hooks")
723
768
 
769
+ baked = f'"${{OKS_PYTHON:-{sys.executable}}}"'
724
770
  created: list[str] = []
725
771
  for name in _RECALL_HOOK_SCRIPTS:
726
772
  dest = hooks_dir / name
727
773
  if dest.exists():
728
- continue
774
+ if not name.endswith(".sh"):
775
+ continue
776
+ try:
777
+ if baked in dest.read_text(encoding="utf-8"):
778
+ continue
779
+ except OSError:
780
+ pass
781
+ # Stale interpreter bake — fall through to re-copy + re-bake.
729
782
  if src_dir is None or not (src_dir / name).is_file():
730
783
  raise FileNotFoundError(
731
784
  f"bundled hook script not found: {name} (asset source: {src_dir})"
732
785
  )
733
786
  shutil.copy2(src_dir / name, dest)
734
787
  if name.endswith(".sh"):
735
- import sys
736
788
  text = dest.read_text(encoding="utf-8").replace(
737
- '"${OKS_PYTHON:-python3}"', f'"${{OKS_PYTHON:-{sys.executable}}}"'
789
+ '"${OKS_PYTHON:-python3}"', baked
738
790
  )
739
791
  dest.write_text(text, encoding="utf-8")
740
792
  dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@@ -743,7 +795,11 @@ def _ensure_recall_scripts(root: Path) -> list[str]:
743
795
 
744
796
 
745
797
  def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
746
- """Idempotently add a UserPromptSubmit command hook. Returns 'wired'|'exists'."""
798
+ """Idempotently add a UserPromptSubmit command hook. Returns 'wired'|'exists'.
799
+
800
+ Recognizes previously wired entries (old relative or stale absolute
801
+ paths) by script name and rewrites them in place instead of duplicating.
802
+ """
747
803
  data: dict = {}
748
804
  if settings_path.exists():
749
805
  try:
@@ -752,11 +808,18 @@ def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
752
808
  raise ValueError(f"{settings_path} is not valid JSON: {e}") from e
753
809
  hooks = data.setdefault("hooks", {})
754
810
  ups = hooks.setdefault("UserPromptSubmit", [])
811
+ stale: dict | None = None
755
812
  for group in ups:
756
813
  for h in group.get("hooks", []):
757
- if h.get("command") == command:
814
+ cmd = h.get("command", "")
815
+ if cmd == command:
758
816
  return "exists"
759
- ups.append({"hooks": [{"type": "command", "command": command}]})
817
+ if cmd.endswith(_RECALL_HOOK_SCRIPT_NAME):
818
+ stale = h
819
+ if stale is not None:
820
+ stale["command"] = command
821
+ else:
822
+ ups.append({"hooks": [{"type": "command", "command": command}]})
760
823
  settings_path.parent.mkdir(parents=True, exist_ok=True)
761
824
  settings_path.write_text(
762
825
  json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
@@ -764,7 +827,7 @@ def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
764
827
  return "wired"
765
828
 
766
829
 
767
- def _hook_is_wired(settings_path: Path, command: str) -> bool:
830
+ def _hook_is_wired(settings_path: Path) -> bool:
768
831
  if not settings_path.exists():
769
832
  return False
770
833
  try:
@@ -773,7 +836,7 @@ def _hook_is_wired(settings_path: Path, command: str) -> bool:
773
836
  return False
774
837
  for group in data.get("hooks", {}).get("UserPromptSubmit", []):
775
838
  for h in group.get("hooks", []):
776
- if h.get("command") == command:
839
+ if h.get("command", "").endswith(_RECALL_HOOK_SCRIPT_NAME):
777
840
  return True
778
841
  return False
779
842
 
@@ -824,10 +887,11 @@ def hook_install(
824
887
  if created:
825
888
  console.print(f"[green]Installed hook script:[/green] {', '.join(created)}")
826
889
 
890
+ hook_cmd = (root / ".claude" / "hooks" / _RECALL_HOOK_SCRIPT_NAME).resolve().as_posix()
827
891
  editors = ("claude", "qoder") if editor == "both" else (editor,)
828
892
  for name in editors:
829
893
  settings_path = root / _HOOK_EDITORS[name]
830
- result = _wire_userpromptsubmit(settings_path, _RECALL_HOOK_CMD)
894
+ result = _wire_userpromptsubmit(settings_path, hook_cmd)
831
895
  label = "[green]wired[/green]" if result == "wired" else "[dim]already wired[/dim]"
832
896
  console.print(f" {name}: {label} → {settings_path}")
833
897
 
@@ -860,11 +924,12 @@ def hook_status(
860
924
  except (OSError, subprocess.TimeoutExpired):
861
925
  ok = False
862
926
  state = ("[green]importable[/green]" if ok
863
- else "[red]NOT importable — hook will silently no-op; re-run oks hook install[/red]")
927
+ else "[red]hook script has stale interpreter — "
928
+ "run `oks hook install` to re-bake[/red]")
864
929
  console.print(f" engine: {state} (python: {py})")
865
930
  for name, rel in _HOOK_EDITORS.items():
866
931
  settings_path = root / rel
867
- wired = _hook_is_wired(settings_path, _RECALL_HOOK_CMD)
932
+ wired = _hook_is_wired(settings_path)
868
933
  state = "[green]wired[/green]" if wired else "[dim]not wired[/dim]"
869
934
  console.print(f" {name}: {state}")
870
935
 
@@ -90,37 +90,75 @@ def save_config(config: dict[str, Any]) -> None:
90
90
 
91
91
 
92
92
  def init_config(kb_path: str | None = None) -> Path:
93
- """Initialize global config. Returns the config path."""
93
+ """Initialize global config. Returns the config path.
94
+
95
+ Raises ValueError when no kb_path is given and the existing config has
96
+ no knowledge_base_path — we never silently default to cwd.
97
+ """
94
98
  config = load_config()
95
99
 
96
100
  if kb_path:
97
- config["knowledge_base_path"] = kb_path
101
+ config["knowledge_base_path"] = str(Path(kb_path).expanduser().resolve())
98
102
  elif not config.get("knowledge_base_path"):
99
- try:
100
- from knowledge_studio.store import repo_root
101
- config["knowledge_base_path"] = str(repo_root())
102
- except Exception:
103
- config["knowledge_base_path"] = str(Path.cwd())
103
+ raise ValueError(
104
+ "knowledge_base_path required: pass --kb-path or run `oks init <path>`"
105
+ )
104
106
 
105
107
  save_config(config)
106
108
  return config_path()
107
109
 
108
110
 
111
+ # Warn at most once per process when the configured root lacks wiki/.
112
+ _warned_missing_wiki = False
113
+
114
+
115
+ def _warn_if_not_kb(root: Path) -> None:
116
+ global _warned_missing_wiki
117
+ if _warned_missing_wiki:
118
+ return
119
+ if not (root / "wiki").is_dir():
120
+ _warned_missing_wiki = True
121
+ import sys
122
+ print(
123
+ f"oks: warning: configured KB path {root} does not look like a "
124
+ f"knowledge base (missing wiki/); run `oks init <path>` or "
125
+ f"`oks config set knowledge_base_path <path>`",
126
+ file=sys.stderr,
127
+ )
128
+
129
+
109
130
  def get_kb_root() -> Path:
110
- """Get the knowledge base root path.
131
+ """Get the knowledge base root path (single source of truth).
111
132
 
112
133
  Priority:
113
134
  1. OKS_ROOT env var
114
135
  2. ~/.oks/config.json → knowledge_base_path
115
136
  3. Current working directory
137
+
138
+ A corrupt config warns on stderr and falls back to cwd instead of
139
+ raising, matching the historical store.repo_root() behavior.
116
140
  """
117
141
  env_root = os.environ.get("OKS_ROOT")
118
142
  if env_root:
119
- return Path(env_root)
143
+ root = Path(env_root).expanduser().resolve()
144
+ _warn_if_not_kb(root)
145
+ return root
146
+
147
+ try:
148
+ config = load_config()
149
+ except Exception as e:
150
+ import sys
151
+ print(
152
+ f"oks: warning: could not read {config_path()} ({e}); "
153
+ f"falling back to current directory as KB root",
154
+ file=sys.stderr,
155
+ )
156
+ return Path.cwd()
120
157
 
121
- config = load_config()
122
158
  kb_path = config.get("knowledge_base_path")
123
159
  if kb_path:
124
- return Path(kb_path)
160
+ root = Path(kb_path).expanduser().resolve()
161
+ _warn_if_not_kb(root)
162
+ return root
125
163
 
126
164
  return Path.cwd()
@@ -221,6 +221,8 @@ def _tokenize(text: str) -> set[str]:
221
221
  try:
222
222
  import jieba
223
223
 
224
+ import logging as _logging
225
+ jieba.setLogLevel(_logging.WARNING) # silence "Building prefix dict" chatter
224
226
  raw_words = list(jieba.cut_for_search(text))
225
227
  except Exception:
226
228
  raw_words = text.split()
@@ -35,23 +35,10 @@ DEFAULT_CONFIG: dict = {
35
35
 
36
36
 
37
37
  def repo_root() -> Path:
38
- env_root = os.environ.get("OKS_ROOT")
39
- if env_root:
40
- return Path(env_root)
41
- try:
42
- from knowledge_studio.config import load_config
38
+ """Thin delegate — config.get_kb_root() is the single root resolver."""
39
+ from knowledge_studio.config import get_kb_root
43
40
 
44
- kb_path = load_config().get("knowledge_base_path")
45
- if kb_path:
46
- return Path(kb_path)
47
- except Exception as e:
48
- import sys
49
- print(
50
- f"oks: warning: could not read ~/.oks/config.json ({e}); "
51
- f"falling back to current directory as KB root",
52
- file=sys.stderr,
53
- )
54
- return Path(os.getcwd())
41
+ return get_kb_root()
55
42
 
56
43
 
57
44
  def wiki_dir() -> Path:
@@ -108,9 +95,9 @@ def load_active_goals() -> list[dict]:
108
95
 
109
96
 
110
97
  def _access_log_path() -> Path:
111
- log_dir = repo_root() / ".oks"
112
- log_dir.mkdir(parents=True, exist_ok=True)
113
- return log_dir / "access.json"
98
+ # Read-only: no mkdir here. Writers go through _atomic_write, which
99
+ # creates the parent directory.
100
+ return repo_root() / ".oks" / "access.json"
114
101
 
115
102
 
116
103
  def _load_access_counts() -> dict[str, int]:
@@ -255,9 +242,9 @@ def _fingerprint(content: str) -> str:
255
242
 
256
243
 
257
244
  def _fingerprint_index_path() -> Path:
258
- d = repo_root() / ".oks"
259
- d.mkdir(parents=True, exist_ok=True)
260
- return d / "fingerprints.json"
245
+ # Read-only: no mkdir here. Writers go through _atomic_write, which
246
+ # creates the parent directory.
247
+ return repo_root() / ".oks" / "fingerprints.json"
261
248
 
262
249
 
263
250
  def _load_fingerprint_index() -> dict[str, str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-knowledge-studio
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: File-based knowledge engineering CLI for Claude Code
5
5
  Author: open-agent-power
6
6
  License: MIT
@@ -42,16 +42,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
42
42
  ## Install
43
43
 
44
44
  ```bash
45
- pip install open-knowledge-studio
45
+ pipx install open-knowledge-studio && pipx ensurepath
46
46
  ```
47
47
 
48
+ We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
49
+ PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
50
+ PyPI, add `--pip-args="-i https://pypi.org/simple"`.
51
+
48
52
  Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
49
53
  separate, heavier package that you can pull in on demand:
50
54
 
51
55
  ```bash
52
- pip install "open-knowledge-studio[connector]"
56
+ pipx inject open-knowledge-studio oks-connector
53
57
  ```
54
58
 
59
+ (pipx apps share one venv without a standalone pip executable — `pipx inject` is how
60
+ you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
61
+
55
62
  ## What you get
56
63
 
57
64
  - **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
@@ -67,7 +74,7 @@ orchestrate the pipeline around it.
67
74
  ## Quick start
68
75
 
69
76
  ```bash
70
- pip install open-knowledge-studio # 1. install the CLI
77
+ pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
71
78
  oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
72
79
  cd my-knowledge-base
73
80
  oks status # 3. use it
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-knowledge-studio"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "File-based knowledge engineering CLI for Claude Code"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,90 @@
1
+ """Tests for `oks init` — instance scaffolding + shareable-asset materialization."""
2
+ from pathlib import Path
3
+
4
+ from typer.testing import CliRunner
5
+
6
+ from knowledge_studio.cli import app
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_init_scaffolds_buckets_and_data_gitignore(tmp_path):
12
+ target = tmp_path / "kb"
13
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
14
+ assert result.exit_code == 0, result.output
15
+
16
+ for d in ["wiki", "drafts", "raw", "profiles/goals", "settings", "_meta", "templates"]:
17
+ assert (target / d).is_dir(), f"missing bucket {d}"
18
+
19
+ gi = (target / ".gitignore").read_text(encoding="utf-8")
20
+ # instance gitignore ignores only per-machine state, and TRACKS memory
21
+ assert ".oks/" in gi
22
+ assert "wiki/**/*.md" not in gi
23
+ assert "drafts/*.md" not in gi
24
+
25
+
26
+ def test_init_materializes_shareable_assets(tmp_path):
27
+ target = tmp_path / "kb"
28
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
29
+ assert result.exit_code == 0, result.output
30
+
31
+ # skills + templates arrive so the Claude Code experience works out of the box
32
+ assert (target / ".claude" / "skills" / "ingest").is_dir()
33
+ assert (target / ".claude" / "settings.json").is_file()
34
+ assert (target / "templates").is_dir()
35
+
36
+
37
+ def test_init_upgrade_refreshes_assets_but_keeps_user_files(tmp_path):
38
+ target = tmp_path / "kb"
39
+ runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
40
+
41
+ marker = target / ".claude" / "MARKER.txt"
42
+ marker.write_text("local edit", encoding="utf-8")
43
+
44
+ bundled = target / ".claude" / "settings.json"
45
+ original = bundled.read_text(encoding="utf-8")
46
+ bundled.write_text("{}", encoding="utf-8")
47
+
48
+ # re-init without --upgrade keeps existing assets untouched
49
+ runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
50
+ assert marker.exists()
51
+ assert bundled.read_text(encoding="utf-8") == "{}"
52
+
53
+ # --upgrade merge-copies bundled assets: bundled files refreshed,
54
+ # user-owned files (marker) survive — no more whole-tree deletion
55
+ runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default", "--upgrade"])
56
+ assert marker.exists()
57
+ assert bundled.read_text(encoding="utf-8") == original
58
+
59
+
60
+ def test_init_requires_path_argument():
61
+ result = runner.invoke(app, ["init"])
62
+ assert result.exit_code != 0
63
+
64
+
65
+ def test_init_aborts_on_nonempty_non_kb_dir(tmp_path):
66
+ target = tmp_path / "documents"
67
+ target.mkdir()
68
+ (target / "important.txt").write_text("do not touch", encoding="utf-8")
69
+
70
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
71
+ assert result.exit_code == 1
72
+ assert not (target / "wiki").exists()
73
+
74
+ # --force overrides the guard
75
+ result = runner.invoke(
76
+ app, ["init", str(target), "--no-git", "--no-set-default", "--force"]
77
+ )
78
+ assert result.exit_code == 0, result.output
79
+ assert (target / "wiki").is_dir()
80
+ assert (target / "important.txt").exists()
81
+
82
+
83
+ def test_init_rerun_on_existing_kb_is_idempotent(tmp_path):
84
+ target = tmp_path / "kb"
85
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
86
+ assert result.exit_code == 0, result.output
87
+
88
+ # target now contains wiki/ → treated as an existing KB, no --force needed
89
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
90
+ assert result.exit_code == 0, result.output
@@ -1,50 +0,0 @@
1
- """Tests for `oks init` — instance scaffolding + shareable-asset materialization."""
2
- from pathlib import Path
3
-
4
- from typer.testing import CliRunner
5
-
6
- from knowledge_studio.cli import app
7
-
8
- runner = CliRunner()
9
-
10
-
11
- def test_init_scaffolds_buckets_and_data_gitignore(tmp_path):
12
- target = tmp_path / "kb"
13
- result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
14
- assert result.exit_code == 0, result.output
15
-
16
- for d in ["wiki", "drafts", "raw", "profiles/goals", "settings", "_meta", "templates"]:
17
- assert (target / d).is_dir(), f"missing bucket {d}"
18
-
19
- gi = (target / ".gitignore").read_text(encoding="utf-8")
20
- # instance gitignore ignores only per-machine state, and TRACKS memory
21
- assert ".oks/" in gi
22
- assert "wiki/**/*.md" not in gi
23
- assert "drafts/*.md" not in gi
24
-
25
-
26
- def test_init_materializes_shareable_assets(tmp_path):
27
- target = tmp_path / "kb"
28
- result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
29
- assert result.exit_code == 0, result.output
30
-
31
- # skills + templates arrive so the Claude Code experience works out of the box
32
- assert (target / ".claude" / "skills" / "ingest").is_dir()
33
- assert (target / ".claude" / "settings.json").is_file()
34
- assert (target / "templates").is_dir()
35
-
36
-
37
- def test_init_upgrade_refreshes_assets(tmp_path):
38
- target = tmp_path / "kb"
39
- runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
40
-
41
- marker = target / ".claude" / "MARKER.txt"
42
- marker.write_text("local edit", encoding="utf-8")
43
-
44
- # re-init without --upgrade keeps existing assets (marker survives)
45
- runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
46
- assert marker.exists()
47
-
48
- # --upgrade re-copies bundled assets, dropping the local marker
49
- runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default", "--upgrade"])
50
- assert not marker.exists()