Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ claude mcp add lantern -- <lantern 경로> mcp <프로젝트>

처음 켜면 **AI 모델 연결하기** 화면이 안내합니다 (나중에는 **설정 → 모델**). 클라우드 제공자(Anthropic, OpenAI, Google Gemini, DeepSeek, xAI, Mistral, Groq, OpenRouter)를 고르고 키를 넣은 뒤 그 회사의 모델 목록에서 모델을 고르거나, Ollama·LM Studio 로컬 모델(무료, 코드가 컴퓨터 밖으로 나가지 않음)을 연결합니다. 그 밖의 OpenAI 호환 API는 **설정 → 모델**에서 추가합니다. 채팅 입력창의 모델 버튼으로 연결해 둔 모델끼리 바로 바꿀 수 있습니다. 키는 OS 자격 증명 저장소나 환경변수에 두고, 설정 파일에는 적지 않습니다.

### ChatGPT 구독이나 Google 계정으로 쓰기 (외부 에이전트)

Lantern은 각 회사의 공식 에이전트 CLI를 [Agent Client Protocol](https://agentclientprotocol.com)로 부릴 수 있습니다. 로그인은 그 CLI가 직접 하므로 API 키 없이 ChatGPT 구독이나 Google 계정으로 쓸 수 있고, Lantern은 로그인 정보를 보지 않습니다.

| 에이전트 | 설치 | 로그인 |
|---|---|---|
| Codex | `npm install -g @zed-industries/codex-acp` | ChatGPT(유료 요금제) 또는 OpenAI API 키 |
| Gemini CLI | `npm install -g @google/gemini-cli` | Google 계정 또는 Gemini API 키 |

채팅의 에이전트 선택에서 **Codex (외부)**나 **Gemini CLI (외부)**를 고릅니다. 처음에는 그 에이전트의 로그인 방법(대개 브라우저)이 카드로 나옵니다. 나머지는 Lantern 그대로입니다: 질문마다 Lantern 맥락을 붙이고(에이전트에게 Lantern을 MCP 서버로도 넘김), 수정은 영향 반경이 붙은 승인 카드로 오며, 바뀐 것은 되돌릴 수 있습니다. 외부 에이전트는 신뢰한 폴더에서만 켜집니다. 다른 ACP 에이전트는 `config.toml`의 `[acp.<이름>] command = "…"`로 더할 수 있습니다.

Claude 구독(Pro/Max)은 이렇게 쓸 수 없습니다. Anthropic 약관이 구독 로그인을 자사 앱에만 허용합니다. Anthropic API 키를 쓰세요.

## 소스로 빌드

필요한 것: Rust(stable), Node.js 22, Windows는 WebView2(Windows 11에는 기본 설치). Linux 패키지는 [CI 설정](.github/workflows/ci.yml)을 참고하세요.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ Download from [Releases](https://github.com/Lantern-IDE/lantern/releases). Build

On first run, **Connect an AI Model** walks you through it (later: **Settings → Models**): pick a cloud provider (Anthropic, OpenAI, Google Gemini, DeepSeek, xAI, Mistral, Groq, OpenRouter), paste its key, and choose a model from its model list; or use a local model through Ollama / LM Studio (free, and your code never leaves the machine). Any other OpenAI-compatible API works from **Settings → Models**. The model button in the chat box switches between the models you've connected. Keys go to the OS credential store or environment variables, never to the config file.

### Use your ChatGPT subscription or Google account (external agents)

Lantern can drive official agent CLIs over the [Agent Client Protocol](https://agentclientprotocol.com). The CLI signs in by itself, so a ChatGPT subscription or a Google account works without an API key, and Lantern never sees your credentials.

| Agent | Install | Sign-in |
|---|---|---|
| Codex | `npm install -g @zed-industries/codex-acp` | ChatGPT (paid plan), or an OpenAI API key |
| Gemini CLI | `npm install -g @google/gemini-cli` | Google account, or a Gemini API key |

Pick **Codex (external)** or **Gemini CLI (external)** in the chat's agent selector. The first time, a card offers the agent's own sign-in (usually in your browser). Everything else stays Lantern: each question carries Lantern's context (and the agent gets Lantern as an MCP server), edits come to the approval card with the impact radius, and changes can be undone. External agents run only in trusted folders. Any other ACP agent can be added in `config.toml` with `[acp.<name>] command = "…"`.

Claude subscriptions (Pro/Max) can't be used this way: Anthropic's terms limit subscription sign-in to its own apps. Use an Anthropic API key instead.

## Build from source

Requirements: Rust (stable), Node.js 22, and on Windows WebView2 (preinstalled on Windows 11). Linux needs `libwebkit2gtk-4.1-dev` and friends (see [CI](.github/workflows/ci.yml)).
Expand Down
77 changes: 77 additions & 0 deletions app/e2e/acp-agent.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// E2E용 가짜 외부 에이전트 (Agent Client Protocol, 줄 단위 JSON-RPC).
// 실제 Gemini CLI·Codex와 같은 순서로 움직인다: 로그인 전 session/new는 -32000 → authenticate →
// 프롬프트: 파일 읽기 요청 → 수정 권한 요청(diff) → 승인되면 쓰기 → 프로젝트 밖 쓰기 시도(거절돼야 함).
// 받은 요청은 ACP_LOG 파일에 한 줄씩 남겨 run.mjs가 확인한다.
// 환경변수: ACP_FILE(프로젝트 기준 경로), ACP_FROM, ACP_TO(바꿀 글자)
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";

const { ACP_LOG, ACP_FILE = "src/a.ts", ACP_FROM = "1", ACP_TO = "2" } = process.env;
const log = (o) => ACP_LOG && fs.appendFileSync(ACP_LOG, JSON.stringify(o) + "\n");
const sessions = new Map();
const waiting = new Map();
let authed = false;
let nextId = 1000;
const send = (o) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...o }) + "\n");
const ask = (method, params) => new Promise((r) => { const id = nextId++; waiting.set(id, r); send({ id, method, params }); });
const update = (sessionId, u) => send({ method: "session/update", params: { sessionId, update: u } });

async function prompt(id, p) {
const sid = p.sessionId;
const cwd = sessions.get(sid);
const file = path.join(cwd, ACP_FILE);
log({ hasContext: p.prompt[0].text.includes("<project_context>") });
update(sid, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "파일을 읽겠습니다. " } });
update(sid, { sessionUpdate: "tool_call", toolCallId: "t1", title: "Read", kind: "read", status: "in_progress", locations: [{ path: file }] });
const read = await ask("fs/read_text_file", { sessionId: sid, path: file });
log({ read: read.result?.content ?? null });
update(sid, { sessionUpdate: "tool_call_update", toolCallId: "t1", status: "completed" });
const old = read.result.content;
const next = old.replace(ACP_FROM, ACP_TO);
update(sid, { sessionUpdate: "tool_call", toolCallId: "t2", title: "Edit", kind: "edit", status: "pending", locations: [{ path: file }] });
const perm = await ask("session/request_permission", {
sessionId: sid,
toolCall: { toolCallId: "t2", title: "Edit", kind: "edit", content: [{ type: "diff", path: file, oldText: old, newText: next }] },
options: [{ optionId: "yes", name: "Allow", kind: "allow_once" }, { optionId: "no", name: "Reject", kind: "reject_once" }],
});
log({ permission: perm.result?.outcome?.optionId ?? "cancelled" });
if (perm.result?.outcome?.optionId === "yes") {
await ask("fs/write_text_file", { sessionId: sid, path: file, content: next });
const outside = await ask("fs/write_text_file", { sessionId: sid, path: path.join(cwd, "..", "acp-escape.txt"), content: "x" });
log({ outside: outside.error ? "rejected" : "written" });
update(sid, { sessionUpdate: "tool_call_update", toolCallId: "t2", status: "completed" });
update(sid, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "고쳤습니다." } });
} else {
update(sid, { sessionUpdate: "tool_call_update", toolCallId: "t2", status: "failed" });
}
send({ id, result: { stopReason: "end_turn" } });
}

readline.createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;
const m = JSON.parse(line);
if (m.method === undefined) {
waiting.get(m.id)?.(m);
waiting.delete(m.id);
return;
}
switch (m.method) {
case "initialize":
log({ clientCapabilities: m.params.clientCapabilities });
return send({ id: m.id, result: { protocolVersion: 1, agentInfo: { name: "e2e", title: "E2E Agent", version: "1" }, authMethods: [{ id: "browser", name: "Log in with E2E" }, { type: "env_var", id: "key", name: "Use E2E_KEY" }], agentCapabilities: {} } });
case "authenticate":
authed = m.params.methodId === "browser";
log({ authenticate: m.params.methodId });
return send({ id: m.id, result: {} });
case "session/new":
log({ mcp: m.params.mcpServers?.[0]?.args?.[0] ?? null });
if (!authed) return send({ id: m.id, error: { code: -32000, message: "Authentication required" } });
sessions.set("s1", m.params.cwd);
return send({ id: m.id, result: { sessionId: "s1" } });
case "session/prompt":
return void prompt(m.id, m.params);
default:
if (m.id !== undefined) send({ id: m.id, error: { code: -32601, message: "no" } });
}
});
49 changes: 49 additions & 0 deletions app/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,19 @@ price_output = 0.0

[routing]
default = "mock"

[acp.e2e]
name = "E2E"
command = "node"
args = [${JSON.stringify(path.join(HERE, "acp-agent.mjs"))}]

[acp.e2e.env]
ACP_LOG = ${JSON.stringify(path.join(TMP, "acp.log"))}
ACP_FILE = "src/api.ts"
ACP_FROM = "'/login'"
ACP_TO = "'/signin'"
`);
const acpLog = () => (fs.existsSync(path.join(TMP, "acp.log")) ? fs.readFileSync(path.join(TMP, "acp.log"), "utf8").trim().split("\n").map((l) => JSON.parse(l)) : []);

// ── 앱 띄우기와 CDP ────────────────────────────────────────
let app = null;
Expand Down Expand Up @@ -546,6 +558,43 @@ scenario("소스 제어: 원격 연결, 커밋 이력, 푸시, 브랜치 만들
if (git("branch", "--show-current").trim() !== "main") throw new Error("main으로 돌아가지 않음");
});

scenario("외부 에이전트(ACP): 로그인 카드 → 로그인 → 읽기 → 승인 카드 → 적용", async () => {
await run(() => {
document.querySelector("#btn-new-task").click();
return true;
});
await run(ui.sendTask, "로그인 경로를 /signin으로 바꿔줘", "acp:e2e", false);
// 로그인 전: 로그인 카드 (환경변수 방법은 버튼이 아니라 안내)
const card = await waitFor(() => {
const c = document.querySelector(".task-log:not(.hidden) .auth-card");
return c && { buttons: [...c.querySelectorAll("button")].map((b) => b.textContent), text: c.innerText };
}, 30000, "로그인 카드");
if (card.buttons.length !== 1 || !card.buttons[0].includes("Log in with E2E") || !card.text.includes("E2E_KEY")) throw new Error(`로그인 카드: ${JSON.stringify(card)}`);
const init = acpLog().find((l) => l.clientCapabilities);
if (!init?.clientCapabilities?.fs?.writeTextFile || init.clientCapabilities.terminal !== false) throw new Error(`클라이언트 기능: ${JSON.stringify(init)}`);
if (acpLog().find((l) => "mcp" in l)?.mcp !== "--mcp") throw new Error("Lantern MCP 서버를 넘기지 않음");
await run(() => {
document.querySelector(".task-log:not(.hidden) .auth-card button").click();
return true;
});
await waitFor(() => document.querySelector(".task-log:not(.hidden) .auth-card .auth-status")?.textContent.includes("로그인되었습니다"), 15000, "로그인");
await run(() => {
[...document.querySelectorAll(".task-log:not(.hidden) .auth-card button")].find((b) => b.textContent.includes("다시 보내기")).click();
return true;
});
// 수정 권한 요청 → 기존과 같은 승인 카드와 영향 반경
const impact = await waitFor(() => document.querySelector(".task-log:not(.hidden) .approval .impact:not(.loading)")?.innerText, 30000, "영향 반경");
if (!impact.includes("직접 호출") && !impact.includes("찾지 못했습니다")) throw new Error(`영향 반경: ${impact}`);
if (file("src/api.ts").includes("/signin")) throw new Error("승인 전에 파일이 바뀜");
if (!acpLog().some((l) => l.hasContext) || !acpLog().some((l) => typeof l.read === "string" && l.read.includes("/login"))) throw new Error("맥락 또는 파일 읽기가 에이전트에 가지 않음");
await run(ui.approve);
await waitFor(() => !!document.querySelector(".task-log:not(.hidden) .changed"), 15000, "완료");
if (!file("src/api.ts").includes("'/signin'")) throw new Error("적용 후 파일이 그대로");
if (!acpLog().some((l) => l.outside === "rejected") || fs.existsSync(path.join(TMP, "acp-escape.txt"))) throw new Error("프로젝트 밖 쓰기를 막지 못함");
const meta = await run(() => document.querySelector("#task-list .task.active .task-meta")?.textContent ?? "");
if (!meta.includes("수정 1")) throw new Error(`발자취: ${meta}`);
});

scenario("다시 켜면 작업이 복원된다", async () => {
await run(() => {
setTimeout(() => document.querySelector("#win-close").click(), 50);
Expand Down
Loading
Loading