crowdtime-cli 0.1.0__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.
@@ -0,0 +1,266 @@
1
+ """Skill commands: install agent skills for LLM coding tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from ..formatters import format_error, format_success
15
+
16
+ app = typer.Typer(name="skill", help="Manage CrowdTime agent skills for LLM coding tools.")
17
+ console = Console()
18
+
19
+ # Bundled skill files live next to this package
20
+ SKILLS_SOURCE = Path(__file__).resolve().parent.parent / "skills" / "crowdtime"
21
+
22
+
23
+ class Tool(str, Enum):
24
+ claude = "claude"
25
+ codex = "codex"
26
+ gemini = "gemini"
27
+ cursor = "cursor"
28
+
29
+
30
+ # Maps tool name -> (global skills dir, project skills dir pattern)
31
+ TOOL_PATHS: dict[str, tuple[Path, str]] = {
32
+ "claude": (
33
+ Path.home() / ".claude" / "skills",
34
+ ".claude/skills",
35
+ ),
36
+ "codex": (
37
+ Path.home() / ".codex" / "skills",
38
+ ".agents/skills",
39
+ ),
40
+ "gemini": (
41
+ Path.home() / ".gemini" / "skills",
42
+ ".gemini/skills",
43
+ ),
44
+ "cursor": (
45
+ # Cursor doesn't have a global skills dir; uses project-level only
46
+ Path.home() / ".cursor" / "skills",
47
+ ".cursor-plugin/skills",
48
+ ),
49
+ }
50
+
51
+ SKILL_NAME = "crowdtime"
52
+
53
+
54
+ def _detect_installed_tools() -> list[str]:
55
+ """Detect which LLM coding tools are installed by checking config dirs."""
56
+ found = []
57
+ indicators = {
58
+ "claude": [Path.home() / ".claude"],
59
+ "codex": [Path.home() / ".codex"],
60
+ "gemini": [Path.home() / ".gemini"],
61
+ "cursor": [Path.home() / ".cursor"],
62
+ }
63
+ for tool, paths in indicators.items():
64
+ if any(p.exists() for p in paths):
65
+ found.append(tool)
66
+ return found
67
+
68
+
69
+ def _get_project_dir() -> Path:
70
+ """Return the current working directory for project-level installs."""
71
+ return Path.cwd()
72
+
73
+
74
+ def _install_skill(target_dir: Path) -> tuple[bool, str]:
75
+ """Copy bundled skill files to the target directory.
76
+
77
+ Returns (success, message).
78
+ """
79
+ if not SKILLS_SOURCE.exists():
80
+ return False, "Bundled skill files not found. Reinstall crowdtime-cli."
81
+
82
+ dest = target_dir / SKILL_NAME
83
+ already_exists = dest.exists()
84
+
85
+ # Copy skill tree
86
+ if dest.exists():
87
+ shutil.rmtree(dest)
88
+ shutil.copytree(SKILLS_SOURCE, dest)
89
+
90
+ verb = "Updated" if already_exists else "Installed"
91
+ return True, f"{verb} skill at {dest}"
92
+
93
+
94
+ @app.command("install")
95
+ def install(
96
+ tool: Optional[Tool] = typer.Option(
97
+ None, "--tool", "-t",
98
+ help="Target tool (claude, codex, gemini, cursor). Auto-detects if omitted.",
99
+ ),
100
+ global_install: bool = typer.Option(
101
+ False, "--global", "-g",
102
+ help="Install to user-wide skills directory (~/.claude/skills/, etc.).",
103
+ ),
104
+ all_tools: bool = typer.Option(
105
+ False, "--all", "-a",
106
+ help="Install for all detected tools.",
107
+ ),
108
+ ) -> None:
109
+ """Install the CrowdTime skill for your LLM coding tool.
110
+
111
+ By default installs to the current project. Use --global for user-wide install.
112
+
113
+ Examples:
114
+ ct skill install # auto-detect tool, project-level
115
+ ct skill install --global # auto-detect tool, user-wide
116
+ ct skill install --tool claude # explicit tool
117
+ ct skill install --all --global # all detected tools, user-wide
118
+ """
119
+ if not SKILLS_SOURCE.exists():
120
+ format_error("Bundled skill files not found. Reinstall crowdtime-cli.")
121
+ raise typer.Exit(1)
122
+
123
+ # Determine which tools to install for
124
+ if tool:
125
+ tools = [tool.value]
126
+ elif all_tools:
127
+ tools = _detect_installed_tools()
128
+ if not tools:
129
+ format_error(
130
+ "No LLM coding tools detected. "
131
+ "Use --tool to specify one (claude, codex, gemini, cursor)."
132
+ )
133
+ raise typer.Exit(1)
134
+ else:
135
+ # Auto-detect: pick the first one found
136
+ tools = _detect_installed_tools()
137
+ if not tools:
138
+ format_error(
139
+ "No LLM coding tools detected.\n"
140
+ " Looked for: ~/.claude, ~/.codex, ~/.gemini, ~/.cursor\n"
141
+ " Use --tool to specify one explicitly."
142
+ )
143
+ raise typer.Exit(1)
144
+ # Default to first detected
145
+ tools = [tools[0]]
146
+
147
+ installed_count = 0
148
+
149
+ for t in tools:
150
+ global_dir, project_pattern = TOOL_PATHS[t]
151
+
152
+ if global_install:
153
+ target = global_dir
154
+ else:
155
+ target = _get_project_dir() / project_pattern
156
+
157
+ success, message = _install_skill(target)
158
+ if success:
159
+ console.print(f"[bold green]{t}:[/bold green] {message}")
160
+ installed_count += 1
161
+ else:
162
+ console.print(f"[bold red]{t}:[/bold red] {message}")
163
+
164
+ if installed_count == 0:
165
+ raise typer.Exit(1)
166
+
167
+ # Show usage hint
168
+ console.print()
169
+ if "claude" in tools:
170
+ console.print("[dim]Claude Code: skill auto-activates or use /crowdtime[/dim]")
171
+ if "codex" in tools:
172
+ console.print("[dim]Codex: skill auto-activates or use $crowdtime[/dim]")
173
+ if "gemini" in tools:
174
+ console.print("[dim]Gemini: skill auto-activates when time tracking is mentioned[/dim]")
175
+ if "cursor" in tools:
176
+ console.print("[dim]Cursor: skill auto-activates in agent mode[/dim]")
177
+
178
+
179
+ @app.command("uninstall")
180
+ def uninstall(
181
+ tool: Optional[Tool] = typer.Option(
182
+ None, "--tool", "-t",
183
+ help="Target tool. Auto-detects if omitted.",
184
+ ),
185
+ global_install: bool = typer.Option(
186
+ False, "--global", "-g",
187
+ help="Remove from user-wide skills directory.",
188
+ ),
189
+ all_tools: bool = typer.Option(
190
+ False, "--all", "-a",
191
+ help="Remove from all detected tools.",
192
+ ),
193
+ ) -> None:
194
+ """Remove the CrowdTime skill from your LLM coding tool."""
195
+ if tool:
196
+ tools = [tool.value]
197
+ elif all_tools:
198
+ tools = list(TOOL_PATHS.keys())
199
+ else:
200
+ tools = _detect_installed_tools() or list(TOOL_PATHS.keys())
201
+
202
+ removed = 0
203
+ for t in tools:
204
+ global_dir, project_pattern = TOOL_PATHS[t]
205
+
206
+ if global_install:
207
+ target = global_dir / SKILL_NAME
208
+ else:
209
+ target = _get_project_dir() / project_pattern / SKILL_NAME
210
+
211
+ if target.exists():
212
+ shutil.rmtree(target)
213
+ console.print(f"[bold green]{t}:[/bold green] Removed skill from {target}")
214
+ removed += 1
215
+
216
+ if removed == 0:
217
+ console.print("[dim]No installed skills found to remove.[/dim]")
218
+
219
+
220
+ @app.command("status")
221
+ def status() -> None:
222
+ """Show where the CrowdTime skill is installed."""
223
+ table = Table(title="CrowdTime Skill Status")
224
+ table.add_column("Tool", style="bold")
225
+ table.add_column("Scope")
226
+ table.add_column("Path")
227
+ table.add_column("Status")
228
+
229
+ project_dir = _get_project_dir()
230
+
231
+ for tool_name, (global_dir, project_pattern) in TOOL_PATHS.items():
232
+ # Check global
233
+ global_skill = global_dir / SKILL_NAME
234
+ if global_skill.exists():
235
+ table.add_row(
236
+ tool_name, "global", str(global_skill),
237
+ "[green]installed[/green]",
238
+ )
239
+ else:
240
+ table.add_row(
241
+ tool_name, "global", str(global_skill),
242
+ "[dim]not installed[/dim]",
243
+ )
244
+
245
+ # Check project
246
+ project_skill = project_dir / project_pattern / SKILL_NAME
247
+ if project_skill.exists():
248
+ table.add_row(
249
+ "", "project", str(project_skill),
250
+ "[green]installed[/green]",
251
+ )
252
+ else:
253
+ table.add_row(
254
+ "", "project", str(project_skill),
255
+ "[dim]not installed[/dim]",
256
+ )
257
+
258
+ console.print()
259
+ console.print(table)
260
+ console.print()
261
+
262
+ detected = _detect_installed_tools()
263
+ if detected:
264
+ console.print(f"[dim]Detected tools: {', '.join(detected)}[/dim]")
265
+ else:
266
+ console.print("[dim]No LLM coding tools detected on this machine.[/dim]")
@@ -0,0 +1,101 @@
1
+ """Task commands: list, create."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ..client import APIError, CrowdTimeClient
12
+ from ..formatters import format_error, format_success, print_json
13
+ from ..models import Task
14
+
15
+ app = typer.Typer(name="tasks", help="Manage tasks.")
16
+ console = Console()
17
+
18
+
19
+ @app.command("list")
20
+ def list_tasks(
21
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Filter by project."),
22
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
23
+ ) -> None:
24
+ """List available tasks."""
25
+ client = CrowdTimeClient(require_auth=True, require_org=True)
26
+
27
+ params: dict = {}
28
+ if project:
29
+ params["project"] = project
30
+
31
+ try:
32
+ data = client.get("/tasks/", params=params)
33
+ tasks_list = data if isinstance(data, list) else data.get("results", [])
34
+ tasks = [Task(**item) for item in tasks_list]
35
+
36
+ if output_json:
37
+ print_json(tasks)
38
+ return
39
+
40
+ if not tasks:
41
+ console.print("[dim]No tasks found.[/dim]")
42
+ return
43
+
44
+ table = Table(show_header=True, header_style="bold")
45
+ table.add_column("ID", style="dim")
46
+ table.add_column("Name", width=25)
47
+ table.add_column("Project", style="cyan", width=15)
48
+ table.add_column("Billable", justify="center", width=8)
49
+
50
+ for t in tasks:
51
+ table.add_row(
52
+ str(t.id),
53
+ t.name,
54
+ t.project_name or "",
55
+ "[green]$[/green]" if t.is_billable else "",
56
+ )
57
+
58
+ console.print(table)
59
+ except APIError as e:
60
+ format_error(e.message)
61
+ raise typer.Exit(1)
62
+
63
+
64
+ @app.command()
65
+ def create(
66
+ name: str = typer.Argument(..., help="Task name."),
67
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project to assign to."),
68
+ billable: bool = typer.Option(True, "--billable/--no-billable", "-b/-B",
69
+ help="Mark as billable."),
70
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
71
+ ) -> None:
72
+ """Create a new task. If --project is specified, also assigns it to that project."""
73
+ client = CrowdTimeClient(require_auth=True, require_org=True)
74
+
75
+ payload: dict = {"name": name, "is_billable": billable}
76
+
77
+ try:
78
+ data = client.post("/tasks/", data=payload)
79
+ task = Task(**data)
80
+
81
+ # If a project is specified, assign the task to it
82
+ if project:
83
+ try:
84
+ client.post(f"/projects/{project}/tasks/", data={"task": task.id})
85
+ except APIError as e:
86
+ # Task was created but assignment failed — warn, don't fail
87
+ console.print(
88
+ f"[yellow]Warning:[/yellow] Task created but could not assign "
89
+ f"to project '{project}': {e.message}"
90
+ )
91
+
92
+ if output_json:
93
+ print_json(task)
94
+ else:
95
+ msg = f"Task '{task.name}' created (ID: {task.id})"
96
+ if project:
97
+ msg += f" and assigned to project"
98
+ format_success(msg)
99
+ except APIError as e:
100
+ format_error(e.message)
101
+ raise typer.Exit(1)
@@ -0,0 +1,207 @@
1
+ """Timer commands: start, stop, status, switch, discard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from ..client import APIError, CrowdTimeClient
11
+ from ..formatters import format_entry_summary, format_error, format_success, format_timer, print_json
12
+ from ..models import TimeEntry
13
+ from ..resolvers import resolve_task
14
+
15
+ app = typer.Typer(name="timer", help="Timer commands for tracking time.")
16
+ console = Console()
17
+
18
+
19
+ @app.command()
20
+ def start(
21
+ description: str = typer.Argument("", help="What are you working on?"),
22
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name or ID."),
23
+ task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
24
+ billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
25
+ help="Mark as billable/non-billable."),
26
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
27
+ ) -> None:
28
+ """Start a new timer.
29
+
30
+ If a timer is already running, you'll be asked to stop it first.
31
+ Use 'ct timer switch' to stop and start in one step.
32
+ """
33
+ client = CrowdTimeClient(require_auth=True, require_org=True)
34
+
35
+ project_id = project or client.config.default_project
36
+ if not project_id:
37
+ format_error("Project is required. Use -p <project-id> or set a default with: ct projects switch <slug>")
38
+ raise typer.Exit(1)
39
+
40
+ payload: dict = {
41
+ "project_id": project_id,
42
+ }
43
+ if description:
44
+ payload["notes"] = description
45
+ if task:
46
+ try:
47
+ payload["task_id"] = resolve_task(client, task, project_id=project_id)
48
+ except APIError as e:
49
+ format_error(f"Failed to resolve task '{task}': {e.message}")
50
+ raise typer.Exit(1)
51
+ if billable is not None:
52
+ payload["is_billable"] = billable
53
+
54
+ try:
55
+ data = client.post("/time/start/", data=payload)
56
+ entry = TimeEntry(**data)
57
+
58
+ if output_json:
59
+ print_json(entry)
60
+ else:
61
+ format_success("Timer started")
62
+ format_timer(entry)
63
+ except APIError as e:
64
+ format_error(e.message)
65
+ raise typer.Exit(1)
66
+
67
+
68
+ @app.command()
69
+ def stop(
70
+ note: Optional[str] = typer.Option(None, "--note", "-n", help="Add a note to the entry."),
71
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
72
+ ) -> None:
73
+ """Stop the currently running timer."""
74
+ client = CrowdTimeClient(require_auth=True, require_org=True)
75
+
76
+ payload: dict = {}
77
+ if note:
78
+ payload["note"] = note
79
+
80
+ try:
81
+ data = client.post("/time/stop/", data=payload if payload else None)
82
+ entry = TimeEntry(**data)
83
+
84
+ if output_json:
85
+ print_json(entry)
86
+ else:
87
+ format_success("Timer stopped")
88
+ format_entry_summary(entry)
89
+ except APIError as e:
90
+ if e.status_code == 404:
91
+ format_error("No timer is currently running.")
92
+ else:
93
+ format_error(e.message)
94
+ raise typer.Exit(1)
95
+
96
+
97
+ @app.command()
98
+ def status(
99
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
100
+ ) -> None:
101
+ """Show the currently running timer."""
102
+ client = CrowdTimeClient(require_auth=True, require_org=True)
103
+
104
+ try:
105
+ data = client.get("/time/running/")
106
+ entry = TimeEntry(**data)
107
+
108
+ if output_json:
109
+ print_json(entry)
110
+ else:
111
+ format_timer(entry)
112
+ except APIError as e:
113
+ if e.status_code == 404:
114
+ console.print("[dim]No timer is currently running.[/dim]")
115
+ else:
116
+ format_error(e.message)
117
+ raise typer.Exit(1)
118
+
119
+
120
+ @app.command()
121
+ def switch(
122
+ description: str = typer.Argument("", help="What are you switching to?"),
123
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name or ID."),
124
+ task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
125
+ billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
126
+ help="Mark as billable/non-billable."),
127
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
128
+ ) -> None:
129
+ """Stop the current timer and start a new one."""
130
+ client = CrowdTimeClient(require_auth=True, require_org=True)
131
+
132
+ # Stop current timer
133
+ try:
134
+ stop_data = client.post("/time/stop/")
135
+ stopped = TimeEntry(**stop_data)
136
+ if not output_json:
137
+ console.print(f"[dim]Stopped: {stopped.description or '(no description)'} "
138
+ f"({stopped.project_name or ''})[/dim]")
139
+ except APIError as e:
140
+ if e.status_code != 404:
141
+ format_error(f"Could not stop current timer: {e.message}")
142
+ raise typer.Exit(1)
143
+
144
+ # Start new timer
145
+ project_id = project or client.config.default_project
146
+ if not project_id:
147
+ format_error("Project is required. Use -p <project-id> or set a default with: ct projects switch <slug>")
148
+ raise typer.Exit(1)
149
+
150
+ payload: dict = {
151
+ "project_id": project_id,
152
+ }
153
+ if description:
154
+ payload["notes"] = description
155
+ if task:
156
+ try:
157
+ payload["task_id"] = resolve_task(client, task, project_id=project_id)
158
+ except APIError as e:
159
+ format_error(f"Failed to resolve task '{task}': {e.message}")
160
+ raise typer.Exit(1)
161
+ if billable is not None:
162
+ payload["is_billable"] = billable
163
+
164
+ try:
165
+ data = client.post("/time/start/", data=payload)
166
+ entry = TimeEntry(**data)
167
+
168
+ if output_json:
169
+ print_json(entry)
170
+ else:
171
+ format_success("Switched timer")
172
+ format_timer(entry)
173
+ except APIError as e:
174
+ format_error(e.message)
175
+ raise typer.Exit(1)
176
+
177
+
178
+ @app.command()
179
+ def discard(
180
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
181
+ ) -> None:
182
+ """Discard the running timer without saving."""
183
+ client = CrowdTimeClient(require_auth=True, require_org=True)
184
+
185
+ # Get the running entry
186
+ try:
187
+ data = client.get("/time/running/")
188
+ entry = TimeEntry(**data)
189
+ except APIError as e:
190
+ if e.status_code == 404:
191
+ console.print("[dim]No timer is currently running.[/dim]")
192
+ return
193
+ format_error(e.message)
194
+ raise typer.Exit(1)
195
+
196
+ if not force:
197
+ desc = entry.description or "(no description)"
198
+ if not typer.confirm(f"Discard timer '{desc}'?"):
199
+ console.print("[dim]Cancelled.[/dim]")
200
+ return
201
+
202
+ try:
203
+ client.delete(f"/time/{entry.id}/")
204
+ format_success("Timer discarded.")
205
+ except APIError as e:
206
+ format_error(e.message)
207
+ raise typer.Exit(1)
@@ -0,0 +1,125 @@
1
+ """Configuration management for CrowdTime CLI.
2
+
3
+ Config is stored at ~/.crowdtime/config.toml using platformdirs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import tomlkit
12
+ from platformdirs import user_config_dir
13
+
14
+ CONFIG_DIR = Path(user_config_dir("crowdtime", ensure_exists=True))
15
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
16
+
17
+ DEFAULT_CONFIG = {
18
+ "server": {
19
+ "url": "https://api.crowdtime.lat",
20
+ },
21
+ "defaults": {
22
+ "organization": "",
23
+ "project": "",
24
+ "daily_target": "8h",
25
+ "weekly_target": "40h",
26
+ "date_format": "%Y-%m-%d",
27
+ "time_format": "24h",
28
+ },
29
+ "display": {
30
+ "theme": "auto",
31
+ "color": True,
32
+ "compact": False,
33
+ },
34
+ }
35
+
36
+
37
+ class CrowdTimeConfig:
38
+ """Manages CrowdTime CLI configuration."""
39
+
40
+ def __init__(self) -> None:
41
+ self._ensure_config_dir()
42
+ self._doc = self._load()
43
+
44
+ def _ensure_config_dir(self) -> None:
45
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
46
+
47
+ def _load(self) -> tomlkit.TOMLDocument:
48
+ if CONFIG_FILE.exists():
49
+ return tomlkit.parse(CONFIG_FILE.read_text())
50
+ # Create default config
51
+ doc = tomlkit.document()
52
+ for section, values in DEFAULT_CONFIG.items():
53
+ table = tomlkit.table()
54
+ for key, value in values.items():
55
+ table.add(key, value)
56
+ doc.add(section, table)
57
+ CONFIG_FILE.write_text(tomlkit.dumps(doc))
58
+ return doc
59
+
60
+ def get(self, key: str, default: Any = None) -> Any:
61
+ """Get a config value using dot notation (e.g. 'server.url')."""
62
+ parts = key.split(".")
63
+ current: Any = self._doc
64
+ for part in parts:
65
+ if isinstance(current, dict) and part in current:
66
+ current = current[part]
67
+ else:
68
+ return default
69
+ return current
70
+
71
+ def set(self, key: str, value: Any) -> None:
72
+ """Set a config value using dot notation."""
73
+ parts = key.split(".")
74
+ if len(parts) < 2:
75
+ raise ValueError("Config key must have at least section.key format (e.g. 'server.url')")
76
+
77
+ section = parts[0]
78
+ if section not in self._doc:
79
+ self._doc.add(section, tomlkit.table())
80
+
81
+ current: Any = self._doc[section]
82
+ for part in parts[1:-1]:
83
+ if part not in current:
84
+ current[part] = tomlkit.table()
85
+ current = current[part]
86
+
87
+ current[parts[-1]] = value
88
+ self.save()
89
+
90
+ def save(self) -> None:
91
+ """Write config to disk."""
92
+ CONFIG_FILE.write_text(tomlkit.dumps(self._doc))
93
+
94
+ def all(self) -> dict[str, Any]:
95
+ """Return the full config as a dict."""
96
+ return dict(self._doc)
97
+
98
+ @property
99
+ def server_url(self) -> str:
100
+ return str(self.get("server.url", "https://api.crowdtime.lat")).rstrip("/")
101
+
102
+ @property
103
+ def organization(self) -> str:
104
+ return str(self.get("defaults.organization", ""))
105
+
106
+ @property
107
+ def default_project(self) -> str:
108
+ return str(self.get("defaults.project", ""))
109
+
110
+ @property
111
+ def daily_target(self) -> str:
112
+ return str(self.get("defaults.daily_target", "8h"))
113
+
114
+ @property
115
+ def weekly_target(self) -> str:
116
+ return str(self.get("defaults.weekly_target", "40h"))
117
+
118
+ @property
119
+ def config_path(self) -> Path:
120
+ return CONFIG_FILE
121
+
122
+
123
+ def get_config() -> CrowdTimeConfig:
124
+ """Get a CrowdTimeConfig instance."""
125
+ return CrowdTimeConfig()