ltcai 0.1.3 → 0.1.8

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.
File without changes
File without changes
@@ -0,0 +1,94 @@
1
+ """Integration tests for FastAPI endpoints.
2
+
3
+ Run against a live server: pytest tests/integration/ --base-url http://localhost:8899
4
+ Default base URL falls back to http://localhost:8899 if flag not provided.
5
+ """
6
+ import os
7
+ import pytest
8
+ import httpx
9
+
10
+ BASE_URL = os.environ.get("LTCAI_TEST_BASE_URL", "http://localhost:8899")
11
+
12
+
13
+ def _session_cookie() -> dict:
14
+ """Return session cookie if LTCAI_TEST_SESSION env var is set, else empty dict."""
15
+ sid = os.environ.get("LTCAI_TEST_SESSION", "")
16
+ return {"session_id": sid} if sid else {}
17
+
18
+
19
+ @pytest.fixture(scope="session")
20
+ def client():
21
+ with httpx.Client(base_url=BASE_URL, cookies=_session_cookie(), timeout=15) as c:
22
+ yield c
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Health / Info
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def test_health(client):
30
+ r = client.get("/health")
31
+ assert r.status_code == 200
32
+ data = r.json()
33
+ assert "status" in data
34
+
35
+
36
+ def test_mode_endpoint(client):
37
+ r = client.get("/mode")
38
+ assert r.status_code == 200
39
+ data = r.json()
40
+ assert "model" in data or "mode" in data
41
+
42
+
43
+ def test_runtime_features(client):
44
+ r = client.get("/runtime_features")
45
+ assert r.status_code == 200
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Local filesystem endpoints
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def test_local_list_home(client):
53
+ r = client.get("/local/list", params={"path": "~"})
54
+ # 200 if authenticated, 401/403 if no session
55
+ assert r.status_code in (200, 401, 403)
56
+
57
+
58
+ def test_local_list_requires_auth(client):
59
+ """Without a session cookie the endpoint must reject the request."""
60
+ r = httpx.get(f"{BASE_URL}/local/list", params={"path": "~"}, timeout=10)
61
+ assert r.status_code in (401, 403)
62
+
63
+
64
+ def test_local_serve_missing_file(client):
65
+ r = client.get("/local/serve", params={"path": "/nonexistent_lattice_ai_test_xyz.txt"})
66
+ assert r.status_code in (400, 401, 403, 404)
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # /tools/read_document
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def test_read_document_missing(client):
74
+ r = client.post("/tools/read_document", json={"path": "/nonexistent_lattice_ai_doc.txt"})
75
+ assert r.status_code in (400, 401, 403, 404, 422)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # /chat — smoke test (no model required)
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def test_chat_requires_message(client):
83
+ r = client.post("/chat", json={})
84
+ # Missing message → 422 validation error or 401 unauth
85
+ assert r.status_code in (401, 403, 422)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # /agent — smoke test
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def test_agent_requires_task(client):
93
+ r = client.post("/agent", json={})
94
+ assert r.status_code in (401, 403, 422)
File without changes
@@ -0,0 +1,127 @@
1
+ """Unit tests for tools.py core functions."""
2
+ import sys
3
+ import pytest
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
7
+
8
+ from tools import local_list, local_read, local_write, read_document, ToolError
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # local_list
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def test_local_list_returns_items(tmp_path):
16
+ (tmp_path / "a.txt").write_text("hello")
17
+ (tmp_path / "sub").mkdir()
18
+ result = local_list(str(tmp_path))
19
+ names = [i["name"] for i in result["items"]]
20
+ assert "a.txt" in names
21
+ assert "sub" in names
22
+
23
+
24
+ def test_local_list_dirs_before_files(tmp_path):
25
+ (tmp_path / "z_file.txt").write_text("x")
26
+ (tmp_path / "a_dir").mkdir()
27
+ result = local_list(str(tmp_path))
28
+ types = [i["type"] for i in result["items"]]
29
+ assert types[0] == "directory"
30
+
31
+
32
+ def test_local_list_nonexistent_raises(tmp_path):
33
+ with pytest.raises(ToolError):
34
+ local_list(str(tmp_path / "does_not_exist"))
35
+
36
+
37
+ def test_local_list_file_raises(tmp_path):
38
+ f = tmp_path / "file.txt"
39
+ f.write_text("x")
40
+ with pytest.raises(ToolError):
41
+ local_list(str(f))
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # local_read
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def test_local_read_text_file(tmp_path):
49
+ f = tmp_path / "hello.txt"
50
+ f.write_text("hello world")
51
+ result = local_read(str(f))
52
+ assert "hello world" in result["content"]
53
+ assert result["path"] == str(f)
54
+
55
+
56
+ def test_local_read_missing_file_raises(tmp_path):
57
+ with pytest.raises(ToolError):
58
+ local_read(str(tmp_path / "missing.txt"))
59
+
60
+
61
+ def test_local_read_tilde_expansion(tmp_path, monkeypatch):
62
+ monkeypatch.setenv("HOME", str(tmp_path))
63
+ f = tmp_path / "testfile.txt"
64
+ f.write_text("tilde test")
65
+ result = local_read("~/testfile.txt")
66
+ assert "tilde test" in result["content"]
67
+
68
+
69
+ def test_local_read_returns_size(tmp_path):
70
+ f = tmp_path / "sized.txt"
71
+ f.write_text("abc")
72
+ result = local_read(str(f))
73
+ assert result["size"] == 3
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # local_write
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def test_local_write_creates_file(tmp_path):
81
+ target = tmp_path / "out.txt"
82
+ result = local_write(str(target), "new content")
83
+ assert target.read_text() == "new content"
84
+ assert "bytes" in result
85
+
86
+
87
+ def test_local_write_overwrites_file(tmp_path):
88
+ target = tmp_path / "out.txt"
89
+ target.write_text("old")
90
+ local_write(str(target), "new")
91
+ assert target.read_text() == "new"
92
+
93
+
94
+ def test_local_write_creates_parent_dirs(tmp_path):
95
+ target = tmp_path / "deep" / "nested" / "file.txt"
96
+ local_write(str(target), "deep write")
97
+ assert target.exists()
98
+
99
+
100
+ def test_local_write_returns_path(tmp_path):
101
+ target = tmp_path / "x.txt"
102
+ result = local_write(str(target), "hi")
103
+ assert result["path"] == str(target)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # read_document
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def test_read_document_plain_text(tmp_path):
111
+ f = tmp_path / "doc.txt"
112
+ f.write_text("plain text content")
113
+ result = read_document(str(f))
114
+ assert "plain text content" in result.get("text", "") or "plain text content" in str(result)
115
+
116
+
117
+ def test_read_document_missing_file_raises(tmp_path):
118
+ with pytest.raises(ToolError):
119
+ read_document(str(tmp_path / "missing.pdf"))
120
+
121
+
122
+ def test_read_document_csv(tmp_path):
123
+ f = tmp_path / "data.csv"
124
+ f.write_text("col1,col2\n1,2\n3,4\n")
125
+ result = read_document(str(f))
126
+ # should not raise; returns some text content
127
+ assert result is not None
package/tools.py CHANGED
@@ -8,6 +8,7 @@ workspace.
8
8
 
9
9
  import base64
10
10
  import os
11
+ import platform
11
12
  import re
12
13
  import shlex
13
14
  import socket
@@ -18,6 +19,8 @@ from html.parser import HTMLParser
18
19
  from pathlib import Path
19
20
  from typing import Any, Dict, List, Optional
20
21
 
22
+ _PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
23
+
21
24
  from p_reinforce import BRAIN_DIR, STRUCTURE
22
25
 
23
26
  # ── Computer Use ──────────────────────────────────────────────────────────────
@@ -43,12 +46,19 @@ def computer_screenshot() -> Dict[str, Any]:
43
46
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file:
44
47
  tmp = tmp_file.name
45
48
  try:
46
- r = subprocess.run(
47
- ["screencapture", "-x", "-t", "png", tmp],
48
- capture_output=True, timeout=10, check=False,
49
- )
50
- if r.returncode != 0:
51
- raise ToolError(f"screencapture 실패: {r.stderr.decode()}")
49
+ if _PLATFORM == "Darwin":
50
+ r = subprocess.run(
51
+ ["screencapture", "-x", "-t", "png", tmp],
52
+ capture_output=True, timeout=10, check=False,
53
+ )
54
+ if r.returncode != 0:
55
+ raise ToolError(f"screencapture 실패: {r.stderr.decode()}")
56
+ elif _CU_AVAILABLE:
57
+ # Windows / Linux: use pyautogui screenshot
58
+ screenshot = _pyautogui.screenshot()
59
+ screenshot.save(tmp)
60
+ else:
61
+ raise ToolError("스크린샷 불가: macOS 전용 screencapture 또는 pyautogui 필요")
52
62
  with open(tmp, "rb") as f:
53
63
  b64 = base64.b64encode(f.read()).decode()
54
64
  size = os.path.getsize(tmp)
@@ -69,11 +79,17 @@ def computer_screenshot() -> Dict[str, Any]:
69
79
 
70
80
 
71
81
  def computer_open_app(app: str = "Google Chrome") -> Dict[str, Any]:
72
- """macOS 앱을 실행하거나 앞으로 가져옵니다."""
82
+ """앱을 실행하거나 앞으로 가져옵니다 (macOS/Windows/Linux)."""
73
83
  app = str(app or "Google Chrome").strip()
74
84
  if not app:
75
85
  raise ToolError("앱 이름이 필요합니다.")
76
- r = subprocess.run(["open", "-a", app], capture_output=True, timeout=10, check=False)
86
+ if _PLATFORM == "Darwin":
87
+ cmd = ["open", "-a", app]
88
+ elif _PLATFORM == "Windows":
89
+ cmd = ["cmd", "/c", "start", "", app]
90
+ else:
91
+ cmd = ["xdg-open", app]
92
+ r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
77
93
  if r.returncode != 0:
78
94
  err = r.stderr.decode("utf-8", errors="replace").strip()
79
95
  raise ToolError(f"앱 열기 실패: {err or app}")
@@ -81,18 +97,24 @@ def computer_open_app(app: str = "Google Chrome") -> Dict[str, Any]:
81
97
 
82
98
 
83
99
  def computer_open_url(url: str, app: str = "Google Chrome") -> Dict[str, Any]:
84
- """URL을 지정한 macOS 앱으로 엽니다."""
100
+ """URL을 브라우저로 엽니다 (macOS/Windows/Linux)."""
85
101
  url = str(url or "").strip()
86
- app = str(app or "Google Chrome").strip()
102
+ app = str(app or "").strip()
87
103
  if not url:
88
104
  raise ToolError("URL이 필요합니다.")
89
105
  if "://" not in url and not url.startswith(("localhost", "127.0.0.1")):
90
106
  url = "https://" + url
91
- r = subprocess.run(["open", "-a", app, url], capture_output=True, timeout=10, check=False)
107
+ if _PLATFORM == "Darwin":
108
+ cmd = ["open", "-a", app, url] if app else ["open", url]
109
+ elif _PLATFORM == "Windows":
110
+ cmd = ["cmd", "/c", "start", "", url]
111
+ else:
112
+ cmd = ["xdg-open", url]
113
+ r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
92
114
  if r.returncode != 0:
93
115
  err = r.stderr.decode("utf-8", errors="replace").strip()
94
116
  raise ToolError(f"URL 열기 실패: {err or url}")
95
- return {"action": "open_url", "app": app, "url": url}
117
+ return {"action": "open_url", "app": app or "default", "url": url}
96
118
 
97
119
 
98
120
  def computer_click(x: int, y: int, button: str = "left", double: bool = False) -> Dict[str, Any]:
@@ -216,7 +238,19 @@ ALLOWED_COMMANDS = {
216
238
  }
217
239
 
218
240
  BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
219
- DEPLOY_SCRIPT_NAMES = {"deploy", "preview", "release"}
241
+ DEPLOY_SCRIPT_NAMES = {
242
+ "deploy",
243
+ "preview",
244
+ "release",
245
+ "package",
246
+ "dist",
247
+ "make",
248
+ "build:installer",
249
+ "build:pkg",
250
+ "build:exe",
251
+ "package:mac",
252
+ "package:win",
253
+ }
220
254
 
221
255
  ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
222
256
 
@@ -443,6 +477,126 @@ def preview_url(path: str = "index.html") -> Dict[str, Any]:
443
477
  }
444
478
 
445
479
 
480
+ def create_web_project(path: str, framework: str = "react", template: str = "vite") -> Dict[str, Any]:
481
+ framework = str(framework or "").strip().lower()
482
+ template = str(template or "").strip().lower()
483
+ if framework != "react" or template != "vite":
484
+ raise ToolError("Only React + Vite template is currently supported.")
485
+ if not path:
486
+ raise ToolError("Project path is required.")
487
+
488
+ root = _resolve_path(path)
489
+ root.mkdir(parents=True, exist_ok=True)
490
+
491
+ files = {
492
+ "package.json": json.dumps(
493
+ {
494
+ "name": Path(path).name.replace(" ", "-").lower() or "vite-react-app",
495
+ "private": True,
496
+ "version": "0.0.0",
497
+ "type": "module",
498
+ "scripts": {
499
+ "dev": "vite",
500
+ "build": "vite build",
501
+ "preview": "vite preview",
502
+ },
503
+ "dependencies": {
504
+ "react": "^18.3.1",
505
+ "react-dom": "^18.3.1",
506
+ },
507
+ "devDependencies": {
508
+ "@vitejs/plugin-react": "^4.3.1",
509
+ "vite": "^5.4.0",
510
+ },
511
+ },
512
+ ensure_ascii=False,
513
+ indent=2,
514
+ ) + "\n",
515
+ "index.html": """<!doctype html>
516
+ <html lang="en">
517
+ <head>
518
+ <meta charset="UTF-8" />
519
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
520
+ <title>Vite React App</title>
521
+ </head>
522
+ <body>
523
+ <div id="root"></div>
524
+ <script type="module" src="/src/main.jsx"></script>
525
+ </body>
526
+ </html>
527
+ """,
528
+ "vite.config.js": """import { defineConfig } from 'vite'
529
+ import react from '@vitejs/plugin-react'
530
+
531
+ export default defineConfig({
532
+ plugins: [react()],
533
+ })
534
+ """,
535
+ "README.md": """# Vite React App
536
+
537
+ ## Run
538
+
539
+ ```bash
540
+ npm install
541
+ npm run dev
542
+ ```
543
+
544
+ ## Build
545
+
546
+ ```bash
547
+ npm run build
548
+ npm run preview
549
+ ```
550
+ """,
551
+ "src/main.jsx": """import React from 'react'
552
+ import ReactDOM from 'react-dom/client'
553
+ import App from './App.jsx'
554
+ import './index.css'
555
+
556
+ ReactDOM.createRoot(document.getElementById('root')).render(
557
+ <React.StrictMode>
558
+ <App />
559
+ </React.StrictMode>,
560
+ )
561
+ """,
562
+ "src/App.jsx": """import { useState } from 'react'
563
+
564
+ export default function App() {
565
+ const [count, setCount] = useState(0)
566
+ return (
567
+ <main style={{ maxWidth: 680, margin: '48px auto', fontFamily: 'system-ui, sans-serif' }}>
568
+ <h1>Vite + React</h1>
569
+ <p>Starter generated by Lattice AI agent.</p>
570
+ <button onClick={() => setCount((c) => c + 1)}>count is {count}</button>
571
+ </main>
572
+ )
573
+ }
574
+ """,
575
+ "src/index.css": """* { box-sizing: border-box; }
576
+ body { margin: 0; background: #f6f7fb; color: #111; }
577
+ button { padding: 10px 14px; border-radius: 10px; border: 1px solid #d6d6d6; background: #fff; cursor: pointer; }
578
+ """,
579
+ }
580
+
581
+ created: List[str] = []
582
+ total_bytes = 0
583
+ for rel_path, content in files.items():
584
+ target = (root / rel_path).resolve()
585
+ target.parent.mkdir(parents=True, exist_ok=True)
586
+ target.write_text(content, encoding="utf-8")
587
+ created.append(_relative(target))
588
+ total_bytes += target.stat().st_size
589
+
590
+ return {
591
+ "path": _relative(root),
592
+ "framework": framework,
593
+ "template": template,
594
+ "created_files": created,
595
+ "file_count": len(created),
596
+ "bytes": total_bytes,
597
+ }
598
+
599
+
446
600
  def _safe_filename(name: str, suffix: str) -> str:
447
601
  base = Path(name or f"artifact{suffix}").name
448
602
  if not base.lower().endswith(suffix):
@@ -1075,6 +1229,8 @@ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
1075
1229
  return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
1076
1230
  if action == "create_pdf":
1077
1231
  return create_pdf(args.get("title", ""), args.get("body", ""), args.get("filename", "document.pdf"))
1232
+ if action == "create_web_project":
1233
+ return create_web_project(args.get("path", ""), args.get("framework", "react"), args.get("template", "vite"))
1078
1234
  if action == "local_list":
1079
1235
  return local_list(args["path"])
1080
1236
  if action == "local_read":