hire-ai 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.
hire/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """hire - CLI to orchestrate AI agents (Claude, Codex, Gemini)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,21 @@
1
+ """Agent adapters."""
2
+
3
+ from .base import AgentAdapter
4
+ from .claude import ClaudeAdapter
5
+ from .codex import CodexAdapter
6
+ from .gemini import GeminiAdapter
7
+
8
+
9
+ def get_adapter(agent: str) -> AgentAdapter:
10
+ """Get an adapter for the specified agent."""
11
+ adapters = {
12
+ "claude": ClaudeAdapter,
13
+ "codex": CodexAdapter,
14
+ "gemini": GeminiAdapter,
15
+ }
16
+ if agent not in adapters:
17
+ raise ValueError(f"Unknown agent: {agent}. Available: {list(adapters.keys())}")
18
+ return adapters[agent]()
19
+
20
+
21
+ __all__ = ["AgentAdapter", "ClaudeAdapter", "CodexAdapter", "GeminiAdapter", "get_adapter"]
hire/adapters/base.py ADDED
@@ -0,0 +1,42 @@
1
+ """Base adapter class."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class AgentAdapter(ABC):
8
+ """Abstract base class for agent adapters."""
9
+
10
+ name: str = "base"
11
+
12
+ @abstractmethod
13
+ def ask(
14
+ self,
15
+ message: str,
16
+ session_id: str | None = None,
17
+ model: str | None = None,
18
+ ) -> dict[str, Any]:
19
+ """
20
+ Send a message to the agent and get a response.
21
+
22
+ Args:
23
+ message: The message to send
24
+ session_id: Optional CLI session ID for continuation
25
+ model: Optional model to use
26
+
27
+ Returns:
28
+ dict with keys:
29
+ - response: The agent's response text
30
+ - session_id: The CLI session ID for future continuation
31
+ - raw: The raw output from the CLI (for debugging)
32
+ """
33
+ pass
34
+
35
+ def build_command(
36
+ self,
37
+ message: str,
38
+ session_id: str | None = None,
39
+ model: str | None = None,
40
+ ) -> list[str]:
41
+ """Build the command to execute. Override in subclasses."""
42
+ raise NotImplementedError
@@ -0,0 +1,74 @@
1
+ """Claude CLI adapter."""
2
+
3
+ import json
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from ..config import get_adapter_config
8
+ from .base import AgentAdapter
9
+
10
+
11
+ class ClaudeAdapter(AgentAdapter):
12
+ """Adapter for Claude CLI."""
13
+
14
+ name = "claude"
15
+
16
+ def build_command(
17
+ self,
18
+ message: str,
19
+ session_id: str | None = None,
20
+ model: str | None = None,
21
+ ) -> list[str]:
22
+ """Build the claude command."""
23
+ config = get_adapter_config("claude")
24
+ command = config.get("command", "claude")
25
+ args = config.get("args", [])
26
+
27
+ cmd = [command, "-p", message, "--output-format", "json"]
28
+ cmd.extend(args)
29
+
30
+ if session_id:
31
+ cmd.extend(["--resume", session_id])
32
+
33
+ if model:
34
+ cmd.extend(["--model", model])
35
+
36
+ return cmd
37
+
38
+ def ask(
39
+ self,
40
+ message: str,
41
+ session_id: str | None = None,
42
+ model: str | None = None,
43
+ ) -> dict[str, Any]:
44
+ """Send a message to Claude and get a response."""
45
+ cmd = self.build_command(message, session_id, model)
46
+
47
+ result = subprocess.run(
48
+ cmd,
49
+ capture_output=True,
50
+ text=True,
51
+ )
52
+
53
+ if result.returncode != 0:
54
+ return {
55
+ "response": None,
56
+ "session_id": session_id,
57
+ "error": result.stderr or "Command failed",
58
+ "raw": result.stdout,
59
+ }
60
+
61
+ try:
62
+ data = json.loads(result.stdout)
63
+ return {
64
+ "response": data.get("result", ""),
65
+ "session_id": data.get("session_id", session_id),
66
+ "raw": data,
67
+ }
68
+ except json.JSONDecodeError:
69
+ # If not JSON, return raw output
70
+ return {
71
+ "response": result.stdout,
72
+ "session_id": session_id,
73
+ "raw": result.stdout,
74
+ }
hire/adapters/codex.py ADDED
@@ -0,0 +1,98 @@
1
+ """Codex CLI adapter."""
2
+
3
+ import json
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from ..config import get_adapter_config
8
+ from .base import AgentAdapter
9
+
10
+
11
+ class CodexAdapter(AgentAdapter):
12
+ """Adapter for Codex CLI."""
13
+
14
+ name = "codex"
15
+
16
+ def build_command(
17
+ self,
18
+ message: str,
19
+ session_id: str | None = None,
20
+ model: str | None = None,
21
+ ) -> list[str]:
22
+ """Build the codex command."""
23
+ config = get_adapter_config("codex")
24
+ command = config.get("command", "codex")
25
+ args = config.get("args", [])
26
+
27
+ if session_id:
28
+ # Resume session: codex exec resume <SESSION_ID> "message"
29
+ cmd = [command, "exec", "resume", session_id, message, "--json", "--skip-git-repo-check"]
30
+ else:
31
+ # New session: codex exec "message"
32
+ cmd = [command, "exec", message, "--json", "--skip-git-repo-check"]
33
+
34
+ cmd.extend(args)
35
+
36
+ if model:
37
+ cmd.extend(["--model", model])
38
+
39
+ return cmd
40
+
41
+ def ask(
42
+ self,
43
+ message: str,
44
+ session_id: str | None = None,
45
+ model: str | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Send a message to Codex and get a response."""
48
+ cmd = self.build_command(message, session_id, model)
49
+
50
+ result = subprocess.run(
51
+ cmd,
52
+ capture_output=True,
53
+ text=True,
54
+ )
55
+
56
+ if result.returncode != 0:
57
+ return {
58
+ "response": None,
59
+ "session_id": session_id,
60
+ "error": result.stderr or "Command failed",
61
+ "raw": result.stdout,
62
+ }
63
+
64
+ # Codex outputs JSONL (one JSON object per line)
65
+ # Parse all lines and extract the final response
66
+ lines = result.stdout.strip().split("\n")
67
+ response_text = ""
68
+ new_session_id = session_id
69
+
70
+ for line in lines:
71
+ if not line.strip():
72
+ continue
73
+ try:
74
+ event = json.loads(line)
75
+ event_type = event.get("type", "")
76
+
77
+ # Get thread_id from thread.started event
78
+ if event_type == "thread.started":
79
+ new_session_id = event.get("thread_id", new_session_id)
80
+
81
+ # Get response text from item.completed with agent_message
82
+ if event_type == "item.completed":
83
+ item = event.get("item", {})
84
+ if item.get("type") == "agent_message":
85
+ response_text = item.get("text", "")
86
+
87
+ except json.JSONDecodeError:
88
+ continue
89
+
90
+ # If no structured response found, use raw output
91
+ if not response_text:
92
+ response_text = result.stdout.strip()
93
+
94
+ return {
95
+ "response": response_text,
96
+ "session_id": new_session_id,
97
+ "raw": result.stdout,
98
+ }
@@ -0,0 +1,91 @@
1
+ """Gemini CLI adapter."""
2
+
3
+ import json
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from ..config import get_adapter_config
8
+ from .base import AgentAdapter
9
+
10
+
11
+ class GeminiAdapter(AgentAdapter):
12
+ """Adapter for Gemini CLI."""
13
+
14
+ name = "gemini"
15
+
16
+ def build_command(
17
+ self,
18
+ message: str,
19
+ session_id: str | None = None,
20
+ model: str | None = None,
21
+ ) -> list[str]:
22
+ """Build the gemini command."""
23
+ config = get_adapter_config("gemini")
24
+ command = config.get("command", "gemini")
25
+ args = config.get("args", [])
26
+
27
+ # gemini -p "message" -o json -y
28
+ cmd = [command, "-p", message, "-o", "json"]
29
+ cmd.extend(args)
30
+
31
+ # Resume uses "latest" or index number, not session ID
32
+ if session_id:
33
+ cmd.extend(["-r", session_id])
34
+
35
+ if model:
36
+ cmd.extend(["-m", model])
37
+
38
+ return cmd
39
+
40
+ def ask(
41
+ self,
42
+ message: str,
43
+ session_id: str | None = None,
44
+ model: str | None = None,
45
+ ) -> dict[str, Any]:
46
+ """Send a message to Gemini and get a response."""
47
+ cmd = self.build_command(message, session_id, model)
48
+
49
+ result = subprocess.run(
50
+ cmd,
51
+ capture_output=True,
52
+ text=True,
53
+ )
54
+
55
+ if result.returncode != 0:
56
+ return {
57
+ "response": None,
58
+ "session_id": session_id,
59
+ "error": result.stderr or "Command failed",
60
+ "raw": result.stdout,
61
+ }
62
+
63
+ # Try to parse JSON output
64
+ try:
65
+ data = json.loads(result.stdout)
66
+ # Extract response - structure may vary
67
+ response_text = ""
68
+ new_session_id = session_id
69
+
70
+ if isinstance(data, dict):
71
+ response_text = data.get("result", data.get("response", data.get("text", "")))
72
+ new_session_id = data.get("session_id", data.get("sessionId", session_id))
73
+ elif isinstance(data, str):
74
+ response_text = data
75
+
76
+ # For continuation, use "latest" as session identifier
77
+ if not new_session_id:
78
+ new_session_id = "latest"
79
+
80
+ return {
81
+ "response": response_text,
82
+ "session_id": new_session_id,
83
+ "raw": data,
84
+ }
85
+ except json.JSONDecodeError:
86
+ # Plain text output
87
+ return {
88
+ "response": result.stdout.strip(),
89
+ "session_id": "latest", # Use "latest" for continuation
90
+ "raw": result.stdout,
91
+ }
hire/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """CLI entry point."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__
7
+ from .commands import run_ask, run_delete, run_sessions, run_show
8
+
9
+
10
+ SUBCOMMANDS = {"sessions", "show", "delete", "help", "--help", "-h", "--version"}
11
+
12
+
13
+ def main() -> int:
14
+ """Main entry point."""
15
+ # Check if first arg is a subcommand, if not, treat as default (hire) action
16
+ if len(sys.argv) > 1 and sys.argv[1] not in SUBCOMMANDS:
17
+ # Default action: hire an agent
18
+ return run_default()
19
+
20
+ # Subcommand mode
21
+ parser = argparse.ArgumentParser(
22
+ prog="hire",
23
+ description="Hire AI agents to do tasks (Claude, Codex, Gemini)",
24
+ )
25
+ parser.add_argument(
26
+ "--version",
27
+ action="version",
28
+ version=f"%(prog)s {__version__}",
29
+ )
30
+
31
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
32
+
33
+ # sessions command
34
+ sessions_parser = subparsers.add_parser("sessions", help="List sessions")
35
+ sessions_parser.add_argument(
36
+ "target",
37
+ nargs="?",
38
+ choices=["claude", "codex", "gemini"],
39
+ help="Filter by agent",
40
+ )
41
+ sessions_parser.add_argument(
42
+ "--json",
43
+ action="store_true",
44
+ help="Output in JSON format",
45
+ )
46
+
47
+ # show command
48
+ show_parser = subparsers.add_parser("show", help="Show session details")
49
+ show_parser.add_argument(
50
+ "name_or_id",
51
+ help="Session name or ID",
52
+ )
53
+ show_parser.add_argument(
54
+ "--json",
55
+ action="store_true",
56
+ help="Output in JSON format",
57
+ )
58
+
59
+ # delete command
60
+ delete_parser = subparsers.add_parser("delete", help="Delete a session")
61
+ delete_parser.add_argument(
62
+ "name_or_id",
63
+ help="Session name or ID",
64
+ )
65
+ delete_parser.add_argument(
66
+ "-f", "--force",
67
+ action="store_true",
68
+ help="Delete without confirmation",
69
+ )
70
+
71
+ args = parser.parse_args()
72
+
73
+ if args.command is None:
74
+ print_usage()
75
+ return 0
76
+
77
+ # Dispatch to command handlers
78
+ if args.command == "sessions":
79
+ return run_sessions(args)
80
+ elif args.command == "show":
81
+ return run_show(args)
82
+ elif args.command == "delete":
83
+ return run_delete(args)
84
+ else:
85
+ print_usage()
86
+ return 1
87
+
88
+
89
+ def run_default() -> int:
90
+ """Run the default hire action."""
91
+ parser = argparse.ArgumentParser(
92
+ prog="hire",
93
+ description="Hire AI agents to do tasks",
94
+ usage="hire [options] <target> <message>\n hire <subcommand> [args]",
95
+ )
96
+ parser.add_argument(
97
+ "target",
98
+ nargs="?",
99
+ help="Target agent: claude, codex, or gemini",
100
+ )
101
+ parser.add_argument(
102
+ "message",
103
+ nargs="?",
104
+ help="Message to send",
105
+ )
106
+ parser.add_argument(
107
+ "-c", "--continue",
108
+ dest="continue_session",
109
+ action="store_true",
110
+ help="Continue the latest session",
111
+ )
112
+ parser.add_argument(
113
+ "-s", "--session",
114
+ help="Continue a specific session (by name or ID)",
115
+ )
116
+ parser.add_argument(
117
+ "-n", "--name",
118
+ help="Name for the session",
119
+ )
120
+ parser.add_argument(
121
+ "-m", "--model",
122
+ help="Model to use",
123
+ )
124
+ parser.add_argument(
125
+ "--json",
126
+ action="store_true",
127
+ help="Output in JSON format",
128
+ )
129
+
130
+ args = parser.parse_args()
131
+ return run_ask(args)
132
+
133
+
134
+ def print_usage():
135
+ """Print usage information."""
136
+ print("""hire - Hire AI agents to do tasks (Claude, Codex, Gemini)
137
+
138
+ Usage:
139
+ hire <target> <message> Hire an agent to do a task
140
+ hire -s <session> <message> Continue a specific session
141
+ hire sessions [target] List sessions
142
+ hire show <name-or-id> Show session details
143
+ hire delete <name-or-id> Delete a session
144
+
145
+ Targets:
146
+ claude, codex, gemini
147
+
148
+ Options:
149
+ -c, --continue Continue the latest session
150
+ -s, --session ID Continue a specific session
151
+ -n, --name NAME Name the session
152
+ -m, --model MODEL Specify model
153
+ --json Output in JSON format
154
+
155
+ Examples:
156
+ hire codex "Design a REST API"
157
+ hire gemini "Research React 19 features" --json
158
+ hire -s abc123 "Tell me more"
159
+ hire sessions codex
160
+ """)
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ """CLI commands."""
2
+
3
+ from .ask import run_ask
4
+ from .sessions import run_sessions
5
+ from .show import run_show
6
+ from .delete import run_delete
7
+
8
+ __all__ = ["run_ask", "run_sessions", "run_show", "run_delete"]
hire/commands/ask.py ADDED
@@ -0,0 +1,143 @@
1
+ """Ask command implementation."""
2
+
3
+ import json
4
+ import sys
5
+ from argparse import Namespace
6
+
7
+ from ..adapters import get_adapter
8
+ from ..session import (
9
+ create_session,
10
+ find_session,
11
+ get_latest_session,
12
+ list_sessions,
13
+ save_session,
14
+ )
15
+
16
+
17
+ VALID_TARGETS = {"claude", "codex", "gemini"}
18
+
19
+
20
+ def run_ask(args: Namespace) -> int:
21
+ """Run the ask command."""
22
+ target = args.target
23
+ message = args.message
24
+ continue_session = getattr(args, "continue_session", False)
25
+ session_id = args.session
26
+ name = args.name
27
+ model = args.model
28
+ output_json = args.json
29
+
30
+ # Handle case where target is actually the message (when target is omitted)
31
+ # e.g., "delegate 'message'" -> target='message', message=None
32
+ if target and target not in VALID_TARGETS and message is None:
33
+ message = target
34
+ target = None
35
+
36
+ # Load config for defaults
37
+ from ..config import load_config
38
+ config = load_config()
39
+
40
+ if not target:
41
+ target = config.get("defaults", {}).get("agent")
42
+
43
+ # Determine which session to use
44
+ cli_session_id = None
45
+ existing_session = None
46
+
47
+ if session_id:
48
+ # Use specified session
49
+ existing_session = find_session(session_id)
50
+ if existing_session:
51
+ cli_session_id = existing_session.get("cli_session_id")
52
+ # If target not specified, get it from session
53
+ if not target:
54
+ target = existing_session.get("agent")
55
+ else:
56
+ print(f"Error: Session not found: {session_id}", file=sys.stderr)
57
+ return 1
58
+ elif name:
59
+ # Check if named session exists
60
+ existing_session = find_session(name)
61
+ if existing_session:
62
+ if continue_session:
63
+ cli_session_id = existing_session.get("cli_session_id")
64
+ # If target not specified, get it from session
65
+ if not target:
66
+ target = existing_session.get("agent")
67
+ # else: create new session with this name (will replace)
68
+ elif continue_session:
69
+ # Continue latest session
70
+ if not target:
71
+ # Try to find latest session across all agents
72
+ sessions = list_sessions()
73
+ if sessions:
74
+ existing_session = sessions[0]
75
+ target = existing_session.get("agent")
76
+ else:
77
+ existing_session = get_latest_session(target)
78
+
79
+ if existing_session:
80
+ cli_session_id = existing_session.get("cli_session_id")
81
+ else:
82
+ print(f"Warning: No previous session found{' for ' + target if target else ''}, starting new session", file=sys.stderr)
83
+
84
+ # Validate target
85
+ if not target:
86
+ print("Error: Target agent is required (claude, codex, or gemini)", file=sys.stderr)
87
+ return 1
88
+
89
+ # Validate message
90
+ if not message:
91
+ print("Error: Message is required", file=sys.stderr)
92
+ print("Usage: hire <target> <message>", file=sys.stderr)
93
+ return 1
94
+
95
+ # Get the adapter for the target agent
96
+ try:
97
+ adapter = get_adapter(target)
98
+ except ValueError as e:
99
+ print(f"Error: {e}", file=sys.stderr)
100
+ return 1
101
+
102
+ # Call the agent
103
+ result = adapter.ask(message, session_id=cli_session_id, model=model)
104
+
105
+ if result.get("error"):
106
+ print(f"Error: {result['error']}", file=sys.stderr)
107
+ if result.get("raw"):
108
+ print(f"Raw output: {result['raw']}", file=sys.stderr)
109
+ return 1
110
+
111
+ # Get the new session ID from the response
112
+ new_cli_session_id = result.get("session_id")
113
+
114
+ # Save or update session
115
+ if existing_session and cli_session_id:
116
+ # Update existing session
117
+ existing_session["cli_session_id"] = new_cli_session_id or cli_session_id
118
+ if name:
119
+ existing_session["name"] = name
120
+ save_session(existing_session)
121
+ session = existing_session
122
+ else:
123
+ # Create new session
124
+ session = create_session(
125
+ agent=target,
126
+ cli_session_id=new_cli_session_id or "unknown",
127
+ name=name,
128
+ )
129
+
130
+ # Output
131
+ if output_json:
132
+ output = {
133
+ "response": result.get("response"),
134
+ "session_id": session["id"],
135
+ "cli_session_id": session["cli_session_id"],
136
+ "agent": target,
137
+ "name": session.get("name"),
138
+ }
139
+ print(json.dumps(output, indent=2, ensure_ascii=False))
140
+ else:
141
+ print(result.get("response", ""))
142
+
143
+ return 0
@@ -0,0 +1,35 @@
1
+ """Delete command implementation."""
2
+
3
+ import sys
4
+ from argparse import Namespace
5
+
6
+ from ..session import delete_session, find_session
7
+
8
+
9
+ def run_delete(args: Namespace) -> int:
10
+ """Run the delete command."""
11
+ name_or_id = args.name_or_id
12
+ force = getattr(args, "force", False)
13
+
14
+ session = find_session(name_or_id)
15
+
16
+ if not session:
17
+ print(f"Error: Session not found: {name_or_id}", file=sys.stderr)
18
+ return 1
19
+
20
+ # Confirm deletion unless --force
21
+ if not force:
22
+ agent = session.get("agent", "")
23
+ name = session.get("name", "-")
24
+ print(f"Delete session {session['id'][:8]} ({agent}, name={name})?")
25
+ response = input("Type 'yes' to confirm: ")
26
+ if response.lower() != "yes":
27
+ print("Cancelled")
28
+ return 0
29
+
30
+ if delete_session(session):
31
+ print(f"Deleted session: {session['id'][:8]}")
32
+ return 0
33
+ else:
34
+ print(f"Error: Failed to delete session", file=sys.stderr)
35
+ return 1
@@ -0,0 +1,38 @@
1
+ """Sessions command implementation."""
2
+
3
+ import json
4
+ from argparse import Namespace
5
+
6
+ from ..session import list_sessions
7
+
8
+
9
+ def run_sessions(args: Namespace) -> int:
10
+ """Run the sessions command."""
11
+ target = args.target
12
+ output_json = getattr(args, "json", False)
13
+
14
+ sessions = list_sessions(agent=target)
15
+
16
+ if output_json:
17
+ print(json.dumps(sessions, indent=2, ensure_ascii=False))
18
+ else:
19
+ if not sessions:
20
+ if target:
21
+ print(f"No sessions found for {target}")
22
+ else:
23
+ print("No sessions found")
24
+ return 0
25
+
26
+ # Print table header
27
+ print(f"{'AGENT':<10} {'NAME':<20} {'ID':<10} {'UPDATED':<20}")
28
+ print("-" * 62)
29
+
30
+ for session in sessions:
31
+ agent = session.get("agent", "")
32
+ name = session.get("name", "-") or "-"
33
+ session_id = session.get("id", "")[:8]
34
+ updated = session.get("updated_at", "")[:19].replace("T", " ")
35
+
36
+ print(f"{agent:<10} {name:<20} {session_id:<10} {updated:<20}")
37
+
38
+ return 0
hire/commands/show.py ADDED
@@ -0,0 +1,31 @@
1
+ """Show command implementation."""
2
+
3
+ import json
4
+ import sys
5
+ from argparse import Namespace
6
+
7
+ from ..session import find_session
8
+
9
+
10
+ def run_show(args: Namespace) -> int:
11
+ """Run the show command."""
12
+ name_or_id = args.name_or_id
13
+ output_json = getattr(args, "json", False)
14
+
15
+ session = find_session(name_or_id)
16
+
17
+ if not session:
18
+ print(f"Error: Session not found: {name_or_id}", file=sys.stderr)
19
+ return 1
20
+
21
+ if output_json:
22
+ print(json.dumps(session, indent=2, ensure_ascii=False))
23
+ else:
24
+ print(f"Session: {session.get('id')}")
25
+ print(f"Agent: {session.get('agent')}")
26
+ print(f"Name: {session.get('name', '-')}")
27
+ print(f"CLI ID: {session.get('cli_session_id')}")
28
+ print(f"Created: {session.get('created_at')}")
29
+ print(f"Updated: {session.get('updated_at')}")
30
+
31
+ return 0
hire/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """Configuration management."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .paths import get_config_path
7
+
8
+ DEFAULT_CONFIG = {
9
+ "adapters": {
10
+ "claude": {
11
+ "command": "claude",
12
+ "args": ["--dangerously-skip-permissions"]
13
+ },
14
+ "codex": {
15
+ "command": "codex",
16
+ "args": ["--full-auto"]
17
+ },
18
+ "gemini": {
19
+ "command": "gemini",
20
+ "args": ["-y"]
21
+ }
22
+ },
23
+ "defaults": {
24
+ "agent": "claude"
25
+ }
26
+ }
27
+
28
+
29
+ def load_config() -> dict[str, Any]:
30
+ """Load configuration from file, or return defaults."""
31
+ config_path = get_config_path()
32
+ if config_path.exists():
33
+ with open(config_path) as f:
34
+ return json.load(f)
35
+ return DEFAULT_CONFIG.copy()
36
+
37
+
38
+ def save_config(config: dict[str, Any]) -> None:
39
+ """Save configuration to file."""
40
+ config_path = get_config_path()
41
+ with open(config_path, "w") as f:
42
+ json.dump(config, f, indent=2)
43
+
44
+
45
+ def get_adapter_config(agent: str) -> dict[str, Any]:
46
+ """Get configuration for a specific adapter."""
47
+ config = load_config()
48
+ return config.get("adapters", {}).get(agent, {})
hire/paths.py ADDED
@@ -0,0 +1,53 @@
1
+ """XDG-compliant path management for hire CLI."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ APP_NAME = "hire"
7
+
8
+
9
+ def get_config_dir() -> Path:
10
+ """Get XDG config directory (~/.config/hire/)."""
11
+ xdg_config = os.environ.get("XDG_CONFIG_HOME", "")
12
+ if xdg_config:
13
+ base = Path(xdg_config)
14
+ else:
15
+ base = Path.home() / ".config"
16
+
17
+ config_dir = base / APP_NAME
18
+ config_dir.mkdir(parents=True, exist_ok=True)
19
+ return config_dir
20
+
21
+
22
+ def get_data_dir() -> Path:
23
+ """Get XDG data directory (~/.local/share/hire/)."""
24
+ xdg_data = os.environ.get("XDG_DATA_HOME", "")
25
+ if xdg_data:
26
+ base = Path(xdg_data)
27
+ else:
28
+ base = Path.home() / ".local" / "share"
29
+
30
+ data_dir = base / APP_NAME
31
+ data_dir.mkdir(parents=True, exist_ok=True)
32
+ return data_dir
33
+
34
+
35
+ def get_config_path() -> Path:
36
+ """Get config file path (~/.config/hire/config.json)."""
37
+ return get_config_dir() / "config.json"
38
+
39
+
40
+ def get_sessions_dir(agent: str | None = None) -> Path:
41
+ """Get sessions directory for an agent.
42
+
43
+ Args:
44
+ agent: Agent name (claude, codex, gemini). If None, returns base sessions dir.
45
+
46
+ Returns:
47
+ Path to sessions directory.
48
+ """
49
+ sessions_dir = get_data_dir() / "sessions"
50
+ if agent:
51
+ sessions_dir = sessions_dir / agent
52
+ sessions_dir.mkdir(parents=True, exist_ok=True)
53
+ return sessions_dir
hire/session.py ADDED
@@ -0,0 +1,163 @@
1
+ """Session management."""
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from .paths import get_data_dir, get_sessions_dir
9
+
10
+
11
+ def create_session(agent: str, cli_session_id: str, name: str | None = None) -> dict[str, Any]:
12
+ """Create a new session."""
13
+ session = {
14
+ "id": str(uuid.uuid4()),
15
+ "cli_session_id": cli_session_id,
16
+ "agent": agent,
17
+ "name": name,
18
+ "created_at": datetime.now().isoformat(),
19
+ "updated_at": datetime.now().isoformat(),
20
+ }
21
+ save_session(session)
22
+ return session
23
+
24
+
25
+ def save_session(session: dict[str, Any]) -> None:
26
+ """Save a session to file."""
27
+ session["updated_at"] = datetime.now().isoformat()
28
+ sessions_dir = get_sessions_dir(session["agent"])
29
+
30
+ # Use session ID as filename (1 file per session)
31
+ filename = f"{session['id']}.json"
32
+ filepath = sessions_dir / filename
33
+
34
+ with open(filepath, "w") as f:
35
+ json.dump(session, f, indent=2)
36
+
37
+ # Update latest pointer
38
+ latest_path = sessions_dir / "latest.json"
39
+ with open(latest_path, "w") as f:
40
+ json.dump({"session_id": session["id"], "filename": filename}, f)
41
+
42
+
43
+ def get_latest_session(agent: str) -> dict[str, Any] | None:
44
+ """Get the latest session for an agent."""
45
+ sessions_dir = get_sessions_dir(agent)
46
+ latest_path = sessions_dir / "latest.json"
47
+
48
+ if not latest_path.exists():
49
+ return None
50
+
51
+ with open(latest_path) as f:
52
+ latest = json.load(f)
53
+
54
+ filepath = sessions_dir / latest["filename"]
55
+ if not filepath.exists():
56
+ return None
57
+
58
+ with open(filepath) as f:
59
+ return json.load(f)
60
+
61
+
62
+ def get_session_by_id(session_id: str) -> dict[str, Any] | None:
63
+ """Get a session by its ID (searches all agents)."""
64
+ sessions_base = get_sessions_dir()
65
+
66
+ if not sessions_base.exists():
67
+ return None
68
+
69
+ for agent_dir in sessions_base.iterdir():
70
+ if not agent_dir.is_dir():
71
+ continue
72
+ for session_file in agent_dir.glob("*.json"):
73
+ if session_file.name == "latest.json":
74
+ continue
75
+ with open(session_file) as f:
76
+ session = json.load(f)
77
+ if session["id"] == session_id or session["id"].startswith(session_id):
78
+ return session
79
+ return None
80
+
81
+
82
+ def get_session_by_name(name: str) -> dict[str, Any] | None:
83
+ """Get a session by its name (searches all agents)."""
84
+ sessions_base = get_sessions_dir()
85
+
86
+ if not sessions_base.exists():
87
+ return None
88
+
89
+ for agent_dir in sessions_base.iterdir():
90
+ if not agent_dir.is_dir():
91
+ continue
92
+ for session_file in agent_dir.glob("*.json"):
93
+ if session_file.name == "latest.json":
94
+ continue
95
+ with open(session_file) as f:
96
+ session = json.load(f)
97
+ if session.get("name") == name:
98
+ return session
99
+ return None
100
+
101
+
102
+ def find_session(name_or_id: str) -> dict[str, Any] | None:
103
+ """Find a session by name or ID."""
104
+ # Try by name first
105
+ session = get_session_by_name(name_or_id)
106
+ if session:
107
+ return session
108
+
109
+ # Then try by ID
110
+ return get_session_by_id(name_or_id)
111
+
112
+
113
+ def list_sessions(agent: str | None = None) -> list[dict[str, Any]]:
114
+ """List all sessions, optionally filtered by agent."""
115
+ sessions_base = get_sessions_dir()
116
+
117
+ if not sessions_base.exists():
118
+ return []
119
+
120
+ sessions = []
121
+
122
+ if agent:
123
+ agent_dirs = [sessions_base / agent]
124
+ else:
125
+ agent_dirs = [d for d in sessions_base.iterdir() if d.is_dir()]
126
+
127
+ for agent_dir in agent_dirs:
128
+ if not agent_dir.exists():
129
+ continue
130
+ for session_file in agent_dir.glob("*.json"):
131
+ if session_file.name == "latest.json":
132
+ continue
133
+ with open(session_file) as f:
134
+ sessions.append(json.load(f))
135
+
136
+ # Sort by updated_at descending
137
+ sessions.sort(key=lambda s: s.get("updated_at", ""), reverse=True)
138
+ return sessions
139
+
140
+
141
+ def delete_session(session: dict[str, Any]) -> bool:
142
+ """Delete a session."""
143
+ sessions_dir = get_sessions_dir(session["agent"])
144
+
145
+ # Find and delete the session file
146
+ for session_file in sessions_dir.glob("*.json"):
147
+ if session_file.name == "latest.json":
148
+ continue
149
+ with open(session_file) as f:
150
+ s = json.load(f)
151
+ if s["id"] == session["id"]:
152
+ session_file.unlink()
153
+
154
+ # Update latest if needed
155
+ latest_path = sessions_dir / "latest.json"
156
+ if latest_path.exists():
157
+ with open(latest_path) as f:
158
+ latest = json.load(f)
159
+ if latest["session_id"] == session["id"]:
160
+ latest_path.unlink()
161
+
162
+ return True
163
+ return False
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: hire-ai
3
+ Version: 0.1.0
4
+ Summary: CLI to orchestrate AI agents (Claude, Codex, Gemini)
5
+ Project-URL: Homepage, https://github.com/nichiki/hire
6
+ Project-URL: Repository, https://github.com/nichiki/hire
7
+ Project-URL: Issues, https://github.com/nichiki/hire/issues
8
+ Author: nichiki
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,ai,claude,cli,codex,gemini,llm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.11
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.0; extra == 'dev'
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Requires-Dist: twine>=5.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # hire
33
+
34
+ CLI to orchestrate AI agents (Claude, Codex, Gemini).
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ # Using pipx (recommended)
40
+ pipx install hire
41
+
42
+ # Using pip
43
+ pip install hire
44
+
45
+ # Using Homebrew
46
+ brew install nichiki/tap/hire
47
+ ```
48
+
49
+ ## Prerequisites
50
+
51
+ You need at least one of the following CLI tools installed:
52
+
53
+ - [Claude CLI](https://docs.anthropic.com/claude-code)
54
+ - [Codex CLI](https://github.com/openai/codex)
55
+ - [Gemini CLI](https://github.com/google-gemini/gemini-cli)
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ # Basic usage - hire an agent
61
+ hire codex "Design a REST API for a todo app"
62
+ hire gemini "Research the latest React 19 features"
63
+ hire claude "Review this code for security issues"
64
+
65
+ # Continue a session
66
+ hire -c codex "Tell me more about the authentication"
67
+ hire -s SESSION_ID "Follow up question"
68
+
69
+ # Name a session for later
70
+ hire -n my-project codex "Start designing the architecture"
71
+ hire -s my-project "What about the database schema?"
72
+
73
+ # Output as JSON
74
+ hire gemini "Summarize this" --json
75
+
76
+ # Session management
77
+ hire sessions # List all sessions
78
+ hire sessions codex # List Codex sessions only
79
+ hire show SESSION_ID # Show session details
80
+ hire delete SESSION_ID # Delete a session
81
+ ```
82
+
83
+ ## Options
84
+
85
+ | Option | Description |
86
+ |--------|-------------|
87
+ | `-c, --continue` | Continue the latest session |
88
+ | `-s, --session ID` | Continue a specific session |
89
+ | `-n, --name NAME` | Name the session |
90
+ | `-m, --model MODEL` | Specify model to use |
91
+ | `--json` | Output in JSON format |
92
+
93
+ ## Configuration
94
+
95
+ Config is stored at `~/.config/hire/config.json`:
96
+
97
+ ```json
98
+ {
99
+ "adapters": {
100
+ "claude": {
101
+ "command": "claude",
102
+ "args": ["--dangerously-skip-permissions"]
103
+ },
104
+ "codex": {
105
+ "command": "codex",
106
+ "args": ["--full-auto"]
107
+ },
108
+ "gemini": {
109
+ "command": "gemini",
110
+ "args": ["-y"]
111
+ }
112
+ },
113
+ "defaults": {
114
+ "agent": "claude"
115
+ }
116
+ }
117
+ ```
118
+
119
+ ## Data Storage
120
+
121
+ Sessions are stored at `~/.local/share/hire/sessions/`.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,20 @@
1
+ hire/__init__.py,sha256=-V9lKavG7egEg5jYKkWGmwMTdpZzvGX_Tkl5sM9U6l0,90
2
+ hire/cli.py,sha256=jGCt6rAKTy_Ydk4sorBAzJ6uSfuuJDeAEVgxyZ14pqg,4244
3
+ hire/config.py,sha256=s058dSnojmIdT1KRULWXchdMv6WM9aZ8CKgCqMGdVvA,1164
4
+ hire/paths.py,sha256=PMLwEil1qRmW-9fJ-nTnvWZOUuJvMc-N54xrPp2NHfA,1372
5
+ hire/session.py,sha256=Y-A2zCqiVshEOkBTNTAgaYu_FtCy1JBp4R91NMs5ffY,4874
6
+ hire/adapters/__init__.py,sha256=Hl3ZpDFaWQL0RSKT_XaNTWHwMP6VQMorq7Ce_pn3qpU,612
7
+ hire/adapters/base.py,sha256=rubjoIXu7S8nIhBhksejR_Pb5YG4kwTZUj3dAMff_l4,1105
8
+ hire/adapters/claude.py,sha256=tM7G25jGqinjK_7QjtF_N0E089V9p1dzNReXJnq0VO4,1929
9
+ hire/adapters/codex.py,sha256=_4MXGTz-0YujuutDVCliaj9P94bm-Z1yNEElRtN3wzA,2895
10
+ hire/adapters/gemini.py,sha256=Qm_e1zzhxu2ooXANfFvVCwzxteVatlbDnBZeuf-jHNw,2613
11
+ hire/commands/__init__.py,sha256=sp3kdLPcKmqqgh_PyiOyDxJeHhnCHw0GIaoHWccgSCk,204
12
+ hire/commands/ask.py,sha256=tsM3VK3i06zTQgb0iEAXX97_13b9bHVyX8Ju7dV369g,4490
13
+ hire/commands/delete.py,sha256=1fLZ64xmyIzR5s7i7lsNe-WornsD_qsOAe1cCdOodDU,991
14
+ hire/commands/sessions.py,sha256=aIsh8oC4N3KtMeUdIrCvU3hONMxAfV_Z4qnprBiP62k,1073
15
+ hire/commands/show.py,sha256=gztFedJ553fvru0dPbHyperOMXDew-Yri3E3APu9d3c,860
16
+ hire_ai-0.1.0.dist-info/METADATA,sha256=FCFDGQIV4XBQ7ZhLCHq9hlJ6HRP2nahzJw3ievV3y9s,3151
17
+ hire_ai-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
18
+ hire_ai-0.1.0.dist-info/entry_points.txt,sha256=oq7wq8Ju9C2Lw79ljxSkQIaeKOHA_aIN9JKQAaDbz_Q,39
19
+ hire_ai-0.1.0.dist-info/licenses/LICENSE,sha256=Hj8NYmaOGk4xCcCYqD_5U91CVJwcJXEFSMWS965dUI0,1064
20
+ hire_ai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hire = hire.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 nichiki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.