rakit-cli 2.0.1__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.
- rakit_cli/__init__.py +3 -0
- rakit_cli/branding.py +21 -0
- rakit_cli/config.py +267 -0
- rakit_cli/main.py +1397 -0
- rakit_cli/mcp_server.py +325 -0
- rakit_cli/security.py +90 -0
- rakit_cli/skill_installer.py +218 -0
- rakit_cli/tasks_parser.py +59 -0
- rakit_cli/templates/SKILL.md +125 -0
- rakit_cli-2.0.1.dist-info/METADATA +108 -0
- rakit_cli-2.0.1.dist-info/RECORD +13 -0
- rakit_cli-2.0.1.dist-info/WHEEL +4 -0
- rakit_cli-2.0.1.dist-info/entry_points.txt +3 -0
rakit_cli/__init__.py
ADDED
rakit_cli/branding.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Banner dan identitas visual terminal (ASCII/Unicode Logo) untuk `rakit-cli` & `rakit-mcp`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
RAKIT_CLI_VERSION = "2.0.1"
|
|
6
|
+
|
|
7
|
+
RAKIT_ASCII_LOGO = r"""
|
|
8
|
+
____ _ _ _____ _____ ____ ___
|
|
9
|
+
| _ \ / \ | |/ /_ _|_ _| __ _|___ \ / _ \
|
|
10
|
+
| |_) | / _ \ | ' / | | | | \ \ / / __) | | | |
|
|
11
|
+
| _ < / ___ \| . \ | | | | \ V / / __/| |_| |
|
|
12
|
+
|_| \_\/_/ \_\_|\_\___| |_| \_/ |_____|\___/
|
|
13
|
+
[G1: Spec Gate] ---> [G2: E2E Video UAT] ---> [G3: Human Merge]
|
|
14
|
+
""".strip("\n")
|
|
15
|
+
|
|
16
|
+
RAKIT_COMPACT_BADGE = "◆ RAKIT v2.0 · Autonomous SDLC & Hybrid Workspace Engine"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_cli_banner() -> str:
|
|
20
|
+
"""Mengembalikan banner teks logo RAKIT v2.0 untuk layar bantuan CLI (`rakit banner`)."""
|
|
21
|
+
return f"{RAKIT_ASCII_LOGO}\n {RAKIT_COMPACT_BADGE}\n"
|
rakit_cli/config.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Pengelolaan berkas konfigurasi workspace lokal `.rakit/workspace.json` bebas rahasia."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
DEFAULT_SECRET_BLOCKLIST: tuple[str, ...] = (
|
|
13
|
+
".env",
|
|
14
|
+
".env.*",
|
|
15
|
+
"*.pem",
|
|
16
|
+
"*.key",
|
|
17
|
+
"*.p12",
|
|
18
|
+
"*.pfx",
|
|
19
|
+
"id_rsa*",
|
|
20
|
+
"id_ed25519*",
|
|
21
|
+
"secrets/**",
|
|
22
|
+
"credentials.json",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class WorkspaceConfig:
|
|
28
|
+
"""
|
|
29
|
+
TUJUAN: Menyimpan identitas proyek, server RAKIT, sidik jari folder,
|
|
30
|
+
token API lokal, dan daftar blokir rahasia.
|
|
31
|
+
|
|
32
|
+
KEPUTUSAN TEKNIS: Token autentikasi disimpan di `.rakit/workspace.json` yang secara otomatis
|
|
33
|
+
masuk ke `.rakit/.gitignore` agar tidak pernah ter-commit ke repositori Git.
|
|
34
|
+
DIPANGGIL DARI: `rakit init`, `rakit status`, `rakit token`, dan server MCP `stdio`
|
|
35
|
+
DEPENDENSI: pathlib, hashlib, json
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
project_slug: str
|
|
39
|
+
api_base_url: str
|
|
40
|
+
machine_label: str
|
|
41
|
+
root_fingerprint: str
|
|
42
|
+
active_ticket_key: str = ""
|
|
43
|
+
api_token: str = ""
|
|
44
|
+
secret_blocklist: tuple[str, ...] = DEFAULT_SECRET_BLOCKLIST
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compute_workspace_fingerprint(workspace_root: Path) -> str:
|
|
48
|
+
"""Menghasilkan hash SHA-256 satu arah dari jalur absolut folder kerja notebook."""
|
|
49
|
+
normalized = str(workspace_root.resolve()).lower().encode("utf-8")
|
|
50
|
+
return hashlib.sha256(normalized).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_workspace_config(workspace_root: Path) -> WorkspaceConfig | None:
|
|
54
|
+
"""Membaca `.rakit/workspace.json` bila sudah diinisialisasi."""
|
|
55
|
+
config_file = workspace_root / ".rakit" / "workspace.json"
|
|
56
|
+
if not config_file.exists():
|
|
57
|
+
return None
|
|
58
|
+
raw_data = json.loads(config_file.read_text(encoding="utf-8"))
|
|
59
|
+
raw_blocklist = raw_data.get("secret_blocklist")
|
|
60
|
+
resolved_blocklist = (
|
|
61
|
+
tuple(str(item) for item in raw_blocklist)
|
|
62
|
+
if isinstance(raw_blocklist, list) and raw_blocklist
|
|
63
|
+
else DEFAULT_SECRET_BLOCKLIST
|
|
64
|
+
)
|
|
65
|
+
return WorkspaceConfig(
|
|
66
|
+
project_slug=str(raw_data["project_slug"]),
|
|
67
|
+
api_base_url=str(raw_data["api_base_url"]),
|
|
68
|
+
machine_label=str(raw_data.get("machine_label") or socket.gethostname()),
|
|
69
|
+
root_fingerprint=str(raw_data["root_fingerprint"]),
|
|
70
|
+
active_ticket_key=str(raw_data.get("active_ticket_key") or ""),
|
|
71
|
+
api_token=str(raw_data.get("api_token") or ""),
|
|
72
|
+
secret_blocklist=resolved_blocklist,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_workspace_config(workspace_root: Path, config: WorkspaceConfig) -> Path:
|
|
77
|
+
"""Menyimpan `.rakit/workspace.json` dan memastikan `.rakit/.gitignore` dibuat."""
|
|
78
|
+
rakit_dir = workspace_root / ".rakit"
|
|
79
|
+
rakit_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
gitignore_file = rakit_dir / ".gitignore"
|
|
82
|
+
if not gitignore_file.exists():
|
|
83
|
+
gitignore_file.write_text(
|
|
84
|
+
"workspace.json\n*.token\ncache/\noffline_queue.json\n", encoding="utf-8"
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
existing_gi = gitignore_file.read_text(encoding="utf-8")
|
|
88
|
+
if "workspace.json" not in existing_gi:
|
|
89
|
+
gitignore_file.write_text("workspace.json\n" + existing_gi, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
config_file = rakit_dir / "workspace.json"
|
|
92
|
+
payload = asdict(config)
|
|
93
|
+
payload["secret_blocklist"] = list(config.secret_blocklist)
|
|
94
|
+
config_file.write_text(
|
|
95
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
96
|
+
encoding="utf-8",
|
|
97
|
+
)
|
|
98
|
+
return config_file
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_effective_api_token(config: WorkspaceConfig | None) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Mengambil token API aktif dengan urutan prioritas:
|
|
104
|
+
1. Environment variable `RAKIT_API_TOKEN`
|
|
105
|
+
2. `api_token` tersimpan di `.rakit/workspace.json`
|
|
106
|
+
"""
|
|
107
|
+
import os
|
|
108
|
+
|
|
109
|
+
env_token = os.environ.get("RAKIT_API_TOKEN", "").strip()
|
|
110
|
+
if env_token:
|
|
111
|
+
return env_token
|
|
112
|
+
if config and config.api_token:
|
|
113
|
+
return config.api_token.strip()
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def enqueue_offline_sync_event(workspace_root: Path, event_payload: dict[str, Any]) -> Path:
|
|
118
|
+
"""
|
|
119
|
+
TUJUAN: Mencatat progres TASKS.md ke antrean luring `.rakit/offline_queue.json` (US-011).
|
|
120
|
+
|
|
121
|
+
ALUR:
|
|
122
|
+
1. Membaca daftar event yang belum terkirim di `.rakit/offline_queue.json`.
|
|
123
|
+
2. Bila `idempotency_key` belum ada di antrean, menambahkan event baru.
|
|
124
|
+
3. Menyimpan kembali berkas antrean luring.
|
|
125
|
+
|
|
126
|
+
DIPANGGIL DARI: `rakit sync` dan perkakas MCP `rakit_sync_progress`
|
|
127
|
+
DEPENDENSI: json, pathlib
|
|
128
|
+
"""
|
|
129
|
+
rakit_dir = workspace_root / ".rakit"
|
|
130
|
+
rakit_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
queue_file = rakit_dir / "offline_queue.json"
|
|
132
|
+
|
|
133
|
+
existing_items: list[dict[str, Any]] = []
|
|
134
|
+
if queue_file.exists():
|
|
135
|
+
loaded = json.loads(queue_file.read_text(encoding="utf-8"))
|
|
136
|
+
if isinstance(loaded, list):
|
|
137
|
+
existing_items = loaded
|
|
138
|
+
|
|
139
|
+
idem_key = str(event_payload.get("idempotency_key") or "")
|
|
140
|
+
if not idem_key or all(item.get("idempotency_key") != idem_key for item in existing_items):
|
|
141
|
+
existing_items.append(event_payload)
|
|
142
|
+
|
|
143
|
+
queue_file.write_text(
|
|
144
|
+
json.dumps(existing_items, indent=2, ensure_ascii=False) + "\n",
|
|
145
|
+
encoding="utf-8",
|
|
146
|
+
)
|
|
147
|
+
return queue_file
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_active_task_prompt(
|
|
151
|
+
workspace_root: Path,
|
|
152
|
+
ticket_data: dict[str, Any],
|
|
153
|
+
) -> Path:
|
|
154
|
+
"""
|
|
155
|
+
Menulis berkas `.rakit/PROMPT.md` yang memuat konteks instruksi kerja aktif
|
|
156
|
+
sehingga agen AI lokal dapat langsung mengeksekusi dengan perintah
|
|
157
|
+
'Lanjutkan pekerjaan dari RAKIT'.
|
|
158
|
+
"""
|
|
159
|
+
rakit_dir = workspace_root / ".rakit"
|
|
160
|
+
rakit_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
prompt_file = rakit_dir / "PROMPT.md"
|
|
162
|
+
|
|
163
|
+
key = str(ticket_data.get("key") or "UNKNOWN")
|
|
164
|
+
title = str(ticket_data.get("title") or "Tugas Aktif")
|
|
165
|
+
kind = str(ticket_data.get("kind") or "WORK_ITEM")
|
|
166
|
+
category = str(ticket_data.get("category") or "").strip()
|
|
167
|
+
category_md = f"- **Kategori Alur / Event**: `{category}`\n" if category else ""
|
|
168
|
+
desc = str(ticket_data.get("description") or "Tidak ada deskripsi spesifik.")
|
|
169
|
+
allowed_list = ticket_data.get("allowed_paths") or []
|
|
170
|
+
allowed = ", ".join(allowed_list) if allowed_list else "Semua jalur modul proyek"
|
|
171
|
+
forbidden_list = ticket_data.get("forbidden_paths") or []
|
|
172
|
+
forbidden = ", ".join(forbidden_list) if forbidden_list else "Tidak ada"
|
|
173
|
+
|
|
174
|
+
ac_items = ticket_data.get("acceptance_criteria") or []
|
|
175
|
+
ac_lines: list[str] = []
|
|
176
|
+
if isinstance(ac_items, list) and ac_items:
|
|
177
|
+
for idx, ac in enumerate(ac_items, 1):
|
|
178
|
+
if isinstance(ac, dict):
|
|
179
|
+
given = ac.get("given", "-")
|
|
180
|
+
when = ac.get("when", "-")
|
|
181
|
+
then = ac.get("then", "-")
|
|
182
|
+
ac_lines.append(
|
|
183
|
+
f"{idx}. **Given**: {given}\n **When**: {when}\n **Then**: {then}"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
ac_lines.append(f"{idx}. {ac}")
|
|
187
|
+
ac_formatted = (
|
|
188
|
+
"\n\n".join(ac_lines) if ac_lines else "- Mengikuti spesifikasi arsitektur standar modul."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
content = (
|
|
192
|
+
f"# 📋 TUGAS AKTIF RAKIT: [{key}] {title}\n\n"
|
|
193
|
+
f"Kamu sedang mengerjakan tiket aktif dari orkestrator **RAKIT v2.0**.\n\n"
|
|
194
|
+
f"## 1. Identitas & Kontrak Tiket\n"
|
|
195
|
+
f"- **Kunci Tiket**: `{key}`\n"
|
|
196
|
+
f"- **Jenis**: `{kind}`\n"
|
|
197
|
+
f"{category_md}"
|
|
198
|
+
f"- **Pagar Direktori Diizinkan (`allowed_paths`)**: `{allowed}`\n"
|
|
199
|
+
f"- **Direktori Terlarang (`forbidden_paths`)**: `{forbidden}`\n\n"
|
|
200
|
+
f"## 2. Deskripsi Pekerjaan\n"
|
|
201
|
+
f"{desc}\n\n"
|
|
202
|
+
f"## 3. Kriteria Penerimaan (Acceptance Criteria / GWT)\n"
|
|
203
|
+
f"{ac_formatted}\n\n"
|
|
204
|
+
f"## 4. Instruksi Eksekusi untuk AI\n"
|
|
205
|
+
f"1. Periksa kode dan modul terkait di folder proyek ini sesuai `allowed_paths`.\n"
|
|
206
|
+
f"2. Tulis kode implementasi dan unit test pendukung hingga semua kriteria di atas "
|
|
207
|
+
f"terpenuhi dan tes lulus hijau.\n"
|
|
208
|
+
f"3. Selama pengerjaan, laporkan progresmu secara berkala ke Web Console RAKIT:\n"
|
|
209
|
+
f' `rakit sync --note "Deskripsi progres milestone aktif"`\n'
|
|
210
|
+
f"4. Bila kamu ragu atau butuh input arsitektur, panggil tool MCP `rakit_ask_human` "
|
|
211
|
+
f"atau laporkan via `rakit sync`.\n"
|
|
212
|
+
f"5. Setelah kode selesai dan semua pengujian lulus, jalankan:\n"
|
|
213
|
+
f" `rakit verify`\n"
|
|
214
|
+
f" untuk mengajukan tiket ke tahap QA & UAT Review (Gerbang G2).\n\n"
|
|
215
|
+
f"> **PENTING**: Kerjakan perubahan kode secara nyata di repositori ini. "
|
|
216
|
+
f"Dilarang hanya mensimulasikan hasil di chat!\n"
|
|
217
|
+
)
|
|
218
|
+
prompt_file.write_text(content, encoding="utf-8")
|
|
219
|
+
return prompt_file
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def fetch_and_write_prompt(
|
|
223
|
+
workspace_root: Path,
|
|
224
|
+
cfg: WorkspaceConfig,
|
|
225
|
+
ticket_key: str | None = None,
|
|
226
|
+
) -> Path | None:
|
|
227
|
+
"""
|
|
228
|
+
TUJUAN: Mengambil data tiket dari peladen RAKIT dan menulis .rakit/PROMPT.md.
|
|
229
|
+
|
|
230
|
+
DIPANGGIL DARI: `rakit prompt`, `rakit sync` (bila belum ada), dan `rakit start`.
|
|
231
|
+
"""
|
|
232
|
+
key = (ticket_key or "").strip() or (cfg.active_ticket_key or "").strip()
|
|
233
|
+
if not key:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
token = get_effective_api_token(cfg)
|
|
237
|
+
ticket_obj: dict[str, Any] = {
|
|
238
|
+
"key": key,
|
|
239
|
+
"title": f"Tugas aktif {key}",
|
|
240
|
+
"kind": "FEATURE",
|
|
241
|
+
"description": "Dikerjakan dari lingkungan lokal.",
|
|
242
|
+
"allowed_paths": [],
|
|
243
|
+
"forbidden_paths": [],
|
|
244
|
+
"acceptance_criteria": [],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if cfg.api_base_url:
|
|
248
|
+
try:
|
|
249
|
+
import urllib.request
|
|
250
|
+
|
|
251
|
+
url = f"{cfg.api_base_url}/tickets/{key}/?project_slug={cfg.project_slug}"
|
|
252
|
+
headers = {"Content-Type": "application/json"}
|
|
253
|
+
if token:
|
|
254
|
+
headers["Authorization"] = f"Token {token}"
|
|
255
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
256
|
+
with urllib.request.urlopen(req, timeout=4) as resp:
|
|
257
|
+
if resp.status == 200:
|
|
258
|
+
raw_data = json.loads(resp.read().decode("utf-8"))
|
|
259
|
+
payload = raw_data.get("data") or {}
|
|
260
|
+
if "ticket" in payload and isinstance(payload["ticket"], dict):
|
|
261
|
+
ticket_obj = payload["ticket"]
|
|
262
|
+
elif "key" in payload:
|
|
263
|
+
ticket_obj = payload
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
return write_active_task_prompt(workspace_root, ticket_obj)
|