CopilotTaskMaster 0.1.1__py3-none-any.whl

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.
taskmaster/search.py ADDED
@@ -0,0 +1,241 @@
1
+ """
2
+ Task Searcher - Search functionality for task cards
3
+ """
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import List, Dict, Any, Optional, Set
8
+ import frontmatter
9
+
10
+
11
+ class TaskSearcher:
12
+ """Searches markdown task cards efficiently"""
13
+
14
+ def __init__(self, base_path: str):
15
+ """
16
+ Initialize TaskSearcher
17
+
18
+ Args:
19
+ base_path: Root directory for task cards
20
+ """
21
+ self.base_path = Path(base_path).resolve()
22
+
23
+ def search(
24
+ self,
25
+ query: str = "",
26
+ metadata_filters: Optional[Dict[str, Any]] = None,
27
+ path_pattern: str = "",
28
+ max_results: int = 50,
29
+ include_content: bool = False
30
+ ) -> List[Dict[str, Any]]:
31
+ """
32
+ Search tasks with text and metadata filters
33
+
34
+ Args:
35
+ query: Text to search in title and content
36
+ metadata_filters: Dict of metadata key-value pairs to filter by
37
+ path_pattern: Glob pattern to filter paths (e.g., 'project1/**')
38
+ max_results: Maximum number of results to return
39
+ include_content: Include full content in results (token-expensive)
40
+
41
+ Returns:
42
+ List of matching tasks with relevance scores
43
+ """
44
+ if not self.base_path.exists():
45
+ return []
46
+
47
+ pattern = f"{path_pattern}/*.md" if path_pattern and not path_pattern.endswith('.md') else "**/*.md"
48
+ results = []
49
+ query_lower = query.lower() if query else ""
50
+
51
+ for md_file in self.base_path.glob(pattern):
52
+ try:
53
+ with open(md_file, 'r', encoding='utf-8') as f:
54
+ post = frontmatter.load(f)
55
+
56
+ # Apply metadata filters
57
+ if metadata_filters:
58
+ if not self._matches_metadata(post.metadata, metadata_filters):
59
+ continue
60
+
61
+ # Calculate relevance score
62
+ score = 0
63
+ title = post.get('title', '')
64
+ content = post.content
65
+
66
+ if query_lower:
67
+ # Title matches are weighted higher
68
+ if query_lower in title.lower():
69
+ score += 10
70
+
71
+ # Content matches
72
+ content_matches = content.lower().count(query_lower)
73
+ score += content_matches
74
+
75
+ # Skip if no matches
76
+ if score == 0:
77
+ continue
78
+ else:
79
+ # If no query, all filtered tasks get score 1
80
+ score = 1
81
+
82
+ result = {
83
+ 'path': md_file.relative_to(self.base_path).as_posix(),
84
+ 'title': title,
85
+ 'score': score,
86
+ 'metadata': post.metadata
87
+ }
88
+
89
+ if include_content:
90
+ result['content'] = content
91
+ else:
92
+ # Include a snippet if there's a query match
93
+ if query_lower and query_lower in content.lower():
94
+ result['snippet'] = self._extract_snippet(content, query_lower)
95
+
96
+ results.append(result)
97
+
98
+ # Early exit for performance: stop scanning once we have enough results
99
+ if len(results) >= max_results:
100
+ break
101
+
102
+ except Exception as e:
103
+ continue
104
+
105
+ # Sort by relevance score
106
+ results.sort(key=lambda x: x['score'], reverse=True)
107
+
108
+ return results[:max_results]
109
+
110
+ def search_by_tags(
111
+ self,
112
+ tags: List[str],
113
+ match_all: bool = False,
114
+ max_results: int = 50
115
+ ) -> List[Dict[str, Any]]:
116
+ """
117
+ Search tasks by tags
118
+
119
+ Args:
120
+ tags: List of tags to search for
121
+ match_all: If True, task must have all tags. If False, any tag matches
122
+ max_results: Maximum number of results
123
+
124
+ Returns:
125
+ List of matching tasks
126
+ """
127
+ tags_lower = [tag.lower() for tag in tags]
128
+ results = []
129
+ if not tags_lower:
130
+ return results
131
+
132
+ for md_file in self.base_path.glob("**/*.md"):
133
+ try:
134
+ with open(md_file, 'r', encoding='utf-8') as f:
135
+ post = frontmatter.load(f)
136
+
137
+ if self._matches_metadata(post.metadata, {'tags': tags_lower}, match_all=match_all):
138
+ results.append({
139
+ 'path': md_file.relative_to(self.base_path).as_posix(),
140
+ 'title': post.get('title', ''),
141
+ 'metadata': post.metadata
142
+ })
143
+
144
+ if len(results) >= max_results:
145
+ break
146
+ except Exception:
147
+ continue
148
+
149
+ return results
150
+
151
+ def get_all_tags(self, project: Optional[str] = None) -> Set[str]:
152
+ """
153
+ Get all unique tags, optionally scoped to a project directory
154
+
155
+ Args:
156
+ project: Optional project name to scope tag collection
157
+
158
+ Returns:
159
+ Set of all tags
160
+ """
161
+ tags = set()
162
+
163
+ # Determine search root based on project param
164
+ if project:
165
+ search_root = self.base_path / project
166
+ if not search_root.exists():
167
+ return tags
168
+ iterator = search_root.glob("**/*.md")
169
+ else:
170
+ iterator = self.base_path.glob("**/*.md")
171
+
172
+ for md_file in iterator:
173
+ try:
174
+ with open(md_file, 'r', encoding='utf-8') as f:
175
+ post = frontmatter.load(f)
176
+
177
+ task_tags = post.metadata.get('tags', [])
178
+ if isinstance(task_tags, str):
179
+ task_tags = [task_tags]
180
+ # Normalize tags to lowercase strings
181
+ tags.update(str(t).lower() for t in task_tags)
182
+ except Exception:
183
+ continue
184
+
185
+ return tags
186
+
187
+ def _matches_metadata(self, metadata: Dict[str, Any], filters: Dict[str, Any], match_all: bool = False) -> bool:
188
+ """Check if metadata matches all filters (case-insensitive, flexible list/scalar handling)
189
+
190
+ For list-valued filters:
191
+ - if match_all is False (default): return True if any filter value is present in metadata
192
+ - if match_all is True: return True only if all filter values are present in metadata
193
+ """
194
+ for key, value in filters.items():
195
+ if key not in metadata:
196
+ return False
197
+
198
+ meta_value = metadata[key]
199
+
200
+ # Normalize metadata values to list of lowercase strings
201
+ if isinstance(meta_value, str):
202
+ meta_list = [meta_value.lower()]
203
+ elif isinstance(meta_value, list):
204
+ meta_list = [str(v).lower() for v in meta_value]
205
+ else:
206
+ meta_list = [str(meta_value).lower()]
207
+
208
+ # Normalize filter values to list of lowercase strings
209
+ if isinstance(value, list):
210
+ filter_list = [str(v).lower() for v in value]
211
+ if match_all:
212
+ if not all(f in meta_list for f in filter_list):
213
+ return False
214
+ else:
215
+ if not any(f in meta_list for f in filter_list):
216
+ return False
217
+ else:
218
+ if str(value).lower() not in meta_list:
219
+ return False
220
+
221
+ return True
222
+
223
+ def _extract_snippet(self, content: str, query: str, context_chars: int = 100) -> str:
224
+ """Extract a snippet around the query match"""
225
+ query_lower = query.lower()
226
+ content_lower = content.lower()
227
+
228
+ index = content_lower.find(query_lower)
229
+ if index == -1:
230
+ return content[:200] + "..." if len(content) > 200 else content
231
+
232
+ start = max(0, index - context_chars)
233
+ end = min(len(content), index + len(query) + context_chars)
234
+
235
+ snippet = content[start:end]
236
+ if start > 0:
237
+ snippet = "..." + snippet
238
+ if end < len(content):
239
+ snippet = snippet + "..."
240
+
241
+ return snippet
@@ -0,0 +1,388 @@
1
+ """
2
+ Task Manager - Core functionality for managing markdown task cards
3
+ """
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Any
9
+ from datetime import datetime
10
+ import frontmatter
11
+
12
+
13
+ class TaskManager:
14
+ """Manages markdown task cards in a hierarchical folder structure"""
15
+
16
+ def __init__(self, base_path: str = None):
17
+ """
18
+ Initialize TaskManager
19
+
20
+ Args:
21
+ base_path: Root directory for task cards. Defaults to TASKMASTER_TASKS_DIR env var or ./tasks
22
+ """
23
+ if base_path is None:
24
+ base_path = os.environ.get('TASKMASTER_TASKS_DIR', './tasks')
25
+ self.base_path = Path(base_path).resolve()
26
+ self.base_path.mkdir(parents=True, exist_ok=True)
27
+
28
+ def _resolve_path(self, project: Optional[str], path: str):
29
+ """Resolve a (project, path) pair into a filesystem Path and a POSIX relative path.
30
+
31
+ Rules:
32
+ - If `path` includes a top-level folder (e.g., 'project/file.md'), that folder will be
33
+ treated as the project when `project` is not specified.
34
+ - If `project` is provided and `path` also includes a different project prefix, a
35
+ ValueError is raised to avoid ambiguity.
36
+ - If neither a project argument nor a project prefix in the path is available, a
37
+ ValueError is raised — callers must explicitly provide a project.
38
+ - `path` must be relative and must not contain parent ('..') references.
39
+ """
40
+ p = Path(path)
41
+ if p.is_absolute():
42
+ raise ValueError("path must be relative")
43
+ if any(part == ".." for part in p.parts):
44
+ raise ValueError("path must not contain parent references")
45
+
46
+ # If project is not provided, the first path part is treated as project when present.
47
+ if project is None:
48
+ if len(p.parts) >= 2:
49
+ project = p.parts[0]
50
+ rest = Path(*p.parts[1:])
51
+ else:
52
+ # No project specified anywhere: error out — callers must be explicit
53
+ raise ValueError("project must be specified either as argument or as the top-level folder in path")
54
+ else:
55
+ # Project was provided explicitly — interpret the path relative to that project.
56
+ # If the path redundantly includes the same project prefix (e.g., 'proj/file'), strip it.
57
+ if len(p.parts) >= 2 and p.parts[0] == project:
58
+ rest = Path(*p.parts[1:])
59
+ else:
60
+ rest = p
61
+
62
+ full_path = self.base_path / project / rest
63
+ relative_posix = full_path.relative_to(self.base_path).as_posix()
64
+ return full_path, relative_posix
65
+
66
+ def create_task(
67
+ self,
68
+ path: str,
69
+ title: str,
70
+ content: str = "",
71
+ metadata: Optional[Dict[str, Any]] = None,
72
+ project: Optional[str] = None
73
+ ) -> Dict[str, Any]:
74
+ """
75
+ Create a new task card
76
+
77
+ Args:
78
+ path: Relative path within project (e.g., 'task1.md') or with project prefix ('project1/task1.md')
79
+ project: Optional project name. If omitted, project will be inferred from the path or
80
+ `default_project` will be used if configured.
81
+ title: Task title
82
+ content: Task content (markdown)
83
+ metadata: Additional metadata (status, priority, tags, etc.)
84
+
85
+ Returns:
86
+ Dict with task information
87
+ """
88
+ full_path, rel_path = self._resolve_path(project, path)
89
+
90
+ # Ensure parent directories exist
91
+ full_path.parent.mkdir(parents=True, exist_ok=True)
92
+
93
+ # Create frontmatter document
94
+ post = frontmatter.Post(content)
95
+ post['title'] = title
96
+ post['created'] = datetime.now().isoformat()
97
+ post['updated'] = datetime.now().isoformat()
98
+
99
+ if metadata:
100
+ post.metadata.update(metadata)
101
+
102
+ # Write to file
103
+ with open(full_path, 'w', encoding='utf-8') as f:
104
+ f.write(frontmatter.dumps(post))
105
+
106
+ return {
107
+ 'path': rel_path,
108
+ 'title': title,
109
+ 'created': post['created'],
110
+ 'metadata': post.metadata
111
+ }
112
+
113
+ def read_task(self, path: str, project: Optional[str] = None) -> Optional[Dict[str, Any]]:
114
+ """
115
+ Read a task card
116
+
117
+ Args:
118
+ path: Relative path within project or with project prefix
119
+ project: Optional project name to scope the read
120
+
121
+ Returns:
122
+ Dict with task information or None if not found
123
+ """
124
+ full_path, rel_path = self._resolve_path(project, path)
125
+
126
+ if not full_path.exists():
127
+ return None
128
+
129
+ with open(full_path, 'r', encoding='utf-8') as f:
130
+ post = frontmatter.load(f)
131
+
132
+ return {
133
+ 'path': rel_path,
134
+ 'title': post.get('title', ''),
135
+ 'content': post.content,
136
+ 'metadata': post.metadata
137
+ }
138
+
139
+ def update_task(
140
+ self,
141
+ path: str,
142
+ title: Optional[str] = None,
143
+ content: Optional[str] = None,
144
+ metadata: Optional[Dict[str, Any]] = None,
145
+ project: Optional[str] = None
146
+ ) -> Optional[Dict[str, Any]]:
147
+ """
148
+ Update an existing task card
149
+
150
+ Args:
151
+ path: Relative path within project or with project prefix
152
+ project: Optional project name to scope the update
153
+ title: New title (optional)
154
+ content: New content (optional)
155
+ metadata: Metadata to update (merged with existing)
156
+
157
+ Returns:
158
+ Dict with updated task information or None if not found
159
+ """
160
+ full_path, rel_path = self._resolve_path(project, path)
161
+
162
+ if not full_path.exists():
163
+ return None
164
+
165
+ with open(full_path, 'r', encoding='utf-8') as f:
166
+ post = frontmatter.load(f)
167
+
168
+ if title is not None:
169
+ post['title'] = title
170
+
171
+ if content is not None:
172
+ post.content = content
173
+
174
+ if metadata:
175
+ post.metadata.update(metadata)
176
+
177
+ post['updated'] = datetime.now().isoformat()
178
+
179
+ with open(full_path, 'w', encoding='utf-8') as f:
180
+ f.write(frontmatter.dumps(post))
181
+
182
+ return {
183
+ 'path': rel_path,
184
+ 'title': post.get('title', ''),
185
+ 'content': post.content,
186
+ 'metadata': post.metadata
187
+ }
188
+
189
+ def delete_task(self, path: str, project: Optional[str] = None) -> bool:
190
+ """
191
+ Delete a task card
192
+
193
+ Args:
194
+ path: Relative path within project or with project prefix
195
+ project: Optional project name to scope the delete
196
+
197
+ Returns:
198
+ True if deleted, False if not found
199
+ """
200
+ full_path, rel_path = self._resolve_path(project, path)
201
+
202
+ if not full_path.exists():
203
+ return False
204
+
205
+ full_path.unlink()
206
+
207
+ # Clean up empty parent directories
208
+ try:
209
+ parent = full_path.parent
210
+ while parent != self.base_path and not any(parent.iterdir()):
211
+ parent.rmdir()
212
+ parent = parent.parent
213
+ except Exception:
214
+ pass
215
+
216
+ return True
217
+
218
+ def list_tasks(
219
+ self,
220
+ subpath: str = "",
221
+ recursive: bool = True,
222
+ include_content: bool = False,
223
+ project: Optional[str] = None
224
+ ) -> List[Dict[str, Any]]:
225
+ """
226
+ List all tasks in a directory
227
+
228
+ Args:
229
+ subpath: Subdirectory within project or a project name when `project` is omitted
230
+ recursive: Include subdirectories
231
+ include_content: Include full content in results (token-expensive)
232
+ project: Optional project name to scope the listing
233
+
234
+ Returns:
235
+ List of task dictionaries
236
+ """
237
+ # Resolve search path based on project and subpath
238
+ if subpath:
239
+ sp = Path(subpath)
240
+ if project is None:
241
+ # If subpath looks like 'project/...' or a single project name, take it as the project
242
+ if len(sp.parts) >= 2:
243
+ project = sp.parts[0]
244
+ sub = Path(*sp.parts[1:])
245
+ else:
246
+ project = sp.parts[0]
247
+ sub = Path()
248
+ else:
249
+ sub = Path(subpath)
250
+ else:
251
+ sub = Path()
252
+
253
+ if project is None:
254
+ search_path = self.base_path
255
+ else:
256
+ search_path = self.base_path / project / sub
257
+
258
+ if not search_path.exists():
259
+ return []
260
+
261
+ pattern = "**/*.md" if recursive else "*.md"
262
+ tasks = []
263
+
264
+ for md_file in search_path.glob(pattern):
265
+ try:
266
+ with open(md_file, 'r', encoding='utf-8') as f:
267
+ post = frontmatter.load(f)
268
+
269
+ task_info = {
270
+ 'path': md_file.relative_to(self.base_path).as_posix(),
271
+ 'title': post.get('title', ''),
272
+ 'metadata': post.metadata
273
+ }
274
+
275
+ if include_content:
276
+ task_info['content'] = post.content
277
+
278
+ tasks.append(task_info)
279
+ except Exception as e:
280
+ # Skip files that can't be parsed
281
+ continue
282
+
283
+ return tasks
284
+
285
+ def get_structure(self, subpath: str = "", project: Optional[str] = None) -> Dict[str, Any]:
286
+ """
287
+ Get the hierarchical structure of tasks
288
+
289
+ Args:
290
+ subpath: Subdirectory within base_path or a project/subpath when `project` is omitted
291
+ project: Optional project name to scope the structure
292
+
293
+ Returns:
294
+ Nested dictionary representing folder structure
295
+ """
296
+ # Resolve search path similarly to `list_tasks` to support the same CLI semantics
297
+ if subpath:
298
+ sp = Path(subpath)
299
+ if project is None:
300
+ # If subpath looks like 'project/...' or a single project name, take it as the project
301
+ if len(sp.parts) >= 2:
302
+ project = sp.parts[0]
303
+ sub = Path(*sp.parts[1:])
304
+ else:
305
+ project = sp.parts[0]
306
+ sub = Path()
307
+ else:
308
+ sub = Path(subpath)
309
+ else:
310
+ sub = Path()
311
+
312
+ # Validate relative path usage
313
+ if any(part == ".." for part in sub.parts):
314
+ raise ValueError("path must not contain parent references")
315
+
316
+ if project is None:
317
+ search_path = self.base_path
318
+ else:
319
+ search_path = self.base_path / project / sub
320
+
321
+ if project is not None and not search_path.exists():
322
+ # Signal a project resolution failure to callers (CLI / MCP handlers expect ValueError)
323
+ raise ValueError(f"project '{project}' not found")
324
+
325
+ def build_tree(path: Path) -> Dict[str, Any]:
326
+ tree = {
327
+ 'type': 'directory',
328
+ 'name': path.name or 'root',
329
+ 'children': []
330
+ }
331
+
332
+ try:
333
+ for item in sorted(path.iterdir()):
334
+ if item.is_dir():
335
+ tree['children'].append(build_tree(item))
336
+ elif item.suffix == '.md':
337
+ try:
338
+ with open(item, 'r', encoding='utf-8') as f:
339
+ post = frontmatter.load(f)
340
+
341
+ tree['children'].append({
342
+ 'type': 'task',
343
+ 'name': item.name,
344
+ 'path': item.relative_to(self.base_path).as_posix(),
345
+ 'title': post.get('title', ''),
346
+ 'metadata': {k: v for k, v in post.metadata.items()
347
+ if k in ['status', 'priority', 'tags']}
348
+ })
349
+ except Exception:
350
+ continue
351
+ except Exception:
352
+ pass
353
+
354
+ return tree
355
+
356
+ return build_tree(search_path)
357
+
358
+ def move_task(self, old_path: str, new_path: str, project: Optional[str] = None) -> bool:
359
+ """
360
+ Move/rename a task card
361
+
362
+ Args:
363
+ old_path: Current relative path within project or with project prefix
364
+ new_path: New relative path within project or with project prefix
365
+ project: Optional project name to scope the move (applies to both paths if provided)
366
+
367
+ Returns:
368
+ True if moved, False if source not found or destination exists
369
+ """
370
+ old_full, old_rel = self._resolve_path(project, old_path)
371
+ new_full, new_rel = self._resolve_path(project, new_path)
372
+
373
+ if not old_full.exists() or new_full.exists():
374
+ return False
375
+
376
+ new_full.parent.mkdir(parents=True, exist_ok=True)
377
+ old_full.rename(new_full)
378
+
379
+ # Clean up empty parent directories
380
+ try:
381
+ parent = old_full.parent
382
+ while parent != self.base_path and not any(parent.iterdir()):
383
+ parent.rmdir()
384
+ parent = parent.parent
385
+ except Exception:
386
+ pass
387
+
388
+ return True
taskmaster/utils.py ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ Utilities for TaskMaster shared by CLI and MCP adapters
3
+ """
4
+ from typing import Any
5
+
6
+
7
+ def project_resolution_error_msg(exc: Exception) -> str:
8
+ """Format a consistent user-facing error message when project resolution fails.
9
+
10
+ Returns a short sentence that suggests remedies (use `--project` or prefix the path).
11
+ """
12
+ return f"✗ {exc}. Provide a project with `--project <name>` or prefix the path with 'project/'."