obris-cli 0.4.2__tar.gz → 0.5.0__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 (45) hide show
  1. {obris_cli-0.4.2 → obris_cli-0.5.0}/.gitignore +3 -0
  2. {obris_cli-0.4.2 → obris_cli-0.5.0}/PKG-INFO +1 -1
  3. {obris_cli-0.4.2 → obris_cli-0.5.0}/pyproject.toml +1 -1
  4. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/topics.py +25 -7
  5. obris_cli-0.5.0/src/obris/commands/sync.py +273 -0
  6. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/routes.py +4 -0
  7. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/commands.py +15 -10
  8. obris_cli-0.5.0/src/obris/sync/engine/__init__.py +13 -0
  9. obris_cli-0.5.0/src/obris/sync/engine/filters.py +62 -0
  10. obris_cli-0.5.0/src/obris/sync/engine/run.py +208 -0
  11. obris_cli-0.5.0/src/obris/sync/engine/subtree.py +165 -0
  12. obris_cli-0.5.0/src/obris/sync/engine/tracked.py +198 -0
  13. obris_cli-0.5.0/src/obris/sync/mapping.py +239 -0
  14. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/models.py +52 -0
  15. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/state.py +48 -9
  16. obris_cli-0.4.2/src/obris/commands/sync.py +0 -180
  17. obris_cli-0.4.2/src/obris/sync/engine.py +0 -253
  18. obris_cli-0.4.2/src/obris/sync/mapping.py +0 -122
  19. {obris_cli-0.4.2 → obris_cli-0.5.0}/.claude/settings.local.json +0 -0
  20. {obris_cli-0.4.2 → obris_cli-0.5.0}/README.md +0 -0
  21. {obris_cli-0.4.2 → obris_cli-0.5.0}/ruff.toml +0 -0
  22. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/__init__.py +0 -0
  23. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/__init__.py +0 -0
  24. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/client.py +0 -0
  25. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/knowledge.py +0 -0
  26. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/assets/icon.png +0 -0
  27. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/auth/__init__.py +0 -0
  28. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/auth/session.py +0 -0
  29. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/cli.py +0 -0
  30. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/__init__.py +0 -0
  31. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/auth.py +0 -0
  32. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/env.py +0 -0
  33. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/knowledge.py +0 -0
  34. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/save.py +0 -0
  35. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/topic.py +0 -0
  36. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/config.py +0 -0
  37. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/output.py +0 -0
  38. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/__init__.py +0 -0
  39. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/constants.py +0 -0
  40. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/io.py +0 -0
  41. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/__init__.py +0 -0
  42. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/capture.py +0 -0
  43. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/notify.py +0 -0
  44. {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/upload.py +0 -0
  45. {obris_cli-0.4.2 → obris_cli-0.5.0}/uv.lock +0 -0
@@ -224,3 +224,6 @@ __marimo__/
224
224
 
225
225
  # MCP build artifacts
226
226
  *.mcpb
227
+
228
+ # Claude plugin build artifacts
229
+ extras/claude-plugin.zip
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obris-cli
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Save, organize, and access your knowledge from the command line
5
5
  Project-URL: Homepage, https://obris.ai
6
6
  Project-URL: Repository, https://github.com/obris-dev/obris
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "obris-cli"
3
- version = "0.4.2"
3
+ version = "0.5.0"
4
4
  description = "Save, organize, and access your knowledge from the command line"
5
5
 
6
6
  requires-python = ">=3.10"
@@ -1,5 +1,6 @@
1
1
  from obris import routes
2
2
  from obris.api.client import get, post
3
+ from obris.sync.models import RemoteTopic
3
4
 
4
5
 
5
6
  def list_topics(*, name=None, is_system=None):
@@ -11,12 +12,21 @@ def list_topics(*, name=None, is_system=None):
11
12
  return get(routes.topics(), params=params, action="List topics", unwrap=True)
12
13
 
13
14
 
14
- def get_topic(topic_id):
15
- return get(routes.topic(topic_id), action="Get topic")
15
+ def get_topic(topic_id) -> RemoteTopic:
16
+ """Fetch a single topic. Returns a ``RemoteTopic`` DTO so callers
17
+ do attribute access instead of dict .get() calls — same pattern as
18
+ ``RemoteItem`` on the knowledge side."""
19
+ return RemoteTopic.from_api(get(routes.topic(topic_id), action="Get topic"))
16
20
 
17
21
 
18
- def create_topic(name):
19
- return post(routes.topics(), json={"name": name}, action="Create topic")
22
+ def create_topic(name) -> RemoteTopic:
23
+ return RemoteTopic.from_api(post(routes.topics(), json={"name": name}, action="Create topic"))
24
+
25
+
26
+ def fetch_subtree(topic_id) -> list[RemoteTopic]:
27
+ """Return ``RemoteTopic`` entries for the topic and all descendants."""
28
+ raw = get(routes.topic_subtree(topic_id), action="Fetch topic subtree", unwrap=True)
29
+ return [RemoteTopic.from_api(r) for r in raw]
20
30
 
21
31
 
22
32
  def list_all_topics(**kwargs):
@@ -49,13 +59,21 @@ def list_all_knowledge(topic_id):
49
59
  return list(iter_knowledge(topic_id))
50
60
 
51
61
 
52
- def iter_knowledge(topic_id):
53
- """Yield knowledge items for a topic, page by page."""
62
+ def iter_knowledge(topic_id, *, recursive=False):
63
+ """Yield knowledge items for a topic, page by page.
64
+
65
+ When ``recursive`` is True, the server returns items from the topic
66
+ and all its descendants. Items carry ``topic_id`` so callers can
67
+ place them under the right subtopic.
68
+ """
54
69
  page = 1
70
+ base_params = {"page_size": 100}
71
+ if recursive:
72
+ base_params["recursive"] = "true"
55
73
  while True:
56
74
  data = get(
57
75
  routes.topic_knowledge(topic_id),
58
- params={"page": page, "page_size": 100},
76
+ params={**base_params, "page": page},
59
77
  action="List knowledge",
60
78
  )
61
79
  if isinstance(data, dict) and "results" in data:
@@ -0,0 +1,273 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from obris.api.topics import create_topic, fetch_subtree, get_topic
6
+ from obris.output import as_json, is_json
7
+ from obris.sync.commands import add_file, link_file
8
+ from obris.sync.engine import run_sync
9
+ from obris.sync.state import SyncState
10
+
11
+
12
+ @click.group("sync", invoke_without_command=True)
13
+ @click.option("--path", "-p", default=".", help="Local directory to sync (defaults to current directory)")
14
+ @click.option("--topic", "-t", "topic_id", default=None, help="Root topic ID to sync")
15
+ @click.option("--item", "-i", "item_ids", multiple=True, help="Specific item IDs to sync (repeatable)")
16
+ @click.option(
17
+ "--include",
18
+ "include_patterns",
19
+ multiple=True,
20
+ help="Glob pattern for subtopics to include. Repeatable; patterns are OR'd together. "
21
+ "Supports ** for any depth, * ? [abc] within a segment. Example: 'Projects/**' or '**/skill-*'",
22
+ )
23
+ @click.option("--dry-run", is_flag=True, help="Preview changes without modifying anything")
24
+ @click.option("-y", "--yes", is_flag=True, help="Auto-confirm prompts")
25
+ @click.pass_context
26
+ def sync(ctx, path, topic_id, item_ids, include_patterns, dry_run, yes):
27
+ """Sync a local directory with an Obris root topic and its subtopics.
28
+
29
+ On first sync, provide --topic <id> to link to an existing root topic,
30
+ or omit it to create a new root topic named after the directory.
31
+ Subsequent syncs auto-detect the linked topic from state.
32
+
33
+ Only root topics (topics with no parent) can be sync targets. Child
34
+ topics appear as subdirectories of their root. If you pass a child
35
+ topic ID, the command will tell you the root to use instead.
36
+
37
+ Use --include to sync only matching subtopics. Patterns OR together.
38
+ Use --item to sync specific items only.
39
+ """
40
+ ctx.ensure_object(dict)
41
+ ctx.obj["path"] = path
42
+ ctx.obj["topic_id"] = topic_id
43
+
44
+ if ctx.invoked_subcommand is not None:
45
+ return
46
+
47
+ sync_dir = Path(path).resolve()
48
+ patterns = list(include_patterns) if include_patterns else None
49
+
50
+ targets = _resolve_targets(sync_dir, topic_id, yes)
51
+
52
+ total_pulled, total_pushed, total_conflicts, total_errors = 0, 0, 0, 0
53
+
54
+ if dry_run:
55
+ click.echo(" (dry run — no changes will be made)")
56
+
57
+ for state, tid, topic_name in targets:
58
+ # Root-only enforcement: fetch the topic and check parent_id.
59
+ topic = get_topic(tid)
60
+ if topic.parent_id:
61
+ root = _find_root(tid)
62
+ raise SystemExit(
63
+ f'"{topic_name}" is a subtopic of "{root.name}". '
64
+ f"Sync from the root topic instead:\n"
65
+ f" obris sync --topic {root.id} --path {sync_dir}"
66
+ )
67
+
68
+ click.echo(f'Syncing "{topic_name}" \u2194 {sync_dir}/')
69
+
70
+ try:
71
+ subtree = fetch_subtree(tid)
72
+ except Exception as e:
73
+ click.echo(f" Error fetching subtree: {type(e).__name__}: {e}", err=True)
74
+ total_errors += 1
75
+ continue
76
+
77
+ pulled, pushed, conflicts, errors = run_sync(
78
+ tid,
79
+ str(sync_dir),
80
+ state=state,
81
+ subtree=subtree,
82
+ item_ids=item_ids or None,
83
+ include_patterns=patterns,
84
+ dry_run=dry_run,
85
+ )
86
+ total_pulled += pulled
87
+ total_pushed += pushed
88
+ total_conflicts += conflicts
89
+ total_errors += errors
90
+
91
+ if not pulled and not pushed and not conflicts and not errors:
92
+ click.echo(" Everything up to date.")
93
+ else:
94
+ parts = []
95
+ if pushed:
96
+ parts.append(f"{pushed} pushed")
97
+ if pulled:
98
+ parts.append(f"{pulled} pulled")
99
+ if conflicts:
100
+ parts.append(f"{conflicts} conflicts")
101
+ if errors:
102
+ parts.append(f"{errors} failed")
103
+ click.echo(f" Done: {', '.join(parts)}")
104
+
105
+ if is_json():
106
+ as_json(
107
+ {
108
+ "path": str(sync_dir),
109
+ "pulled": total_pulled,
110
+ "pushed": total_pushed,
111
+ "conflicts": total_conflicts,
112
+ "errors": total_errors,
113
+ }
114
+ )
115
+
116
+ if total_errors:
117
+ raise SystemExit(1)
118
+
119
+
120
+ @sync.command("add")
121
+ @click.argument("file")
122
+ @click.option("--topic", "-t", "topic_id", default=None, help="Target topic ID (root or subtopic)")
123
+ def sync_add(file, topic_id):
124
+ """Add a local file to a synced topic.
125
+
126
+ FILE is the path to the file to add.
127
+
128
+ If the directory is synced to exactly one root topic, that root is
129
+ used automatically. Provide --topic to specify a subtopic of that
130
+ root to place the item under.
131
+ """
132
+ filepath = Path(file).resolve()
133
+ if not filepath.exists():
134
+ raise SystemExit(f"File not found: {filepath}")
135
+
136
+ sync_dir = filepath.parent
137
+ states = SyncState.find_all_for_path(sync_dir)
138
+
139
+ if not states and not topic_id:
140
+ raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync --topic <id>' first.")
141
+
142
+ # Determine the root topic (state key). Explicit topic_id may be a root
143
+ # OR a subtopic under one of the known roots.
144
+ if states and topic_id:
145
+ # User passed an explicit topic — figure out which root it belongs to.
146
+ target_topic = get_topic(topic_id)
147
+ root_id = _find_root_id(topic_id, target_topic)
148
+ root_state_match = next(((s, rid) for s, rid in states if rid == root_id), None)
149
+ if root_state_match is None:
150
+ raise SystemExit(f"Topic {topic_id} is under root {root_id}, which is not synced at {sync_dir}/.")
151
+ _root_state, root_id = root_state_match
152
+ target_topic_id = topic_id
153
+ topic_name = target_topic.name
154
+ elif states:
155
+ if len(states) > 1:
156
+ click.echo(f"Multiple root topics sync to {sync_dir}/. Specify which with --topic <id>:")
157
+ for _state, tid in states:
158
+ topic = get_topic(tid)
159
+ click.echo(f" {topic.name} ({tid})")
160
+ raise SystemExit(1)
161
+ _root_state, root_id = states[0]
162
+ target_topic_id = root_id
163
+ topic_name = get_topic(root_id).name
164
+ else:
165
+ # No existing sync state but user passed --topic. Must be a root.
166
+ target_topic = get_topic(topic_id)
167
+ if target_topic.parent_id:
168
+ raise SystemExit(
169
+ f"\"{target_topic.name}\" is a subtopic. Run 'obris sync --topic <root-id>' first to set up the sync."
170
+ )
171
+ root_id = topic_id
172
+ target_topic_id = topic_id
173
+ topic_name = target_topic.name
174
+
175
+ item = add_file(root_id, str(sync_dir), str(filepath), target_topic_id=target_topic_id)
176
+
177
+ if is_json():
178
+ as_json(item)
179
+ else:
180
+ click.echo(f'Added "{filepath.name}" to "{topic_name}"')
181
+
182
+
183
+ @sync.command("link")
184
+ @click.argument("file")
185
+ @click.option("--item", "-i", "item_id", required=True, help="Knowledge item ID to link to")
186
+ @click.option("--topic", "-t", "topic_id", default=None, help="Target topic ID (root or subtopic)")
187
+ def sync_link(file, item_id, topic_id):
188
+ """Link a local file to an existing remote item.
189
+
190
+ Use after renaming a file locally to reattach it to its remote item.
191
+ """
192
+ filepath = Path(file).resolve()
193
+ if not filepath.exists():
194
+ raise SystemExit(f"File not found: {filepath}")
195
+
196
+ sync_dir = filepath.parent
197
+ states = SyncState.find_all_for_path(sync_dir)
198
+
199
+ if not states:
200
+ raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync -t <id>' first.")
201
+
202
+ if topic_id:
203
+ target_topic = get_topic(topic_id)
204
+ root_id = _find_root_id(topic_id, target_topic)
205
+ match = next(((s, rid) for s, rid in states if rid == root_id), None)
206
+ if match is None:
207
+ raise SystemExit(f"Topic {topic_id} is under root {root_id}, which is not synced at {sync_dir}/.")
208
+ _, root_id = match
209
+ target_topic_id = topic_id
210
+ else:
211
+ if len(states) > 1:
212
+ click.echo(f"Multiple root topics sync to {sync_dir}/. Specify which with -t <id>:")
213
+ for _state, tid in states:
214
+ topic = get_topic(tid)
215
+ click.echo(f" {topic.name} ({tid})")
216
+ raise SystemExit(1)
217
+ _, root_id = states[0]
218
+ target_topic_id = root_id
219
+
220
+ link_file(root_id, str(sync_dir), str(filepath), item_id, target_topic_id=target_topic_id)
221
+ click.echo(f'Linked "{filepath.name}" to item {item_id}')
222
+
223
+
224
+ def _resolve_targets(sync_dir, topic_id, yes):
225
+ """Determine which topic(s) to sync.
226
+
227
+ Returns list of (SyncState | None, topic_id, topic_name) tuples.
228
+ """
229
+ if topic_id:
230
+ state = SyncState.load(topic_id, sync_dir)
231
+ topic = get_topic(topic_id)
232
+ return [(state, topic_id, topic.name)]
233
+
234
+ states = SyncState.find_all_for_path(sync_dir)
235
+
236
+ if states:
237
+ targets = []
238
+ for state, tid in states:
239
+ topic = get_topic(tid)
240
+ targets.append((state, tid, topic.name))
241
+ return targets
242
+
243
+ topic_name = sync_dir.name
244
+ if not yes:
245
+ click.confirm(f'Create topic "{topic_name}" and sync to {sync_dir}/?', default=True, abort=True)
246
+ topic = create_topic(topic_name)
247
+ click.echo(f'Created topic "{topic_name}"')
248
+ return [(None, topic.id, topic_name)]
249
+
250
+
251
+ def _find_root(topic_id, topic=None):
252
+ """Walk up parent_id pointers to find the root topic.
253
+
254
+ Cycle-safe: visited set + depth cap. Raises SystemExit on cycle.
255
+ """
256
+ if topic is None:
257
+ topic = get_topic(topic_id)
258
+ visited = {topic_id}
259
+ depth = 0
260
+ while topic.parent_id:
261
+ depth += 1
262
+ if depth > 64:
263
+ raise SystemExit(f"Topic ancestor chain exceeds depth 64 starting at {topic_id}")
264
+ parent_id = topic.parent_id
265
+ if parent_id in visited:
266
+ raise SystemExit(f"Topic ancestor cycle detected walking up from {topic_id}")
267
+ visited.add(parent_id)
268
+ topic = get_topic(parent_id)
269
+ return topic
270
+
271
+
272
+ def _find_root_id(topic_id, topic=None):
273
+ return _find_root(topic_id, topic).id
@@ -9,6 +9,10 @@ def topic(topic_id):
9
9
  return f"v1/topics/{topic_id}"
10
10
 
11
11
 
12
+ def topic_subtree(topic_id):
13
+ return f"v1/topics/{topic_id}/subtree"
14
+
15
+
12
16
  def topic_knowledge(topic_id):
13
17
  return f"v1/topics/{topic_id}/knowledge"
14
18
 
@@ -9,18 +9,22 @@ from .mapping import hash_file, now_iso, read_if_text
9
9
  from .state import SyncState
10
10
 
11
11
 
12
- def add_file(topic_id, sync_dir, filepath):
13
- """Add a local file to a topic and start tracking it.
12
+ def add_file(root_topic_id, sync_dir, filepath, *, target_topic_id=None):
13
+ """Add a local file to a topic (root or subtopic) and start tracking it.
14
+
15
+ ``root_topic_id`` is the root of the sync tree (state key). ``target_topic_id``
16
+ is the topic the item is actually created under — defaults to the root.
14
17
 
15
18
  Returns the created knowledge item dict.
16
19
  """
17
20
  sync_dir = Path(sync_dir)
18
21
  filepath = Path(filepath)
22
+ target_topic_id = target_topic_id or root_topic_id
19
23
 
20
24
  if not filepath.exists():
21
25
  raise FileNotFoundError(f"File not found: {filepath}")
22
26
 
23
- state = SyncState.load(topic_id, sync_dir) or SyncState(topic_id, sync_dir)
27
+ state = SyncState.load(root_topic_id, sync_dir) or SyncState(root_topic_id, sync_dir)
24
28
 
25
29
  filename = filepath.name
26
30
  existing_kid, _ = state.find_by_filename(filename)
@@ -30,32 +34,33 @@ def add_file(topic_id, sync_dir, filepath):
30
34
  file_hash = hash_file(filepath)
31
35
 
32
36
  content, is_text = read_if_text(filepath)
33
- if is_text: # noqa: SIM108
34
- result = knowledge_add(topic_id, filename, content)
37
+ if is_text:
38
+ result = knowledge_add(target_topic_id, filename, content)
35
39
  else:
36
- result = upload_file(topic_id, filepath, filename)
40
+ result = upload_file(target_topic_id, filepath, filename)
37
41
 
38
42
  synced_at = result.get("updated_at") or result.get("created_at") or now_iso()
39
- state.track(result["id"], filename, file_hash, synced_at)
43
+ state.track(result["id"], filename, file_hash, synced_at, topic_id=target_topic_id)
40
44
  state.save()
41
45
 
42
46
  return result
43
47
 
44
48
 
45
- def link_file(topic_id, sync_dir, filepath, knowledge_id):
49
+ def link_file(root_topic_id, sync_dir, filepath, knowledge_id, *, target_topic_id=None):
46
50
  """Link a local file to an existing remote item.
47
51
 
48
52
  Use after renaming a local file to reattach it to its remote item.
49
53
  """
50
54
  sync_dir = Path(sync_dir)
51
55
  filepath = Path(filepath)
56
+ target_topic_id = target_topic_id or root_topic_id
52
57
 
53
58
  if not filepath.exists():
54
59
  raise FileNotFoundError(f"File not found: {filepath}")
55
60
 
56
- state = SyncState.load(topic_id, sync_dir) or SyncState(topic_id, sync_dir)
61
+ state = SyncState.load(root_topic_id, sync_dir) or SyncState(root_topic_id, sync_dir)
57
62
 
58
63
  filename = filepath.name
59
64
  file_hash = hash_file(filepath)
60
- state.track(knowledge_id, filename, file_hash, now_iso())
65
+ state.track(knowledge_id, filename, file_hash, now_iso(), topic_id=target_topic_id)
61
66
  state.save()
@@ -0,0 +1,13 @@
1
+ """Bidirectional sync engine package.
2
+
3
+ Public entry point is ``run_sync``. Internals are split for readability:
4
+
5
+ - ``run.py`` — main sync loop (fetch + dispatch + reconcile)
6
+ - ``subtree.py`` — name-path / ancestor walking and directory reconciliation
7
+ - ``tracked.py`` — per-item sync for items already present in state
8
+ - ``filters.py`` — glob pattern matching for ``--include``
9
+ """
10
+
11
+ from .run import run_sync
12
+
13
+ __all__ = ["run_sync"]
@@ -0,0 +1,62 @@
1
+ """Glob-style include-pattern matching for sync.
2
+
3
+ Patterns match against a topic's name path from the root, slash-joined.
4
+ For example, a topic ``Projects/Obris/Sync`` has path segments
5
+ ``["Projects", "Obris", "Sync"]``.
6
+
7
+ Semantics:
8
+ - ``*``, ``?``, ``[abc]`` are intra-segment wildcards (fnmatch-style).
9
+ They match within a single segment and never cross ``/``.
10
+ - ``**`` is the cross-segment wildcard — zero or more segments.
11
+ - Matching is case-insensitive.
12
+ - A topic matches if *any* of the supplied patterns matches its path.
13
+
14
+ Examples (root is ``Work``):
15
+ - ``Projects/**`` → ``Projects``, ``Projects/Obris``, ``Projects/Obris/Sync``
16
+ - ``**/skill-*`` → any topic whose leaf starts with ``skill-``
17
+ - ``Archive/2024`` → exact match
18
+ - ``Sk*/skill-py*`` → ``Skills/skill-python``
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import fnmatch
24
+
25
+
26
+ def _split_pattern(pattern: str) -> list[str]:
27
+ return [seg for seg in pattern.split("/") if seg != ""]
28
+
29
+
30
+ def _match_segments(path: list[str], pat: list[str]) -> bool:
31
+ """Recursive matcher supporting ``**`` as zero-or-more segments."""
32
+ if not pat:
33
+ return not path
34
+ head, *rest = pat
35
+ if head == "**":
36
+ if _match_segments(path, rest):
37
+ return True
38
+ if not path:
39
+ return False
40
+ return _match_segments(path[1:], pat)
41
+ if not path:
42
+ return False
43
+ if fnmatch.fnmatchcase(path[0].lower(), head.lower()):
44
+ return _match_segments(path[1:], rest)
45
+ return False
46
+
47
+
48
+ def match_path(path_segments: list[str], pattern: str) -> bool:
49
+ """Return True if the topic path matches the single pattern."""
50
+ return _match_segments(list(path_segments), _split_pattern(pattern))
51
+
52
+
53
+ def any_match(path_segments: list[str], patterns: list[str]) -> bool:
54
+ """Return True if any pattern matches the topic path.
55
+
56
+ Callers must special-case empty ``patterns`` themselves — an empty
57
+ pattern list means "no filter configured", which is a policy
58
+ decision (match everything) that shouldn't be baked into the
59
+ matcher semantics. Strict semantics: ``any([...])`` is False for
60
+ empty input, and that's what this returns.
61
+ """
62
+ return any(match_path(path_segments, p) for p in patterns)