Initial commit: git-forge-plugin DSH plugin with docs

This commit is contained in:
Agent committed 2026-10-10 17:37:32 +00:00
commit e303c51b23
11 files changed
+2351

No files matched your search

+554
View File
@@ -0,0 +1,554 @@
// @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')
}