[G1-Sync] Manual knowledge update

This commit is contained in:
Antigravity Agent
2026-05-14 00:31:28 +09:00
parent e04c424862
commit 50d8832624
234 changed files with 16589 additions and 0 deletions
@@ -0,0 +1,27 @@
<!-- version: lint_test_v1 -->
# 🧪 lint_test — 자가 검증 + 결과 inject
코다리가 코드를 만든 직후 호출 → 결과가 다음 LLM 컨텍스트로 inject → 실패 시 자동 재시도.
## 동작
1. `package.json``scripts.{typecheck, lint, test, build}` 자동 감지·실행
2. scripts 없으면 직접:
- `.ts/.tsx` 있고 `tsconfig.json` 있으면 → `npx tsc --noEmit`
- `.py` 파일 있으면 → `python -m py_compile <각 파일>` (최대 30개)
3. 마크다운 리포트 — 각 검사 통과/실패 + 실패 시 마지막 15줄
## 설정
- `PROJECT_PATH`: 비우면 web_init 마지막 결과
- `STRICT`: `true` 면 첫 실패에서 중단. 기본 `false` (전부 시도)
## 코다리 권장 흐름
```
1. <create_file 또는 edit_file>
2. <run_command>python3 .../lint_test.py</run_command>
3. 결과를 다음 답변 컨텍스트로 자동 받음
4. 실패면 그 에러 보고 자동 수정 시도
```
## 한계
- `eslint --fix` 같은 자동 수정은 별도 — 도구가 단지 보고만 함
- 단위 테스트 미통과 시 코드 수정 책임은 코다리에게
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
# version: lint_test_v1
"""프로젝트 자가 검증 — 타입체크·테스트·린트 자동 실행 + 결과 요약.
코다리가 코드를 만든 직후 이 도구를 호출하면:
1. package.json 의 scripts 자동 감지 (test/lint/typecheck/build)
2. 또는 .ts/.tsx 파일 있으면 npx tsc --noEmit
3. .py 파일 있으면 python -m py_compile <각 파일>
4. 결과 마크다운 리포트
config:
PROJECT_PATH — 검증할 프로젝트 (비우면 web_init 마지막 결과)
STRICT — 'true' 면 첫 실패에서 멈춤. 기본 false (모두 시도)
"""
import os, sys, json, subprocess, glob
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG = os.path.join(HERE, "lint_test.json")
WEB_INIT_CFG = os.path.join(HERE, "web_init.json")
def _log(msg, kind="info"):
prefix = {"info": "🧪", "ok": "", "warn": "⚠️ ", "err": "", "step": ""}.get(kind, "")
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
def _load(p):
if not os.path.exists(p):
return {}
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _run(cmd, cwd, timeout=180):
_log(f"$ {cmd}", "step")
try:
r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout)
return r.returncode, (r.stdout or "") + "\n" + (r.stderr or "")
except subprocess.TimeoutExpired:
return -1, f"⏱ Timeout ({timeout}s)"
except Exception as e:
return -2, str(e)
def main():
cfg = _load(CONFIG)
init_cfg = _load(WEB_INIT_CFG)
project = (cfg.get("PROJECT_PATH") or "").strip()
if not project:
project = (init_cfg.get("LAST_PROJECT") or "").strip()
if not project:
_log("PROJECT_PATH 비어있고 web_init 기록도 없음", "err")
sys.exit(1)
project = os.path.expanduser(project)
if not os.path.isdir(project):
_log(f"폴더 없음: {project}", "err")
sys.exit(1)
strict = str(cfg.get("STRICT", "")).lower() in ("true", "1", "yes")
_log(f"검증 대상: {project}", "info")
results = [] # (label, code, output)
# 1) package.json scripts 자동 감지
pkg_path = os.path.join(project, "package.json")
if os.path.exists(pkg_path):
try:
with open(pkg_path, "r", encoding="utf-8") as f:
pkg = json.load(f)
scripts = pkg.get("scripts", {})
for key in ["typecheck", "lint", "test", "build"]:
if key in scripts:
code, out = _run(f"npm run {key}", cwd=project, timeout=300)
results.append((f"npm run {key}", code, out))
if strict and code != 0:
break
except Exception as e:
_log(f"package.json 파싱 실패: {e}", "warn")
# 2) scripts 없으면 직접 tsc/py_compile
if not results:
# TS/TSX
ts_files = glob.glob(os.path.join(project, "**/*.ts"), recursive=True) + \
glob.glob(os.path.join(project, "**/*.tsx"), recursive=True)
ts_files = [f for f in ts_files if "node_modules" not in f and "dist" not in f]
if ts_files:
tsconfig = os.path.join(project, "tsconfig.json")
if os.path.exists(tsconfig):
code, out = _run("npx tsc --noEmit", cwd=project, timeout=180)
results.append(("npx tsc --noEmit", code, out))
# Python
py_files = glob.glob(os.path.join(project, "**/*.py"), recursive=True)
py_files = [f for f in py_files if "venv" not in f and ".venv" not in f and "__pycache__" not in f]
if py_files:
errs = []
for pf in py_files[:30]: # 30개 cap
code, out = _run(f"python3 -m py_compile {json.dumps(pf)}", cwd=project, timeout=10)
if code != 0:
errs.append((pf, out.strip()[:120]))
if errs:
results.append((f"py_compile ({len(errs)}/{len(py_files)} 실패)", 1, "\n".join(f"{f}: {e}" for f, e in errs[:10])))
else:
results.append((f"py_compile {len(py_files)} files", 0, "All OK"))
# 결과 리포트
print()
print(f"# 🧪 검증 결과 — {os.path.basename(project)}")
print()
if not results:
print("⚠️ 실행할 검증 없음 (package.json scripts 없고 .ts/.py 파일도 없음)")
return
passed = sum(1 for _, c, _ in results if c == 0)
print(f"**{passed}/{len(results)} 통과**\n")
for label, code, out in results:
icon = "" if code == 0 else ""
print(f"## {icon} {label}")
if code == 0:
print(f"성공 (exit code 0)")
else:
print(f"실패 (exit code {code})")
print()
print("```")
for line in out.strip().split("\n")[-15:]:
print(line)
print("```")
print()
if passed == len(results):
print("> 🎉 모든 검증 통과. 안전하게 다음 단계로.")
else:
print(f"> ⚠️ {len(results) - passed}개 실패 — 위 출력 보고 수정 필요.")
if __name__ == "__main__":
main()
@@ -0,0 +1,69 @@
<!-- version: pack_apply_v1 -->
# 📋 pack_apply — 키트 한 명령으로 적용
두뇌에 주입된 템플릿 팩을 사용자 프로젝트에 자동 적용. 파일 복사 + 의존성 설치 + App.tsx 자동 업데이트.
## 사용
설정 (pack_apply.json):
- `KIT_NAME`: 'landing-kit' / 'portfolio-kit' / 'dashboard-kit' / 'mobile-kit'
- `PROJECT_PATH`: 적용할 사용자 프로젝트 (비우면 web_init 결과 자동)
실행:
```
python3 pack_apply.py
```
## 동작 (3단계)
1. **파일 복사**: 키트의 `files/` 폴더를 manifest의 `apply.copy_to` 경로로 (예: `src/components/`)
2. **의존성 자동 설치**: manifest의 `apply.post_install` 명령 순차 실행
- 예: `npm install lucide-react`
- Expo: `npx expo install @react-navigation/native ...`
3. **App.tsx 자동 업데이트**: manifest의 `apply.app_imports` + `app_body` 로 import + JSX 본문 추가
## 키트별 동작
### landing-kit (vite-react)
- 복사: 6개 컴포넌트 → src/components/
- 설치: lucide-react
- App.tsx: Hero·Features·Pricing·FAQ·CTA·Footer 자동 배치
### portfolio-kit (vite-react)
- 복사: 5개 컴포넌트
- 설치: lucide-react
- App.tsx: Nav·About·Work·Skills·Contact 자동 배치
### dashboard-kit (vite-react)
- 복사: 5개 컴포넌트
- 설치: lucide-react
- App.tsx: `<DashboardLayout />` 한 줄로 풀스크린 대시보드
### mobile-kit (Expo)
- 복사: App.tsx + screens/ 3개
- 설치: @react-navigation/native + bottom-tabs + screens + safe-area-context
- App.tsx: 기존 덮어쓰기 (Bottom Tab Navigator)
## 코다리 사용 예시
```
사용자: "다이어트 SaaS 랜딩 만들어줘"
코다리:
1. web_init (TEMPLATE=vite-react, PROJECT_NAME=diet-saas)
2. pack_apply (KIT_NAME=landing-kit) ← 새 도구
3. edit_file 로 텍스트만 다이어트 SaaS 카피로 교체
4. web_preview (자동 dev server + 브라우저)
→ 5분 안에 완성 + 모바일·데스크탑 반응형 + Tailwind 컨벤션 일관
```
## 안전장치
- `KIT_NAME` 없거나 잘못되면 종료 + 사용 가능 키트 안내
- 두뇌 폴더 자동 탐색 (~/.connect-ai-brain 또는 fallback 경로)
- 파일 복사는 덮어쓰기 (사용자가 수정한 거 있으면 백업 권장)
- 의존성 설치 실패해도 계속 진행 (warn만, 사용자 수동 가능)
- App.tsx 패턴 매칭 실패 시 수동 안내
## 한계
- App.tsx 자동 업데이트는 best-effort (단순 패턴 매칭). 복잡한 기존 App.tsx 는 수동 권장.
- 키트가 React 외 (Vue·Svelte 등)에 적용되면 App.tsx 패턴 안 맞음 — 키트가 진짜 React 인지 manifest.base 로 검증 필요.
@@ -0,0 +1,485 @@
#!/usr/bin/env python3
# version: pack_apply_v7
"""두뇌의 템플릿 팩을 사용자 프로젝트에 한 번에 적용.
흐름:
1. KIT_NAME — 두뇌의 40_템플릿/developer/<KIT_NAME>/ 폴더
2. PROJECT_PATH — 적용할 사용자 프로젝트 (비우면 web_init 결과 자동)
3. manifest.json 의 apply.{copy_to, post_install, app_imports, app_body} 사용:
- files/* → PROJECT_PATH/copy_to/ (예: src/components/)
- post_install: npm install / npx expo install 자동 실행
- app_imports: App.tsx 또는 App.tsx 에 import 추가 + JSX 본문 자동
4. 결과 출력 — 다음 단계 안내 (npm run dev 등)
이 도구가 코다리에게 주는 슈퍼파워:
- 매뉴얼 cp + npm install 호출 안 해도 됨
- 한 명령으로 "키트 적용 완료"
- 의존성 누락 없음 (manifest 가 진실 소스)
"""
import os, sys, json, subprocess, shutil
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG = os.path.join(HERE, "pack_apply.json")
WEB_INIT_CFG = os.path.join(HERE, "web_init.json")
def _log(msg, kind="info"):
prefix = {"info": "📋", "ok": "", "warn": "⚠️ ", "err": "", "step": ""}.get(kind, "")
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
def _load(p):
if not os.path.exists(p):
return {}
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _run(cmd, cwd):
_log(f"$ {cmd}", "step")
r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=600)
if r.returncode != 0:
for line in (r.stderr or "").splitlines()[-8:]:
_log(line, "warn")
return False
return True
def _load_operator_credentials(brain_root):
"""v7: 운영자(1인 기업)의 자격증명을 두뇌에서 로드. pack_apply 가 키트 HTML/JS
의 placeholder 를 운영자 키로 자동 교체.
지원 placeholder:
__GEMINI_API_KEY__ → Gemini API 키
__GEMINI_TEXT_MODEL__ → 텍스트 모델명
__GEMINI_IMAGE_MODEL__ → 이미지 모델명
__PAYPAL_CLIENT_ID__ → PayPal Live/Sandbox Client ID
자격증명은 외부 연결 패널 (Connect AI) 에서 입력. 키트 사용자(고객) 는
이 키를 볼 일이 없음 — 운영자가 빌드 시점에 박힘. """
creds = {
"__GEMINI_API_KEY__": "",
"__GEMINI_TEXT_MODEL__": "gemini-3.1-flash-lite-preview",
"__GEMINI_IMAGE_MODEL__": "gemini-3.1-flash-image-preview",
"__PAYPAL_CLIENT_ID__": "",
}
business_tools = os.path.join(brain_root, "_company", "_agents", "business", "tools")
# Gemini
try:
gp = os.path.join(business_tools, "gemini_account.json")
if os.path.exists(gp):
with open(gp, "r", encoding="utf-8") as f:
g = json.load(f)
if g.get("API_KEY"): creds["__GEMINI_API_KEY__"] = g["API_KEY"]
if g.get("TEXT_MODEL"): creds["__GEMINI_TEXT_MODEL__"] = g["TEXT_MODEL"]
if g.get("IMAGE_MODEL"): creds["__GEMINI_IMAGE_MODEL__"] = g["IMAGE_MODEL"]
except Exception:
pass
# PayPal
try:
pp = os.path.join(business_tools, "paypal_revenue.json")
if os.path.exists(pp):
with open(pp, "r", encoding="utf-8") as f:
p = json.load(f)
if p.get("CLIENT_ID"): creds["__PAYPAL_CLIENT_ID__"] = p["CLIENT_ID"]
except Exception:
pass
return creds
def _inject_credentials(file_path, creds):
"""v7: 텍스트 파일 안의 placeholder 를 운영자 자격증명으로 교체.
바이너리·이미지 파일은 skip. UTF-8 못 읽으면 skip. """
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except (UnicodeDecodeError, IsADirectoryError):
return False
except Exception:
return False
replaced = False
for placeholder, value in creds.items():
if placeholder in content and value:
content = content.replace(placeholder, value)
replaced = True
if replaced:
try:
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
return True
except Exception:
return False
return False
def _copy_tree(src_dir, dst_dir, creds=None):
"""v2: 기존 파일이 있으면 .backup 자동 생성 (사용자 코드 보호).
백업이 이미 있으면 덮어쓰지 않음 (멱등성).
v7: creds 가 주어지면 복사 후 각 파일에서 placeholder 교체.
v7.1: 자격증명 누락 placeholder 가 남으면 경고 (운영자 입력 유도)."""
os.makedirs(dst_dir, exist_ok=True)
copied = 0
backed_up = []
injected = 0
missing_placeholders = {} # placeholder -> count
for root, _dirs, files in os.walk(src_dir):
rel = os.path.relpath(root, src_dir)
target = os.path.join(dst_dir, rel) if rel != "." else dst_dir
os.makedirs(target, exist_ok=True)
for f in files:
dst_path = os.path.join(target, f)
if os.path.exists(dst_path):
bk = dst_path + ".backup"
if not os.path.exists(bk):
try:
shutil.copy2(dst_path, bk)
backed_up.append(os.path.relpath(dst_path, dst_dir))
except Exception:
pass
shutil.copy2(os.path.join(root, f), dst_path)
copied += 1
# v7: 자격증명 placeholder 자동 inline
if creds and any(creds.values()):
if _inject_credentials(dst_path, creds):
injected += 1
# v7.1: 남은 placeholder 스캔 (빈 자격증명 감지)
if creds:
try:
with open(dst_path, "r", encoding="utf-8") as fh:
body = fh.read()
for ph, val in creds.items():
if not val and ph in body:
missing_placeholders[ph] = missing_placeholders.get(ph, 0) + 1
except Exception:
pass
if backed_up:
_log(f"기존 파일 {len(backed_up)}개 .backup 보존: {', '.join(backed_up[:3])}{'' if len(backed_up) > 3 else ''}", "info")
if injected:
_log(f"🔐 운영자 자격증명 {injected}개 파일에 자동 inline (Gemini/PayPal placeholder 교체)", "ok")
if missing_placeholders:
guide = {
"__GEMINI_API_KEY__": "Connect AI → 외부 연결 → ✨ Google Gemini → API Key 입력",
"__PAYPAL_CLIENT_ID__": "Connect AI → 외부 연결 → 💰 PayPal → Client ID 입력",
}
_log("⚠️ 운영자 자격증명 누락 — 키트는 복사됐지만 실제 호출은 안 됨:", "warn")
for ph in sorted(missing_placeholders):
_log(f"{ph}{guide.get(ph, '외부 연결 패널에서 입력 필요')}", "warn")
_log(" ↳ 키 입력 후 키트 다시 적용하면 자동 inline 됩니다.", "warn")
return copied
def _find_app_file(project_path):
"""vite/next 모두 커버. src/App.tsx 우선, 없으면 App.tsx (expo)."""
for cand in ["src/App.tsx", "App.tsx", "src/app/page.tsx", "app/page.tsx"]:
p = os.path.join(project_path, cand)
if os.path.exists(p):
return p
return None
def _update_app_tsx(app_path, imports, body):
"""App.tsx 를 깨끗하게 새로 작성. 원본은 .backup 으로 보존.
v2: regex 부분 매칭으로 옛 JSX 가 남던 사고 → 전체 덮어쓰기 + 백업 방식으로 변경."""
try:
with open(app_path, "r", encoding="utf-8") as f:
original = f.read()
except Exception:
return False
# 이미 키트 적용됐으면 skip
if all(f"from './components/{n}'" in original for n in imports):
return False
# 백업 — 사용자가 손댄 거 잃지 않게
try:
backup_path = app_path + ".backup"
if not os.path.exists(backup_path):
with open(backup_path, "w", encoding="utf-8") as f:
f.write(original)
except Exception:
pass
# 새 App.tsx — 깨끗한 최소 버전
import_lines = "\n".join([f"import {n} from './components/{n}'" for n in imports])
new_content = f"""{import_lines}
export default function App() {{
return (
<main className="min-h-screen bg-white text-gray-900">
{body}
</main>
);
}}
"""
try:
with open(app_path, "w", encoding="utf-8") as f:
f.write(new_content)
return True
except Exception:
return False
def _find_brain_root():
"""두뇌 폴더 자동 탐색 (한국어 폴더명 포함).
v4: BRAIN_ROOT 환경변수가 가장 강함 (Connect AI 익스텐션이 직접 지정).
이전엔 ~/.connect-ai-brain 가 빈 폴더로 존재만 해도 우선 매칭돼서
실제 사용자 두뇌(~/Downloads/지식메모리) 의 키트를 못 찾던 사고 차단.
"""
env = os.environ.get("BRAIN_ROOT", "").strip()
if env:
ep = os.path.expanduser(env)
if os.path.exists(ep):
return ep
cands = [
os.path.expanduser("~/Downloads/지식메모리"),
os.path.expanduser("~/.connect-ai-brain"),
os.path.expanduser("~/.connect-ai-brain-imported"),
]
for c in cands:
if os.path.exists(c):
return c
return cands[0] # 첫 번째 fallback
def _list_kits(brain_root):
"""developer 카테고리의 모든 키트와 manifest 반환."""
tdir = os.path.join(brain_root, "40_템플릿", "developer")
if not os.path.exists(tdir):
return []
kits = []
for name in os.listdir(tdir):
d = os.path.join(tdir, name)
if not os.path.isdir(d):
continue
mp = os.path.join(d, "manifest.json")
if not os.path.exists(mp):
continue
try:
with open(mp, "r", encoding="utf-8") as f:
manifest = json.load(f)
kits.append({"name": name, "manifest": manifest})
except Exception:
pass
return kits
def _score_kit(manifest, intent_text):
"""매니페스트 vs 사용자 의도(intent_text) 매칭 점수.
keywords + name + description 단어 매칭. 한국어·영어 모두."""
if not intent_text:
return 0
haystack = " ".join([
manifest.get("name", ""),
manifest.get("description", ""),
" ".join(manifest.get("keywords") or []),
manifest.get("category", ""),
]).lower()
intent_lc = intent_text.lower()
score = 0
# keywords 직접 매칭 (높은 가중치)
for kw in (manifest.get("keywords") or []):
if kw.lower() in intent_lc:
score += 10
# name 자체가 의도에 있으면 (예: "landing-kit" → "landing")
for token in manifest.get("name", "").split():
if len(token) >= 3 and token.lower() in intent_lc:
score += 5
# 카테고리
if (manifest.get("category", "").lower() or "") in intent_lc:
score += 3
return score
def _autodetect_kit(brain_root, intent_text):
"""사용자 의도에서 가장 적합한 키트 자동 추론. (kit_name, score, alternatives) 반환."""
kits = _list_kits(brain_root)
if not kits:
return None, 0, []
scored = [(k["name"], _score_kit(k["manifest"], intent_text), k["manifest"].get("description", "")) for k in kits]
scored.sort(key=lambda x: -x[1])
if scored[0][1] == 0:
# 매치 0 — fallback: 가장 일반적인 landing-kit
for k in kits:
if k["name"] == "landing-kit":
return "landing-kit", 0, scored[:3]
return kits[0]["name"], 0, scored[:3]
return scored[0][0], scored[0][1], scored[:3]
def _parse_cli_args():
"""v4: 로컬 LLM 이 CLI 인자로 호출하는 패턴도 지원.
`--kit landing-kit --user-intent "..." --project /path` 또는
환경변수 KIT_NAME / USER_INTENT / PROJECT_PATH."""
out = {}
args = sys.argv[1:]
i = 0
aliases = {
"--kit": "KIT_NAME", "--kit-name": "KIT_NAME",
"--user-intent": "USER_INTENT", "--intent": "USER_INTENT",
"--project": "PROJECT_PATH", "--project-path": "PROJECT_PATH",
"--brain-root": "BRAIN_ROOT", "--brain": "BRAIN_ROOT",
}
while i < len(args):
a = args[i]
if a in aliases and i + 1 < len(args):
out[aliases[a]] = args[i + 1]
i += 2
elif "=" in a and a.startswith("--"):
k, v = a[2:].split("=", 1)
key = aliases.get("--" + k)
if key:
out[key] = v
i += 1
else:
i += 1
for k in ("KIT_NAME", "USER_INTENT", "PROJECT_PATH", "BRAIN_ROOT"):
if k in os.environ and os.environ[k].strip():
out.setdefault(k, os.environ[k])
return out
def main():
cfg = _load(CONFIG)
init_cfg = _load(WEB_INIT_CFG)
cli = _parse_cli_args()
for k, v in cli.items():
if v and str(v).strip():
cfg[k] = v
kit_name = (cfg.get("KIT_NAME") or "").strip()
user_intent = (cfg.get("USER_INTENT") or "").strip()
# v5: CLI --brain-root 가 있으면 env 처럼 작동시켜 _find_brain_root 우선순위 활용
cli_brain = cli.get("BRAIN_ROOT", "").strip() if cli else ""
if cli_brain:
os.environ["BRAIN_ROOT"] = cli_brain
# 두뇌 폴더 찾기 (자동 추론에도 필요)
brain_root = _find_brain_root()
# v3: KIT_NAME 비어있고 USER_INTENT 있으면 자동 매칭
selection_card = ""
if not kit_name and user_intent:
detected, score, alts = _autodetect_kit(brain_root, user_intent)
if detected:
kit_name = detected
_log(f"자동 추론 → '{kit_name}' (매칭 점수 {score})", "info")
if score == 0:
_log(" ⚠️ 사용자 의도와 명확한 매칭 없음. 가장 일반적인 키트로 fallback.", "warn")
# 시각 카드 (stdout에 마크다운 — 채팅창에 렌더링됨)
card_lines = [
"",
"## 🎯 키트 자동 선택",
"",
f"> 사용자 의도: _\"{user_intent}\"_",
"",
"| 순위 | 키트 | 매칭 점수 | 비고 |",
"|---|---|---|---|",
]
for i, (n, s, desc) in enumerate(alts):
marker = "**⭐ 선택**" if n == kit_name else ""
d_short = (desc[:50] + "") if len(desc) > 50 else desc
card_lines.append(f"| {i+1} | `{n}` | **{s}** | {marker} {d_short} |")
if score == 0:
card_lines.append("")
card_lines.append("⚠️ _명확한 매칭 없음 — fallback으로 가장 일반적인 키트 선택._")
card_lines.append("")
card_lines.append("> 💡 다른 키트로 바꾸려면 `pack_apply` 를 `KIT_NAME=<원하는 키트>` 로 다시 실행.")
card_lines.append("")
selection_card = "\n".join(card_lines)
if not kit_name:
kits = _list_kits(brain_root)
avail = ", ".join([f"'{k['name']}'" for k in kits]) or "(두뇌에 키트 없음 — EZER 에서 먼저 주입)"
_log(f"KIT_NAME 비어있고 USER_INTENT 도 없음.", "err")
_log(f" 방법 1: KIT_NAME 명시 → {avail}", "info")
_log(f" 방법 2: USER_INTENT 에 '다이어트 SaaS 랜딩' 같은 자연어 입력 → 자동 추론", "info")
sys.exit(1)
project = (cfg.get("PROJECT_PATH") or "").strip()
if not project:
project = (init_cfg.get("LAST_PROJECT") or "").strip()
if not project:
_log("PROJECT_PATH 비어있고 web_init 기록도 없음", "err")
sys.exit(1)
project = os.path.expanduser(project)
if not os.path.isdir(project):
_log(f"프로젝트 폴더 없음: {project}", "err")
sys.exit(1)
kit_dir = os.path.join(brain_root, "40_템플릿", "developer", kit_name)
if not os.path.exists(kit_dir):
_log(f"키트 없음: {kit_dir}", "err")
_log(f"먼저 EZER Pack Vault 에서 '{kit_name}' 주입하세요.", "info")
sys.exit(1)
manifest_path = os.path.join(kit_dir, "manifest.json")
if not os.path.exists(manifest_path):
_log(f"manifest 없음: {manifest_path}", "err")
sys.exit(1)
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
apply = manifest.get("apply", {})
copy_to = apply.get("copy_to", "src/components/")
post_install = apply.get("post_install", [])
app_imports = apply.get("app_imports", [])
app_body = apply.get("app_body", "")
_log(f"키트: {manifest.get('name', kit_name)}{project}", "info")
_log(f"기반: {manifest.get('base', '?')}", "info")
# v7: 운영자 자격증명 로드 (Gemini/PayPal placeholder 자동 inline)
creds = _load_operator_credentials(brain_root)
# 1) 파일 복사 (+ placeholder 교체)
src_files = os.path.join(kit_dir, "files")
dst_files = os.path.join(project, copy_to.lstrip("./"))
if not os.path.exists(src_files):
_log("키트의 files/ 폴더 없음 — 파일 복사 스킵", "warn")
else:
n = _copy_tree(src_files, dst_files, creds=creds)
_log(f"{n}개 파일 복사 → {dst_files}", "ok")
# 2) 의존성 자동 설치
if post_install:
_log(f"의존성 {len(post_install)}개 설치 중...", "info")
for cmd in post_install:
ok = _run(cmd, cwd=project)
if not ok:
_log(f"부가 명령 실패: {cmd} — 계속 진행", "warn")
# 3) App.tsx 자동 업데이트 (best-effort)
if app_imports:
app_file = _find_app_file(project)
if app_file:
changed = _update_app_tsx(app_file, app_imports,
app_body or "\n".join([f"<{n} />" for n in app_imports]))
if changed:
_log(f"App.tsx 자동 업데이트: {app_file}", "ok")
else:
_log(f"App.tsx 이미 정정됨 또는 패턴 매칭 실패 — 수동 확인: {app_file}", "warn")
else:
_log("App.tsx 못 찾음 — 수동으로 import + JSX 추가 필요", "warn")
# 결과 — stdout 으로 마크다운 (채팅창 렌더링)
if selection_card:
print(selection_card)
print()
print(f"## ✅ 적용 완료: `{manifest.get('name', kit_name)}`")
print()
print(f"- **위치**: `{project}`")
print(f"- **기반**: {manifest.get('base', '?')}")
if "expo" in (manifest.get("base", "").lower()):
print(f"- **실행**: `cd {project} && npm start` → 폰에 Expo Go 깔고 QR 스캔")
else:
print(f"- **실행**: `cd {project} && npm run dev` → http://localhost:5173")
print()
_log(f"적용 완료: {kit_name}", "ok")
if __name__ == "__main__":
main()
@@ -0,0 +1,37 @@
<!-- version: pwa_setup_v1 -->
# 💻 PWA 자동 셋업 — 웹사이트 → 모바일 앱처럼
기존 웹 프로젝트를 PWA(Progressive Web App)로 변환. 사용자가 폰에서 "홈 화면에 추가" 누르면 풀스크린 앱처럼 작동.
## 자동 생성 파일
- `public/manifest.json` — 앱 메타 (이름·아이콘·테마색)
- `public/icon-192.svg` + `icon-512.svg` — 이모지 기반 라운드 아이콘
- `public/sw.js` — 서비스 워커 (오프라인 캐싱)
- `index.html`에 자동 주입: meta·link·script
## 설정
- `PROJECT_PATH`: 비우면 web_init이 마지막에 만든 프로젝트 자동 사용
- `APP_NAME`: 앱 이름 (홈화면 라벨)
- `APP_SHORT_NAME`: 12자 이하 짧은 이름
- `THEME_COLOR`: 상단 바 색 (예: `#667eea`)
- `BACKGROUND_COLOR`: 스플래시 배경 (예: `#ffffff`)
- `ICON_EMOJI`: 아이콘에 쓸 이모지 (예: `📚`)
## 사용 흐름
```
1. web_init으로 사이트 만듦 (vite-react·astro 등)
2. pwa_setup 실행 → manifest·아이콘·sw 생성
3. 배포 (Vercel·Netlify) 또는 로컬 dev server
4. 폰 브라우저로 URL 접속
5. iOS Safari: 공유 → 홈 화면에 추가
Android Chrome: ⋮ → 홈 화면에 추가
6. 홈 화면 아이콘 클릭 → 풀스크린 앱
```
## Next.js 사용자
Next.js 13+ App Router 는 `app/layout.tsx``export const metadata` 에 PWA 정보를 넣어야 함. 도구가 자동 감지하면 안내 메시지 표시.
## 한계
- 진짜 네이티브 기능 (푸시 알림·블루투스·카메라) 은 PWA로 부분 지원
- 복잡한 모바일 앱은 Expo 권장
- 아이콘은 SVG로 생성 (PNG 변환 필요시 ImageMagick 또는 사용자 디자인)
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
# version: pwa_setup_v1
"""웹사이트를 PWA(모바일 앱처럼)로 변환.
config:
PROJECT_PATH — 대상 폴더 (web_init 결과 자동 사용)
APP_NAME — 앱 이름 (홈화면에 표시)
APP_SHORT_NAME — 짧은 이름 (12자 이하)
THEME_COLOR — 상단 바 색 (예: #667eea)
BACKGROUND_COLOR — 스플래시 배경
ICON_EMOJI — 아이콘 자동 생성에 쓸 이모지 (예: 📚)
생성 파일:
public/manifest.json — PWA 메타
public/sw.js — 서비스 워커 (오프라인)
public/icon-192.png + icon-512.png — 자동 생성 (이모지 기반)
index.html (또는 public/index.html) — meta·link·script 자동 주입
설치 방법 (사용자):
사파리·크롬에서 사이트 접속 → "홈 화면에 추가" → 풀스크린 앱 작동
"""
import os, sys, json, base64, re
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG = os.path.join(HERE, "pwa_setup.json")
WEB_INIT_CONFIG = os.path.join(HERE, "web_init.json")
def _log(msg, kind="info"):
prefix = {"info": "💻", "ok": "", "warn": "⚠️ ", "err": "", "step": ""}.get(kind, "")
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
def _load(p):
if os.path.exists(p):
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _generate_icon_svg(emoji, bg_color, size=512):
"""이모지 기반 SVG 아이콘 생성 (라운드 코너 + 그라데이션)."""
return f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="{bg_color}" stop-opacity="1"/>
<stop offset="100%" stop-color="{bg_color}" stop-opacity="0.7"/>
</linearGradient>
</defs>
<rect width="{size}" height="{size}" rx="{size//8}" ry="{size//8}" fill="url(#g)"/>
<text x="50%" y="50%" font-size="{int(size*0.55)}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif">{emoji}</text>
</svg>
'''
def _find_html(project_path):
"""프로젝트의 메인 HTML 찾기 (index.html, public/index.html, app/layout.tsx 등)."""
candidates = [
os.path.join(project_path, "index.html"),
os.path.join(project_path, "public", "index.html"),
os.path.join(project_path, "public", "manifest.json"), # 이미 있으면 표시
]
for c in candidates:
if os.path.exists(c):
return c
return None
def _find_public_dir(project_path):
"""public 디렉토리 찾기 또는 만들기."""
public = os.path.join(project_path, "public")
if os.path.exists(public):
return public
# Vite는 public/, Next.js도 public/
os.makedirs(public, exist_ok=True)
return public
def main():
cfg = _load(CONFIG)
init_cfg = _load(WEB_INIT_CONFIG)
project_path = (cfg.get("PROJECT_PATH") or "").strip()
if not project_path:
project_path = (init_cfg.get("LAST_PROJECT") or "").strip()
if not project_path:
_log("PROJECT_PATH가 비어있고 web_init 기록도 없음", "err")
sys.exit(1)
project_path = os.path.expanduser(project_path)
if not os.path.isdir(project_path):
_log(f"폴더 없음: {project_path}", "err")
sys.exit(1)
app_name = (cfg.get("APP_NAME") or "").strip() or os.path.basename(project_path)
short_name = (cfg.get("APP_SHORT_NAME") or "").strip() or app_name[:12]
theme = (cfg.get("THEME_COLOR") or "").strip() or "#667eea"
bg = (cfg.get("BACKGROUND_COLOR") or "").strip() or "#ffffff"
icon_emoji = (cfg.get("ICON_EMOJI") or "").strip() or ""
_log(f"PWA 셋업 시작 → {project_path}", "info")
public = _find_public_dir(project_path)
# 1. manifest.json
manifest = {
"name": app_name,
"short_name": short_name,
"description": f"{app_name} — Connect AI로 만들어진 PWA",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": theme,
"background_color": bg,
"icons": [
{"src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "any maskable"},
{"src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any maskable"},
],
}
manifest_path = os.path.join(public, "manifest.json")
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
_log(f"manifest.json 생성: {manifest_path}", "ok")
# 2. 아이콘 (SVG로 — 모든 기기에서 잘 보임 + 작은 사이즈)
for size in (192, 512):
icon_path = os.path.join(public, f"icon-{size}.svg")
with open(icon_path, "w", encoding="utf-8") as f:
f.write(_generate_icon_svg(icon_emoji, theme, size))
_log(f"icon-{size}.svg 생성", "ok")
# 3. service worker
sw_path = os.path.join(public, "sw.js")
sw_content = f'''// Connect AI PWA Service Worker
// version: pwa_v1 — auto-generated
const CACHE = "{short_name}-v1";
const ASSETS = ["/", "/manifest.json", "/icon-192.svg", "/icon-512.svg"];
self.addEventListener("install", e => {{
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS).catch(()=>{{/* offline OK */}})));
self.skipWaiting();
}});
self.addEventListener("activate", e => {{
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
));
self.clients.claim();
}});
self.addEventListener("fetch", e => {{
const req = e.request;
if (req.method !== "GET") return;
e.respondWith(
caches.match(req).then(hit => hit || fetch(req).then(res => {{
const copy = res.clone();
caches.open(CACHE).then(c => c.put(req, copy)).catch(()=>{{/* ignore */}});
return res;
}}).catch(() => caches.match("/") || new Response("offline", {{ status: 503 }})))
);
}});
'''
with open(sw_path, "w", encoding="utf-8") as f:
f.write(sw_content)
_log(f"sw.js 생성: {sw_path}", "ok")
# 4. HTML에 meta + link + script 주입
# 후보: index.html, public/index.html, app/layout.tsx (Next.js)
html_candidates = [
os.path.join(project_path, "index.html"),
os.path.join(project_path, "public", "index.html"),
]
html_file = None
for c in html_candidates:
if os.path.exists(c):
html_file = c
break
pwa_head = f'''
<!-- PWA: 자동 생성 — pwa_setup.py -->
<meta name="theme-color" content="{theme}">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/icon-512.svg">
<link rel="apple-touch-icon" href="/icon-512.svg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="{short_name}">
<meta name="mobile-web-app-capable" content="yes">'''
pwa_script = '''
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(()=>{/* ignore */});
});
}
</script>'''
if html_file:
with open(html_file, "r", encoding="utf-8") as f:
html = f.read()
if "manifest.json" in html:
_log(f"HTML 이미 PWA 메타 있음. 스킵: {html_file}", "warn")
else:
# </head> 직전에 head 삽입
new_html = re.sub(r"</head>", pwa_head + "\n </head>", html, count=1, flags=re.IGNORECASE)
# </body> 직전에 script 삽입
new_html = re.sub(r"</body>", pwa_script + "\n </body>", new_html, count=1, flags=re.IGNORECASE)
with open(html_file, "w", encoding="utf-8") as f:
f.write(new_html)
_log(f"HTML 메타·script 주입: {html_file}", "ok")
else:
# Next.js app/layout.tsx 안내만
next_layout = os.path.join(project_path, "src", "app", "layout.tsx")
next_layout_alt = os.path.join(project_path, "app", "layout.tsx")
layout = next_layout if os.path.exists(next_layout) else (next_layout_alt if os.path.exists(next_layout_alt) else None)
if layout:
_log(f"Next.js 감지 — {layout} 의 metadata에 manifest 추가하세요", "warn")
_log(f" export const metadata = {{ ... manifest: '/manifest.json' }}", "info")
else:
_log("HTML 파일을 찾지 못함. PWA 메타·script 수동 추가 필요.", "warn")
_log(f"head: {pwa_head.strip()}", "info")
_log(f"body: {pwa_script.strip()}", "info")
# 결과 저장
cfg["LAST_PROJECT"] = project_path
cfg["LAST_APP_NAME"] = app_name
cfg["LAST_THEME"] = theme
with open(CONFIG, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
print()
_log(f"PWA 셋업 완료: {app_name}", "ok")
_log("테스트:", "info")
_log(" 1. dev server 또는 배포된 URL을 모바일 브라우저로 열기", "info")
_log(" 2. iOS Safari: 공유 → 홈 화면에 추가", "info")
_log(" 3. Android Chrome: 우측 ⋮ → 홈 화면에 추가", "info")
_log(" 4. 풀스크린·아이콘·오프라인 작동 확인", "info")
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
<!-- version: web_init_v1 -->
# 💻 웹·모바일 프로젝트 자동 시작
5개 템플릿 중 골라서 한 번에 프로젝트 폴더 + 의존성 설치 + 첫 실행 가능한 상태로.
## 템플릿
| 템플릿 | 용도 | 의존성 | 첫 실행 |
|---|---|---|---|
| **vite-react** ⭐ 추천 | SPA·대시보드·SaaS UI | Node·npm | `npm run dev` → :5173 |
| **nextjs** | full-stack·SEO·서버 컴포넌트 | Node·npm | `npm run dev` → :3000 |
| **astro** | 블로그·콘텐츠·랜딩 | Node·npm | `npm run dev` → :4321 |
| **expo** | 진짜 모바일 앱 (iOS/Android) | Node·npm·Expo Go | `npm start` → QR |
| **vanilla** | 단순 HTML/CSS/JS | 없음 | `python3 -m http.server` |
## 사용법
설정 (web_init.json):
- `TEMPLATE`: 위 5개 중 하나
- `PROJECT_NAME`: 영문·숫자·하이픈 (예: `my-blog`)
- `OUTPUT_DIR`: 비우면 `~/connect-ai-projects/`
실행:
```
python3 web_init.py
```
## 어떤 걸 골라야 하나
- **이걸로 시작:** vite-react (SPA·대시보드·내부 도구)
- **블로그·기업 사이트:** astro
- **풀스택 (DB·API):** nextjs
- **모바일 앱:** expo (PWA로 충분하면 vite-react)
- **HTML 한 페이지:** vanilla
## 다음 단계
셋업 후 코다리가:
1. `web_preview` 도구로 dev server 실행
2. 사용자 요구사항대로 컴포넌트 추가
3. `pwa_setup` 으로 PWA 만들기 (모바일 앱처럼)
4. Vercel/Netlify에 배포
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
# version: web_init_v3
"""웹·모바일 프로젝트 자동 초기화 — 5개 템플릿 중 선택.
config:
TEMPLATE — vite-react / nextjs / astro / expo / vanilla
PROJECT_NAME — 프로젝트 폴더 이름 (영문·하이픈, 공백 X)
OUTPUT_DIR — 어디에 만들지 (비우면 ~/connect-ai-projects/)
각 템플릿은 검증된 공식 명령어로 셋업. 5분 안에 dev server 띄울 수 있는 상태로.
"""
import os, sys, json, subprocess, shutil
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG = os.path.join(HERE, "web_init.json")
def _log(msg, kind="info"):
prefix = {"info": "💻", "ok": "", "warn": "⚠️ ", "err": "", "step": ""}.get(kind, "")
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
def _load():
if os.path.exists(CONFIG):
try:
with open(CONFIG, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save(c):
try:
with open(CONFIG, "w", encoding="utf-8") as f:
json.dump(c, f, indent=2, ensure_ascii=False)
except Exception:
pass
def _check_cmd(cmd):
"""Check if a CLI tool exists."""
return shutil.which(cmd) is not None
def _run(cmd, cwd=None, capture=True):
"""Run shell command, stream stderr live but capture stdout for return."""
_log(f"$ {cmd}", "step")
if capture:
r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=600)
if r.stdout:
for line in r.stdout.splitlines()[:20]:
print(f" {line}")
if r.stderr and r.returncode != 0:
for line in r.stderr.splitlines()[-10:]:
_log(line, "warn")
return r.returncode == 0, r.stdout
else:
return subprocess.run(cmd, shell=True, cwd=cwd, timeout=600).returncode == 0, ""
def _scaffold_vite_react(name, parent):
"""Vite + React + TS + Tailwind v4 (Vite 플러그인 방식).
v2: tailwindcss init 명령이 v4에서 제거됨 → @tailwindcss/vite 플러그인 사용 + 설정 파일 직접 쓰기.
각 단계마다 (cmd, cwd, critical) — critical=False면 실패해도 프로젝트 살림."""
target = os.path.join(parent, name)
return [
("create", f"npm create vite@latest {name} -- --template react-ts", parent, True),
("install", "npm install", target, True),
("tailwind-pkg", "npm install tailwindcss@^4 @tailwindcss/vite@^4", target, False),
("tailwind-config", _write_vite_tailwind_config, target, False),
]
def _write_vite_tailwind_config(target):
"""Tailwind v4 설정 파일 직접 작성 (init 명령 의존 없음)."""
# vite.config.ts: 기본 파일에 tailwindcss 플러그인 추가
vite_cfg = os.path.join(target, "vite.config.ts")
if os.path.exists(vite_cfg):
try:
with open(vite_cfg, "r", encoding="utf-8") as f:
content = f.read()
if "tailwindcss" not in content:
# import 추가
content = "import tailwindcss from '@tailwindcss/vite'\n" + content
# plugins: [react()] → plugins: [react(), tailwindcss()]
content = content.replace("plugins: [react()]", "plugins: [react(), tailwindcss()]")
with open(vite_cfg, "w", encoding="utf-8") as f:
f.write(content)
except Exception:
pass
# src/index.css: Tailwind v4 import 한 줄
css_path = os.path.join(target, "src", "index.css")
if os.path.exists(css_path):
try:
with open(css_path, "r", encoding="utf-8") as f:
cur = f.read()
if '@import "tailwindcss"' not in cur:
with open(css_path, "w", encoding="utf-8") as f:
f.write('@import "tailwindcss";\n\n' + cur)
except Exception:
pass
return True
TEMPLATES = {
"vite-react": {
"label": "⚡ Vite + React + TypeScript + Tailwind v4",
"needs": ["node", "npm"],
"scaffold": _scaffold_vite_react,
"post": "Tailwind v4 (Vite 플러그인) + index.css 자동 설정",
"dev_cmd": "npm run dev",
},
"nextjs": {
"label": "▲ Next.js 14 (App Router) + TypeScript + Tailwind",
"needs": ["node", "npm"],
"scaffold": lambda name, parent: [
("scaffold", f"npx create-next-app@latest {name} --typescript --tailwind --app --src-dir --import-alias '@/*' --no-eslint --use-npm --yes", parent, True),
],
"post": "App Router·Tailwind·src 디렉토리 셋업 완료",
"dev_cmd": "npm run dev",
},
"astro": {
"label": "🚀 Astro + Tailwind (정적·콘텐츠 사이트)",
"needs": ["node", "npm"],
"scaffold": lambda name, parent: [
("scaffold", f"npm create astro@latest {name} -- --template minimal --typescript strict --install --git --yes", parent, True),
("tailwind", f"npx astro add tailwind --yes", os.path.join(parent, name), False),
],
"post": "Astro + Tailwind",
"dev_cmd": "npm run dev",
},
"expo": {
"label": "📱 Expo (React Native · iOS/Android/Web 동시)",
"needs": ["node", "npm"],
"scaffold": lambda name, parent: [
("scaffold", f"npx create-expo-app@latest {name} --template blank-typescript", parent, True),
],
"post": "Expo Go 앱(iOS/Android) 깔고 'npm start' 후 QR 스캔",
"dev_cmd": "npm start",
},
"vanilla": {
"label": "📄 Vanilla HTML + CSS + JS (프레임워크 없음)",
"needs": [],
"scaffold": "VANILLA", # 특수 케이스 — 직접 파일 생성
"post": "단일 폴더 + index.html + style.css + script.js + README",
"dev_cmd": "python3 -m http.server 8000",
},
}
def _scaffold_vanilla(target_dir, name):
"""프레임워크 없이 정적 사이트 시드."""
os.makedirs(target_dir, exist_ok=True)
files = {
"index.html": f'''<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>{name}</h1>
<p>Connect AI · 코다리가 만든 사이트</p>
</header>
<main>
<p>여기에 콘텐츠를 추가하세요.</p>
</main>
<script src="script.js"></script>
</body>
</html>
''',
"style.css": '''* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; color: #1a1a1a; background: #fafafa; }
header { padding: 60px 24px; text-align: center; background: linear-gradient(135deg, #667eea, #764ba2); color: white; }
header h1 { font-size: 48px; font-weight: 800; margin-bottom: 8px; }
main { max-width: 720px; margin: 40px auto; padding: 0 24px; }
''',
"script.js": '''// 코다리가 첨부한 스크립트
console.log("✦ Connect AI 사이트 로드 완료");
''',
"README.md": f'''# {name}
Connect AI 코다리가 셋업한 정적 웹사이트.
## 미리보기
```
python3 -m http.server 8000
```
그 다음 브라우저에서 http://localhost:8000
## 구조
- `index.html` — 메인 페이지
- `style.css` — 스타일
- `script.js` — JS
## 배포
- Vercel: `npx vercel --prod`
- Netlify: `npx netlify deploy --prod`
- Cloudflare Pages: GitHub 연결
''',
}
for filename, content in files.items():
with open(os.path.join(target_dir, filename), "w", encoding="utf-8") as f:
f.write(content)
return True
def main():
cfg = _load()
template = (cfg.get("TEMPLATE") or "").strip().lower() or "vite-react"
name = (cfg.get("PROJECT_NAME") or "").strip() or "my-app"
out_dir = (cfg.get("OUTPUT_DIR") or "").strip()
if template not in TEMPLATES:
_log(f"알 수 없는 템플릿: {template}", "err")
_log(f"사용 가능: {', '.join(TEMPLATES.keys())}", "info")
sys.exit(1)
# 이름 검증
import re
if not re.match(r"^[a-z0-9][a-z0-9_-]*$", name):
_log(f"프로젝트 이름은 소문자·숫자·하이픈·언더스코어만: {name}", "err")
sys.exit(1)
# 출력 위치
if not out_dir:
out_dir = os.path.expanduser("~/connect-ai-projects")
out_dir = os.path.expanduser(out_dir)
os.makedirs(out_dir, exist_ok=True)
target = os.path.join(out_dir, name)
if os.path.exists(target):
_log(f"이미 존재: {target} — 다른 이름 쓰거나 폴더 지우세요", "err")
sys.exit(1)
spec = TEMPLATES[template]
_log(f"{spec['label']} 셋업 시작 → {target}", "info")
# 의존성 체크
for cmd in spec.get("needs", []):
if not _check_cmd(cmd):
_log(f"`{cmd}` 명령이 없음. 먼저 Node.js를 설치하세요 (nodejs.org).", "err")
sys.exit(1)
# 실행
if spec["scaffold"] == "VANILLA":
ok = _scaffold_vanilla(target, name)
if not ok:
_log("vanilla 셋업 실패", "err")
sys.exit(1)
else:
steps = spec["scaffold"](name, out_dir)
warnings = []
for step in steps:
# 4-tuple 형식: (label, cmd_or_func, cwd, critical)
if len(step) != 4:
_log(f"잘못된 step 형식 (4-tuple 필요): {step}", "err")
sys.exit(1)
label, action, cwd, critical = step
if callable(action):
# Python 함수 직접 호출 (설정 파일 쓰기 등)
_log(f"[{label}] 설정 파일 작성 중...", "step")
try:
ok = bool(action(cwd))
except Exception as e:
_log(f"[{label}] 예외: {e}", "warn")
ok = False
else:
ok, _ = _run(action, cwd=cwd)
if not ok:
if critical:
_log(f"❌ 핵심 단계 실패: [{label}] — 중단합니다", "err")
sys.exit(1)
else:
_log(f"⚠️ 부가 단계 실패: [{label}] — 계속 진행합니다", "warn")
warnings.append(label)
if warnings:
_log(f"부가 단계 {len(warnings)}개 실패 ({', '.join(warnings)}). 프로젝트 자체는 작동합니다.", "warn")
_log("Tailwind 등은 사용자가 수동 추가 가능. README 참고.", "info")
_log(f"셋업 완료: {target}", "ok")
_log(f"다음 단계:", "info")
_log(f" cd {target}", "info")
_log(f" {spec['dev_cmd']}", "info")
if spec.get("post"):
_log(f" {spec['post']}", "info")
# 결과 저장
cfg["LAST_PROJECT"] = target
cfg["LAST_TEMPLATE"] = template
cfg["LAST_DEV_CMD"] = spec["dev_cmd"]
_save(cfg)
print()
print(f"PROJECT_PATH={target}")
print(f"DEV_CMD={spec['dev_cmd']}")
if __name__ == "__main__":
main()
@@ -0,0 +1,31 @@
<!-- version: web_preview_v1 -->
# 💻 웹 dev server 백그라운드 실행 + URL 안내
`npm run dev` 같은 dev server를 백그라운드로 띄우고 미리보기 URL을 자동 감지·반환.
## 동작
1. PROJECT_PATH의 package.json scripts.dev 자동 감지
2. 백그라운드 실행 (nohup·detached) + PID 파일 저장
3. 첫 8초 동안 로그에서 `localhost:포트` URL 파싱
4. AUTO_OPEN=true 면 브라우저 자동 열기
## 설정
- `PROJECT_PATH`: 비우면 web_init이 마지막에 만든 프로젝트 자동 사용
- `DEV_CMD`: 비우면 자동 감지 (`npm run dev` / `npm start`)
- `AUTO_OPEN`: `true`면 미리보기 URL을 브라우저로 열기
## 종료
- 같은 도구 재실행 → 이전 PID 자동 kill 후 새로 시작
- 수동 종료: `kill <PID>` (PID는 출력에 표시)
- macOS/Linux: `pkill -f "npm run dev"`
## 사용 예시
```
1. web_init으로 프로젝트 셋업 (예: nextjs, my-blog)
2. web_preview 실행 → http://localhost:3000 자동 표시
3. 코드 변경 → HMR로 즉시 반영 (브라우저)
4. 작업 끝나면 kill 또는 도구 재실행
```
## 한계
- 진짜 라이브 미리보기 칩 (사이드바 안의 상태 인디케이터)은 별도 UI 작업 필요. 현재는 출력에 URL만 반환.
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
# version: web_preview_v1
"""웹 프로젝트 dev server 시작 + URL 추출.
config:
PROJECT_PATH — 프로젝트 폴더 (web_init이 만든 건 자동 감지)
PORT — 비워두면 자동 (vite=5173, next=3000, astro=4321)
AUTO_OPEN — 'true' 면 브라우저 자동 열기
특징:
- package.json scripts.dev 자동 감지
- 백그라운드 실행 (nohup) + PID 파일 저장
- 첫 5초 동안 출력에서 localhost URL 파싱
- 같은 프로젝트 재실행 시 이전 PID 자동 종료
"""
import os, sys, json, subprocess, time, signal, re
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG = os.path.join(HERE, "web_preview.json")
WEB_INIT_CONFIG = os.path.join(HERE, "web_init.json")
def _log(msg, kind="info"):
prefix = {"info": "💻", "ok": "", "warn": "⚠️ ", "err": "", "step": ""}.get(kind, "")
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
def _load(p):
if os.path.exists(p):
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save(p, c):
try:
with open(p, "w", encoding="utf-8") as f:
json.dump(c, f, indent=2, ensure_ascii=False)
except Exception:
pass
def _detect_dev_command(project_path):
"""package.json의 scripts.dev 또는 start를 감지."""
pkg = os.path.join(project_path, "package.json")
if not os.path.exists(pkg):
return None
try:
with open(pkg, "r", encoding="utf-8") as f:
data = json.load(f)
scripts = data.get("scripts", {})
for key in ["dev", "start", "develop", "serve"]:
if key in scripts:
return f"npm run {key}"
except Exception:
pass
return None
def _kill_old_pid(pid_file):
"""이전 실행이 있으면 종료."""
if not os.path.exists(pid_file):
return
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
try:
os.kill(pid, signal.SIGTERM)
time.sleep(0.5)
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
except PermissionError:
pass
_log(f"이전 dev server 종료 (PID {pid})", "info")
except Exception:
pass
try:
os.remove(pid_file)
except Exception:
pass
def main():
cfg = _load(CONFIG)
init_cfg = _load(WEB_INIT_CONFIG)
project_path = (cfg.get("PROJECT_PATH") or "").strip()
if not project_path:
# web_init 결과 자동 사용
project_path = (init_cfg.get("LAST_PROJECT") or "").strip()
if not project_path:
_log("PROJECT_PATH가 비어있고 web_init 기록도 없음. 프로젝트 경로 지정하세요.", "err")
sys.exit(1)
project_path = os.path.expanduser(project_path)
if not os.path.isdir(project_path):
_log(f"폴더 없음: {project_path}", "err")
sys.exit(1)
# dev 명령 감지
dev_cmd = (cfg.get("DEV_CMD") or "").strip()
if not dev_cmd:
dev_cmd = init_cfg.get("LAST_DEV_CMD", "")
if not dev_cmd:
dev_cmd = _detect_dev_command(project_path) or "npm run dev"
_log(f"프로젝트: {project_path}", "info")
_log(f"명령: {dev_cmd}", "info")
# PID 파일
pid_file = os.path.join(project_path, ".connect-ai-dev.pid")
log_file = os.path.join(project_path, ".connect-ai-dev.log")
_kill_old_pid(pid_file)
# 백그라운드 실행
try:
with open(log_file, "w") as logf:
if sys.platform == "win32":
proc = subprocess.Popen(
dev_cmd, shell=True, cwd=project_path,
stdout=logf, stderr=subprocess.STDOUT,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
proc = subprocess.Popen(
dev_cmd, shell=True, cwd=project_path,
stdout=logf, stderr=subprocess.STDOUT,
start_new_session=True,
)
with open(pid_file, "w") as f:
f.write(str(proc.pid))
_log(f"dev server 시작됨 (PID {proc.pid})", "ok")
except Exception as e:
_log(f"실행 실패: {e}", "err")
sys.exit(1)
# 첫 8초 동안 로그에서 URL 파싱
url = None
deadline = time.time() + 8
while time.time() < deadline:
time.sleep(0.5)
try:
with open(log_file, "r") as f:
content = f.read()
# 흔한 패턴: "Local: http://localhost:3000" / "ready - started server on http://localhost:3000"
m = re.search(r"https?://(?:localhost|127\.0\.0\.1):\d+(?:/\S*)?", content)
if m:
url = m.group(0)
break
except Exception:
pass
if proc.poll() is not None:
_log("dev server가 일찍 종료됐어요. 로그 확인:", "warn")
try:
with open(log_file, "r") as f:
for line in f.read().splitlines()[-10:]:
print(f" {line}")
except Exception:
pass
sys.exit(1)
if url:
_log(f"미리보기 URL: {url}", "ok")
else:
_log("URL을 자동 감지 못 함. 로그 확인:", "warn")
try:
with open(log_file, "r") as f:
for line in f.read().splitlines()[-15:]:
print(f" {line}")
except Exception:
pass
url = "http://localhost:3000"
auto_open = str(cfg.get("AUTO_OPEN", "")).lower() in ("true", "1", "yes")
if auto_open and url:
try:
if sys.platform == "darwin":
subprocess.Popen(["open", url])
elif sys.platform == "win32":
subprocess.Popen(["cmd", "/c", "start", "", url], shell=False)
else:
subprocess.Popen(["xdg-open", url])
_log("브라우저 열림", "ok")
except Exception:
pass
# 결과 저장
cfg["LAST_PROJECT"] = project_path
cfg["LAST_PID"] = proc.pid
cfg["LAST_URL"] = url
cfg["LAST_LOG"] = log_file
_save(CONFIG, cfg)
print()
print(f"PID={proc.pid}")
print(f"URL={url}")
print(f"LOG={log_file}")
print()
_log("dev server는 백그라운드에서 계속 실행됩니다.", "info")
_log(f"종료: kill {proc.pid} (또는 같은 도구 재실행)", "info")
if __name__ == "__main__":
main()