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.
- copilottaskmaster-0.1.1.dist-info/METADATA +136 -0
- copilottaskmaster-0.1.1.dist-info/RECORD +12 -0
- copilottaskmaster-0.1.1.dist-info/WHEEL +5 -0
- copilottaskmaster-0.1.1.dist-info/entry_points.txt +3 -0
- copilottaskmaster-0.1.1.dist-info/top_level.txt +1 -0
- taskmaster/__init__.py +39 -0
- taskmaster/_version.py +34 -0
- taskmaster/cli.py +333 -0
- taskmaster/mcp_server.py +496 -0
- taskmaster/search.py +241 -0
- taskmaster/task_manager.py +388 -0
- taskmaster/utils.py +12 -0
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/'."
|