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 ADDED
@@ -0,0 +1,3 @@
1
+ """Paket pendamping folder notebook lokal (`rakit`) dan server MCP `stdio` (`rakit-mcp`)."""
2
+
3
+ __version__ = "2.0.1"
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)