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/main.py
ADDED
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
"""Titik masuk CLI `rakit` (`init`, `scan`, `start`, `sync`, `verify`, `generate-guides`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rakit_cli.branding import RAKIT_CLI_VERSION, RAKIT_COMPACT_BADGE, render_cli_banner
|
|
15
|
+
from rakit_cli.config import (
|
|
16
|
+
WorkspaceConfig,
|
|
17
|
+
compute_workspace_fingerprint,
|
|
18
|
+
enqueue_offline_sync_event,
|
|
19
|
+
fetch_and_write_prompt,
|
|
20
|
+
get_effective_api_token,
|
|
21
|
+
load_workspace_config,
|
|
22
|
+
save_workspace_config,
|
|
23
|
+
write_active_task_prompt,
|
|
24
|
+
)
|
|
25
|
+
from rakit_cli.security import is_safe_workspace_path, redact_sensitive_text
|
|
26
|
+
from rakit_cli.skill_installer import (
|
|
27
|
+
get_default_global_skill_dir,
|
|
28
|
+
get_default_workspace_skill_dir,
|
|
29
|
+
install_skill,
|
|
30
|
+
)
|
|
31
|
+
from rakit_cli.tasks_parser import parse_tasks_markdown
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _scan_safe_files(workspace_root: Path) -> list[str]:
|
|
35
|
+
"""Mengumpulkan daftar jalur relatif berkas yang aman dari folder kerja lokal."""
|
|
36
|
+
discovered: list[str] = []
|
|
37
|
+
for item in sorted(workspace_root.rglob("*")):
|
|
38
|
+
if not item.is_file():
|
|
39
|
+
continue
|
|
40
|
+
rel = item.relative_to(workspace_root).as_posix()
|
|
41
|
+
if is_safe_workspace_path(rel):
|
|
42
|
+
discovered.append(rel)
|
|
43
|
+
return discovered
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _detect_local_modules(safe_files: Sequence[str]) -> list[str]:
|
|
47
|
+
"""Mendeteksi nama modul aplikasi dari daftar jalur berkas lokal (Keranjang 1 Smart Scan)."""
|
|
48
|
+
modules: set[str] = set()
|
|
49
|
+
for rel_path in safe_files:
|
|
50
|
+
parts = rel_path.split("/")
|
|
51
|
+
if "apps" in parts:
|
|
52
|
+
idx = parts.index("apps")
|
|
53
|
+
if idx + 1 < len(parts) and "." not in parts[idx + 1]:
|
|
54
|
+
modules.add(f"apps.{parts[idx + 1]}")
|
|
55
|
+
elif "features" in parts:
|
|
56
|
+
idx = parts.index("features")
|
|
57
|
+
if idx + 1 < len(parts) and "." not in parts[idx + 1]:
|
|
58
|
+
modules.add(f"mobile.{parts[idx + 1]}")
|
|
59
|
+
elif len(parts) >= 2 and parts[0] == "src":
|
|
60
|
+
modules.add(f"cli.{parts[1]}")
|
|
61
|
+
return sorted(modules)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _detect_verification_commands(
|
|
65
|
+
workspace_root: Path,
|
|
66
|
+
safe_files: Sequence[str],
|
|
67
|
+
) -> dict[str, str]:
|
|
68
|
+
"""Mendeteksi perintah lint, unit test, dan E2E standar sesuai ekosistem proyek target."""
|
|
69
|
+
has_python = (workspace_root / "pyproject.toml").exists() or any(
|
|
70
|
+
f.endswith(".py") for f in safe_files
|
|
71
|
+
)
|
|
72
|
+
has_node = (workspace_root / "package.json").exists()
|
|
73
|
+
has_flutter = (workspace_root / "pubspec.yaml").exists()
|
|
74
|
+
|
|
75
|
+
has_pytest_e2e = (workspace_root / "tests" / "e2e").is_dir() or any(
|
|
76
|
+
f.startswith("tests/e2e/") for f in safe_files
|
|
77
|
+
)
|
|
78
|
+
has_playwright_ts = (
|
|
79
|
+
(workspace_root / "playwright.config.ts").exists()
|
|
80
|
+
or (workspace_root / "playwright.config.js").exists()
|
|
81
|
+
or (workspace_root / "runners" / "playwright.rakit.config.ts").exists()
|
|
82
|
+
or any(f.endswith(".spec.ts") for f in safe_files)
|
|
83
|
+
)
|
|
84
|
+
has_flutter_it = (workspace_root / "integration_test").is_dir()
|
|
85
|
+
|
|
86
|
+
if has_python:
|
|
87
|
+
lint_cmd = "uv run ruff check ."
|
|
88
|
+
test_cmd = "uv run pytest"
|
|
89
|
+
elif has_flutter:
|
|
90
|
+
lint_cmd = "flutter analyze"
|
|
91
|
+
test_cmd = "flutter test"
|
|
92
|
+
elif has_node:
|
|
93
|
+
lint_cmd = "npm run lint"
|
|
94
|
+
test_cmd = "npm test"
|
|
95
|
+
else:
|
|
96
|
+
lint_cmd = "uv run ruff check ."
|
|
97
|
+
test_cmd = "uv run pytest"
|
|
98
|
+
|
|
99
|
+
if has_pytest_e2e:
|
|
100
|
+
e2e_dir = "tests/e2e/"
|
|
101
|
+
e2e_cmd = "uv run pytest tests/e2e/ -m e2e --no-cov"
|
|
102
|
+
elif has_playwright_ts:
|
|
103
|
+
e2e_dir = "runners/" if (workspace_root / "runners").is_dir() else "e2e/"
|
|
104
|
+
e2e_cmd = "npx playwright test"
|
|
105
|
+
elif has_flutter and has_flutter_it:
|
|
106
|
+
e2e_dir = "integration_test/"
|
|
107
|
+
e2e_cmd = "flutter test integration_test"
|
|
108
|
+
elif has_node and not has_python:
|
|
109
|
+
e2e_dir = "e2e/"
|
|
110
|
+
e2e_cmd = "npx playwright test"
|
|
111
|
+
else:
|
|
112
|
+
e2e_dir = "tests/e2e/"
|
|
113
|
+
e2e_cmd = "uv run pytest -m e2e --no-cov"
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"lint_command": lint_cmd,
|
|
117
|
+
"test_command": test_cmd,
|
|
118
|
+
"e2e_dir": e2e_dir,
|
|
119
|
+
"e2e_command": e2e_cmd,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _sync_completed_to_markdown(
|
|
124
|
+
tasks_markdown: str,
|
|
125
|
+
completed_titles: Sequence[str],
|
|
126
|
+
) -> tuple[str, list[str]]:
|
|
127
|
+
"""
|
|
128
|
+
Otomatis mencentang [x] butir to-do di TASKS.md lokal yang tiketnya
|
|
129
|
+
telah mencapai status DONE di server (US-037).
|
|
130
|
+
"""
|
|
131
|
+
import re
|
|
132
|
+
|
|
133
|
+
if not tasks_markdown.strip() or not completed_titles:
|
|
134
|
+
return tasks_markdown, []
|
|
135
|
+
|
|
136
|
+
updated_lines: list[str] = []
|
|
137
|
+
synced_items: list[str] = []
|
|
138
|
+
norm_completed = [
|
|
139
|
+
re.sub(r"[^\w\s]", "", t.strip().lower()) for t in completed_titles if t.strip()
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
for line in tasks_markdown.splitlines():
|
|
143
|
+
match = re.match(r"^(\s*[-*]\s*\[)\s*(\]\s*)(.*)$", line)
|
|
144
|
+
if match:
|
|
145
|
+
prefix, bracket, task_content = match.groups()
|
|
146
|
+
task_clean = re.sub(r"[^\w\s]", "", task_content.strip().lower())
|
|
147
|
+
matched = any(
|
|
148
|
+
(c in task_clean or task_clean in c or (len(c) > 15 and c[:25] in task_clean))
|
|
149
|
+
for c in norm_completed
|
|
150
|
+
if c
|
|
151
|
+
)
|
|
152
|
+
if matched:
|
|
153
|
+
updated_lines.append(f"{prefix}x{bracket}{task_content}")
|
|
154
|
+
synced_items.append(task_content.strip())
|
|
155
|
+
continue
|
|
156
|
+
updated_lines.append(line)
|
|
157
|
+
|
|
158
|
+
return "\n".join(updated_lines), synced_items
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _request_json(
|
|
162
|
+
url: str,
|
|
163
|
+
*,
|
|
164
|
+
method: str = "POST",
|
|
165
|
+
payload: dict[str, Any] | None = None,
|
|
166
|
+
token: str = "",
|
|
167
|
+
timeout: int = 5,
|
|
168
|
+
) -> tuple[str, dict[str, Any]]:
|
|
169
|
+
"""Mengirim permintaan JSON via HTTP dan mengembalikan (status_label, body_json)."""
|
|
170
|
+
import urllib.error
|
|
171
|
+
import urllib.request
|
|
172
|
+
|
|
173
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
174
|
+
headers: dict[str, str] = {}
|
|
175
|
+
if payload is not None:
|
|
176
|
+
headers["Content-Type"] = "application/json"
|
|
177
|
+
if token:
|
|
178
|
+
headers["Authorization"] = f"Token {token}"
|
|
179
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
180
|
+
try:
|
|
181
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
182
|
+
raw = resp.read().decode("utf-8")
|
|
183
|
+
parsed = json.loads(raw) if raw.strip() else {}
|
|
184
|
+
return "synced_to_server", parsed if isinstance(parsed, dict) else {}
|
|
185
|
+
except urllib.error.HTTPError as err:
|
|
186
|
+
err_body: dict[str, Any] = {}
|
|
187
|
+
try:
|
|
188
|
+
raw_err = err.read().decode("utf-8")
|
|
189
|
+
parsed_err = json.loads(raw_err) if raw_err.strip() else {}
|
|
190
|
+
if isinstance(parsed_err, dict):
|
|
191
|
+
err_body = parsed_err
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
return f"http_{err.code}", err_body
|
|
195
|
+
except Exception:
|
|
196
|
+
return "offline", {}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _post_json(
|
|
200
|
+
url: str,
|
|
201
|
+
payload: dict[str, Any],
|
|
202
|
+
*,
|
|
203
|
+
token: str = "",
|
|
204
|
+
timeout: int = 5,
|
|
205
|
+
) -> tuple[str, dict[str, Any]]:
|
|
206
|
+
"""Mengirim JSON via HTTP POST dan mengembalikan (status_label, body_json)."""
|
|
207
|
+
return _request_json(url, method="POST", payload=payload, token=token, timeout=timeout)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _get_json(
|
|
211
|
+
url: str,
|
|
212
|
+
*,
|
|
213
|
+
token: str = "",
|
|
214
|
+
timeout: int = 8,
|
|
215
|
+
) -> tuple[str, dict[str, Any]]:
|
|
216
|
+
"""Mengambil JSON via HTTP GET dan mengembalikan (status_label, body_json)."""
|
|
217
|
+
return _request_json(url, method="GET", token=token, timeout=timeout)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_DOC_START_MARKER = "<!-- RAKIT-AUTO-DOCS:START -->"
|
|
221
|
+
_DOC_END_MARKER = "<!-- RAKIT-AUTO-DOCS:END -->"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _upsert_managed_doc_section(
|
|
225
|
+
target_path: Path,
|
|
226
|
+
doc_content: str,
|
|
227
|
+
*,
|
|
228
|
+
overwrite: bool = False,
|
|
229
|
+
dry_run: bool = False,
|
|
230
|
+
) -> str:
|
|
231
|
+
"""Menulis atau memperbarui blok dokumentasi terkelola RAKIT tanpa menimpa isi manual."""
|
|
232
|
+
block = f"{_DOC_START_MARKER}\n{doc_content.strip()}\n{_DOC_END_MARKER}\n"
|
|
233
|
+
if overwrite or not target_path.exists():
|
|
234
|
+
action = "written"
|
|
235
|
+
updated_text = block
|
|
236
|
+
else:
|
|
237
|
+
existing = target_path.read_text(encoding="utf-8")
|
|
238
|
+
if _DOC_START_MARKER in existing and _DOC_END_MARKER in existing:
|
|
239
|
+
before, _, rest = existing.partition(_DOC_START_MARKER)
|
|
240
|
+
_, _, after = rest.partition(_DOC_END_MARKER)
|
|
241
|
+
updated_text = f"{before.rstrip()}\n\n{block.rstrip()}\n{after.lstrip()}"
|
|
242
|
+
updated_text = updated_text.lstrip("\n")
|
|
243
|
+
action = "updated_managed_block"
|
|
244
|
+
else:
|
|
245
|
+
sep = "\n\n---\n\n" if existing.strip() else ""
|
|
246
|
+
updated_text = f"{existing.rstrip()}{sep}{block}"
|
|
247
|
+
action = "appended_managed_block"
|
|
248
|
+
|
|
249
|
+
if not dry_run:
|
|
250
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
target_path.write_text(updated_text, encoding="utf-8")
|
|
252
|
+
return action
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _close_ticket_on_server(
|
|
256
|
+
*,
|
|
257
|
+
workspace_root: Path,
|
|
258
|
+
cfg: WorkspaceConfig,
|
|
259
|
+
ticket_key: str,
|
|
260
|
+
token: str,
|
|
261
|
+
approve_g2: bool,
|
|
262
|
+
pr_url: str,
|
|
263
|
+
note: str,
|
|
264
|
+
) -> dict[str, Any]:
|
|
265
|
+
"""Mengirim pengesahan G3 lokal/non-PR dan opsi G2 ke `/tickets/<key>/g3-close/`."""
|
|
266
|
+
sanitized_note = redact_sensitive_text(note.strip())[:1000] if note.strip() else ""
|
|
267
|
+
srv_status, srv_body = _post_json(
|
|
268
|
+
f"{cfg.api_base_url}/tickets/{ticket_key}/g3-close/",
|
|
269
|
+
{
|
|
270
|
+
"project_slug": cfg.project_slug,
|
|
271
|
+
"pr_url": pr_url.strip(),
|
|
272
|
+
"note": sanitized_note,
|
|
273
|
+
"approve_g2": bool(approve_g2),
|
|
274
|
+
},
|
|
275
|
+
token=token,
|
|
276
|
+
timeout=8,
|
|
277
|
+
)
|
|
278
|
+
data_obj = srv_body.get("data") if isinstance(srv_body.get("data"), dict) else {}
|
|
279
|
+
ticket_obj = data_obj.get("ticket") if isinstance(data_obj.get("ticket"), dict) else {}
|
|
280
|
+
ticket_status = str(ticket_obj.get("status") or "")
|
|
281
|
+
|
|
282
|
+
if srv_status == "synced_to_server":
|
|
283
|
+
if ticket_status == "DONE" and cfg.active_ticket_key == ticket_key:
|
|
284
|
+
save_workspace_config(
|
|
285
|
+
workspace_root,
|
|
286
|
+
WorkspaceConfig(
|
|
287
|
+
project_slug=cfg.project_slug,
|
|
288
|
+
api_base_url=cfg.api_base_url,
|
|
289
|
+
machine_label=cfg.machine_label,
|
|
290
|
+
root_fingerprint=cfg.root_fingerprint,
|
|
291
|
+
active_ticket_key="",
|
|
292
|
+
api_token=cfg.api_token,
|
|
293
|
+
secret_blocklist=cfg.secret_blocklist,
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
return {
|
|
297
|
+
"status": "closed",
|
|
298
|
+
"ticket_key": ticket_key,
|
|
299
|
+
"ticket_status": ticket_status or "DONE",
|
|
300
|
+
"server_close_status": srv_status,
|
|
301
|
+
"unlocked_dependents": data_obj.get("unlocked_dependents", []),
|
|
302
|
+
"documentation_draft_id": data_obj.get("documentation_draft_id"),
|
|
303
|
+
"message": srv_body.get("message", ""),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"status": "close_failed",
|
|
308
|
+
"ticket_key": ticket_key,
|
|
309
|
+
"ticket_status": ticket_status,
|
|
310
|
+
"server_close_status": srv_status,
|
|
311
|
+
"server_message": srv_body.get("message", ""),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
316
|
+
"""Membentuk parser argumen CLI `rakit`."""
|
|
317
|
+
parser = argparse.ArgumentParser(
|
|
318
|
+
prog="rakit",
|
|
319
|
+
description=render_cli_banner(),
|
|
320
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
321
|
+
)
|
|
322
|
+
parser.add_argument(
|
|
323
|
+
"--version",
|
|
324
|
+
action="version",
|
|
325
|
+
version=f"rakit-cli {RAKIT_CLI_VERSION} ({RAKIT_COMPACT_BADGE})",
|
|
326
|
+
)
|
|
327
|
+
parser.add_argument(
|
|
328
|
+
"--workspace",
|
|
329
|
+
type=Path,
|
|
330
|
+
default=Path.cwd(),
|
|
331
|
+
help="Jalur folder kerja lokal (bawaan: direktori aktif)",
|
|
332
|
+
)
|
|
333
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
334
|
+
|
|
335
|
+
subparsers.add_parser("banner", help="Tampilkan logo ASCII dan identitas versi RAKIT v2.0")
|
|
336
|
+
|
|
337
|
+
init_cmd = subparsers.add_parser("init", help="Ikat folder kerja notebook ke proyek RAKIT")
|
|
338
|
+
init_cmd.add_argument("--project", required=True, help="Slug proyek di server RAKIT")
|
|
339
|
+
init_cmd.add_argument(
|
|
340
|
+
"--api-url",
|
|
341
|
+
default=os.getenv("RAKIT_API_URL", "http://localhost:8000/api/v1"),
|
|
342
|
+
help="URL dasar API RAKIT Core (bawaan: http://localhost:8000/api/v1)",
|
|
343
|
+
)
|
|
344
|
+
init_cmd.add_argument(
|
|
345
|
+
"--token",
|
|
346
|
+
default=os.getenv("RAKIT_API_TOKEN", ""),
|
|
347
|
+
help="Token autentikasi DRF API RAKIT (opsional untuk sinkronisasi otomatis ke server)",
|
|
348
|
+
)
|
|
349
|
+
init_cmd.add_argument(
|
|
350
|
+
"--machine-label",
|
|
351
|
+
default=socket.gethostname(),
|
|
352
|
+
help="Nama identitas mesin notebook ini",
|
|
353
|
+
)
|
|
354
|
+
init_cmd.add_argument(
|
|
355
|
+
"--no-skill",
|
|
356
|
+
action="store_true",
|
|
357
|
+
help="Jangan pasang berkas AI Skill (.agents/skills/rakit/SKILL.md) secara otomatis",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
scan_cmd = subparsers.add_parser(
|
|
361
|
+
"scan",
|
|
362
|
+
help="Pindai struktur berkas aman dan TASKS.md serta sinkronkan ke Web Console",
|
|
363
|
+
)
|
|
364
|
+
scan_cmd.add_argument(
|
|
365
|
+
"--token",
|
|
366
|
+
default="",
|
|
367
|
+
help="Token API RAKIT (opsional bila memakai RAKIT_API_TOKEN)",
|
|
368
|
+
)
|
|
369
|
+
scan_cmd.add_argument(
|
|
370
|
+
"--persist",
|
|
371
|
+
action="store_true",
|
|
372
|
+
default=True,
|
|
373
|
+
help="Simpan draf tiket langsung ke BACKLOG Web Console (bawaan: True)",
|
|
374
|
+
)
|
|
375
|
+
scan_cmd.add_argument(
|
|
376
|
+
"--no-persist",
|
|
377
|
+
action="store_false",
|
|
378
|
+
dest="persist",
|
|
379
|
+
help="Hanya pindai tanpa menyimpan draf tiket ke BACKLOG",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
start_cmd = subparsers.add_parser(
|
|
383
|
+
"start",
|
|
384
|
+
help="Tandai tiket aktif untuk dikerjakan di notebook",
|
|
385
|
+
)
|
|
386
|
+
start_cmd.add_argument("ticket_key", help="Kunci tiket, misal W-001")
|
|
387
|
+
|
|
388
|
+
sync_cmd = subparsers.add_parser(
|
|
389
|
+
"sync",
|
|
390
|
+
help="Hitung progres TASKS.md, antrekan secara idempoten, & cetak muatan sinkronisasi",
|
|
391
|
+
)
|
|
392
|
+
sync_cmd.add_argument("--note", default="", help="Catatan ringkas milestone aktif")
|
|
393
|
+
|
|
394
|
+
verify_cmd = subparsers.add_parser(
|
|
395
|
+
"verify",
|
|
396
|
+
help="Periksa kesiapan kontrak berkas lokal dan laporkan hasil verifikasi ke server RAKIT",
|
|
397
|
+
)
|
|
398
|
+
verify_cmd.add_argument(
|
|
399
|
+
"--ticket",
|
|
400
|
+
default="",
|
|
401
|
+
help="Kunci tiket (bawaan: tiket aktif di .rakit/workspace.json)",
|
|
402
|
+
)
|
|
403
|
+
verify_cmd.add_argument(
|
|
404
|
+
"--note",
|
|
405
|
+
default="",
|
|
406
|
+
help="Ringkasan hasil uji / log konsol dari notebook",
|
|
407
|
+
)
|
|
408
|
+
verify_cmd.add_argument(
|
|
409
|
+
"--failed",
|
|
410
|
+
action="store_true",
|
|
411
|
+
help="Tandai hasil verifikasi gagal (memicu self-healing kembali ke CODING)",
|
|
412
|
+
)
|
|
413
|
+
verify_cmd.add_argument(
|
|
414
|
+
"--auto-close",
|
|
415
|
+
action="store_true",
|
|
416
|
+
help="Mode Auto: bila verifikasi lulus (UAT_REVIEW), otomatis sahkan G2 + G3 hingga DONE",
|
|
417
|
+
)
|
|
418
|
+
verify_cmd.add_argument(
|
|
419
|
+
"--pr-url",
|
|
420
|
+
default="",
|
|
421
|
+
help="Tautan PR / referensi commit lokal opsional saat --auto-close aktif",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
close_cmd = subparsers.add_parser(
|
|
425
|
+
"close",
|
|
426
|
+
help="Tutup tiket melalui Gerbang G3 lokal/non-PR hingga DONE (Mode Manual & Auto)",
|
|
427
|
+
)
|
|
428
|
+
close_cmd.add_argument(
|
|
429
|
+
"ticket_key",
|
|
430
|
+
nargs="?",
|
|
431
|
+
default="",
|
|
432
|
+
help="Kunci tiket (bawaan: tiket aktif di .rakit/workspace.json)",
|
|
433
|
+
)
|
|
434
|
+
close_cmd.add_argument(
|
|
435
|
+
"--ticket",
|
|
436
|
+
dest="ticket_flag",
|
|
437
|
+
default="",
|
|
438
|
+
help="Alternatif penulisan kunci tiket (--ticket W-070)",
|
|
439
|
+
)
|
|
440
|
+
close_cmd.add_argument(
|
|
441
|
+
"--approve-g2",
|
|
442
|
+
action="store_true",
|
|
443
|
+
help="Sahkan Gerbang G2 (bila bukti E2E lulus) sekaligus sebelum penutupan G3 ke DONE",
|
|
444
|
+
)
|
|
445
|
+
close_cmd.add_argument(
|
|
446
|
+
"--pr-url",
|
|
447
|
+
default="",
|
|
448
|
+
help="Tautan PR GitHub atau referensi commit/branch lokal opsional",
|
|
449
|
+
)
|
|
450
|
+
close_cmd.add_argument(
|
|
451
|
+
"--note",
|
|
452
|
+
default="",
|
|
453
|
+
help="Catatan keputusan / penutupan tiket",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
docs_cmd = subparsers.add_parser(
|
|
457
|
+
"docs",
|
|
458
|
+
help="Tarik bundel dokumentasi gabungan (User Guide, FAQ, Help, Changelog) dari RAKIT",
|
|
459
|
+
)
|
|
460
|
+
docs_sub = docs_cmd.add_subparsers(dest="docs_action", required=True)
|
|
461
|
+
docs_pull_cmd = docs_sub.add_parser(
|
|
462
|
+
"pull",
|
|
463
|
+
help="Unduh dan gabungkan dokumen pasca-G3 ke folder docs/ proyek lokal",
|
|
464
|
+
)
|
|
465
|
+
docs_pull_cmd.add_argument(
|
|
466
|
+
"--out-dir",
|
|
467
|
+
default="docs",
|
|
468
|
+
help="Direktori tujuan untuk USER_GUIDE.md, FAQ.md, HELP.md (bawaan: docs)",
|
|
469
|
+
)
|
|
470
|
+
docs_pull_cmd.add_argument(
|
|
471
|
+
"--only",
|
|
472
|
+
default="",
|
|
473
|
+
help="Filter berkas opsional, pisah koma (guide,faq,help,changelog)",
|
|
474
|
+
)
|
|
475
|
+
docs_pull_cmd.add_argument(
|
|
476
|
+
"--overwrite",
|
|
477
|
+
action="store_true",
|
|
478
|
+
help="Timpa penuh isi berkas target alih-alih memperbarui blok terkelola RAKIT",
|
|
479
|
+
)
|
|
480
|
+
docs_pull_cmd.add_argument(
|
|
481
|
+
"--dry-run",
|
|
482
|
+
action="store_true",
|
|
483
|
+
help="Tampilkan pratinjau berkas yang akan ditulis tanpa mengubah disk",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
guides_cmd = subparsers.add_parser(
|
|
487
|
+
"generate-guides",
|
|
488
|
+
help="Hasilkan draf AGENTS.md dan rakit.yaml untuk proyek (US-004)",
|
|
489
|
+
)
|
|
490
|
+
guides_cmd.add_argument(
|
|
491
|
+
"--write",
|
|
492
|
+
action="store_true",
|
|
493
|
+
help="Tulis berkas draf ke .rakit/AGENTS.draft.md dan .rakit/rakit.draft.yaml",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
subparsers.add_parser(
|
|
497
|
+
"install-hook",
|
|
498
|
+
help="Pasang skrip .git/hooks/post-commit untuk menjalankan rakit sync otomatis (US-011)",
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
token_cmd = subparsers.add_parser(
|
|
502
|
+
"token",
|
|
503
|
+
help="Simpan atau periksa token API akun RAKIT di .rakit/workspace.json",
|
|
504
|
+
)
|
|
505
|
+
token_cmd.add_argument("value", nargs="?", default="", help="Token API akun dari Web Console")
|
|
506
|
+
|
|
507
|
+
prompt_cmd = subparsers.add_parser(
|
|
508
|
+
"prompt",
|
|
509
|
+
help="Unduh spesifikasi tiket dari peladen dan tulis berkas .rakit/PROMPT.md (US-014)",
|
|
510
|
+
)
|
|
511
|
+
prompt_cmd.add_argument(
|
|
512
|
+
"ticket_key",
|
|
513
|
+
nargs="?",
|
|
514
|
+
default="",
|
|
515
|
+
help="Kunci tiket (opsional jika tiket aktif sudah tersimpan di workspace.json)",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
skill_cmd = subparsers.add_parser(
|
|
519
|
+
"skill",
|
|
520
|
+
help="Kelola berkas AI Assistant Skill RAKIT v2.0 untuk Antigravity/Claude/Cursor",
|
|
521
|
+
)
|
|
522
|
+
skill_sub = skill_cmd.add_subparsers(dest="skill_action", required=True)
|
|
523
|
+
skill_install_cmd = skill_sub.add_parser(
|
|
524
|
+
"install",
|
|
525
|
+
help="Pasang berkas SKILL.md ke workspace lokal (.agents/skills/rakit/) atau global",
|
|
526
|
+
)
|
|
527
|
+
skill_install_cmd.add_argument(
|
|
528
|
+
"--global",
|
|
529
|
+
dest="is_global",
|
|
530
|
+
action="store_true",
|
|
531
|
+
help="Pasang ke direktori global (~/.agents/skills/rakit/)",
|
|
532
|
+
)
|
|
533
|
+
skill_install_cmd.add_argument(
|
|
534
|
+
"--force",
|
|
535
|
+
action="store_true",
|
|
536
|
+
help="Timpa berkas SKILL.md jika sudah ada sebelumnya",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return parser
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
543
|
+
"""
|
|
544
|
+
TUJUAN: Menjalankan sub-perintah `rakit` dan mengeluarkan ringkasan JSON terstruktur.
|
|
545
|
+
|
|
546
|
+
DIPANGGIL DARI: Entrypoint konsol `rakit`
|
|
547
|
+
DEPENDENSI: config, security, tasks_parser
|
|
548
|
+
"""
|
|
549
|
+
args = build_parser().parse_args(argv)
|
|
550
|
+
workspace_root: Path = args.workspace.resolve()
|
|
551
|
+
|
|
552
|
+
if args.command == "banner":
|
|
553
|
+
print(
|
|
554
|
+
json.dumps(
|
|
555
|
+
{
|
|
556
|
+
"status": "ok",
|
|
557
|
+
"name": "RAKIT",
|
|
558
|
+
"version": RAKIT_CLI_VERSION,
|
|
559
|
+
"badge": RAKIT_COMPACT_BADGE,
|
|
560
|
+
"ascii_logo": render_cli_banner(),
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
return 0
|
|
565
|
+
|
|
566
|
+
if args.command == "init":
|
|
567
|
+
raw_token = getattr(args, "token", "") or os.getenv("RAKIT_API_TOKEN", "")
|
|
568
|
+
token = raw_token.strip() if isinstance(raw_token, str) else ""
|
|
569
|
+
|
|
570
|
+
cfg = WorkspaceConfig(
|
|
571
|
+
project_slug=args.project,
|
|
572
|
+
api_base_url=args.api_url.rstrip("/"),
|
|
573
|
+
machine_label=args.machine_label,
|
|
574
|
+
root_fingerprint=compute_workspace_fingerprint(workspace_root),
|
|
575
|
+
api_token=token,
|
|
576
|
+
)
|
|
577
|
+
saved_path = save_workspace_config(workspace_root, cfg)
|
|
578
|
+
|
|
579
|
+
safe_files = _scan_safe_files(workspace_root)
|
|
580
|
+
verification_cmds = _detect_verification_commands(workspace_root, safe_files)
|
|
581
|
+
|
|
582
|
+
# 1. Otomatis buat rakit.yaml di root jika belum ada
|
|
583
|
+
rakit_yaml_path = workspace_root / "rakit.yaml"
|
|
584
|
+
rakit_yaml_status = "existing"
|
|
585
|
+
if not rakit_yaml_path.exists():
|
|
586
|
+
rakit_yaml_content = (
|
|
587
|
+
f"# rakit.yaml — Konfigurasi Orkestrasi Proyek RAKIT v2.0\n"
|
|
588
|
+
f"version: 2\n"
|
|
589
|
+
f"project:\n"
|
|
590
|
+
f' slug: "{cfg.project_slug}"\n'
|
|
591
|
+
f' name: "{cfg.project_slug}"\n'
|
|
592
|
+
f' default_branch: "main"\n'
|
|
593
|
+
f" daily_token_limit: 1000000\n"
|
|
594
|
+
f"security:\n"
|
|
595
|
+
f" block_dotenv: true\n"
|
|
596
|
+
f" staging_allowlist:\n"
|
|
597
|
+
f' - "127.0.0.1"\n'
|
|
598
|
+
f' - "localhost"\n'
|
|
599
|
+
f"verification:\n"
|
|
600
|
+
f' lint_command: "{verification_cmds["lint_command"]}"\n'
|
|
601
|
+
f' test_command: "{verification_cmds["test_command"]}"\n'
|
|
602
|
+
f' e2e_dir: "{verification_cmds["e2e_dir"]}"\n'
|
|
603
|
+
f' e2e_command: "{verification_cmds["e2e_command"]}"\n'
|
|
604
|
+
)
|
|
605
|
+
rakit_yaml_path.write_text(rakit_yaml_content, encoding="utf-8")
|
|
606
|
+
rakit_yaml_status = "created"
|
|
607
|
+
|
|
608
|
+
# 2. Otomatis buat AGENTS.md jika belum ada, atau simpan snippet jika sudah ada
|
|
609
|
+
agents_md_path = workspace_root / "AGENTS.md"
|
|
610
|
+
agents_md_status = "existing_preserved"
|
|
611
|
+
modules = _detect_local_modules(safe_files)
|
|
612
|
+
modules_list = (
|
|
613
|
+
"\n".join(f"- `{m}`" for m in modules) if modules else "- `apps/core` (Modul Inti)"
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
protocol_lines = (
|
|
617
|
+
"## 0. Protokol Wajib Awal Sesi AI/LLM (Periksa RAKIT Terlebih Dahulu)\n"
|
|
618
|
+
"Setiap kali agen AI/LLM memulai sesi di repositori ini, kamu DIWAJIBKAN:\n"
|
|
619
|
+
"1. **Eksekusi Riil (Bukan Simulasi)**: DILARANG KERAS hanya menampilkan atau "
|
|
620
|
+
"mensimulasikan output teks/markdown di obrolan chat! Agen WAJIB mengeksekusi "
|
|
621
|
+
"tool MCP (`rakit_claim_ticket`, `rakit_sync_progress`, dll.) atau menjalankan "
|
|
622
|
+
"perintah terminal secara nyata ke peladen RAKIT.\n"
|
|
623
|
+
"2. **Autentikasi Token RAKIT**: Gunakan environment variable `RAKIT_API_TOKEN` "
|
|
624
|
+
"atau header `Authorization: Token <TOKEN>`. Token aktif dapat dilihat di menu "
|
|
625
|
+
"Profil & Token Web Console RAKIT (`/accounts/profile/`).\n"
|
|
626
|
+
"3. **Cek Tiket Backlog**: Jalankan `rakit status` di terminal atau panggil tool MCP "
|
|
627
|
+
"`rakit_status` untuk melihat tiket di `READY` / `BACKLOG`.\n"
|
|
628
|
+
"4. **Klaim Tiket Sebelum Koding**: DILARANG mengubah berkas tanpa tiket aktif! "
|
|
629
|
+
"Jalankan `rakit start <KEY>` atau panggil tool MCP `rakit_claim_ticket`.\n"
|
|
630
|
+
"5. **Disiplin TDD**: Setiap modifikasi fungsi wajib disertai Unit Test yang sesuai.\n"
|
|
631
|
+
"6. **Sinkronisasi Progres**: Centang to-do `[x]` pada `TASKS.md`, lalu jalankan "
|
|
632
|
+
"`rakit sync` untuk melaporkan progres ke Web Console RAKIT.\n"
|
|
633
|
+
"7. **Ambiguitas / Keraguan**: Jangan berasumsi — panggil tool MCP `rakit_ask_human` "
|
|
634
|
+
"atau laporkan via `rakit sync --note` agar dijawab pengembang di Web Console.\n"
|
|
635
|
+
"8. **Verifikasi Akhir**: Setelah kode selesai & tes lulus, jalankan `rakit verify` "
|
|
636
|
+
"menuju Gerbang G2 (QA & UAT Review).\n\n"
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
agents_rakit_section = (
|
|
640
|
+
f"# AGENTS.md — Kontrak Eksekusi Proyek {cfg.project_slug}\n\n"
|
|
641
|
+
f"{protocol_lines}"
|
|
642
|
+
f"## 1. Identitas Proyek\n"
|
|
643
|
+
f"- **Slug RAKIT**: `{cfg.project_slug}`\n"
|
|
644
|
+
f"- **Mesin Notebook**: `{cfg.machine_label}`\n\n"
|
|
645
|
+
f"## 2. Modul Terdeteksi (Keranjang 1 Smart Scan)\n"
|
|
646
|
+
f"{modules_list}\n\n"
|
|
647
|
+
f"## 3. Aturan Keamanan Ketat\n"
|
|
648
|
+
f"1. Dilarang keras membaca, memodifikasi, atau mengirim berkas rahasia `.env*`, "
|
|
649
|
+
f"`*.pem`, `*.key`, dan folder `secrets/`.\n"
|
|
650
|
+
f"2. Selalu gunakan `uv` untuk eksekusi Python (`uv run ...`). "
|
|
651
|
+
f"Dilarang memakai `pip` atau `poetry` langsung.\n"
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if not agents_md_path.exists():
|
|
655
|
+
agents_md_path.write_text(agents_rakit_section, encoding="utf-8")
|
|
656
|
+
agents_md_status = "created"
|
|
657
|
+
else:
|
|
658
|
+
rakit_dir = workspace_root / ".rakit"
|
|
659
|
+
rakit_dir.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
(rakit_dir / "AGENTS.rakit.md").write_text(agents_rakit_section, encoding="utf-8")
|
|
661
|
+
agents_md_status = "existing_preserved_snippet_saved_to_dot_rakit"
|
|
662
|
+
|
|
663
|
+
# 3. Otomatis pasang SKILL.md ke .agents/skills/rakit/ jika tidak diabaikan
|
|
664
|
+
skill_status = "skipped"
|
|
665
|
+
skill_path_str = ""
|
|
666
|
+
if not getattr(args, "no_skill", False):
|
|
667
|
+
skill_dir = get_default_workspace_skill_dir(workspace_root)
|
|
668
|
+
skill_file, skill_status = install_skill(skill_dir, force=False)
|
|
669
|
+
skill_path_str = skill_file.as_posix()
|
|
670
|
+
|
|
671
|
+
# 4. Coba kirim binding ke peladen Web Console RAKIT (bila peladen aktif)
|
|
672
|
+
server_binding_status = "local_only"
|
|
673
|
+
server_message = "Konfigurasi lokal berhasil disimpan."
|
|
674
|
+
raw_token = getattr(args, "token", "") or os.getenv("RAKIT_API_TOKEN", "")
|
|
675
|
+
token = raw_token.strip() if isinstance(raw_token, str) else ""
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
import urllib.error
|
|
679
|
+
import urllib.request
|
|
680
|
+
|
|
681
|
+
bind_url = f"{cfg.api_base_url}/workspaces/bind/"
|
|
682
|
+
req_data = json.dumps(
|
|
683
|
+
{
|
|
684
|
+
"project_slug": cfg.project_slug,
|
|
685
|
+
"machine_label": cfg.machine_label,
|
|
686
|
+
"root_fingerprint": cfg.root_fingerprint,
|
|
687
|
+
}
|
|
688
|
+
).encode("utf-8")
|
|
689
|
+
headers = {"Content-Type": "application/json"}
|
|
690
|
+
if token:
|
|
691
|
+
headers["Authorization"] = f"Token {token}"
|
|
692
|
+
|
|
693
|
+
req = urllib.request.Request(bind_url, data=req_data, headers=headers, method="POST")
|
|
694
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
695
|
+
if resp.status == 200:
|
|
696
|
+
server_binding_status = "connected"
|
|
697
|
+
server_message = "Workspace berhasil terikat ke Web Console RAKIT!"
|
|
698
|
+
except urllib.error.HTTPError as exc:
|
|
699
|
+
if exc.code in {401, 403}:
|
|
700
|
+
server_binding_status = "auth_required"
|
|
701
|
+
server_message = (
|
|
702
|
+
"Konfigurasi lokal tersimpan. Web Console memerlukan --token "
|
|
703
|
+
"atau klik '+ Ikat Komputer Ini' di browser Web Console."
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
server_binding_status = f"http_{exc.code}"
|
|
707
|
+
server_message = f"Peladen merespons kode HTTP {exc.code}."
|
|
708
|
+
except Exception:
|
|
709
|
+
server_binding_status = "offline"
|
|
710
|
+
server_message = "Konfigurasi lokal tersimpan (peladen offline atau belum dijangkau)."
|
|
711
|
+
|
|
712
|
+
print(
|
|
713
|
+
json.dumps(
|
|
714
|
+
{
|
|
715
|
+
"status": "initialized",
|
|
716
|
+
"project_slug": cfg.project_slug,
|
|
717
|
+
"config_file": saved_path.as_posix(),
|
|
718
|
+
"rakit_yaml": rakit_yaml_status,
|
|
719
|
+
"agents_md": agents_md_status,
|
|
720
|
+
"skill": skill_status,
|
|
721
|
+
"skill_path": skill_path_str,
|
|
722
|
+
"root_fingerprint": cfg.root_fingerprint,
|
|
723
|
+
"secret_blocklist": list(cfg.secret_blocklist),
|
|
724
|
+
"server_binding": server_binding_status,
|
|
725
|
+
"message": server_message,
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
return 0
|
|
730
|
+
|
|
731
|
+
if args.command == "skill":
|
|
732
|
+
if getattr(args, "skill_action", "") == "install":
|
|
733
|
+
dest_dir = (
|
|
734
|
+
get_default_global_skill_dir()
|
|
735
|
+
if getattr(args, "is_global", False)
|
|
736
|
+
else get_default_workspace_skill_dir(workspace_root)
|
|
737
|
+
)
|
|
738
|
+
skill_file, skill_status = install_skill(
|
|
739
|
+
dest_dir, force=getattr(args, "force", False)
|
|
740
|
+
)
|
|
741
|
+
target_scope = "global" if getattr(args, "is_global", False) else "workspace"
|
|
742
|
+
action_desc = (
|
|
743
|
+
"berhasil diperbarui" if skill_status == "updated" else "berhasil dipasang"
|
|
744
|
+
)
|
|
745
|
+
print(
|
|
746
|
+
json.dumps(
|
|
747
|
+
{
|
|
748
|
+
"status": "skill_installed",
|
|
749
|
+
"target": target_scope,
|
|
750
|
+
"skill_file": skill_file.as_posix(),
|
|
751
|
+
"install_status": skill_status,
|
|
752
|
+
"message": (
|
|
753
|
+
f"Skill RAKIT v2.0 ({target_scope}) {action_desc}: "
|
|
754
|
+
f"{skill_file.as_posix()}."
|
|
755
|
+
),
|
|
756
|
+
},
|
|
757
|
+
ensure_ascii=False,
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
return 0
|
|
761
|
+
|
|
762
|
+
existing_cfg = load_workspace_config(workspace_root)
|
|
763
|
+
if existing_cfg is None:
|
|
764
|
+
print(json.dumps({"error": "WORKSPACE_NOT_INITIALIZED"}))
|
|
765
|
+
return 1
|
|
766
|
+
|
|
767
|
+
if args.command == "scan":
|
|
768
|
+
safe_files = _scan_safe_files(workspace_root)
|
|
769
|
+
tasks_file = workspace_root / "TASKS.md"
|
|
770
|
+
tasks_content = tasks_file.read_text(encoding="utf-8") if tasks_file.exists() else ""
|
|
771
|
+
tasks_info = parse_tasks_markdown(tasks_content)
|
|
772
|
+
|
|
773
|
+
status_file = workspace_root / "STATUS.md"
|
|
774
|
+
status_content = status_file.read_text(encoding="utf-8") if status_file.exists() else ""
|
|
775
|
+
|
|
776
|
+
discovered_modules = _detect_local_modules(safe_files)
|
|
777
|
+
open_tasks_count = max(tasks_info.total_count - tasks_info.completed_count, 0)
|
|
778
|
+
|
|
779
|
+
# Coba sinkronkan langsung ke Web Console RAKIT via API
|
|
780
|
+
server_sync_status = "local_only"
|
|
781
|
+
server_message = "Pemindaian lokal selesai."
|
|
782
|
+
created_tickets: list[str] = []
|
|
783
|
+
raw_token = getattr(args, "token", "") or os.getenv("RAKIT_API_TOKEN", "")
|
|
784
|
+
token = raw_token.strip() if isinstance(raw_token, str) else ""
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
import urllib.error
|
|
788
|
+
import urllib.request
|
|
789
|
+
|
|
790
|
+
scan_url = f"{existing_cfg.api_base_url}/workspaces/smart-scan/"
|
|
791
|
+
req_data = json.dumps(
|
|
792
|
+
{
|
|
793
|
+
"project_slug": existing_cfg.project_slug,
|
|
794
|
+
"machine_label": existing_cfg.machine_label,
|
|
795
|
+
"root_fingerprint": existing_cfg.root_fingerprint,
|
|
796
|
+
"safe_file_paths": safe_files[:1500],
|
|
797
|
+
"tasks_markdown": tasks_content,
|
|
798
|
+
"status_markdown": status_content,
|
|
799
|
+
"persist_drafts": getattr(args, "persist", True),
|
|
800
|
+
}
|
|
801
|
+
).encode("utf-8")
|
|
802
|
+
headers = {"Content-Type": "application/json"}
|
|
803
|
+
if token:
|
|
804
|
+
headers["Authorization"] = f"Token {token}"
|
|
805
|
+
|
|
806
|
+
req = urllib.request.Request(scan_url, data=req_data, headers=headers, method="POST")
|
|
807
|
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
808
|
+
if resp.status == 200:
|
|
809
|
+
resp_json = json.loads(resp.read().decode("utf-8"))
|
|
810
|
+
payload_data = resp_json.get("data") or {}
|
|
811
|
+
server_sync_status = "synced_to_server"
|
|
812
|
+
created_tickets = payload_data.get("created_ticket_keys", [])
|
|
813
|
+
server_message = (
|
|
814
|
+
f"Hasil Smart Scan berhasil disinkronkan ke Web Console RAKIT! "
|
|
815
|
+
f"{len(created_tickets)} draf tiket masuk ke BACKLOG."
|
|
816
|
+
if created_tickets
|
|
817
|
+
else "Hasil Smart Scan berhasil disinkronkan ke Web Console RAKIT."
|
|
818
|
+
)
|
|
819
|
+
except urllib.error.HTTPError as exc:
|
|
820
|
+
server_sync_status = f"http_{exc.code}"
|
|
821
|
+
server_message = f"Gagal sinkron ke server (HTTP {exc.code})."
|
|
822
|
+
except Exception:
|
|
823
|
+
server_sync_status = "offline"
|
|
824
|
+
server_message = "Pemindaian lokal selesai (peladen RAKIT offline)."
|
|
825
|
+
|
|
826
|
+
print(
|
|
827
|
+
json.dumps(
|
|
828
|
+
{
|
|
829
|
+
"project_slug": existing_cfg.project_slug,
|
|
830
|
+
"safe_files_count": len(safe_files),
|
|
831
|
+
"tasks_completed": tasks_info.completed_count,
|
|
832
|
+
"tasks_total": tasks_info.total_count,
|
|
833
|
+
"open_tasks_count": open_tasks_count,
|
|
834
|
+
"discovered_modules": discovered_modules,
|
|
835
|
+
"server_sync": server_sync_status,
|
|
836
|
+
"server_message": server_message,
|
|
837
|
+
"created_ticket_keys": created_tickets,
|
|
838
|
+
}
|
|
839
|
+
)
|
|
840
|
+
)
|
|
841
|
+
return 0
|
|
842
|
+
|
|
843
|
+
if args.command == "token":
|
|
844
|
+
new_token = (args.value or "").strip()
|
|
845
|
+
if new_token:
|
|
846
|
+
updated_cfg = WorkspaceConfig(
|
|
847
|
+
project_slug=existing_cfg.project_slug,
|
|
848
|
+
api_base_url=existing_cfg.api_base_url,
|
|
849
|
+
machine_label=existing_cfg.machine_label,
|
|
850
|
+
root_fingerprint=existing_cfg.root_fingerprint,
|
|
851
|
+
active_ticket_key=existing_cfg.active_ticket_key,
|
|
852
|
+
api_token=new_token,
|
|
853
|
+
secret_blocklist=existing_cfg.secret_blocklist,
|
|
854
|
+
)
|
|
855
|
+
save_workspace_config(workspace_root, updated_cfg)
|
|
856
|
+
masked = f"{new_token[:6]}...{new_token[-4:]}" if len(new_token) > 10 else "***"
|
|
857
|
+
print(
|
|
858
|
+
json.dumps(
|
|
859
|
+
{
|
|
860
|
+
"status": "token_saved",
|
|
861
|
+
"message": (
|
|
862
|
+
f"Token API berhasil disimpan ke .rakit/workspace.json ({masked})."
|
|
863
|
+
),
|
|
864
|
+
"workspace": str(workspace_root),
|
|
865
|
+
},
|
|
866
|
+
ensure_ascii=False,
|
|
867
|
+
)
|
|
868
|
+
)
|
|
869
|
+
return 0
|
|
870
|
+
|
|
871
|
+
effective_tok = get_effective_api_token(existing_cfg)
|
|
872
|
+
if effective_tok:
|
|
873
|
+
masked = (
|
|
874
|
+
f"{effective_tok[:6]}...{effective_tok[-4:]}" if len(effective_tok) > 10 else "***"
|
|
875
|
+
)
|
|
876
|
+
src = "RAKIT_API_TOKEN (env)" if os.getenv("RAKIT_API_TOKEN") else "workspace.json"
|
|
877
|
+
print(
|
|
878
|
+
json.dumps(
|
|
879
|
+
{
|
|
880
|
+
"status": "token_configured",
|
|
881
|
+
"masked_token": masked,
|
|
882
|
+
"source": src,
|
|
883
|
+
"help": "Untuk mengganti: rakit token <TOKEN_BARU>",
|
|
884
|
+
},
|
|
885
|
+
ensure_ascii=False,
|
|
886
|
+
)
|
|
887
|
+
)
|
|
888
|
+
else:
|
|
889
|
+
print(
|
|
890
|
+
json.dumps(
|
|
891
|
+
{
|
|
892
|
+
"status": "token_missing",
|
|
893
|
+
"message": "Token API RAKIT belum tersimpan di folder ini.",
|
|
894
|
+
"action": (
|
|
895
|
+
"Salin token di http://localhost:8000/accounts/profile/ "
|
|
896
|
+
"lalu jalankan: rakit token <TOKEN>"
|
|
897
|
+
),
|
|
898
|
+
},
|
|
899
|
+
ensure_ascii=False,
|
|
900
|
+
)
|
|
901
|
+
)
|
|
902
|
+
return 0
|
|
903
|
+
|
|
904
|
+
if args.command == "prompt":
|
|
905
|
+
target_key = (
|
|
906
|
+
(args.ticket_key or "").strip() or (existing_cfg.active_ticket_key or "").strip()
|
|
907
|
+
)
|
|
908
|
+
if not target_key:
|
|
909
|
+
print(
|
|
910
|
+
json.dumps(
|
|
911
|
+
{
|
|
912
|
+
"status": "error",
|
|
913
|
+
"error": "NO_ACTIVE_TICKET",
|
|
914
|
+
"message": (
|
|
915
|
+
"Tidak ada tiket aktif yang dipilih. "
|
|
916
|
+
"Tentukan kunci tiket: rakit prompt <KEY>, misal: rakit prompt QA-031"
|
|
917
|
+
),
|
|
918
|
+
},
|
|
919
|
+
ensure_ascii=False,
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
return 1
|
|
923
|
+
|
|
924
|
+
prompt_file = fetch_and_write_prompt(workspace_root, existing_cfg, target_key)
|
|
925
|
+
if args.ticket_key and args.ticket_key != existing_cfg.active_ticket_key:
|
|
926
|
+
updated_cfg = WorkspaceConfig(
|
|
927
|
+
project_slug=existing_cfg.project_slug,
|
|
928
|
+
api_base_url=existing_cfg.api_base_url,
|
|
929
|
+
machine_label=existing_cfg.machine_label,
|
|
930
|
+
root_fingerprint=existing_cfg.root_fingerprint,
|
|
931
|
+
active_ticket_key=target_key,
|
|
932
|
+
api_token=existing_cfg.api_token,
|
|
933
|
+
secret_blocklist=existing_cfg.secret_blocklist,
|
|
934
|
+
)
|
|
935
|
+
save_workspace_config(workspace_root, updated_cfg)
|
|
936
|
+
|
|
937
|
+
print(
|
|
938
|
+
json.dumps(
|
|
939
|
+
{
|
|
940
|
+
"status": "prompt_ready",
|
|
941
|
+
"ticket_key": target_key,
|
|
942
|
+
"prompt_file": prompt_file.as_posix() if prompt_file else "",
|
|
943
|
+
"message": (
|
|
944
|
+
f"Berkas instruksi kerja .rakit/PROMPT.md "
|
|
945
|
+
f"untuk [{target_key}] berhasil dibuat."
|
|
946
|
+
),
|
|
947
|
+
},
|
|
948
|
+
ensure_ascii=False,
|
|
949
|
+
)
|
|
950
|
+
)
|
|
951
|
+
return 0
|
|
952
|
+
|
|
953
|
+
if args.command == "start":
|
|
954
|
+
updated_cfg = WorkspaceConfig(
|
|
955
|
+
project_slug=existing_cfg.project_slug,
|
|
956
|
+
api_base_url=existing_cfg.api_base_url,
|
|
957
|
+
machine_label=existing_cfg.machine_label,
|
|
958
|
+
root_fingerprint=existing_cfg.root_fingerprint,
|
|
959
|
+
active_ticket_key=args.ticket_key,
|
|
960
|
+
api_token=existing_cfg.api_token,
|
|
961
|
+
secret_blocklist=existing_cfg.secret_blocklist,
|
|
962
|
+
)
|
|
963
|
+
save_workspace_config(workspace_root, updated_cfg)
|
|
964
|
+
|
|
965
|
+
token = get_effective_api_token(existing_cfg)
|
|
966
|
+
server_claim_status = "saved_locally_offline"
|
|
967
|
+
auth_hint = ""
|
|
968
|
+
if existing_cfg.api_base_url:
|
|
969
|
+
try:
|
|
970
|
+
import urllib.error
|
|
971
|
+
import urllib.request
|
|
972
|
+
|
|
973
|
+
claim_url = f"{existing_cfg.api_base_url}/tickets/{args.ticket_key}/claim/"
|
|
974
|
+
claim_payload = json.dumps(
|
|
975
|
+
{
|
|
976
|
+
"holder_id": existing_cfg.machine_label or "notebook-cli",
|
|
977
|
+
"holder_kind": "NOTEBOOK_CLI",
|
|
978
|
+
"ttl_minutes": 30,
|
|
979
|
+
"project_slug": existing_cfg.project_slug,
|
|
980
|
+
}
|
|
981
|
+
).encode("utf-8")
|
|
982
|
+
headers = {"Content-Type": "application/json"}
|
|
983
|
+
if token:
|
|
984
|
+
headers["Authorization"] = f"Token {token}"
|
|
985
|
+
req = urllib.request.Request(
|
|
986
|
+
claim_url, data=claim_payload, headers=headers, method="POST"
|
|
987
|
+
)
|
|
988
|
+
with urllib.request.urlopen(req, timeout=4) as resp:
|
|
989
|
+
if resp.status in {200, 201}:
|
|
990
|
+
server_claim_status = "claimed_on_server"
|
|
991
|
+
raw_claim = json.loads(resp.read().decode("utf-8"))
|
|
992
|
+
ticket_obj = (raw_claim.get("data") or {}).get("ticket") or {}
|
|
993
|
+
if ticket_obj:
|
|
994
|
+
write_active_task_prompt(workspace_root, ticket_obj)
|
|
995
|
+
except urllib.error.HTTPError as err:
|
|
996
|
+
if err.code in {401, 403}:
|
|
997
|
+
server_claim_status = "unauthorized_401"
|
|
998
|
+
auth_hint = (
|
|
999
|
+
"Token API RAKIT belum tersimpan atau tidak valid. "
|
|
1000
|
+
"Salin token di http://localhost:8000/accounts/profile/ "
|
|
1001
|
+
"lalu jalankan: rakit token <TOKEN>"
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
server_claim_status = f"http_{err.code}"
|
|
1005
|
+
except Exception as e:
|
|
1006
|
+
server_claim_status = f"offline_fallback: {e}"
|
|
1007
|
+
|
|
1008
|
+
result_payload: dict[str, Any] = {
|
|
1009
|
+
"status": "active_ticket_bound",
|
|
1010
|
+
"active_ticket_key": updated_cfg.active_ticket_key,
|
|
1011
|
+
"server_claim_status": server_claim_status,
|
|
1012
|
+
}
|
|
1013
|
+
if auth_hint:
|
|
1014
|
+
result_payload["auth_hint"] = auth_hint
|
|
1015
|
+
print(json.dumps(result_payload, ensure_ascii=False))
|
|
1016
|
+
return 0
|
|
1017
|
+
|
|
1018
|
+
if args.command == "sync":
|
|
1019
|
+
tasks_file = workspace_root / "TASKS.md"
|
|
1020
|
+
synced_done_count = 0
|
|
1021
|
+
server_sync_status = "offline"
|
|
1022
|
+
|
|
1023
|
+
# US-037: Sinkronisasi dua arah dari tiket DONE di server ke TASKS.md lokal
|
|
1024
|
+
if tasks_file.exists():
|
|
1025
|
+
try:
|
|
1026
|
+
import urllib.error
|
|
1027
|
+
import urllib.request
|
|
1028
|
+
|
|
1029
|
+
comp_url = (
|
|
1030
|
+
f"{existing_cfg.api_base_url}/workspaces/completed-tasks/"
|
|
1031
|
+
f"?project_slug={existing_cfg.project_slug}"
|
|
1032
|
+
)
|
|
1033
|
+
req = urllib.request.Request(comp_url, method="GET")
|
|
1034
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
1035
|
+
if resp.status == 200:
|
|
1036
|
+
raw_json = json.loads(resp.read().decode("utf-8"))
|
|
1037
|
+
payload_data = (
|
|
1038
|
+
raw_json.get("data", {}) if isinstance(raw_json, dict) else {}
|
|
1039
|
+
)
|
|
1040
|
+
done_titles = (
|
|
1041
|
+
payload_data.get("completed_titles")
|
|
1042
|
+
or raw_json.get("completed_titles")
|
|
1043
|
+
or []
|
|
1044
|
+
)
|
|
1045
|
+
if done_titles:
|
|
1046
|
+
orig_md = tasks_file.read_text(encoding="utf-8")
|
|
1047
|
+
updated_md, synced_items = _sync_completed_to_markdown(
|
|
1048
|
+
orig_md, done_titles
|
|
1049
|
+
)
|
|
1050
|
+
if synced_items:
|
|
1051
|
+
tasks_file.write_text(updated_md, encoding="utf-8")
|
|
1052
|
+
synced_done_count = len(synced_items)
|
|
1053
|
+
|
|
1054
|
+
# Beritahu server bahwa markdown lokal telah diperbarui
|
|
1055
|
+
sync_md_url = (
|
|
1056
|
+
f"{existing_cfg.api_base_url}/workspaces/sync-tasks-md/"
|
|
1057
|
+
)
|
|
1058
|
+
sync_body = json.dumps(
|
|
1059
|
+
{
|
|
1060
|
+
"project_slug": existing_cfg.project_slug,
|
|
1061
|
+
"tasks_markdown": updated_md,
|
|
1062
|
+
}
|
|
1063
|
+
).encode("utf-8")
|
|
1064
|
+
post_req = urllib.request.Request(
|
|
1065
|
+
sync_md_url,
|
|
1066
|
+
data=sync_body,
|
|
1067
|
+
headers={"Content-Type": "application/json"},
|
|
1068
|
+
method="POST",
|
|
1069
|
+
)
|
|
1070
|
+
try:
|
|
1071
|
+
with urllib.request.urlopen(post_req, timeout=3):
|
|
1072
|
+
pass
|
|
1073
|
+
except Exception:
|
|
1074
|
+
pass
|
|
1075
|
+
server_sync_status = "synced"
|
|
1076
|
+
except Exception:
|
|
1077
|
+
server_sync_status = "offline"
|
|
1078
|
+
|
|
1079
|
+
tasks_info = (
|
|
1080
|
+
parse_tasks_markdown(tasks_file.read_text(encoding="utf-8"))
|
|
1081
|
+
if tasks_file.exists()
|
|
1082
|
+
else parse_tasks_markdown("")
|
|
1083
|
+
)
|
|
1084
|
+
milestone_text = redact_sensitive_text(args.note or tasks_info.next_open_task)
|
|
1085
|
+
raw_idem = (
|
|
1086
|
+
f"{existing_cfg.project_slug}:{existing_cfg.active_ticket_key}:"
|
|
1087
|
+
f"{tasks_info.completed_count}/{tasks_info.total_count}:{milestone_text}"
|
|
1088
|
+
)
|
|
1089
|
+
idempotency_key = hashlib.sha256(raw_idem.encode("utf-8")).hexdigest()[:32]
|
|
1090
|
+
sync_payload = {
|
|
1091
|
+
"project_slug": existing_cfg.project_slug,
|
|
1092
|
+
"active_ticket_key": existing_cfg.active_ticket_key,
|
|
1093
|
+
"tasks_completed": tasks_info.completed_count,
|
|
1094
|
+
"tasks_total": tasks_info.total_count,
|
|
1095
|
+
"progress_pct": tasks_info.progress_pct,
|
|
1096
|
+
"milestone": milestone_text,
|
|
1097
|
+
"idempotency_key": idempotency_key,
|
|
1098
|
+
"synced_done_tasks": synced_done_count,
|
|
1099
|
+
"server_sync_status": server_sync_status,
|
|
1100
|
+
}
|
|
1101
|
+
# Pastikan .rakit/PROMPT.md otomatis dibuat bila belum ada
|
|
1102
|
+
prompt_path = workspace_root / ".rakit" / "PROMPT.md"
|
|
1103
|
+
if existing_cfg.active_ticket_key and not prompt_path.exists():
|
|
1104
|
+
fetch_and_write_prompt(
|
|
1105
|
+
workspace_root, existing_cfg, existing_cfg.active_ticket_key
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
enqueue_offline_sync_event(workspace_root, sync_payload)
|
|
1109
|
+
|
|
1110
|
+
token = get_effective_api_token(existing_cfg)
|
|
1111
|
+
progress_sync_status = "no_token"
|
|
1112
|
+
if existing_cfg.active_ticket_key and existing_cfg.api_base_url and token:
|
|
1113
|
+
progress_sync_status, _ = _post_json(
|
|
1114
|
+
f"{existing_cfg.api_base_url}/workspaces/sync-progress/",
|
|
1115
|
+
{
|
|
1116
|
+
"project_slug": existing_cfg.project_slug,
|
|
1117
|
+
"ticket_key": existing_cfg.active_ticket_key,
|
|
1118
|
+
"holder_id": existing_cfg.machine_label or "notebook-cli",
|
|
1119
|
+
"tasks_completed": tasks_info.completed_count,
|
|
1120
|
+
"tasks_total": tasks_info.total_count,
|
|
1121
|
+
"milestone": milestone_text[:240],
|
|
1122
|
+
"root_fingerprint": existing_cfg.root_fingerprint,
|
|
1123
|
+
"idempotency_key": idempotency_key,
|
|
1124
|
+
},
|
|
1125
|
+
token=token,
|
|
1126
|
+
timeout=4,
|
|
1127
|
+
)
|
|
1128
|
+
if progress_sync_status == "synced_to_server":
|
|
1129
|
+
sync_payload["server_sync_status"] = "synced"
|
|
1130
|
+
sync_payload["progress_sync_status"] = progress_sync_status
|
|
1131
|
+
|
|
1132
|
+
print(json.dumps(sync_payload))
|
|
1133
|
+
return 0
|
|
1134
|
+
|
|
1135
|
+
if args.command == "verify":
|
|
1136
|
+
has_tasks = (workspace_root / "TASKS.md").exists()
|
|
1137
|
+
if not has_tasks:
|
|
1138
|
+
print(json.dumps({"status": "missing_tasks_md", "has_tasks_md": False}))
|
|
1139
|
+
return 2
|
|
1140
|
+
|
|
1141
|
+
ticket_key = (getattr(args, "ticket", "") or existing_cfg.active_ticket_key or "").strip()
|
|
1142
|
+
failed = bool(getattr(args, "failed", False))
|
|
1143
|
+
raw_note = getattr(args, "note", "") or ""
|
|
1144
|
+
verify_payload: dict[str, Any] = {
|
|
1145
|
+
"status": "verification_failed" if failed else "ready_for_qa",
|
|
1146
|
+
"has_tasks_md": True,
|
|
1147
|
+
"ticket_key": ticket_key,
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
token = get_effective_api_token(existing_cfg)
|
|
1151
|
+
if ticket_key and existing_cfg.api_base_url and token:
|
|
1152
|
+
default_log = (
|
|
1153
|
+
f"Verifikasi lokal notebook ({existing_cfg.machine_label or 'notebook-cli'}) "
|
|
1154
|
+
f"untuk [{ticket_key}]: {'GAGAL' if failed else 'LULUS'}."
|
|
1155
|
+
)
|
|
1156
|
+
console_log = redact_sensitive_text(raw_note.strip() or default_log)[:20000]
|
|
1157
|
+
srv_status, srv_body = _post_json(
|
|
1158
|
+
f"{existing_cfg.api_base_url}/qa-uat/execute/",
|
|
1159
|
+
{
|
|
1160
|
+
"project_slug": existing_cfg.project_slug,
|
|
1161
|
+
"ticket_key": ticket_key,
|
|
1162
|
+
"holder_id": existing_cfg.machine_label or "notebook-cli",
|
|
1163
|
+
"target_preview_url": "http://localhost:8000/",
|
|
1164
|
+
"simulate_pass": not failed,
|
|
1165
|
+
"console_errors_count": 1 if failed else 0,
|
|
1166
|
+
"server_5xx_count": 0,
|
|
1167
|
+
"console_log": console_log,
|
|
1168
|
+
},
|
|
1169
|
+
token=token,
|
|
1170
|
+
timeout=8,
|
|
1171
|
+
)
|
|
1172
|
+
verify_payload["server_verify_status"] = srv_status
|
|
1173
|
+
data_obj = srv_body.get("data") if isinstance(srv_body.get("data"), dict) else {}
|
|
1174
|
+
ticket_obj = data_obj.get("ticket") if isinstance(data_obj.get("ticket"), dict) else {}
|
|
1175
|
+
if ticket_obj.get("status"):
|
|
1176
|
+
verify_payload["ticket_status"] = ticket_obj["status"]
|
|
1177
|
+
if data_obj.get("run_id"):
|
|
1178
|
+
verify_payload["run_id"] = data_obj["run_id"]
|
|
1179
|
+
if srv_status.startswith("http_"):
|
|
1180
|
+
verify_payload["server_message"] = srv_body.get("message", "")
|
|
1181
|
+
elif not failed and getattr(args, "auto_close", False):
|
|
1182
|
+
close_res = _close_ticket_on_server(
|
|
1183
|
+
workspace_root=workspace_root,
|
|
1184
|
+
cfg=existing_cfg,
|
|
1185
|
+
ticket_key=ticket_key,
|
|
1186
|
+
token=token,
|
|
1187
|
+
approve_g2=True,
|
|
1188
|
+
pr_url=getattr(args, "pr_url", "") or "",
|
|
1189
|
+
note=raw_note,
|
|
1190
|
+
)
|
|
1191
|
+
verify_payload["auto_close"] = close_res
|
|
1192
|
+
if close_res.get("ticket_status"):
|
|
1193
|
+
verify_payload["ticket_status"] = close_res["ticket_status"]
|
|
1194
|
+
elif ticket_key:
|
|
1195
|
+
verify_payload["server_verify_status"] = "no_token"
|
|
1196
|
+
|
|
1197
|
+
print(json.dumps(verify_payload, ensure_ascii=False))
|
|
1198
|
+
return 1 if failed else 0
|
|
1199
|
+
|
|
1200
|
+
if args.command == "close":
|
|
1201
|
+
ticket_key = (
|
|
1202
|
+
getattr(args, "ticket_flag", "")
|
|
1203
|
+
or getattr(args, "ticket_key", "")
|
|
1204
|
+
or existing_cfg.active_ticket_key
|
|
1205
|
+
or ""
|
|
1206
|
+
).strip()
|
|
1207
|
+
if not ticket_key:
|
|
1208
|
+
print(
|
|
1209
|
+
json.dumps(
|
|
1210
|
+
{
|
|
1211
|
+
"status": "missing_ticket_key",
|
|
1212
|
+
"message": (
|
|
1213
|
+
"Sertakan kunci tiket (mis. rakit close W-070) "
|
|
1214
|
+
"atau klaim tiket terlebih dahulu."
|
|
1215
|
+
),
|
|
1216
|
+
},
|
|
1217
|
+
ensure_ascii=False,
|
|
1218
|
+
)
|
|
1219
|
+
)
|
|
1220
|
+
return 2
|
|
1221
|
+
|
|
1222
|
+
token = get_effective_api_token(existing_cfg)
|
|
1223
|
+
if not (existing_cfg.api_base_url and token):
|
|
1224
|
+
print(
|
|
1225
|
+
json.dumps(
|
|
1226
|
+
{
|
|
1227
|
+
"status": "missing_auth",
|
|
1228
|
+
"ticket_key": ticket_key,
|
|
1229
|
+
"message": (
|
|
1230
|
+
"Token API atau URL server RAKIT belum terpasang "
|
|
1231
|
+
"di .rakit/workspace.json."
|
|
1232
|
+
),
|
|
1233
|
+
},
|
|
1234
|
+
ensure_ascii=False,
|
|
1235
|
+
)
|
|
1236
|
+
)
|
|
1237
|
+
return 2
|
|
1238
|
+
|
|
1239
|
+
close_res = _close_ticket_on_server(
|
|
1240
|
+
workspace_root=workspace_root,
|
|
1241
|
+
cfg=existing_cfg,
|
|
1242
|
+
ticket_key=ticket_key,
|
|
1243
|
+
token=token,
|
|
1244
|
+
approve_g2=bool(getattr(args, "approve_g2", False)),
|
|
1245
|
+
pr_url=getattr(args, "pr_url", "") or "",
|
|
1246
|
+
note=getattr(args, "note", "") or "",
|
|
1247
|
+
)
|
|
1248
|
+
print(json.dumps(close_res, ensure_ascii=False))
|
|
1249
|
+
return 0 if close_res.get("status") == "closed" else 1
|
|
1250
|
+
|
|
1251
|
+
if args.command == "docs" and getattr(args, "docs_action", "") == "pull":
|
|
1252
|
+
token = get_effective_api_token(existing_cfg)
|
|
1253
|
+
if not (existing_cfg.api_base_url and token):
|
|
1254
|
+
print(
|
|
1255
|
+
json.dumps(
|
|
1256
|
+
{
|
|
1257
|
+
"status": "missing_auth",
|
|
1258
|
+
"message": (
|
|
1259
|
+
"Token API atau URL server RAKIT belum terpasang "
|
|
1260
|
+
"di .rakit/workspace.json."
|
|
1261
|
+
),
|
|
1262
|
+
},
|
|
1263
|
+
ensure_ascii=False,
|
|
1264
|
+
)
|
|
1265
|
+
)
|
|
1266
|
+
return 2
|
|
1267
|
+
|
|
1268
|
+
bundle_url = (
|
|
1269
|
+
f"{existing_cfg.api_base_url}/projects/{existing_cfg.project_slug}"
|
|
1270
|
+
"/documentation-bundle/"
|
|
1271
|
+
)
|
|
1272
|
+
srv_status, srv_body = _get_json(bundle_url, token=token, timeout=10)
|
|
1273
|
+
if srv_status != "synced_to_server":
|
|
1274
|
+
print(
|
|
1275
|
+
json.dumps(
|
|
1276
|
+
{
|
|
1277
|
+
"status": "docs_pull_failed",
|
|
1278
|
+
"server_status": srv_status,
|
|
1279
|
+
"server_message": srv_body.get("message", ""),
|
|
1280
|
+
},
|
|
1281
|
+
ensure_ascii=False,
|
|
1282
|
+
)
|
|
1283
|
+
)
|
|
1284
|
+
return 1
|
|
1285
|
+
|
|
1286
|
+
data_obj = srv_body.get("data") if isinstance(srv_body.get("data"), dict) else {}
|
|
1287
|
+
files_map = data_obj.get("files") if isinstance(data_obj.get("files"), dict) else {}
|
|
1288
|
+
out_dir_raw = str(getattr(args, "out_dir", "docs") or "docs").strip()
|
|
1289
|
+
out_dir = workspace_root / out_dir_raw
|
|
1290
|
+
selected_raw = str(getattr(args, "only", "") or "").strip().lower()
|
|
1291
|
+
selected_set = {p.strip() for p in selected_raw.split(",") if p.strip()}
|
|
1292
|
+
overwrite = bool(getattr(args, "overwrite", False))
|
|
1293
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
1294
|
+
|
|
1295
|
+
targets = [
|
|
1296
|
+
("guide", "USER_GUIDE.md", out_dir / "USER_GUIDE.md"),
|
|
1297
|
+
("faq", "FAQ.md", out_dir / "FAQ.md"),
|
|
1298
|
+
("help", "HELP.md", out_dir / "HELP.md"),
|
|
1299
|
+
(
|
|
1300
|
+
"changelog",
|
|
1301
|
+
"CHANGELOG.md",
|
|
1302
|
+
(workspace_root / "CHANGELOG.md")
|
|
1303
|
+
if out_dir_raw == "docs"
|
|
1304
|
+
else (out_dir / "CHANGELOG.md"),
|
|
1305
|
+
),
|
|
1306
|
+
]
|
|
1307
|
+
written: list[dict[str, str]] = []
|
|
1308
|
+
for alias, api_key, target_path in targets:
|
|
1309
|
+
if selected_set and alias not in selected_set:
|
|
1310
|
+
continue
|
|
1311
|
+
content = str(files_map.get(api_key) or "").strip()
|
|
1312
|
+
if not content:
|
|
1313
|
+
continue
|
|
1314
|
+
action = _upsert_managed_doc_section(
|
|
1315
|
+
target_path, content, overwrite=overwrite, dry_run=dry_run
|
|
1316
|
+
)
|
|
1317
|
+
written.append({"file": str(target_path), "action": action})
|
|
1318
|
+
|
|
1319
|
+
print(
|
|
1320
|
+
json.dumps(
|
|
1321
|
+
{
|
|
1322
|
+
"status": "docs_pulled",
|
|
1323
|
+
"project_slug": existing_cfg.project_slug,
|
|
1324
|
+
"drafts_count": data_obj.get("drafts_count", 0),
|
|
1325
|
+
"suggested_bump": data_obj.get("suggested_bump", "patch"),
|
|
1326
|
+
"chapters": data_obj.get("chapters", []),
|
|
1327
|
+
"dry_run": dry_run,
|
|
1328
|
+
"written": written,
|
|
1329
|
+
},
|
|
1330
|
+
ensure_ascii=False,
|
|
1331
|
+
)
|
|
1332
|
+
)
|
|
1333
|
+
return 0
|
|
1334
|
+
|
|
1335
|
+
if args.command == "generate-guides":
|
|
1336
|
+
safe_files = _scan_safe_files(workspace_root)
|
|
1337
|
+
modules = _detect_local_modules(safe_files)
|
|
1338
|
+
verification_cmds = _detect_verification_commands(workspace_root, safe_files)
|
|
1339
|
+
modules_list = "\n".join(f"- `{m}`" for m in modules) if modules else "- `apps/core`"
|
|
1340
|
+
agents_draft = (
|
|
1341
|
+
f"# AGENTS.md — Kontrak Eksekusi {existing_cfg.project_slug}\n\n"
|
|
1342
|
+
f"## Modul Terdeteksi\n{modules_list}\n\n"
|
|
1343
|
+
f"## Aturan Keamanan\n"
|
|
1344
|
+
f"- Dilarang membaca atau mengirim berkas `.env*`, `*.pem`, atau `*.key`.\n"
|
|
1345
|
+
)
|
|
1346
|
+
rakit_yaml_draft = (
|
|
1347
|
+
f'version: 2\nproject:\n slug: "{existing_cfg.project_slug}"\n'
|
|
1348
|
+
f"security:\n block_dotenv: true\n"
|
|
1349
|
+
f"verification:\n"
|
|
1350
|
+
f' lint_command: "{verification_cmds["lint_command"]}"\n'
|
|
1351
|
+
f' test_command: "{verification_cmds["test_command"]}"\n'
|
|
1352
|
+
f' e2e_dir: "{verification_cmds["e2e_dir"]}"\n'
|
|
1353
|
+
f' e2e_command: "{verification_cmds["e2e_command"]}"\n'
|
|
1354
|
+
)
|
|
1355
|
+
if args.write:
|
|
1356
|
+
rakit_dir = workspace_root / ".rakit"
|
|
1357
|
+
rakit_dir.mkdir(parents=True, exist_ok=True)
|
|
1358
|
+
(rakit_dir / "AGENTS.draft.md").write_text(agents_draft, encoding="utf-8")
|
|
1359
|
+
(rakit_dir / "rakit.draft.yaml").write_text(rakit_yaml_draft, encoding="utf-8")
|
|
1360
|
+
|
|
1361
|
+
print(
|
|
1362
|
+
json.dumps(
|
|
1363
|
+
{
|
|
1364
|
+
"status": "guides_generated",
|
|
1365
|
+
"project_slug": existing_cfg.project_slug,
|
|
1366
|
+
"agents_md": agents_draft,
|
|
1367
|
+
"rakit_yaml": rakit_yaml_draft,
|
|
1368
|
+
}
|
|
1369
|
+
)
|
|
1370
|
+
)
|
|
1371
|
+
return 0
|
|
1372
|
+
|
|
1373
|
+
if args.command == "install-hook":
|
|
1374
|
+
hooks_dir = workspace_root / ".git" / "hooks"
|
|
1375
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
1376
|
+
hook_file = hooks_dir / "post-commit"
|
|
1377
|
+
hook_script = (
|
|
1378
|
+
"#!/bin/sh\n"
|
|
1379
|
+
"# RAKIT v2.0 automatic post-commit progress sync (US-011)\n"
|
|
1380
|
+
"rakit sync || true\n"
|
|
1381
|
+
)
|
|
1382
|
+
hook_file.write_text(hook_script, encoding="utf-8")
|
|
1383
|
+
print(
|
|
1384
|
+
json.dumps(
|
|
1385
|
+
{
|
|
1386
|
+
"status": "hook_installed",
|
|
1387
|
+
"hook_path": hook_file.as_posix(),
|
|
1388
|
+
}
|
|
1389
|
+
)
|
|
1390
|
+
)
|
|
1391
|
+
return 0
|
|
1392
|
+
|
|
1393
|
+
return 1
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
if __name__ == "__main__":
|
|
1397
|
+
raise SystemExit(main())
|