karajan-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.
karajan/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Karajan package."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: architect
3
+ description: Technical planning agent. Designs solutions, writes ADRs, and produces implementation specs. Use before any coding task.
4
+ model: google/gemini-2.0-flash
5
+ temperature: 0.3
6
+ mode: primary
7
+ tools:
8
+ - read
9
+ - write
10
+ permissions:
11
+ allow:
12
+ - read: "**"
13
+ - write: ".agent/DECISIONS.md"
14
+ - write: ".workflow-state/**"
15
+ deny:
16
+ - write: "src/**"
17
+ - write: "*.ts"
18
+ - write: "*.py"
19
+ - bash: "*"
20
+ ---
21
+
22
+ You are a Software Architect with deep experience in TypeScript, Node.js, event-driven systems, and complex integrations.
23
+
karajan/agents/qa.md ADDED
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: qa
3
+ description: QA validation agent. Runs tests, verifies acceptance criteria, surfaces edge cases.
4
+ model: ollama/qwen2.5:7b
5
+ temperature: 0.1
6
+ mode: primary
7
+ tools:
8
+ - read
9
+ - bash
10
+ ---
11
+
12
+ You are a QA Engineer.
13
+
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: researcher
3
+ description: Technical research agent. Gathers context, finds patterns, identifies risks before design begins. Always runs before architect.
4
+ model: google/gemini-2.0-flash
5
+ temperature: 0.2
6
+ mode: primary
7
+ tools:
8
+ - read
9
+ - web_search
10
+ ---
11
+
12
+ You are a Technical Research Specialist.
13
+
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: reviewer
3
+ description: Code review agent. Reviews diffs for correctness, conventions, and quality.
4
+ model: google/gemini-2.0-flash
5
+ temperature: 0.1
6
+ mode: primary
7
+ tools:
8
+ - read
9
+ - bash
10
+ ---
11
+
12
+ You are a Tech Lead doing code review.
13
+
karajan/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ """Karajan CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Sequence
7
+
8
+ from . import __version__
9
+
10
+
11
+ def _build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="karajan",
14
+ description="An AI agent harness for software engineers. Built on opencode + LangGraph.",
15
+ )
16
+ subparsers = parser.add_subparsers(dest="command")
17
+
18
+ init_parser = subparsers.add_parser("init", help="Initialize in current project")
19
+ init_parser.add_argument("--profile", "-p", choices=["solo", "team"], default=None)
20
+ init_parser.add_argument("--force", "-f", action="store_true")
21
+
22
+ task_parser = subparsers.add_parser("task", help="Run full workflow")
23
+ task_parser.add_argument("ticket")
24
+ task_parser.add_argument("--model", "-m", default=None)
25
+ task_parser.add_argument("--dev-model", default=None)
26
+ task_parser.add_argument("--qa-model", default=None)
27
+ task_parser.add_argument("--from-stage", default=None)
28
+ task_parser.add_argument("--dry-run", action="store_true")
29
+
30
+ status_parser = subparsers.add_parser("status", help="Show active workflows")
31
+ status_parser.add_argument("ticket", nargs="?", default=None)
32
+
33
+ models_parser = subparsers.add_parser("models", help="List available models")
34
+ models_parser.add_argument("--provider", default=None)
35
+
36
+ config_parser = subparsers.add_parser("config", help="Show current project config")
37
+ config_parser.add_argument("--show", action="store_true")
38
+ config_parser.add_argument("--set", dest="set_key", default=None)
39
+
40
+ subparsers.add_parser("version", help="Show version")
41
+ subparsers.add_parser("profiles", help="List profiles and defaults")
42
+
43
+ logs_parser = subparsers.add_parser("logs", help="Show run logs and cost summary")
44
+ logs_parser.add_argument("ticket", nargs="?", default=None)
45
+ logs_parser.add_argument("--tail", "-n", type=int, default=20)
46
+ logs_parser.add_argument("--summary", "-s", action="store_true")
47
+
48
+ return parser
49
+
50
+
51
+ def app(argv: Sequence[str] | None = None) -> None:
52
+ parser = _build_parser()
53
+ args = parser.parse_args(argv)
54
+
55
+ if args.command is None:
56
+ parser.print_help()
57
+ raise SystemExit(0)
58
+
59
+ if args.command == "init":
60
+ from .commands.init import run_init
61
+
62
+ run_init(profile=args.profile, force=args.force)
63
+ elif args.command == "task":
64
+ from .commands.task import run_task
65
+
66
+ run_task(
67
+ ticket=args.ticket,
68
+ model=args.model,
69
+ dev_model=args.dev_model,
70
+ qa_model=args.qa_model,
71
+ from_stage=args.from_stage,
72
+ dry_run=args.dry_run,
73
+ )
74
+ elif args.command == "status":
75
+ from .commands.status import run_status
76
+
77
+ run_status(ticket=args.ticket)
78
+ elif args.command == "models":
79
+ from .commands.models import run_models
80
+
81
+ run_models(provider=args.provider)
82
+ elif args.command == "config":
83
+ from .commands.config import run_config
84
+
85
+ run_config(show=args.show, set_key=args.set_key)
86
+ elif args.command == "version":
87
+ print(f"karajan v{__version__}")
88
+ elif args.command == "profiles":
89
+ from .commands.profiles import run_profiles
90
+
91
+ run_profiles()
92
+ elif args.command == "logs":
93
+ from .commands.logs import run_logs
94
+
95
+ run_logs(ticket=args.ticket, tail=args.tail, summary=args.summary)
96
+ else:
97
+ parser.error(f"Unknown command: {args.command}")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ app()
102
+
@@ -0,0 +1,2 @@
1
+ """Karajan command implementations."""
2
+
@@ -0,0 +1,89 @@
1
+ """Show or update Karajan config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ from ..config import dump_config_toml, load_config
9
+
10
+
11
+ def run_config(show: bool = False, set_key: str = None):
12
+ config_path = Path(".karajan/config.toml")
13
+
14
+ if set_key:
15
+ _set_config_value(config_path, set_key)
16
+ return
17
+
18
+ if show or not config_path.exists():
19
+ config = load_config()
20
+ print(dump_config_toml(config.model_dump()))
21
+ return
22
+
23
+ print(config_path.read_text(encoding="utf-8"))
24
+
25
+
26
+ def _set_config_value(config_path: Path, assignment: str):
27
+ if not config_path.exists():
28
+ print("No .karajan/config.toml found. Run karajan init first.")
29
+ raise SystemExit(1)
30
+ if "=" not in assignment:
31
+ print(f"Invalid config assignment: {assignment}")
32
+ raise SystemExit(1)
33
+ key, raw_value = (part.strip() for part in assignment.split("=", 1))
34
+ parts = [part for part in key.split(".") if part]
35
+ if not key or len(parts) != len(key.split(".")) or len(parts) > 2:
36
+ print(f"Invalid config path: {key}")
37
+ raise SystemExit(1)
38
+ with config_path.open("rb") as handle:
39
+ config = tomllib.load(handle)
40
+ target = _coerce_config_value(raw_value)
41
+ if len(parts) == 1:
42
+ if isinstance(config.get(parts[0]), dict):
43
+ print(f"Invalid config path: {key}")
44
+ raise SystemExit(1)
45
+ config[parts[0]] = target
46
+ else:
47
+ section, field = parts
48
+ section_data = config.get(section)
49
+ if section_data is None:
50
+ config[section] = {field: target}
51
+ elif isinstance(section_data, dict):
52
+ section_data[field] = target
53
+ else:
54
+ print(f"Invalid config path: {key}")
55
+ raise SystemExit(1)
56
+ _write_config(config_path, config)
57
+ print(f"✅ Set {key} = {_format_config_value(target)}")
58
+
59
+
60
+ def _coerce_config_value(raw_value: str):
61
+ value = raw_value.strip()
62
+ lowered = value.lower()
63
+ if lowered == "true":
64
+ return True
65
+ if lowered == "false":
66
+ return False
67
+ try:
68
+ return int(value)
69
+ except ValueError:
70
+ try:
71
+ return float(value)
72
+ except ValueError:
73
+ return value
74
+
75
+
76
+ def _format_config_value(value) -> str:
77
+ if isinstance(value, bool):
78
+ return "true" if value else "false"
79
+ return str(value)
80
+
81
+
82
+ def _write_config(config_path: Path, config: dict):
83
+ try:
84
+ import tomli_w
85
+ except ModuleNotFoundError:
86
+ config_path.write_text(dump_config_toml(config), encoding="utf-8")
87
+ return
88
+ with config_path.open("wb") as handle:
89
+ tomli_w.dump(config, handle)
@@ -0,0 +1,247 @@
1
+ """Initialize Karajan in a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ CONTEXT_MD = """# {project_name} - Context
10
+
11
+ ## What is this
12
+ One sentence. What problem does it solve and for whom.
13
+
14
+ ## Current state
15
+ Phase: exploration / mvp / production / maintenance
16
+
17
+ ## Immediate goal
18
+ What you want to achieve in the next work sessions.
19
+
20
+ ## Important context
21
+ Anything an agent needs to know that isn't obvious from the code.
22
+ """
23
+
24
+ STACK_MD = """# Stack - {project_name}
25
+
26
+ ## Runtime
27
+ - Node X / Python X
28
+
29
+ ## Framework
30
+ -
31
+
32
+ ## Database
33
+ -
34
+
35
+ ## Infrastructure
36
+ -
37
+
38
+ ## Key dependencies
39
+ -
40
+
41
+ ## Pending decisions
42
+ - [ ]
43
+ """
44
+
45
+ DECISIONS_MD = """# Decisions - {project_name}
46
+
47
+ ## YYYY-MM - Decision title
48
+ **Decided:** what was chosen
49
+ **Why:** reasoning
50
+ **Discarded:** what was rejected and why
51
+ **Revisit when:** condition that would make you reconsider
52
+ """
53
+
54
+ CONSTRAINTS_MD = """# Constraints - {project_name}
55
+
56
+ ## Never touch without discussing
57
+ -
58
+
59
+ ## Never add without approval
60
+ - New dependencies
61
+ - Breaking API changes
62
+
63
+ ## Agent autonomy limits
64
+ - Agent CAN: create files, add tests, refactor within a module
65
+ - Agent NEEDS APPROVAL: schema changes, new dependencies, config changes
66
+ - Agent NEVER: push to main directly
67
+ """
68
+
69
+ TASKS_MD = """# Tasks - {project_name}
70
+
71
+ ## In progress
72
+ - [ ]
73
+
74
+ ## Next
75
+ - [ ]
76
+
77
+ ## Done this week
78
+ - [x]
79
+ """
80
+
81
+ AGENTS_MD = """# Agent Instructions - {project_name}
82
+
83
+ ## Before any task
84
+ 1. Read CONTEXT.md
85
+ 2. Read STACK.md
86
+ 3. Read CONSTRAINTS.md
87
+ 4. Read TASKS.md
88
+
89
+ ## How to work here
90
+ - TypeScript strict - no `any`
91
+ - Atomic commits per logical change
92
+ - Tests required for non-trivial logic
93
+ - If spec is ambiguous: choose conservative option + leave TODO comment
94
+
95
+ ## What agent CAN do without asking
96
+ - Create files and components
97
+ - Add tests
98
+ - Refactor within same module
99
+ - Update docs
100
+
101
+ ## What NEEDS approval
102
+ - New dependencies
103
+ - Schema/migration changes
104
+ - Changes to public API
105
+ - Anything touching production config
106
+ """
107
+
108
+ KARAJAN_CONFIG = """# Karajan config - {project_name}
109
+ # Docs: https://github.com/TU_USER/karajan
110
+
111
+ profile = "{profile}"
112
+
113
+ [models]
114
+ # Override profile defaults here if needed
115
+
116
+ [tracker]
117
+ type = "{tracker_type}"
118
+
119
+ [dev_cli]
120
+ tool = "{dev_cli}"
121
+
122
+ [observability]
123
+ enabled = false
124
+ provider = "local"
125
+ """
126
+
127
+
128
+ def run_init(profile: str = None, force: bool = False):
129
+ project_name = Path.cwd().name
130
+ print(f"\nKarajan init - {project_name}\n")
131
+
132
+ if not profile:
133
+ profile = input("Profile [solo/team] (solo): ").strip() or "solo"
134
+ if profile not in {"solo", "team"}:
135
+ raise ValueError("Profile must be 'solo' or 'team'.")
136
+
137
+ tracker_type = "github" if profile == "solo" else "jira"
138
+ dev_cli = "opencode" if profile == "solo" else "claude"
139
+
140
+ karajan_dir = Path(".karajan")
141
+ if karajan_dir.exists() and not force:
142
+ overwrite = input(".karajan/ already exists. Overwrite? [y/N]: ").strip().lower()
143
+ if overwrite not in {"y", "yes"}:
144
+ print("Aborted.")
145
+ return
146
+
147
+ karajan_dir.mkdir(exist_ok=True)
148
+ agent_dir = Path(".agent")
149
+ agent_dir.mkdir(exist_ok=True)
150
+
151
+ files = {
152
+ agent_dir / "CONTEXT.md": CONTEXT_MD.format(project_name=project_name),
153
+ agent_dir / "STACK.md": STACK_MD.format(project_name=project_name),
154
+ agent_dir / "DECISIONS.md": DECISIONS_MD.format(project_name=project_name),
155
+ agent_dir / "CONSTRAINTS.md": CONSTRAINTS_MD.format(project_name=project_name),
156
+ agent_dir / "TASKS.md": TASKS_MD.format(project_name=project_name),
157
+ agent_dir / "AGENTS.md": AGENTS_MD.format(project_name=project_name),
158
+ karajan_dir / "config.toml": KARAJAN_CONFIG.format(
159
+ project_name=project_name,
160
+ profile=profile,
161
+ tracker_type=tracker_type,
162
+ dev_cli=dev_cli,
163
+ ),
164
+ }
165
+
166
+ for path, content in files.items():
167
+ if not path.exists() or force:
168
+ path.write_text(content, encoding="utf-8")
169
+ print(f" + {path}")
170
+ else:
171
+ print(f" skip {path} (exists)")
172
+
173
+ env_example = Path(".env.karajan.example")
174
+ if not env_example.exists() or force:
175
+ _write_env_example(env_example, profile)
176
+ print(f" + {env_example}")
177
+
178
+ _copy_agent_definitions(profile, force=force)
179
+
180
+ if profile == "solo":
181
+ _setup_github()
182
+
183
+ print(f"\nKarajan initialized ({profile} profile)\n")
184
+
185
+
186
+ def _write_env_example(path: Path, profile: str):
187
+ solo_vars = """
188
+ KARAJAN_MODEL_REASONING=google/gemini-2.0-flash
189
+ KARAJAN_MODEL_DEV=ollama/qwen2.5-coder:14b
190
+ KARAJAN_MODEL_QA=ollama/qwen2.5:7b
191
+
192
+ GEMINI_API_KEY=
193
+ GITHUB_TOKEN=
194
+ GITHUB_REPO=TU_USER/repo-name
195
+ GITHUB_BASE_BRANCH=main
196
+ """
197
+ team_vars = """
198
+ KARAJAN_MODEL_REASONING=google/gemini-2.0-flash
199
+ KARAJAN_MODEL_DEV=anthropic/claude-sonnet-4-20250514
200
+ KARAJAN_MODEL_QA=anthropic/claude-haiku-4-5
201
+
202
+ GEMINI_API_KEY=
203
+ ANTHROPIC_API_KEY=
204
+ JIRA_BASE_URL=https://empresa.atlassian.net
205
+ JIRA_EMAIL=tu@empresa.com
206
+ JIRA_API_TOKEN=
207
+ GITHUB_TOKEN=
208
+ GITHUB_REPO=empresa/repo-name
209
+ GITHUB_BASE_BRANCH=main
210
+ LANGFUSE_HOST=https://cloud.langfuse.com
211
+ LANGFUSE_PUBLIC_KEY=
212
+ LANGFUSE_SECRET_KEY=
213
+ """
214
+ path.write_text(solo_vars if profile == "solo" else team_vars, encoding="utf-8")
215
+
216
+
217
+ def _setup_github():
218
+ try:
219
+ result = subprocess.run(["gh", "--version"], capture_output=True, text=True)
220
+ if result.returncode != 0:
221
+ return
222
+ except FileNotFoundError:
223
+ return
224
+
225
+
226
+ def _copy_agent_definitions(profile: str, force: bool = False):
227
+ agents_src = Path(__file__).resolve().parent.parent / "agents"
228
+ agents_dst = Path(".agents")
229
+ agents_dst.mkdir(parents=True, exist_ok=True)
230
+
231
+ for agent_file in agents_src.glob("*.md"):
232
+ dst = agents_dst / agent_file.name
233
+ if dst.exists() and not force:
234
+ print(f" skip {dst} (exists)")
235
+ continue
236
+
237
+ content = agent_file.read_text(encoding="utf-8")
238
+ if profile == "team":
239
+ content = content.replace(
240
+ "model: google/gemini-2.0-flash",
241
+ "model: anthropic/claude-sonnet-4-20250514",
242
+ ).replace(
243
+ "model: ollama/qwen2.5:7b",
244
+ "model: anthropic/claude-haiku-4-5",
245
+ )
246
+ dst.write_text(content, encoding="utf-8")
247
+ print(f" + {dst}")
@@ -0,0 +1,30 @@
1
+ """Show run logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..scripts.logger import KarajanLogger
8
+
9
+
10
+ def run_logs(ticket: str = None, tail: int = 20, summary: bool = False):
11
+ log_file = Path(".workflow-state/runs.log")
12
+ if not log_file.exists():
13
+ print("No runs logged yet")
14
+ return
15
+
16
+ if summary and ticket:
17
+ KarajanLogger().print_summary(ticket)
18
+ return
19
+
20
+ lines = log_file.read_text(encoding="utf-8").splitlines()
21
+ if ticket:
22
+ lines = [line for line in lines if ticket in line]
23
+
24
+ if not lines:
25
+ print("No runs logged yet")
26
+ return
27
+
28
+ for line in lines[-tail:]:
29
+ print(line)
30
+
@@ -0,0 +1,49 @@
1
+ """List available models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Optional
7
+
8
+
9
+ RECOMMENDED = {
10
+ "solo": [
11
+ ("google/gemini-2.0-flash", "Research, Architect, PRD, Reviewer", "Free tier"),
12
+ ("ollama/qwen2.5-coder:14b", "Dev agent", "Local, free"),
13
+ ("ollama/qwen2.5:7b", "QA agent", "Local, free, fast"),
14
+ ],
15
+ "team": [
16
+ ("google/gemini-2.0-flash", "Research, Architect, PRD, Reviewer", "Free tier"),
17
+ ("anthropic/claude-sonnet-4-20250514", "Dev agent (Claude Code)", "Paid"),
18
+ ("anthropic/claude-haiku-4-5", "QA agent", "Paid, cheaper"),
19
+ ],
20
+ }
21
+
22
+
23
+ def run_models(provider: Optional[str] = None):
24
+ from ..config import load_config
25
+
26
+ config = load_config()
27
+ print(f"Recommended models for profile: {config.profile}\n")
28
+ for model, role, cost in RECOMMENDED.get(config.profile, []):
29
+ print(f"- {model} | {role} | {cost}")
30
+
31
+ print("\nCurrent config:")
32
+ print(f" Reasoning -> {config.models.reasoning}")
33
+ print(f" Dev -> {config.models.dev}")
34
+ print(f" QA -> {config.models.qa}")
35
+
36
+ print("\nAll available models (from opencode):")
37
+ try:
38
+ command = ["opencode", "models"]
39
+ if provider:
40
+ command.extend(["--provider", provider])
41
+ result = subprocess.run(command, capture_output=True, text=True, timeout=10)
42
+ if result.returncode == 0:
43
+ print(result.stdout)
44
+ else:
45
+ print("Run `opencode auth login` to configure providers.")
46
+ except (FileNotFoundError, subprocess.TimeoutExpired):
47
+ print("opencode not installed or not responding.")
48
+ print("Install: npm install -g opencode")
49
+
@@ -0,0 +1,23 @@
1
+ """List profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..config import _load_profile, list_profiles
6
+
7
+
8
+ def run_profiles():
9
+ print("Available profiles:\n")
10
+ for profile_name in list_profiles():
11
+ data = _load_profile(profile_name)
12
+ meta = data.get("meta", {})
13
+ models = data.get("models", {})
14
+ observability = data.get("observability", {})
15
+ print(f"{profile_name} - {meta.get('description', '')}")
16
+ print(f" Tracker: {meta.get('tracker', '—')}")
17
+ print(f" Dev CLI: {meta.get('dev_cli', '—')}")
18
+ print(f" Reasoning: {models.get('reasoning', '—')}")
19
+ print(f" Dev model: {models.get('dev', '—')}")
20
+ print(f" QA model: {models.get('qa', '—')}")
21
+ print(f" Observability: {observability.get('enabled', False)}")
22
+ print()
23
+
@@ -0,0 +1,50 @@
1
+ """Show active workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ STAGE_EMOJI = {
11
+ "research": "🔍",
12
+ "plan": "🏗️",
13
+ "hitl_plan": "👤",
14
+ "prd": "📝",
15
+ "hitl_prd": "👤",
16
+ "dev": "💻",
17
+ "review": "🔎",
18
+ "qa": "🧪",
19
+ "hitl_pr": "👤",
20
+ "done": "✅",
21
+ }
22
+
23
+
24
+ def run_status(ticket: Optional[str] = None):
25
+ state_dir = Path(".workflow-state")
26
+ if not state_dir.exists():
27
+ print("No active workflows")
28
+ return
29
+
30
+ state_files = list(state_dir.glob("*.json"))
31
+ if ticket:
32
+ state_files = [path for path in state_files if ticket.lower() in path.stem.lower()]
33
+
34
+ if not state_files:
35
+ print("No active workflows")
36
+ return
37
+
38
+ print("Active Workflows")
39
+ for state_file in sorted(state_files):
40
+ try:
41
+ state = json.loads(state_file.read_text(encoding="utf-8"))
42
+ except Exception:
43
+ print(f"{state_file.stem}: unknown")
44
+ continue
45
+ ticket_id = state.get("ticket_id", state_file.stem)
46
+ stage = state.get("current_stage", "unknown")
47
+ summary = state.get("ticket", {}).get("summary", "")
48
+ emoji = STAGE_EMOJI.get(stage, "⚙️")
49
+ print(f"{ticket_id} | {emoji} {stage} | {summary[:50]}")
50
+