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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (534) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (542) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # 安装依赖
uv run pytest tests/ -v # 跑测试(当前 534 项)
uv run pytest tests/ -v # 跑测试(当前 542 项)
uv run python -m emrg # 启动 TUI
# CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own.
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # install deps
uv run pytest tests/ -v # run tests (currently 534 items)
uv run pytest tests/ -v # run tests (currently 542 items)
uv run python -m emrg # launch TUI
# CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI

Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const RESPONSE_TYPES = {
rewind_session: "rewind_result", // 补缺:daemon.py:955 rewind_result
read_memory: "memory_content", // 补缺:daemon.py:771/778 memory_content
evolution_summary: "evolution_summary", // WorkBuddy P3:自进化可见化
github_connect: "github_connect_result", // Windows GCM rant Stage 2:PAT 授权(daemon.py github_connect)
github_disconnect: "github_disconnect_result", // Windows GCM rant Stage 2:断开(daemon.py github_disconnect)
};

class DaemonClient {
Expand Down
18 changes: 18 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,24 @@ vision = false
return { count: frame.count ?? 0, recent: frame.recent || [] };
});

ipcMain.handle("emrg:githubStatus", async () => {
// Windows GCM rant Stage 2:设置页 GitHub 连接状态(daemon github_status)
const frame = await client.sendCommandAndWait("github_status", {}, 10000);
return { authenticated: Boolean(frame.authenticated), user: frame.user || null };
});

ipcMain.handle("emrg:githubConnect", async (_e, { token }) => {
// Windows GCM rant Stage 2:PAT 授权 + setup-git(daemon github_connect)
const frame = await client.sendCommandAndWait("github_connect", { token: String(token || "").trim() }, 40000);
return { ok: Boolean(frame.ok), user: frame.user || null, error: frame.error || null };
});

ipcMain.handle("emrg:githubDisconnect", async () => {
// Windows GCM rant Stage 2:断开 GitHub(daemon github_disconnect)
const frame = await client.sendCommandAndWait("github_disconnect", {}, 40000);
return { ok: Boolean(frame.ok), error: frame.error || null };
});

ipcMain.handle("emrg:setModel", async (_e, { model }) => {
await client.sendCommandAndWait("set_model", { model }, 5000);
return { ok: true };
Expand Down
3 changes: 3 additions & 0 deletions emrg/gui/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const api = {
triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload),
sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload),
evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload),
githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"),
githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload),
githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"),
listModels: () => ipcRenderer.invoke("emrg:listModels"),
openFile: (payload) => ipcRenderer.invoke("emrg:openFile", payload),
saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload),
Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ <h2 data-i18n="settings.title">设置</h2>
</div>
</label>
</div>
<div class="settings-group">
<div class="settings-group-title" data-i18n="settings.groupGithub">GitHub 连接</div>
<div id="github-status" class="hint" style="margin-bottom:6px;">—</div>
<div id="github-auth-row" style="display:flex;gap:8px;">
<input type="password" id="set-github-token" placeholder="GitHub Personal Access Token" style="flex:1;min-width:0;" data-i18n-placeholder="settings.githubTokenPlaceholder" />
<button type="button" id="github-connect-btn" class="btn btn-primary" data-i18n="settings.githubConnect">连接</button>
<button type="button" id="github-disconnect-btn" class="btn btn-ghost hidden" data-i18n="settings.githubDisconnect">断开</button>
</div>
<div class="hint" style="margin-top:6px;" data-i18n="settings.githubHint">用于自进化推送 PR;授权后自动执行 gh auth setup-git,git 操作不再弹 GCM</div>
</div>
<div class="settings-group">
<div class="settings-group-title" data-i18n="settings.groupAppearance">外观</div>
<label><span data-i18n="settings.theme">主题</span>
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,7 @@ const App = (() => {
Dialogs.initThemeButtons();
Dialogs.initModelForm();
Dialogs.initRenameDialog();
Dialogs.initGithubSection(); // Windows GCM rant Stage 2:设置页 GitHub 连接
initModelSwitcher();
initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式
ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏)
Expand Down
65 changes: 65 additions & 0 deletions emrg/gui/renderer/js/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ const Dialogs = (() => {
const evo = $("about-evolutions");
if (evo) evo.textContent = `🌱 ${_t("copy.growthCountPrefix")} ${App.state.evolutionCount ?? 0} ${_t("copy.times")}`;
} catch { /* 元素缺失(测试桩)时忽略 */ }
// Windows GCM rant Stage 2:GitHub 连接状态随设置面板打开时刷新
await refreshGithubStatus();
} catch (e) {
Chat.addSystemMessage(_t("settings.readFailed", { msg: e.message }));
}
Expand Down Expand Up @@ -357,6 +359,67 @@ const Dialogs = (() => {
});
}

// ── GitHub 连接(Windows GCM rant Stage 2:设置页 PAT 授权) ────
function initGithubSection() {
$("github-connect-btn").addEventListener("click", async () => {
const token = $("set-github-token").value.trim();
if (!token) {
Chat.addSystemMessage(_t("settings.githubTokenEmpty"));
return;
}
$("github-connect-btn").disabled = true;
$("github-connect-btn").textContent = _t("settings.githubConnecting");
try {
const res = await window.emrg.githubConnect({ token });
if (res && res.ok) {
$("set-github-token").value = "";
Chat.addSystemMessage(_t("settings.githubConnected", { user: res.user || "" }));
await refreshGithubStatus();
} else {
Chat.addSystemMessage(_t("settings.githubConnectFailed", { msg: (res && res.error) || _t("app.unknownError") }));
}
} catch (e) {
Chat.addSystemMessage(_t("settings.githubConnectFailed", { msg: e.message }));
} finally {
$("github-connect-btn").disabled = false;
$("github-connect-btn").textContent = _t("settings.githubConnect");
}
});
$("github-disconnect-btn").addEventListener("click", async () => {
try {
const res = await window.emrg.githubDisconnect();
if (res && res.ok) {
Chat.addSystemMessage(_t("settings.githubDisconnected"));
await refreshGithubStatus();
} else {
Chat.addSystemMessage(_t("settings.githubDisconnectFailed", { msg: (res && res.error) || _t("app.unknownError") }));
}
} catch (e) {
Chat.addSystemMessage(_t("settings.githubDisconnectFailed", { msg: e.message }));
}
});
}

async function refreshGithubStatus() {
const statusEl = $("github-status");
const authRow = $("github-auth-row");
if (!statusEl || !authRow) return; // 元素缺失(测试桩)时忽略
statusEl.textContent = _t("settings.githubChecking");
try {
const s = await window.emrg.githubStatus();
const connected = Boolean(s && s.authenticated);
const user = (s && s.user) || "";
statusEl.textContent = connected
? _t("settings.githubConnectedStatus", { user })
: _t("settings.githubNotConnected");
$("github-disconnect-btn").classList.toggle("hidden", !connected);
$("set-github-token").classList.toggle("hidden", connected);
$("github-connect-btn").classList.toggle("hidden", connected);
} catch {
statusEl.textContent = _t("settings.githubStatusFailed");
}
}

// ── 确认对话框(替代 confirm/alert) ────
let confirmCb = null;
function showConfirm(title, message, opts = {}) {
Expand Down Expand Up @@ -388,6 +451,8 @@ const Dialogs = (() => {
renderLangOptions,
initModelForm,
initRenameDialog,
initGithubSection,
refreshGithubStatus,
showRename,
submitRename,
showSettings,
Expand Down
32 changes: 32 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ const I18N = (() => {
"settings.save": "保存",
"settings.readFailed": "读取设置失败了:{msg}",
"settings.saveFailed": "保存失败了:{msg}",
// Windows GCM rant Stage 2:GitHub 连接
"settings.groupGithub": "GitHub 连接",
"settings.githubTokenPlaceholder": "GitHub Personal Access Token",
"settings.githubConnect": "连接",
"settings.githubDisconnect": "断开",
"settings.githubHint": "用于自进化推送 PR;授权后自动执行 gh auth setup-git,git 操作不再弹 GCM",
"settings.githubChecking": "检查中…",
"settings.githubConnectedStatus": "已连接 @{user}",
"settings.githubNotConnected": "未连接",
"settings.githubStatusFailed": "状态获取失败",
"settings.githubTokenEmpty": "请先粘贴 GitHub Personal Access Token",
"settings.githubConnecting": "连接中…",
"settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)",
"settings.githubConnectFailed": "GitHub 连接失败:{msg}",
"settings.githubDisconnected": "已断开 GitHub 连接",
"settings.githubDisconnectFailed": "断开失败:{msg}",

// 首启引导
"welcome.title": "欢迎使用 EMRG",
Expand Down Expand Up @@ -342,6 +358,22 @@ const I18N = (() => {
"settings.save": "Save",
"settings.readFailed": "Failed to read settings: {msg}",
"settings.saveFailed": "Failed to save settings: {msg}",
// Windows GCM rant Stage 2: GitHub connection
"settings.groupGithub": "GitHub connection",
"settings.githubTokenPlaceholder": "GitHub Personal Access Token",
"settings.githubConnect": "Connect",
"settings.githubDisconnect": "Disconnect",
"settings.githubHint": "Used for self-evolution PR pushes; runs gh auth setup-git after auth so git operations never pop up GCM",
"settings.githubChecking": "Checking…",
"settings.githubConnectedStatus": "Connected as @{user}",
"settings.githubNotConnected": "Not connected",
"settings.githubStatusFailed": "Failed to load status",
"settings.githubTokenEmpty": "Please paste a GitHub Personal Access Token first",
"settings.githubConnecting": "Connecting…",
"settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)",
"settings.githubConnectFailed": "GitHub connect failed: {msg}",
"settings.githubDisconnected": "Disconnected from GitHub",
"settings.githubDisconnectFailed": "Disconnect failed: {msg}",

// Welcome / onboarding
"welcome.title": "Welcome to EMRG",
Expand Down
14 changes: 14 additions & 0 deletions emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,20 @@ test("RESPONSE_TYPES 映射表与 daemon 命令名一致(修正 clear/rename/t
currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "memory_content", content: "x" })));
const r5 = await p5;
assert.strictEqual(r5.type, "memory_content");
// github_connect → github_connect_result(Windows GCM rant Stage 2)
const p6 = client.sendCommandAndWait("github_connect", { token: "ghp_x" }, 2000);
await new Promise((r) => setTimeout(r, 10));
currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "github_connect_result", ok: true, user: "octocat", error: null })));
const r6 = await p6;
assert.strictEqual(r6.type, "github_connect_result");
assert.strictEqual(r6.ok, true);
// github_disconnect → github_disconnect_result(Windows GCM rant Stage 2)
const p7 = client.sendCommandAndWait("github_disconnect", {}, 2000);
await new Promise((r) => setTimeout(r, 10));
currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "github_disconnect_result", ok: true, error: null })));
const r7 = await p7;
assert.strictEqual(r7.type, "github_disconnect_result");
assert.strictEqual(r7.ok, true);
});

test("命令-响应配对超时 → reject(G93)", async () => {
Expand Down
99 changes: 99 additions & 0 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,93 @@ async def _check_github_auth(self) -> dict:
except (asyncio.TimeoutError, OSError, ValueError):
return {"authenticated": False, "user": None, "method": "none"}

async def _github_connect(self, token: str) -> dict:
"""Authenticate gh with a PAT (rant 2026-08-07T10:17:27 Stage 2).

Runs ``gh auth login --with-token`` (token via stdin) followed by
``gh auth setup-git`` so git uses gh as its credential helper and
push/pull/fetch never falls back to a GCM popup on Windows.
Returns:
{"ok": bool, "user": str|None, "error": str|None}
Never raises; any failure degrades to {"ok": False, "error": ...}.
"""
token = (token or "").strip()
if not token:
return {"ok": False, "user": None, "error": "empty token"}
_, gh = resolve_git_gh()
if not gh:
return {"ok": False, "user": None, "error": "gh binary not found"}
try:
proc = await asyncio.create_subprocess_exec(
gh, "auth", "login", "--with-token",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=no_prompt_env(),
)
stdout, _ = await asyncio.wait_for(
proc.communicate(token.encode("utf-8") + b"\n"), timeout=30
)
if proc.returncode != 0:
output = stdout.decode("utf-8", errors="replace").strip()
return {
"ok": False,
"user": None,
"error": output or f"gh auth login failed ({proc.returncode})",
}
# Re-verify via the same parser used by github_status.
user = (await self._check_github_auth()).get("user")
# setup-git: git must use gh as credential helper, otherwise
# git push/pull/fetch would still trigger GCM (acceptance item).
setup_ok = await self._gh_setup_git(gh)
return {
"ok": True,
"user": user,
"error": None if setup_ok else "auth ok but gh auth setup-git failed",
}
except (asyncio.TimeoutError, OSError, ValueError):
return {"ok": False, "user": None, "error": "gh auth login failed"}

async def _gh_setup_git(self, gh: str) -> bool:
"""Run ``gh auth setup-git`` so git uses gh as credential helper."""
try:
proc = await asyncio.create_subprocess_exec(
gh, "auth", "setup-git",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=no_prompt_env(),
)
await asyncio.wait_for(proc.communicate(), timeout=30)
return proc.returncode == 0
except (asyncio.TimeoutError, OSError, ValueError):
return False

async def _github_disconnect(self) -> dict:
"""Log out of gh (rant 2026-08-07T10:17:27 Stage 2).

Returns {"ok": bool, "error": str|None}. Never raises.
"""
_, gh = resolve_git_gh()
if not gh:
return {"ok": False, "error": "gh binary not found"}
try:
proc = await asyncio.create_subprocess_exec(
gh, "auth", "logout", "--hostname", "github.com",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=no_prompt_env(),
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
if proc.returncode != 0:
output = stdout.decode("utf-8", errors="replace").strip()
return {
"ok": False,
"error": output or f"gh auth logout failed ({proc.returncode})",
}
return {"ok": True, "error": None}
except (asyncio.TimeoutError, OSError, ValueError):
return {"ok": False, "error": "gh auth logout failed"}

def _build_system_prompt(self, session: Session | None = None) -> str:
"""Build the system prompt via Jinja2 template.

Expand Down Expand Up @@ -984,6 +1071,18 @@ async def _process_message(
auth = await self._check_github_auth()
await self._send(ws, {"type": "github_status", **auth})

elif msg_type == "github_connect":
# Windows GCM rant Stage 2: GUI PAT auth — gh auth login
# --with-token + gh auth setup-git (git no longer touches GCM).
token = msg.get("token", "")
result = await self._github_connect(token)
await self._send(ws, {"type": "github_connect_result", **result})

elif msg_type == "github_disconnect":
# Windows GCM rant Stage 2: GUI disconnect — gh auth logout.
result = await self._github_disconnect()
await self._send(ws, {"type": "github_disconnect_result", **result})

elif msg_type == "clear_session":
session_id = msg.get("session_id", "")
cwd = msg.get("cwd", "")
Expand Down
Loading
Loading