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.
- crowdtime_cli/__init__.py +3 -0
- crowdtime_cli/auth.py +69 -0
- crowdtime_cli/client.py +177 -0
- crowdtime_cli/commands/__init__.py +1 -0
- crowdtime_cli/commands/ai_cmd.py +211 -0
- crowdtime_cli/commands/auth_cmd.py +160 -0
- crowdtime_cli/commands/clients_cmd.py +150 -0
- crowdtime_cli/commands/config_cmd.py +91 -0
- crowdtime_cli/commands/favorites_cmd.py +128 -0
- crowdtime_cli/commands/log_cmd.py +298 -0
- crowdtime_cli/commands/org_cmd.py +134 -0
- crowdtime_cli/commands/projects_cmd.py +175 -0
- crowdtime_cli/commands/report_cmd.py +242 -0
- crowdtime_cli/commands/skill_cmd.py +266 -0
- crowdtime_cli/commands/tasks_cmd.py +101 -0
- crowdtime_cli/commands/timer_cmd.py +207 -0
- crowdtime_cli/config.py +125 -0
- crowdtime_cli/formatters.py +395 -0
- crowdtime_cli/main.py +334 -0
- crowdtime_cli/models.py +146 -0
- crowdtime_cli/oauth.py +107 -0
- crowdtime_cli/resolvers.py +80 -0
- crowdtime_cli/skills/crowdtime/SKILL.md +193 -0
- crowdtime_cli/skills/crowdtime/references/commands.md +659 -0
- crowdtime_cli/skills/crowdtime/references/workflows.md +286 -0
- crowdtime_cli/utils.py +166 -0
- crowdtime_cli-0.1.0.dist-info/METADATA +140 -0
- crowdtime_cli-0.1.0.dist-info/RECORD +31 -0
- crowdtime_cli-0.1.0.dist-info/WHEEL +4 -0
- crowdtime_cli-0.1.0.dist-info/entry_points.txt +3 -0
- crowdtime_cli-0.1.0.dist-info/licenses/LICENSE +77 -0
|
@@ -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)
|
crowdtime_cli/config.py
ADDED
|
@@ -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()
|