// @local/git-forge — Host side. // HTTP endpoints for the "Clone repository" workspace dialog plus agent tools for // GitHub (gh) and Gitea/Forgejo (tea-cli) accounts. Tokens never leave the Host. import os from 'node:os' import path from 'node:path' import { ForgeError, api, apiList, cloneRepo, deriveName, gitIn, listAccounts, listRepos, normRepo, publicAccount, pushRepo, resolveRepo, } from './forge.mjs' import { accountsWithStatus, cliStatus, giteaLogin, githubDeviceCancel, githubDeviceStart, githubDeviceStatus, githubTokenLogin, logout, } from './auth.mjs' export const name = 'git-forge' export const inject = ['webServer', 'connection', 'tools'] const MAX_BODY = 64 * 1024 const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) const CLONE_ROOT = os.homedir() function sendJson(res, status, payload) { res.statusCode = status res.setHeader('content-type', 'application/json; charset=utf-8') res.setHeader('cache-control', 'no-store') res.end(JSON.stringify(payload)) } async function readJsonBody(req) { const chunks = [] let size = 0 for await (const chunk of req) { size += chunk.byteLength if (size > MAX_BODY) throw new ForgeError('request body too large', 413) chunks.push(chunk) } if (size === 0) return {} const body = JSON.parse(Buffer.concat(chunks, size).toString('utf8')) if (!isObj(body)) throw new ForgeError('body must be a JSON object', 400) return body } // ---- shared operations (UI + tools) ---------------------------------------- let repoCache = null async function allRepos({ accountId, refresh = false } = {}) { if (!refresh && !accountId && repoCache && Date.now() - repoCache.at < 120000) return repoCache.value const accounts = (await listAccounts({ refresh })).filter((a) => !accountId || a.id === accountId) const results = await Promise.allSettled(accounts.map((a) => listRepos(a, { max: 500 }))) const repos = [] const errors = [] results.forEach((r, i) => { if (r.status === 'fulfilled') repos.push(...r.value) else errors.push({ account: accounts[i].id, message: String(r.reason?.message ?? r.reason) }) }) repos.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))) const value = { repos, errors } if (!accountId) repoCache = { at: Date.now(), value } return value } const enc = encodeURIComponent const repoPath = (r) => `/repos/${r.owner.split('/').map(enc).join('/')}/${enc(r.name)}` function slimIssue(i) { return { number: i.number, title: i.title, state: i.state, pull: !!i.pull_request, user: i.user?.login, labels: (i.labels ?? []).map((l) => l.name), assignees: (i.assignees ?? []).map((a) => a.login), comments: i.comments, createdAt: i.created_at, updatedAt: i.updated_at, url: i.html_url, } } function slimPull(p) { return { number: p.number, title: p.title, state: p.state, draft: !!p.draft, merged: !!(p.merged || p.merged_at), user: p.user?.login, head: p.head?.label ?? p.head?.ref, base: p.base?.ref, mergeable: p.mergeable, createdAt: p.created_at, updatedAt: p.updated_at, url: p.html_url, } } const slimComment = (c) => ({ id: c.id, user: c.user?.login, body: c.body, createdAt: c.created_at, url: c.html_url }) function sessionCwd(exec) { return exec?.agent?.session?.header?.cwd || process.cwd() } function need(v, nameOf) { if (v === undefined || v === null || v === '') throw new ForgeError(`${nameOf} is required for this action`, 400) return v } // ---- tool implementations --------------------------------------------------- async function toolAccounts() { const accounts = await listAccounts({ refresh: true }) return { accounts: accounts.map(publicAccount), ...(accounts.length ? {} : { hint: 'No authorized accounts. Ask the user to run `gh auth login` (GitHub) or `tea-cli login add` (Gitea/Forgejo) on the host.' }), } } async function toolRepos(args, exec) { const action = args.action ?? 'list' if (action === 'list' || action === 'search') { const accounts = await listAccounts() const scoped = args.account ? accounts.filter((a) => a.id === args.account || a.name === args.account) : accounts if (!scoped.length) throw new ForgeError(args.account ? `unknown account ${args.account}` : 'no authorized accounts', 400) const limit = Math.min(Math.max(args.limit ?? 50, 1), 500) const results = await Promise.allSettled(scoped.map((a) => listRepos(a, { query: args.query, owner: args.owner, max: limit, signal: exec?.signal }))) const repos = [] const errors = [] results.forEach((r, i) => r.status === 'fulfilled' ? repos.push(...r.value) : errors.push({ account: scoped[i].id, message: String(r.reason?.message) })) repos.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))) return { repos: repos.slice(0, limit).map((r) => ({ account: r.account, fullName: r.fullName, description: r.description || '', private: !!r.private, fork: !!r.fork, archived: !!r.archived, defaultBranch: r.defaultBranch ?? '', updatedAt: r.updatedAt ?? '', cloneUrl: r.cloneUrl ?? '' })), ...(errors.length ? { errors } : {}), } } if (action === 'create') { const accounts = await listAccounts() const account = args.account ? accounts.find((a) => a.id === args.account || a.name === args.account) : accounts[0] if (!account) throw new ForgeError('no matching account; pass account', 400) const body = { name: need(args.name, 'name'), description: args.description, private: args.private ?? true, auto_init: args.autoInit ?? false } if (account.kind === 'gitea' && args.defaultBranch) body.default_branch = args.defaultBranch const p = args.owner && args.owner.toLowerCase() !== account.user.toLowerCase() ? `/orgs/${enc(args.owner)}/repos` : '/user/repos' const r = await api(account, 'POST', p, { body, signal: exec?.signal }) return { created: normRepo(account, r) } } const target = await resolveRepo({ repo: args.repo, account: args.account, cwd: sessionCwd(exec) }) const { account } = target const rp = repoPath(target) if (action === 'get') return { repo: normRepo(account, await api(account, 'GET', rp, { signal: exec?.signal })) } if (action === 'edit') { const body = {} for (const [k, v] of [['description', args.description], ['private', args.private], ['default_branch', args.defaultBranch], ['archived', args.archived], ['name', args.newName], ['website', args.website]]) if (v !== undefined) body[k] = v if (!Object.keys(body).length) throw new ForgeError('nothing to edit: pass description/private/defaultBranch/archived/newName/website', 400) return { repo: normRepo(account, await api(account, 'PATCH', rp, { body, signal: exec?.signal })) } } if (action === 'fork') { const body = args.owner ? (account.kind === 'github' ? { organization: args.owner } : { organization: args.owner }) : {} if (args.newName) body.name = args.newName return { fork: normRepo(account, await api(account, 'POST', `${rp}/forks`, { body, signal: exec?.signal })) } } if (action === 'delete') { if (args.confirm !== `${target.owner}/${target.name}`) throw new ForgeError(`refusing to delete: set confirm to exactly "${target.owner}/${target.name}" after the user explicitly asked for deletion`, 400) await api(account, 'DELETE', rp, { signal: exec?.signal }) return { deleted: `${target.owner}/${target.name}` } } if (action === 'branches') { const list = await apiList(account, `${rp}/branches`, { max: args.limit ?? 100, signal: exec?.signal }) return { branches: list.map((b) => ({ name: b.name, commit: (b.commit?.sha ?? b.commit?.id ?? '').slice(0, 12), protected: b.protected })) } } if (action === 'releases') { const list = await apiList(account, `${rp}/releases`, { max: args.limit ?? 20, signal: exec?.signal }) return { releases: list.map((r) => ({ tag: r.tag_name, name: r.name, draft: r.draft, prerelease: r.prerelease, publishedAt: r.published_at, url: r.html_url })) } } throw new ForgeError(`unknown action ${action}`, 400) } async function toolIssues(args, exec) { const action = args.action ?? 'list' const target = await resolveRepo({ repo: args.repo, account: args.account, cwd: sessionCwd(exec) }) const { account } = target const rp = repoPath(target) const signal = exec?.signal switch (action) { case 'list': { const query = { state: args.state ?? 'open', labels: args.labels?.join(','), ...(account.kind === 'gitea' ? { type: 'issues', q: args.query } : {}) } if (args.assignee) query[account.kind === 'gitea' ? 'assigned_by' : 'assignee'] = args.assignee const raw = await apiList(account, `${rp}/issues`, { query, max: args.limit ?? 30, signal }) let issues = raw.filter((i) => !i.pull_request).map(slimIssue) if (args.query && account.kind === 'github') issues = issues.filter((i) => i.title.toLowerCase().includes(args.query.toLowerCase())) return { repo: `${target.owner}/${target.name}`, account: account.id, issues } } case 'get': { const n = need(args.number, 'number') const i = await api(account, 'GET', `${rp}/issues/${n}`, { signal }) const comments = args.withComments === false ? [] : await apiList(account, `${rp}/issues/${n}/comments`, { max: 100, signal }) return { issue: { ...slimIssue(i), body: i.body }, comments: comments.map(slimComment) } } case 'create': { const body = { title: need(args.title, 'title'), body: args.body ?? '' } if (args.assignees) body.assignees = args.assignees if (args.labels && account.kind === 'github') body.labels = args.labels const i = await api(account, 'POST', `${rp}/issues`, { body, signal }) if (args.labels?.length && account.kind === 'gitea') await setGiteaLabels(account, rp, i.number, args.labels, signal) return { created: slimIssue(i) } } case 'comment': { const c = await api(account, 'POST', `${rp}/issues/${need(args.number, 'number')}/comments`, { body: { body: need(args.body, 'body') }, signal }) return { comment: slimComment(c) } } case 'close': case 'reopen': { const n = need(args.number, 'number') if (args.body) await api(account, 'POST', `${rp}/issues/${n}/comments`, { body: { body: args.body }, signal }) const i = await api(account, 'PATCH', `${rp}/issues/${n}`, { body: { state: action === 'close' ? 'closed' : 'open' }, signal }) return { issue: slimIssue(i) } } case 'edit': { const n = need(args.number, 'number') const body = {} if (args.title !== undefined) body.title = args.title if (args.body !== undefined) body.body = args.body if (args.assignees) body.assignees = args.assignees if (args.labels && account.kind === 'github') body.labels = args.labels const i = Object.keys(body).length ? await api(account, 'PATCH', `${rp}/issues/${n}`, { body, signal }) : await api(account, 'GET', `${rp}/issues/${n}`, { signal }) if (args.labels && account.kind === 'gitea') await setGiteaLabels(account, rp, n, args.labels, signal) return { issue: slimIssue(i) } } default: throw new ForgeError(`unknown action ${action}`, 400) } } /** Gitea takes label ids, not names. */ async function setGiteaLabels(account, rp, number, names, signal) { const all = await apiList(account, `${rp}/labels`, { max: 200, signal }) const ids = names.map((n) => all.find((l) => l.name.toLowerCase() === String(n).toLowerCase())?.id).filter(Boolean) await api(account, 'PUT', `${rp}/issues/${number}/labels`, { body: { labels: ids }, signal }) } async function toolPulls(args, exec) { const action = args.action ?? 'list' const cwd = sessionCwd(exec) const target = await resolveRepo({ repo: args.repo, account: args.account, cwd }) const { account } = target const rp = repoPath(target) const signal = exec?.signal switch (action) { case 'list': { const raw = await apiList(account, `${rp}/pulls`, { query: { state: args.state ?? 'open' }, max: args.limit ?? 30, signal }) return { repo: `${target.owner}/${target.name}`, account: account.id, pulls: raw.map(slimPull) } } case 'get': { const n = need(args.number, 'number') const p = await api(account, 'GET', `${rp}/pulls/${n}`, { signal }) const comments = args.withComments === false ? [] : await apiList(account, `${rp}/issues/${n}/comments`, { max: 100, signal }) let files try { const f = await apiList(account, `${rp}/pulls/${n}/files`, { max: 300, signal }) files = f.map((x) => ({ file: x.filename, status: x.status, additions: x.additions, deletions: x.deletions })) } catch { files = undefined } return { pull: { ...slimPull(p), body: p.body, additions: p.additions, deletions: p.deletions, changedFiles: p.changed_files }, files, comments: comments.map(slimComment) } } case 'create': { let head = args.head if (!head) { const b = await gitIn(cwd, ['symbolic-ref', '--short', 'HEAD'], { timeoutMs: 10000 }) head = b.code === 0 ? b.stdout.trim() : undefined } need(head, 'head') let base = args.base if (!base) base = (await api(account, 'GET', rp, { signal })).default_branch const body = { title: need(args.title, 'title'), body: args.body ?? '', head, base } if (account.kind === 'github' && args.draft) body.draft = true const p = await api(account, 'POST', `${rp}/pulls`, { body, signal }) return { created: slimPull(p) } } case 'comment': { const c = await api(account, 'POST', `${rp}/issues/${need(args.number, 'number')}/comments`, { body: { body: need(args.body, 'body') }, signal }) return { comment: slimComment(c) } } case 'merge': { const n = need(args.number, 'number') const method = args.method ?? 'merge' if (account.kind === 'github') { const r = await api(account, 'PUT', `${rp}/pulls/${n}/merge`, { body: { merge_method: method, commit_title: args.title, commit_message: args.body }, signal }) return { merged: !!r?.merged, sha: r?.sha, message: r?.message } } await api(account, 'POST', `${rp}/pulls/${n}/merge`, { body: { Do: method, MergeTitleField: args.title, MergeMessageField: args.body, delete_branch_after_merge: !!args.deleteBranch }, signal }) return { merged: true } } case 'close': case 'reopen': { const p = await api(account, 'PATCH', `${rp}/pulls/${need(args.number, 'number')}`, { body: { state: action === 'close' ? 'closed' : 'open' }, signal }) return { pull: slimPull(p) } } case 'diff': { const n = need(args.number, 'number') const url = account.kind === 'github' ? `${account.apiBase}${rp}/pulls/${n}` : `${account.apiBase}${rp}/pulls/${n}.diff` const res = await fetch(url, { headers: { authorization: account.kind === 'github' ? `Bearer ${account.token}` : `token ${account.token}`, accept: account.kind === 'github' ? 'application/vnd.github.diff' : 'text/plain', 'user-agent': 'dsh-git-forge', }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000), }) if (!res.ok) throw new ForgeError(`diff → ${res.status}`, res.status) const text = await res.text() const max = 60000 return { diff: text.length > max ? `${text.slice(0, max)}\n… [truncated ${text.length - max} chars]` : text } } default: throw new ForgeError(`unknown action ${action}`, 400) } } async function toolApi(args, exec) { const accounts = await listAccounts() let account if (args.account) account = accounts.find((a) => a.id === args.account || a.name === args.account) else if (args.repo || !args.account) { if (accounts.length === 1) account = accounts[0] else if (args.repo) account = (await resolveRepo({ repo: args.repo, cwd: sessionCwd(exec) })).account } if (!account) throw new ForgeError(`pass account (one of ${accounts.map((a) => a.id).join(', ') || 'none'})`, 400) const method = String(args.method ?? 'GET').toUpperCase() const r = await api(account, method, need(args.path, 'path'), { query: args.query, body: args.body, signal: exec?.signal, raw: true }) let text = JSON.stringify(r.data) const max = 60000 if (text && text.length > max) return { status: r.status, truncated: true, data: text.slice(0, max) } return { status: r.status, data: r.data } } async function toolClone(args, exec) { const cwd = sessionCwd(exec) let url = args.url if (!url) throw new ForgeError('url is required (any https/ssh git URL, or owner/name with account)', 400) if (!/[:@]|\/\//.test(url)) { const t = await resolveRepo({ repo: url, account: args.account, cwd }) url = `${t.account.url}/${t.owner}/${t.name}.git` } const parent = args.parent ? path.resolve(cwd, args.parent.replace(/^~(?=\/|$)/, os.homedir())) : cwd return cloneRepo({ url, parent, name: args.name || deriveName(url), branch: args.branch, depth: args.depth, signal: exec?.signal }) } async function toolPush(args, exec) { const cwd = args.path ? path.resolve(sessionCwd(exec), args.path) : sessionCwd(exec) return pushRepo({ cwd, remote: args.remote, branch: args.branch, force: !!args.force, tags: !!args.tags, signal: exec?.signal }) } // ---- tool definitions --------------------------------------------------------- const S = (type, description, extra = {}) => ({ type, description, ...extra }) const REPO = S('string', 'Repository as "owner/name" or any git/web URL. Omit to use the "origin" remote of the session working directory.') const ACCOUNT = S('string', 'Account id from forge_accounts (e.g. "github:octocat", "gitea:Agent"; Gitea and Forgejo accounts both use the gitea: prefix). Needed only when several accounts could match.') const NUMBER = S('integer', 'Issue or pull request number.') const LIMIT = S('integer', 'Maximum number of results (default 30).') function defineTool(def, fn) { return { name: def.name, description: def.description, parameters: { type: 'object', properties: def.properties, required: def.required ?? [], additionalProperties: false }, output: { schema: {}, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 1) }], }, timeoutMs: def.timeoutMs, async execute(args, exec) { try { // Round-trip drops undefined fields: tool output must be lossless JSON. return JSON.parse(JSON.stringify((await fn(args ?? {}, exec)) ?? null)) } catch (error) { return { error: String(error?.message ?? error) } } }, } } const TOOLS = [ defineTool({ name: 'forge_accounts', description: 'List the GitHub (gh) and Gitea/Forgejo (tea-cli) accounts authorized on this host. Their tokens are used by the other forge_* tools and by git clone/push.', properties: {}, }, toolAccounts), defineTool({ name: 'forge_repos', description: 'Repositories on authorized forge accounts. Actions: list (repos of all accounts, or of owner), search (query), get, create (name, private defaults true), edit, fork, delete (needs confirm = "owner/name" and an explicit user request), branches, releases.', properties: { action: S('string', 'Operation (default list).', { enum: ['list', 'search', 'get', 'create', 'edit', 'fork', 'delete', 'branches', 'releases'] }), repo: REPO, account: ACCOUNT, owner: S('string', 'list/search: user or org to list. create: org to create in (default: the account user). fork: target org.'), query: S('string', 'search: text to match repository names.'), name: S('string', 'create: new repository name.'), newName: S('string', 'edit/fork: new name.'), description: S('string', 'create/edit: description.'), private: S('boolean', 'create/edit: visibility.'), autoInit: S('boolean', 'create: initialize with a README.'), defaultBranch: S('string', 'create (Gitea) / edit: default branch.'), archived: S('boolean', 'edit: archive or unarchive.'), website: S('string', 'edit: website URL.'), confirm: S('string', 'delete: must equal "owner/name".'), limit: LIMIT, }, }, toolRepos), defineTool({ name: 'forge_issues', description: 'Issues of a GitHub/Gitea/Forgejo repository. Actions: list, get (with comments), create, comment, edit, close, reopen.', properties: { action: S('string', 'Operation (default list).', { enum: ['list', 'get', 'create', 'comment', 'edit', 'close', 'reopen'] }), repo: REPO, account: ACCOUNT, number: NUMBER, title: S('string', 'create/edit: title.'), body: S('string', 'create/edit: description; comment/close/reopen: comment text.'), state: S('string', 'list: filter (default open).', { enum: ['open', 'closed', 'all'] }), labels: S('array', 'list: filter; create/edit: label names to set.', { items: { type: 'string' } }), assignees: S('array', 'create/edit: usernames.', { items: { type: 'string' } }), assignee: S('string', 'list: filter by assignee.'), query: S('string', 'list: text filter.'), withComments: S('boolean', 'get: include comments (default true).'), limit: LIMIT, }, }, toolIssues), defineTool({ name: 'forge_pulls', description: 'Pull requests of a GitHub/Gitea/Forgejo repository. Actions: list, get (with files and comments), diff, create (head defaults to the current branch, base to the default branch; push first with forge_push), comment, merge, close, reopen.', properties: { action: S('string', 'Operation (default list).', { enum: ['list', 'get', 'diff', 'create', 'comment', 'merge', 'close', 'reopen'] }), repo: REPO, account: ACCOUNT, number: NUMBER, title: S('string', 'create: title; merge: commit title.'), body: S('string', 'create: description; comment: text; merge: commit message.'), head: S('string', 'create: source branch ("user:branch" for forks). Default: current branch.'), base: S('string', 'create: target branch. Default: repository default branch.'), draft: S('boolean', 'create: draft PR (GitHub).'), method: S('string', 'merge: strategy (default merge).', { enum: ['merge', 'squash', 'rebase'] }), deleteBranch: S('boolean', 'merge (Gitea): delete head branch after merge.'), state: S('string', 'list: filter (default open).', { enum: ['open', 'closed', 'all'] }), withComments: S('boolean', 'get: include comments (default true).'), limit: LIMIT, }, }, toolPulls), defineTool({ name: 'forge_clone', description: 'Clone a repository with the authorized account credentials (works for private repos; SSH URLs are switched to HTTPS+token when no SSH key is available). Returns the absolute path.', properties: { url: S('string', 'Any git URL (https or ssh, GitHub/Gitea/Forgejo) or "owner/name".'), account: ACCOUNT, parent: S('string', 'Directory to clone into (default: session working directory).'), name: S('string', 'Folder name (default: repository name).'), branch: S('string', 'Branch to check out.'), depth: S('integer', 'Shallow clone depth.'), }, required: ['url'], timeoutMs: 30 * 60000, }, toolClone), defineTool({ name: 'forge_push', description: 'Push local commits of a git repository to its remote using the authorized account token (no SSH keys or password prompts needed). Sets upstream. Use force only when the user asked for it (uses --force-with-lease).', properties: { path: S('string', 'Repository directory (default: session working directory).'), remote: S('string', 'Remote name (default origin).'), branch: S('string', 'Branch to push (default: current branch).'), force: S('boolean', 'Force push with lease.'), tags: S('boolean', 'Also push annotated tags (--follow-tags).'), }, timeoutMs: 10 * 60000, }, toolPush), defineTool({ name: 'forge_api', description: 'Raw authenticated REST call for anything the other forge_* tools do not cover (labels, milestones, releases, workflows, org members, notifications, …). GitHub: https://api.github.com paths; Gitea/Forgejo: /api/v1 paths (e.g. "/repos/{owner}/{repo}/labels").', properties: { account: ACCOUNT, repo: S('string', 'Optional "owner/name" or URL used only to pick the account.'), method: S('string', 'HTTP method (default GET).', { enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }), path: S('string', 'Path relative to the API base, starting with "/".'), query: S('object', 'Query parameters.', { additionalProperties: true }), body: S('object', 'JSON request body.', { additionalProperties: true }), }, required: ['path'], }, toolApi), ] // ---- plugin ----------------------------------------------------------------- export function apply(ctx) { const route = (pathname, methods, handler) => ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: pathname, handler: async (req, res) => { const rejection = ctx.connection.requestRejection(req) if (rejection !== undefined) { sendJson(res, rejection, { code: 'rejected' }); return } if (!methods.includes(req.method)) { res.statusCode = 405 res.setHeader('allow', methods.join(', ')) res.end() return } try { const url = new URL(req.url ?? '/', 'http://local') sendJson(res, 200, await handler(req, url)) } catch (error) { const status = error instanceof ForgeError && error.status >= 400 && error.status < 500 ? error.status : 500 sendJson(res, status, { code: 'error', message: String(error?.message ?? error) }) } }, }), `git-forge: ${pathname}`) route('/git-forge/defaults', ['GET'], async () => ({ cloneRoot: CLONE_ROOT })) route('/git-forge/accounts', ['GET'], async (_req, url) => ({ accounts: (await listAccounts({ refresh: url.searchParams.get('refresh') === '1' })).map(publicAccount), })) route('/git-forge/repos', ['GET'], async (_req, url) => allRepos({ accountId: url.searchParams.get('account') || undefined, refresh: url.searchParams.get('refresh') === '1', })) const invalidate = (r) => { repoCache = null; return r } route('/git-forge/auth/status', ['GET'], async (_req, url) => ({ cli: await cliStatus(), accounts: await accountsWithStatus({ verify: url.searchParams.get('verify') === '1' }), })) route('/git-forge/auth/gitea', ['POST'], async (req) => invalidate(await giteaLogin(await readJsonBody(req)))) route('/git-forge/auth/github/token', ['POST'], async (req) => invalidate(await githubTokenLogin(await readJsonBody(req)))) route('/git-forge/auth/github/device', ['POST'], async (req) => githubDeviceStart(await readJsonBody(req))) route('/git-forge/auth/github/device/status', ['GET'], async (_req, url) => { const s = githubDeviceStatus(url.searchParams.get('id') || '') if (s.state === 'success') repoCache = null return s }) route('/git-forge/auth/github/device/cancel', ['POST'], async (req) => githubDeviceCancel((await readJsonBody(req)).id)) route('/git-forge/auth/logout', ['POST'], async (req) => invalidate(await logout((await readJsonBody(req)).account))) // Minimal directory listing for the fallback "Open local folder" dialog. route('/git-forge/ls', ['GET'], async (_req, url) => { const { promises: fsp } = await import('node:fs') const raw = url.searchParams.get('path') || CLONE_ROOT const dir = path.resolve(raw.replace(/^~(?=$|\/)/, os.homedir())) let entries try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch (e) { throw new ForgeError(`cannot list ${dir}: ${e.code || e.message}`, 400) } const hidden = url.searchParams.get('hidden') === '1' const dirs = entries.filter((e) => e.isDirectory() && (hidden || !e.name.startsWith('.'))).map((e) => e.name).sort((a, b) => a.localeCompare(b)) return { path: dir, parent: path.dirname(dir) === dir ? '' : path.dirname(dir), home: os.homedir(), dirs } }) route('/git-forge/clone', ['POST'], async (req) => { const body = await readJsonBody(req) return cloneRepo({ url: body.url, parent: body.parent || CLONE_ROOT, name: body.name, branch: body.branch || undefined }) }) ctx.effect(function* () { for (const tool of TOOLS) yield ctx.tools.register(tool) }, 'git-forge: agent tools') }