jobhound 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. jobhound-0.2.0/PKG-INFO +10 -0
  2. jobhound-0.2.0/pyproject.toml +44 -0
  3. jobhound-0.2.0/src/jobhound/__init__.py +3 -0
  4. jobhound-0.2.0/src/jobhound/cli.py +48 -0
  5. jobhound-0.2.0/src/jobhound/commands/__init__.py +0 -0
  6. jobhound-0.2.0/src/jobhound/commands/_terminal.py +43 -0
  7. jobhound-0.2.0/src/jobhound/commands/accept.py +25 -0
  8. jobhound-0.2.0/src/jobhound/commands/apply.py +48 -0
  9. jobhound-0.2.0/src/jobhound/commands/archive.py +32 -0
  10. jobhound-0.2.0/src/jobhound/commands/contact.py +39 -0
  11. jobhound-0.2.0/src/jobhound/commands/decline.py +25 -0
  12. jobhound-0.2.0/src/jobhound/commands/delete.py +36 -0
  13. jobhound-0.2.0/src/jobhound/commands/edit.py +81 -0
  14. jobhound-0.2.0/src/jobhound/commands/ghost.py +25 -0
  15. jobhound-0.2.0/src/jobhound/commands/link.py +28 -0
  16. jobhound-0.2.0/src/jobhound/commands/list_.py +15 -0
  17. jobhound-0.2.0/src/jobhound/commands/log.py +79 -0
  18. jobhound-0.2.0/src/jobhound/commands/new.py +60 -0
  19. jobhound-0.2.0/src/jobhound/commands/note.py +39 -0
  20. jobhound-0.2.0/src/jobhound/commands/priority.py +36 -0
  21. jobhound-0.2.0/src/jobhound/commands/sync.py +36 -0
  22. jobhound-0.2.0/src/jobhound/commands/tag.py +39 -0
  23. jobhound-0.2.0/src/jobhound/commands/withdraw.py +25 -0
  24. jobhound-0.2.0/src/jobhound/config.py +48 -0
  25. jobhound-0.2.0/src/jobhound/contact.py +44 -0
  26. jobhound-0.2.0/src/jobhound/git.py +48 -0
  27. jobhound-0.2.0/src/jobhound/meta_io.py +98 -0
  28. jobhound-0.2.0/src/jobhound/opportunities.py +171 -0
  29. jobhound-0.2.0/src/jobhound/paths.py +40 -0
  30. jobhound-0.2.0/src/jobhound/priority.py +11 -0
  31. jobhound-0.2.0/src/jobhound/prompts.py +70 -0
  32. jobhound-0.2.0/src/jobhound/repository.py +100 -0
  33. jobhound-0.2.0/src/jobhound/slug.py +34 -0
  34. jobhound-0.2.0/src/jobhound/slug_value.py +44 -0
  35. jobhound-0.2.0/src/jobhound/status.py +89 -0
  36. jobhound-0.2.0/src/jobhound/transitions.py +67 -0
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: jobhound
3
+ Version: 0.2.0
4
+ Summary: Action-based CLI for tracking a job hunt
5
+ Requires-Dist: cyclopts==4.11.2
6
+ Requires-Dist: questionary>=2.0
7
+ Requires-Dist: tomli-w>=1.0
8
+ Requires-Dist: rich>=13
9
+ Requires-Dist: xdg-base-dirs>=6
10
+ Requires-Python: >=3.13
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "jobhound"
3
+ version = "0.2.0"
4
+ description = "Action-based CLI for tracking a job hunt"
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "cyclopts==4.11.2",
8
+ "questionary>=2.0",
9
+ "tomli-w>=1.0",
10
+ "rich>=13",
11
+ "xdg-base-dirs>=6",
12
+ ]
13
+
14
+ [project.scripts]
15
+ jh = "jobhound.cli:app"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8",
20
+ "ruff>=0.6",
21
+ "ty>=0.0.1a8",
22
+ "pytest-cov>=5",
23
+ ]
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.4,<0.12"]
27
+ build-backend = "uv_build"
28
+
29
+ [tool.ruff]
30
+ line-length = 100
31
+ target-version = "py313"
32
+
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
35
+
36
+ [tool.ruff.format]
37
+ quote-style = "double"
38
+
39
+ [tool.ty.rules]
40
+ # strict-by-default; tighten as needed
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ addopts = "-ra --strict-markers"
@@ -0,0 +1,3 @@
1
+ """jh — action-based CLI for tracking a job hunt."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,48 @@
1
+ """The `jh` CLI entry point. Commands are registered explicitly below."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cyclopts import App
6
+
7
+ from jobhound import __version__
8
+ from jobhound.commands import accept as cmd_accept
9
+ from jobhound.commands import apply as cmd_apply
10
+ from jobhound.commands import archive as cmd_archive
11
+ from jobhound.commands import contact as cmd_contact
12
+ from jobhound.commands import decline as cmd_decline
13
+ from jobhound.commands import delete as cmd_delete
14
+ from jobhound.commands import edit as cmd_edit
15
+ from jobhound.commands import ghost as cmd_ghost
16
+ from jobhound.commands import link as cmd_link
17
+ from jobhound.commands import list_ as cmd_list
18
+ from jobhound.commands import log as cmd_log
19
+ from jobhound.commands import new as cmd_new
20
+ from jobhound.commands import note as cmd_note
21
+ from jobhound.commands import priority as cmd_priority
22
+ from jobhound.commands import sync as cmd_sync
23
+ from jobhound.commands import tag as cmd_tag
24
+ from jobhound.commands import withdraw as cmd_withdraw
25
+
26
+ app = App(
27
+ name="jh",
28
+ help="Action-based CLI for tracking a job hunt.",
29
+ version=__version__,
30
+ )
31
+
32
+ app.command(cmd_new.run, name="new")
33
+ app.command(cmd_apply.run, name="apply")
34
+ app.command(cmd_log.run, name="log")
35
+ app.command(cmd_withdraw.run, name="withdraw")
36
+ app.command(cmd_ghost.run, name="ghost")
37
+ app.command(cmd_accept.run, name="accept")
38
+ app.command(cmd_decline.run, name="decline")
39
+ app.command(cmd_note.run, name="note")
40
+ app.command(cmd_priority.run, name="priority")
41
+ app.command(cmd_tag.run, name="tag")
42
+ app.command(cmd_link.run, name="link")
43
+ app.command(cmd_contact.run, name="contact")
44
+ app.command(cmd_list.run, name="list")
45
+ app.command(cmd_edit.run, name="edit")
46
+ app.command(cmd_archive.run, name="archive")
47
+ app.command(cmd_delete.run, name="delete")
48
+ app.command(cmd_sync.run, name="sync")
File without changes
@@ -0,0 +1,43 @@
1
+ """Shared logic for terminal-status verbs (withdraw, ghost, accept, decline)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import date
7
+
8
+ from jobhound.config import load_config
9
+ from jobhound.opportunities import Opportunity
10
+ from jobhound.paths import paths_from_config
11
+ from jobhound.repository import OpportunityRepository
12
+ from jobhound.transitions import InvalidTransitionError
13
+
14
+ _METHODS = {
15
+ "withdraw": Opportunity.withdraw,
16
+ "ghost": Opportunity.ghost,
17
+ "accept": Opportunity.accept,
18
+ "decline": Opportunity.decline,
19
+ }
20
+
21
+
22
+ def run_transition(
23
+ *,
24
+ slug_query: str,
25
+ verb: str,
26
+ today: str | None,
27
+ no_commit: bool,
28
+ ) -> None:
29
+ """Move an opportunity to its terminal status via the entity method."""
30
+ cfg = load_config()
31
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
32
+ today_date = date.fromisoformat(today) if today else date.today()
33
+ opp, opp_dir = repo.find(slug_query)
34
+
35
+ method = _METHODS[verb]
36
+ try:
37
+ updated = method(opp, today=today_date)
38
+ except InvalidTransitionError as exc:
39
+ print(str(exc), file=sys.stderr)
40
+ raise SystemExit(1) from exc
41
+
42
+ repo.save(updated, opp_dir, message=f"{verb}: {opp.slug}", no_commit=no_commit)
43
+ print(f"{verb}: {opp.slug}")
@@ -0,0 +1,25 @@
1
+ """`jh accept` — accepted offer, status → accepted."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import Parameter
8
+
9
+ from jobhound.commands._terminal import run_transition
10
+
11
+
12
+ def run(
13
+ slug_query: str,
14
+ /,
15
+ *,
16
+ today: Annotated[str | None, Parameter(show=False)] = None,
17
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
18
+ ) -> None:
19
+ """Accept the offer."""
20
+ run_transition(
21
+ slug_query=slug_query,
22
+ verb="accept",
23
+ today=today,
24
+ no_commit=no_commit,
25
+ )
@@ -0,0 +1,48 @@
1
+ """`jh apply` — submitted application, status → applied."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import date
7
+ from typing import Annotated
8
+
9
+ from cyclopts import Parameter
10
+
11
+ from jobhound.config import load_config
12
+ from jobhound.paths import paths_from_config
13
+ from jobhound.repository import OpportunityRepository
14
+ from jobhound.transitions import InvalidTransitionError
15
+
16
+
17
+ def run(
18
+ slug_query: str,
19
+ /,
20
+ *,
21
+ on: str | None = None,
22
+ next_action: str,
23
+ next_action_due: str,
24
+ today: Annotated[str | None, Parameter(show=False)] = None,
25
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
26
+ ) -> None:
27
+ """Mark the application as submitted."""
28
+ cfg = load_config()
29
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
30
+
31
+ today_date = date.fromisoformat(today) if today else date.today()
32
+ applied_on = date.fromisoformat(on) if on else today_date
33
+ due = date.fromisoformat(next_action_due)
34
+
35
+ opp, opp_dir = repo.find(slug_query)
36
+ try:
37
+ updated = opp.apply(
38
+ applied_on=applied_on,
39
+ today=today_date,
40
+ next_action=next_action,
41
+ next_action_due=due,
42
+ )
43
+ except InvalidTransitionError as exc:
44
+ print(str(exc), file=sys.stderr)
45
+ raise SystemExit(1) from exc
46
+
47
+ repo.save(updated, opp_dir, message=f"apply: {opp.slug}", no_commit=no_commit)
48
+ print(f"applied: {opp.slug}")
@@ -0,0 +1,32 @@
1
+ """`jh archive` — move <slug> from opportunities/ to archive/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Annotated
7
+
8
+ from cyclopts import Parameter
9
+
10
+ from jobhound.config import load_config
11
+ from jobhound.paths import Paths, paths_from_config
12
+ from jobhound.repository import OpportunityRepository
13
+
14
+
15
+ def run(
16
+ slug_query: str,
17
+ /,
18
+ *,
19
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
20
+ ) -> None:
21
+ """Move an opportunity to the archive directory."""
22
+ cfg = load_config()
23
+ paths = paths_from_config(cfg)
24
+ Paths.ensure(paths)
25
+ repo = OpportunityRepository(paths, cfg)
26
+ _, opp_dir = repo.find(slug_query)
27
+ try:
28
+ repo.archive(opp_dir, no_commit=no_commit)
29
+ except FileExistsError as exc:
30
+ print(str(exc), file=sys.stderr)
31
+ raise SystemExit(1) from exc
32
+ print(f"archived: {opp_dir.name}")
@@ -0,0 +1,39 @@
1
+ """`jh contact` — append a contact entry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import Parameter
8
+
9
+ from jobhound.config import load_config
10
+ from jobhound.contact import Contact
11
+ from jobhound.paths import paths_from_config
12
+ from jobhound.repository import OpportunityRepository
13
+
14
+
15
+ def run(
16
+ slug_query: str,
17
+ /,
18
+ *,
19
+ name: str,
20
+ role_title: str | None = None,
21
+ channel: str | None = None,
22
+ company: str | None = None,
23
+ note: str | None = None,
24
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
25
+ ) -> None:
26
+ """Add a contact to the contacts list."""
27
+ contact = Contact(
28
+ name=name,
29
+ role=role_title,
30
+ channel=channel,
31
+ company=company,
32
+ note=note,
33
+ )
34
+ cfg = load_config()
35
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
36
+ opp, opp_dir = repo.find(slug_query)
37
+ updated = opp.with_contact(contact)
38
+ repo.save(updated, opp_dir, message=f"contact: {opp.slug} {name}", no_commit=no_commit)
39
+ print(f"contact added: {opp.slug} {name}")
@@ -0,0 +1,25 @@
1
+ """`jh decline` — declined offer, status → declined."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import Parameter
8
+
9
+ from jobhound.commands._terminal import run_transition
10
+
11
+
12
+ def run(
13
+ slug_query: str,
14
+ /,
15
+ *,
16
+ today: Annotated[str | None, Parameter(show=False)] = None,
17
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
18
+ ) -> None:
19
+ """Decline the offer."""
20
+ run_transition(
21
+ slug_query=slug_query,
22
+ verb="decline",
23
+ today=today,
24
+ no_commit=no_commit,
25
+ )
@@ -0,0 +1,36 @@
1
+ """`jh delete` — remove an opportunity directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import questionary
8
+ from cyclopts import Parameter
9
+
10
+ from jobhound.config import load_config
11
+ from jobhound.paths import paths_from_config
12
+ from jobhound.repository import OpportunityRepository
13
+
14
+
15
+ def run(
16
+ slug_query: str,
17
+ /,
18
+ *,
19
+ yes: bool = False,
20
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
21
+ ) -> None:
22
+ """Delete an opportunity directory (e.g. a duplicate scaffold).
23
+
24
+ --yes: skip the confirmation prompt.
25
+ """
26
+ cfg = load_config()
27
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
28
+ _, opp_dir = repo.find(slug_query)
29
+ if not yes:
30
+ confirm = questionary.confirm(f"Delete {opp_dir.name}?", default=False).ask()
31
+ if not confirm:
32
+ print("aborted")
33
+ raise SystemExit(1)
34
+ name = opp_dir.name
35
+ repo.delete(opp_dir, no_commit=no_commit)
36
+ print(f"deleted: {name}")
@@ -0,0 +1,81 @@
1
+ """`jh edit` — open meta.toml in $EDITOR with validation loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ from cyclopts import Parameter
12
+
13
+ from jobhound.config import load_config
14
+ from jobhound.meta_io import ValidationError, read_meta
15
+ from jobhound.paths import paths_from_config
16
+ from jobhound.repository import OpportunityRepository
17
+
18
+ _ERROR_PREFIX = "# ERROR:"
19
+
20
+
21
+ def _editor_argv(cfg_editor: str) -> list[str]:
22
+ raw = cfg_editor or os.environ.get("EDITOR") or "vi"
23
+ return shlex.split(raw)
24
+
25
+
26
+ def _strip_error_block(text: str) -> str:
27
+ lines = text.splitlines(keepends=True)
28
+ idx = 0
29
+ while idx < len(lines) and lines[idx].startswith(_ERROR_PREFIX):
30
+ idx += 1
31
+ if idx == 0:
32
+ return text
33
+ if idx < len(lines) and lines[idx].rstrip() == "#":
34
+ idx += 1
35
+ if idx < len(lines) and lines[idx].strip() == "":
36
+ idx += 1
37
+ return "".join(lines[idx:])
38
+
39
+
40
+ def _prepend_error(path: Path, message: str) -> None:
41
+ body = _strip_error_block(path.read_text())
42
+ block = "".join(f"{_ERROR_PREFIX} {line}\n" for line in message.splitlines())
43
+ path.write_text(f"{block}#\n{body}")
44
+
45
+
46
+ def _open_editor(argv: list[str], path: Path) -> None:
47
+ subprocess.run([*argv, str(path)], check=True)
48
+
49
+
50
+ def run(
51
+ slug_query: str,
52
+ /,
53
+ *,
54
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
55
+ ) -> None:
56
+ """Open meta.toml in $EDITOR; validate on save; rename on slug change."""
57
+ cfg = load_config()
58
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
59
+ _, opp_dir = repo.find(slug_query)
60
+ meta = opp_dir / "meta.toml"
61
+ editor_argv = _editor_argv(cfg.editor)
62
+
63
+ while True:
64
+ before = meta.read_text()
65
+ _open_editor(editor_argv, meta)
66
+ after = meta.read_text()
67
+ if after == before:
68
+ print("No changes.")
69
+ return
70
+ try:
71
+ opp = read_meta(meta)
72
+ except ValidationError as exc:
73
+ _prepend_error(meta, str(exc))
74
+ continue
75
+ cleaned = _strip_error_block(meta.read_text())
76
+ if cleaned != after:
77
+ meta.write_text(cleaned)
78
+ opp = read_meta(meta)
79
+ final_dir = repo.save(opp, opp_dir, message=f"edit: {opp.slug}", no_commit=no_commit)
80
+ print(f"Updated {final_dir.relative_to(repo.paths.db_root)}")
81
+ return
@@ -0,0 +1,25 @@
1
+ """`jh ghost` — no response, status → ghosted."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import Parameter
8
+
9
+ from jobhound.commands._terminal import run_transition
10
+
11
+
12
+ def run(
13
+ slug_query: str,
14
+ /,
15
+ *,
16
+ today: Annotated[str | None, Parameter(show=False)] = None,
17
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
18
+ ) -> None:
19
+ """Mark this opportunity as ghosted (no response, giving up)."""
20
+ run_transition(
21
+ slug_query=slug_query,
22
+ verb="ghost",
23
+ today=today,
24
+ no_commit=no_commit,
25
+ )
@@ -0,0 +1,28 @@
1
+ """`jh link` — add or update an entry in the links table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import Parameter
8
+
9
+ from jobhound.config import load_config
10
+ from jobhound.paths import paths_from_config
11
+ from jobhound.repository import OpportunityRepository
12
+
13
+
14
+ def run(
15
+ slug_query: str,
16
+ /,
17
+ *,
18
+ name: str,
19
+ url: str,
20
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
21
+ ) -> None:
22
+ """Add or update a link."""
23
+ cfg = load_config()
24
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
25
+ opp, opp_dir = repo.find(slug_query)
26
+ updated = opp.with_link(name=name, url=url)
27
+ repo.save(updated, opp_dir, message=f"link: {opp.slug} {name}", no_commit=no_commit)
28
+ print(f"link {opp.slug}: {name} = {url}")
@@ -0,0 +1,15 @@
1
+ """`jh list` — one-line summary of every opportunity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from jobhound.config import load_config
6
+ from jobhound.paths import paths_from_config
7
+ from jobhound.repository import OpportunityRepository
8
+
9
+
10
+ def run() -> None:
11
+ """List every opportunity as `<slug> <status> <priority>`, sorted by slug."""
12
+ cfg = load_config()
13
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
14
+ for opp in repo.all():
15
+ print(f"{opp.slug:<55} {opp.status:<12} {opp.priority}")
@@ -0,0 +1,79 @@
1
+ """`jh log` — record an interaction; default next status advances one stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from datetime import date
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ from cyclopts import Parameter
12
+
13
+ from jobhound.config import load_config
14
+ from jobhound.paths import paths_from_config
15
+ from jobhound.repository import OpportunityRepository
16
+ from jobhound.transitions import InvalidTransitionError
17
+
18
+ _NAME_SLUG = re.compile(r"[^a-z0-9]+")
19
+
20
+
21
+ def _name_slug(who: str) -> str:
22
+ return _NAME_SLUG.sub("-", who.lower()).strip("-") or "unknown"
23
+
24
+
25
+ def _correspondence_filename(when: date, channel: str, direction: str, who: str) -> str:
26
+ return f"{when.isoformat()}-{channel}-{direction}-{_name_slug(who)}.md"
27
+
28
+
29
+ def run(
30
+ slug_query: str,
31
+ /,
32
+ *,
33
+ channel: str,
34
+ direction: str,
35
+ who: str,
36
+ body: Path,
37
+ next_status: str = "stay",
38
+ next_action: str | None = None,
39
+ next_action_due: str | None = None,
40
+ force: bool = False,
41
+ today: Annotated[str | None, Parameter(show=False)] = None,
42
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
43
+ ) -> None:
44
+ """Record an interaction (correspondence) and update status + next action."""
45
+ cfg = load_config()
46
+ repo = OpportunityRepository(paths_from_config(cfg), cfg)
47
+ today_date = date.fromisoformat(today) if today else date.today()
48
+
49
+ if direction not in {"from", "to"}:
50
+ print(f"--direction must be 'from' or 'to', got {direction!r}", file=sys.stderr)
51
+ raise SystemExit(1)
52
+ if not body.is_file():
53
+ print(f"--body file not found: {body}", file=sys.stderr)
54
+ raise SystemExit(1)
55
+
56
+ opp, opp_dir = repo.find(slug_query)
57
+ due = date.fromisoformat(next_action_due) if next_action_due else None
58
+ try:
59
+ updated = opp.log_interaction(
60
+ today=today_date,
61
+ next_status=next_status,
62
+ next_action=next_action,
63
+ next_action_due=due,
64
+ force=force,
65
+ )
66
+ except InvalidTransitionError as exc:
67
+ print(str(exc), file=sys.stderr)
68
+ raise SystemExit(1) from exc
69
+
70
+ corr_dir = opp_dir / "correspondence"
71
+ corr_dir.mkdir(exist_ok=True)
72
+ corr_path = corr_dir / _correspondence_filename(today_date, channel, direction, who)
73
+ corr_path.write_text(body.read_text())
74
+
75
+ arrow = (
76
+ f"{opp.status} → {updated.status}" if updated.status != opp.status else "(no status change)"
77
+ )
78
+ repo.save(updated, opp_dir, message=f"log: {opp.slug} {arrow}", no_commit=no_commit)
79
+ print(f"logged: {opp.slug} {arrow}")
@@ -0,0 +1,60 @@
1
+ """`jh new` — scaffold a new opportunity at status `prospect`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import date, timedelta
7
+ from typing import Annotated
8
+
9
+ from cyclopts import Parameter
10
+
11
+ from jobhound.config import load_config
12
+ from jobhound.opportunities import Opportunity
13
+ from jobhound.paths import Paths, paths_from_config
14
+ from jobhound.priority import Priority
15
+ from jobhound.repository import OpportunityRepository
16
+ from jobhound.slug_value import Slug
17
+ from jobhound.status import Status
18
+
19
+
20
+ def run(
21
+ *,
22
+ company: str,
23
+ role: str,
24
+ source: str = "(unspecified)",
25
+ next_action: str = "Initial review of role and company",
26
+ next_action_due: str | None = None,
27
+ today: Annotated[str | None, Parameter(show=False)] = None,
28
+ no_commit: Annotated[bool, Parameter(negative=())] = False,
29
+ ) -> None:
30
+ """Create a new opportunity at status `prospect`."""
31
+ cfg = load_config()
32
+ paths = paths_from_config(cfg)
33
+ Paths.ensure(paths)
34
+ repo = OpportunityRepository(paths, cfg)
35
+
36
+ today_date = date.fromisoformat(today) if today else date.today()
37
+ due = date.fromisoformat(next_action_due) if next_action_due else today_date + timedelta(days=7)
38
+ slug = Slug.build(today_date, company, role)
39
+
40
+ opp = Opportunity(
41
+ slug=slug.value,
42
+ company=company,
43
+ role=role,
44
+ status=Status.PROSPECT,
45
+ priority=Priority.MEDIUM,
46
+ source=source,
47
+ location=None,
48
+ comp_range=None,
49
+ first_contact=today_date,
50
+ applied_on=None,
51
+ last_activity=today_date,
52
+ next_action=next_action,
53
+ next_action_due=due,
54
+ )
55
+ try:
56
+ opp_dir = repo.create(opp, message=f"new: {slug.value}", no_commit=no_commit)
57
+ except FileExistsError as exc:
58
+ print(str(exc), file=sys.stderr)
59
+ raise SystemExit(1) from exc
60
+ print(f"Created {opp_dir.relative_to(paths.db_root)}")