feat: scaffold CLI and minimal issue list (task 0001)

Tracer bullet for gitea-axi: runnable npm package on axi-sdk-js with
gitea-js as the sole HTTP layer, ESM on Node 20+.

- issue list with --state/--limit, default fields, count line from
  X-Total-Count, type=issues guard, explicit empty state, and next-step
  suggestions
- repo context detection from the git origin remote (SSH/scp/HTTPS),
  tea credential discovery with the three-way login-matching split, and
  -R/--repo and --login overrides (flag > env > auto)
- token retrieval via tea login helper get: tea's login list JSON
  carries no token (ADR 0001 amended)
- full AxiError classification table with path-based 404 split, TOON
  errors on stdout, exit codes 0/1/2
- test mode (GITEA_AXI_API_URL/TOKEN/REPO) suppressing subprocesses,
  fixture server, and vitest suites driving the CLI seam (50 tests)
This commit is contained in:
2026-07-11 07:11:07 -04:00
parent 21a075f8cd
commit 38026f963d
34 changed files with 3892 additions and 0 deletions

116
src/cli.ts Normal file
View File

@@ -0,0 +1,116 @@
import { readFileSync } from "node:fs";
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
import { issueCommand } from "./commands/issue.js";
import { resolveRepoContext } from "./context.js";
import type { CliDeps, GlobalFlags } from "./deps.js";
import { consumeFlagValue, splitFlag } from "./flags.js";
import { renderErrorOutput } from "./render.js";
import { suggestCommand } from "./suggestions.js";
const DESCRIPTION = "Agent-ergonomic CLI for Gitea issues and pull requests";
const TOP_LEVEL_HELP = `usage: gitea-axi <command> [flags]
commands:
issue list List issues in the current repository
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
--help Show help (also available on every command)
-v, --version Show version
environment:
GITEA_AXI_REPO Repository override, as OWNER/NAME
GITEA_AXI_LOGIN Login profile override
`;
const GLOBAL_FLAG_NAMES: Record<string, keyof GlobalFlags> = {
"-R": "repo",
"--repo": "repo",
"--login": "login",
};
interface ExtractedArgv {
argv: string[];
globals: GlobalFlags;
}
/**
* Pull the context override flags out of argv before the SDK sees it: they are
* accepted anywhere on the command line, while the SDK rejects any flag placed
* before the command.
*/
export function extractGlobalFlags(argv: string[]): ExtractedArgv {
const globals: GlobalFlags = {};
const rest: string[] = [];
for (let i = 0; i < argv.length; i++) {
const flag = splitFlag(argv[i]!);
const target = GLOBAL_FLAG_NAMES[flag.name];
if (!target) {
rest.push(argv[i]!);
continue;
}
const consumed = consumeFlagValue(argv, i, flag);
globals[target] = consumed.value;
i = consumed.lastIndex;
}
return { argv: rest, globals };
}
function readVersion(): string {
const packageJson = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as { version: string };
return packageJson.version;
}
function homeCommand(deps: CliDeps) {
return async (): Promise<Record<string, unknown>> => {
const context = await resolveRepoContext(deps);
return {
repo: `${context.owner}/${context.name}`,
help: [
suggestCommand(context, "issue list", "to list open issues"),
"Run `gitea-axi --help` to see available commands",
],
};
};
}
export interface RunCliOptions {
argv: string[];
env: Record<string, string | undefined>;
cwd: string;
stdout: { write: (chunk: string) => unknown };
}
export async function runCli(options: RunCliOptions): Promise<number> {
process.exitCode = 0;
let extracted: ExtractedArgv;
try {
extracted = extractGlobalFlags(options.argv);
} catch (error) {
const axi = error as AxiError;
options.stdout.write(`${renderErrorOutput(axi.message, axi.code, axi.suggestions)}\n`);
process.exitCode = exitCodeForError(error);
return process.exitCode;
}
const deps: CliDeps = {
env: options.env,
cwd: options.cwd,
globals: extracted.globals,
};
await runAxiCli({
description: DESCRIPTION,
version: readVersion(),
argv: extracted.argv,
topLevelHelp: TOP_LEVEL_HELP,
commands: {
issue: issueCommand(deps),
},
home: homeCommand(deps),
stdout: options.stdout,
});
return typeof process.exitCode === "number" ? process.exitCode : 0;
}

10
src/client.ts Normal file
View File

@@ -0,0 +1,10 @@
import { giteaApi, type Api } from "gitea-js";
import type { RepoContext } from "./context.js";
export type GiteaClient = Api<unknown>;
export function createClient(context: RepoContext): GiteaClient {
return giteaApi(context.apiUrl, {
token: context.token || undefined,
});
}

162
src/commands/issue.ts Normal file
View File

@@ -0,0 +1,162 @@
import type { Issue } from "gitea-js";
import { createClient } from "../client.js";
import { resolveRepoContext, type RepoContext } from "../context.js";
import type { CliDeps } from "../deps.js";
import { axiError, classifyHttpError } from "../errors.js";
import { extractRow, lowercased, pluck, relativeTimeField, type FieldDef } from "../fields.js";
import { parseFlags } from "../flags.js";
import { formatCountLine, renderList } from "../render.js";
import { suggestCommand } from "../suggestions.js";
export const ISSUE_HELP = `usage: gitea-axi issue <command> [flags]
commands:
list List issues in the current repository
Run \`gitea-axi issue list --help\` for the flags of a command.
`;
export const ISSUE_LIST_HELP = `usage: gitea-axi issue list [flags]
List issues in the current repository. Pull requests are never included.
flags:
--state <open|closed|all> Filter by state (default: open)
--limit <n> Maximum number of issues to return (default: 30)
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
pluck("author", "user.login"),
relativeTimeField("created", "created_at"),
];
const ISSUE_STATES = ["open", "closed", "all"] as const;
type IssueState = (typeof ISSUE_STATES)[number];
const DEFAULT_LIMIT = 30;
const ISSUE_LIST_HELP_SUGGESTION = [
"Run `gitea-axi issue list --help` to see available flags",
];
function parseState(value: string | true | undefined): IssueState {
if (value === undefined) {
return "open";
}
if (value === true || !ISSUE_STATES.includes(value as IssueState)) {
throw axiError(
`Invalid --state value: ${String(value)} (expected open, closed, or all)`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
return value as IssueState;
}
function parseLimit(value: string | true | undefined): number {
if (value === undefined) {
return DEFAULT_LIMIT;
}
const limit = Number(value);
if (value === true || !Number.isInteger(limit) || limit < 1) {
throw axiError(
`Invalid --limit value: ${String(value)} (expected a positive integer)`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
return limit;
}
function issueListSuggestions(
context: RepoContext,
state: IssueState,
shown: number,
total: number | undefined,
): string[] {
const help: string[] = [];
if (state !== "all") {
help.push(suggestCommand(context, "issue list --state all", "to list issues in any state"));
}
if (total !== undefined && shown < total) {
help.push(
suggestCommand(context, "issue list --limit <n>", `to fetch more of the ${total} issues`),
);
}
if (help.length === 0) {
help.push(
suggestCommand(context, "issue list --help", "to see all issue list flags"),
);
}
return help;
}
async function issueList(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return ISSUE_LIST_HELP;
}
const { flags, positionals } = parseFlags(
args,
{ "--state": { takesValue: true }, "--limit": { takesValue: true } },
"issue list",
);
if (positionals.length > 0) {
throw axiError(
`Unexpected argument: ${positionals[0]}`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
const state = parseState(flags["--state"]);
const limit = parseLimit(flags["--limit"]);
const context = await resolveRepoContext(deps);
const api = createClient(context);
let response;
try {
response = await api.repos.issueListIssues(context.owner, context.name, {
state,
type: "issues",
limit,
page: 1,
});
} catch (error) {
throw classifyHttpError(error);
}
const issues = response.data ?? [];
const totalHeader = response.headers.get("x-total-count");
const total = totalHeader !== null ? Number(totalHeader) : undefined;
const resolvedTotal = total !== undefined && Number.isFinite(total) ? total : undefined;
const now = new Date();
const rows = issues.map((issue) => extractRow(issue, ISSUE_LIST_FIELDS, { now }));
return renderList({
noun: "issues",
rows,
countLine: formatCountLine(rows.length, resolvedTotal, rows.length >= limit),
help: issueListSuggestions(context, state, rows.length, resolvedTotal),
});
}
export function issueCommand(deps: CliDeps) {
return async (args: string[]): Promise<string> => {
const [subcommand, ...rest] = args;
if (!subcommand || subcommand === "--help") {
return ISSUE_HELP;
}
if (subcommand === "list") {
return issueList(deps, rest);
}
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
"Run `gitea-axi issue --help` to see available issue commands",
]);
};
}

193
src/context.ts Normal file
View File

@@ -0,0 +1,193 @@
import type { CliDeps } from "./deps.js";
import { axiError } from "./errors.js";
import { detectRemote } from "./git.js";
import { getToken, listLogins, type TeaLogin } from "./tea.js";
export type ContextSource = "flag" | "env" | "auto";
export interface RepoContext {
owner: string;
name: string;
host: string;
/** Gitea instance base URL, without the /api/v1 suffix. */
apiUrl: string;
token: string;
repoSource: ContextSource;
loginSource: ContextSource;
loginName?: string;
}
/** The repo/login overrides resolved from flags and environment, pre-context. */
interface ContextOverrides {
repoSpec?: string;
repoSource: ContextSource;
/** Human label for where the repo spec came from, for error messages. */
repoOrigin: string;
loginName?: string;
loginSource: ContextSource;
}
function resolveOverrides(deps: CliDeps): ContextOverrides {
const repoFromFlag = deps.globals.repo;
const repoFromEnv = deps.env.GITEA_AXI_REPO;
const loginFromFlag = deps.globals.login;
const loginFromEnv = deps.env.GITEA_AXI_LOGIN;
return {
repoSpec: repoFromFlag ?? repoFromEnv,
repoSource: repoFromFlag ? "flag" : repoFromEnv ? "env" : "auto",
repoOrigin: repoFromFlag ? "`-R`" : "`GITEA_AXI_REPO`",
loginName: loginFromFlag ?? loginFromEnv,
loginSource: loginFromFlag ? "flag" : loginFromEnv ? "env" : "auto",
};
}
function parseRepoSpec(spec: string, origin: string): { owner: string; name: string } {
const segments = spec.split("/");
if (segments.length !== 2 || !segments[0] || !segments[1]) {
throw axiError(
`Invalid repository "${spec}" from ${origin}: expected OWNER/NAME`,
"VALIDATION_ERROR",
);
}
return { owner: segments[0], name: segments[1] };
}
function hostnameOf(url: string, origin: string): string {
try {
return new URL(url).hostname;
} catch {
throw axiError(`Invalid URL "${url}" from ${origin}`, "VALIDATION_ERROR");
}
}
function resolveTestModeContext(
deps: CliDeps,
apiUrl: string,
overrides: ContextOverrides,
): RepoContext {
if (!overrides.repoSpec) {
throw axiError(
"Repository context is required when GITEA_AXI_API_URL is set",
"REPO_NOT_FOUND",
["Set `GITEA_AXI_REPO=OWNER/NAME` or pass `-R OWNER/NAME`"],
);
}
return {
...parseRepoSpec(overrides.repoSpec, overrides.repoOrigin),
host: hostnameOf(apiUrl, "`GITEA_AXI_API_URL`"),
apiUrl: apiUrl.replace(/\/+$/, ""),
token: deps.env.GITEA_AXI_TOKEN ?? "",
repoSource: overrides.repoSource,
loginSource: overrides.loginSource,
loginName: overrides.loginName,
};
}
function matchLoginsByHost(logins: TeaLogin[], host: string): TeaLogin[] {
return logins.filter((login) => {
if (login.sshHost && login.sshHost === host) {
return true;
}
try {
return new URL(login.url).hostname === host;
} catch {
return false;
}
});
}
function selectLogin(
logins: TeaLogin[],
loginName: string | undefined,
remoteHost: string | undefined,
): TeaLogin {
if (logins.length === 0) {
throw axiError("No tea logins are configured", "AUTH_REQUIRED", [
"Run `tea login add` to configure a login",
]);
}
if (loginName) {
const login = logins.find((entry) => entry.name === loginName);
if (!login) {
throw axiError(
`Login profile "${loginName}" not found (available: ${logins.map((l) => l.name).join(", ")})`,
"VALIDATION_ERROR",
["Pass `--login <name>` with one of the listed profiles"],
);
}
return login;
}
if (!remoteHost) {
throw axiError(
"Cannot select a tea login without a git remote hostname",
"REPO_NOT_FOUND",
["Pass `--login <name>` together with `-R OWNER/NAME`"],
);
}
const matches = matchLoginsByHost(logins, remoteHost);
if (matches.length === 0) {
throw axiError(
`No tea login matches host "${remoteHost}" — this may not be a Gitea repository`,
"REPO_NOT_FOUND",
[
`Run \`tea login add --url ${remoteHost}\` if this is a Gitea instance`,
"Or pass `-R OWNER/NAME` together with `--login <name>`",
],
);
}
if (matches.length === 1) {
return matches[0]!;
}
const fallback = matches.find((login) => login.isDefault);
if (fallback) {
return fallback;
}
throw axiError(
`Multiple tea logins match host "${remoteHost}": ${matches.map((l) => l.name).join(", ")}`,
"VALIDATION_ERROR",
["Pass `--login <name>` to select one"],
);
}
export async function resolveRepoContext(deps: CliDeps): Promise<RepoContext> {
const overrides = resolveOverrides(deps);
const testApiUrl = deps.env.GITEA_AXI_API_URL;
if (testApiUrl) {
return resolveTestModeContext(deps, testApiUrl, overrides);
}
const remote = await detectRemote(deps);
let owner: string;
let name: string;
if (overrides.repoSpec) {
({ owner, name } = parseRepoSpec(overrides.repoSpec, overrides.repoOrigin));
} else if (remote) {
({ owner, name } = remote);
} else {
throw axiError(
"Could not detect a Gitea repository from the git `origin` remote",
"REPO_NOT_FOUND",
[
"Run inside a git repository whose `origin` remote points at a Gitea instance",
"Or pass `-R OWNER/NAME` together with `--login <name>`",
],
);
}
const logins = await listLogins(deps);
const login = selectLogin(logins, overrides.loginName, remote?.host);
const host = hostnameOf(login.url, `tea login "${login.name}"`);
const token = await getToken(deps, login, host);
return {
owner,
name,
host,
apiUrl: login.url.replace(/\/+$/, ""),
token,
repoSource: overrides.repoSource,
loginSource: overrides.loginSource,
loginName: login.name,
};
}

10
src/deps.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface GlobalFlags {
repo?: string;
login?: string;
}
export interface CliDeps {
env: Record<string, string | undefined>;
cwd: string;
globals: GlobalFlags;
}

133
src/errors.ts Normal file
View File

@@ -0,0 +1,133 @@
import { AxiError } from "axi-sdk-js";
export type AxiErrorCode =
| "REPO_NOT_FOUND"
| "ISSUE_NOT_FOUND"
| "PR_NOT_FOUND"
| "AUTH_REQUIRED"
| "FORBIDDEN"
| "RATE_LIMITED"
| "TEA_NOT_INSTALLED"
| "VALIDATION_ERROR"
| "GIT_ERROR"
| "UNKNOWN";
export function axiError(
message: string,
code: AxiErrorCode,
suggestions: string[] = [],
): AxiError {
return new AxiError(message, code, suggestions);
}
interface HttpResponseLike {
status: number;
url: string;
error: unknown;
}
function isHttpResponseLike(value: unknown): value is HttpResponseLike {
return (
typeof value === "object" &&
value !== null &&
typeof (value as HttpResponseLike).status === "number" &&
typeof (value as HttpResponseLike).url === "string"
);
}
function bodyMessage(response: HttpResponseLike): string | undefined {
const error = response.error;
if (typeof error === "object" && error !== null) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return undefined;
}
function pathname(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
const ISSUE_PATH = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(?:\/|$)/;
const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:\/|$)/;
const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/;
function classify404(response: HttpResponseLike): AxiError {
const path = pathname(response.url);
const issue = ISSUE_PATH.exec(path);
if (issue) {
return axiError(`Issue #${issue[1]} not found`, "ISSUE_NOT_FOUND", [
"Run `gitea-axi issue list` to see existing issues",
]);
}
const pull = PULL_PATH.exec(path);
if (pull) {
return axiError(`Pull request #${pull[1]} not found`, "PR_NOT_FOUND");
}
// Gitea returns 404 for every path under a nonexistent repository, so any
// repo-subtree 404 that is not an indexed issue/pull lookup means the
// repository itself was not found.
const repo = REPO_PATH.exec(path);
if (repo) {
return axiError(
`Repository ${repo[1]}/${repo[2]} not found`,
"REPO_NOT_FOUND",
[
"Check the repository owner and name",
"Pass `-R OWNER/NAME` to target a different repository",
],
);
}
return axiError(`Not found: ${path}`, "UNKNOWN");
}
export function classifyHttpError(error: unknown): AxiError {
if (error instanceof AxiError) {
return error;
}
if (!isHttpResponseLike(error)) {
const message = error instanceof Error ? error.message : String(error);
const cause =
error instanceof Error && error.cause instanceof Error
? ` (${error.cause.message})`
: "";
return axiError(`Request failed: ${message}${cause}`, "UNKNOWN");
}
const detail = bodyMessage(error);
switch (error.status) {
case 401:
return axiError(detail ?? "Authentication required", "AUTH_REQUIRED", [
"Run `tea login add` to configure credentials, or verify the token is still valid",
]);
case 403:
return axiError(detail ?? "Access forbidden", "FORBIDDEN", [
"Verify the token has permission to access this repository",
]);
case 404:
return classify404(error);
case 405:
case 409:
case 422:
return axiError(
detail ?? `Validation failed (HTTP ${error.status})`,
"VALIDATION_ERROR",
);
case 429:
return axiError(detail ?? "Rate limited", "RATE_LIMITED", [
"Wait and retry, or reduce `--limit` to make smaller requests",
]);
default:
return axiError(
detail
? `Gitea API error (HTTP ${error.status}): ${detail}`
: `Gitea API error (HTTP ${error.status})`,
"UNKNOWN",
);
}
}

54
src/fields.ts Normal file
View File

@@ -0,0 +1,54 @@
import { relativeTime } from "./time.js";
export interface ExtractContext {
now: Date;
}
export interface FieldDef<T> {
name: string;
extract: (raw: T, context: ExtractContext) => unknown;
}
function pluckPath(raw: unknown, path: string): unknown {
let value: unknown = raw;
for (const key of path.split(".")) {
if (typeof value !== "object" || value === null) {
return undefined;
}
value = (value as Record<string, unknown>)[key];
}
return value;
}
export function pluck<T>(name: string, path: string = name): FieldDef<T> {
return { name, extract: (raw) => pluckPath(raw, path) ?? "" };
}
export function lowercased<T>(name: string, path: string = name): FieldDef<T> {
return {
name,
extract: (raw) => String(pluckPath(raw, path) ?? "").toLowerCase(),
};
}
export function relativeTimeField<T>(name: string, path: string): FieldDef<T> {
return {
name,
extract: (raw, context) => {
const value = pluckPath(raw, path);
return relativeTime(typeof value === "string" ? value : undefined, context.now);
},
};
}
export function extractRow<T>(
raw: T,
fields: FieldDef<T>[],
context: ExtractContext,
): Record<string, unknown> {
const row: Record<string, unknown> = {};
for (const field of fields) {
row[field.name] = field.extract(raw, context);
}
return row;
}

81
src/flags.ts Normal file
View File

@@ -0,0 +1,81 @@
import { axiError } from "./errors.js";
export interface FlagSpec {
/** Flag names (e.g. "--state") mapped to whether they take a value. */
[name: string]: { takesValue: boolean };
}
export interface ParsedFlags {
flags: Record<string, string | true>;
positionals: string[];
}
export interface SplitFlag {
name: string;
inlineValue?: string;
}
/** Split "--flag=value" into name and inline value; "--flag" has none. */
export function splitFlag(arg: string): SplitFlag {
const equals = arg.indexOf("=");
if (equals === -1) {
return { name: arg };
}
return { name: arg.slice(0, equals), inlineValue: arg.slice(equals + 1) };
}
/**
* Resolve a value-taking flag's value from its inline form or the next
* argument, returning the index of the last argument consumed.
*/
export function consumeFlagValue(
args: string[],
index: number,
flag: SplitFlag,
suggestions: string[] = [],
): { value: string; lastIndex: number } {
if (flag.inlineValue !== undefined) {
if (!flag.inlineValue) {
throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions);
}
return { value: flag.inlineValue, lastIndex: index };
}
const next = args[index + 1];
if (next === undefined || next.startsWith("-")) {
throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions);
}
return { value: next, lastIndex: index + 1 };
}
export function parseFlags(
args: string[],
spec: FlagSpec,
helpCommand: string,
): ParsedFlags {
const flags: Record<string, string | true> = {};
const positionals: string[] = [];
const helpSuggestion = [`Run \`gitea-axi ${helpCommand} --help\` to see available flags`];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (!arg.startsWith("-")) {
positionals.push(arg);
continue;
}
const flag = splitFlag(arg);
const entry = spec[flag.name];
if (!entry) {
throw axiError(`Unknown flag: ${flag.name}`, "VALIDATION_ERROR", helpSuggestion);
}
if (!entry.takesValue) {
if (flag.inlineValue !== undefined) {
throw axiError(`Flag ${flag.name} does not take a value`, "VALIDATION_ERROR", helpSuggestion);
}
flags[flag.name] = true;
continue;
}
const consumed = consumeFlagValue(args, i, flag, helpSuggestion);
flags[flag.name] = consumed.value;
i = consumed.lastIndex;
}
return { flags, positionals };
}

55
src/git.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { CliDeps } from "./deps.js";
import { runSubprocess } from "./subprocess.js";
export interface RemoteRepo {
host: string;
owner: string;
name: string;
}
function parseRepoPath(rawPath: string): { owner: string; name: string } | null {
const path = rawPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "");
const segments = path.split("/");
if (segments.length !== 2 || !segments[0] || !segments[1]) {
return null;
}
return { owner: segments[0], name: segments[1] };
}
export function parseRemoteUrl(url: string): RemoteRepo | null {
const trimmed = url.trim();
if (/^(https?|ssh):\/\//.test(trimmed)) {
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
const repo = parseRepoPath(parsed.pathname);
if (!repo || !parsed.hostname) {
return null;
}
return { host: parsed.hostname, ...repo };
}
// scp-like SSH form: [user@]host:owner/name[.git]
const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(trimmed);
if (scp) {
const repo = parseRepoPath(scp[2]!);
if (!repo) {
return null;
}
return { host: scp[1]!, ...repo };
}
return null;
}
export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
cwd: deps.cwd,
env: deps.env,
});
if (result.enoent || result.code !== 0) {
return null;
}
return parseRemoteUrl(result.stdout);
}

17
src/main.ts Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { runCli } from "./cli.js";
// Exit quietly when the consumer closes the pipe early (e.g. `gitea-axi | head`).
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EPIPE") {
process.exit(0);
}
throw error;
});
await runCli({
argv: process.argv.slice(2),
env: process.env,
cwd: process.cwd(),
stdout: process.stdout,
});

44
src/render.ts Normal file
View File

@@ -0,0 +1,44 @@
import { encode } from "@toon-format/toon";
// Same shape the SDK's own error formatter produces; its renderError helper
// exists in axi-sdk-js but is not exported from the package index.
export function renderErrorOutput(
message: string,
code: string,
suggestions: string[] = [],
): string {
const output: Record<string, unknown> = { error: message, code };
if (suggestions.length > 0) {
output.help = suggestions;
}
return encode(output);
}
export function formatCountLine(
shown: number,
total: number | undefined,
atLimit: boolean,
): string {
if (total === undefined) {
if (atLimit) {
return `count: ${shown} (showing first ${shown})`;
}
return `count: ${shown} of ${shown} total`;
}
return `count: ${shown} of ${total} total`;
}
export interface RenderListOptions {
noun: string;
rows: Record<string, unknown>[];
countLine: string;
help: string[];
}
export function renderList(options: RenderListOptions): string {
const body =
options.rows.length > 0
? encode({ [options.noun]: options.rows })
: `${options.noun}[0]: (none)`;
return [options.countLine, body, encode({ help: options.help })].join("\n");
}

55
src/subprocess.ts Normal file
View File

@@ -0,0 +1,55 @@
import { spawn } from "node:child_process";
export interface SubprocessResult {
enoent: boolean;
code: number | null;
stdout: string;
stderr: string;
}
export interface SubprocessOptions {
cwd?: string;
env: Record<string, string | undefined>;
stdin?: string;
}
export function runSubprocess(
command: string,
args: string[],
options: SubprocessOptions,
): Promise<SubprocessResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env as NodeJS.ProcessEnv,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") {
resolve({ enoent: true, code: null, stdout, stderr });
} else {
reject(error);
}
});
child.on("close", (code) => {
resolve({ enoent: false, code, stdout, stderr });
});
if (options.stdin !== undefined) {
// The child may exit without reading stdin; a late write then raises
// EPIPE, which must not crash the parent.
child.stdin.on("error", () => {});
child.stdin.write(options.stdin);
}
child.stdin.end();
});
}

21
src/suggestions.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { RepoContext } from "./context.js";
/**
* Build a next-step suggestion line, appending `-R`/`--login` overrides when
* the context did not come from the git remote (an agent's next call in the
* same working directory would auto-detect the same context otherwise).
*/
export function suggestCommand(
context: RepoContext,
commandLine: string,
note: string,
): string {
let command = `gitea-axi ${commandLine}`;
if (context.repoSource !== "auto") {
command += ` -R ${context.owner}/${context.name}`;
}
if (context.loginSource !== "auto" && context.loginName) {
command += ` --login ${context.loginName}`;
}
return `Run \`${command}\` ${note}`;
}

102
src/tea.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { CliDeps } from "./deps.js";
import { axiError } from "./errors.js";
import { runSubprocess } from "./subprocess.js";
export interface TeaLogin {
name: string;
url: string;
sshHost: string;
isDefault: boolean;
}
interface RawTeaLogin {
name?: string;
url?: string;
ssh_host?: string;
default?: string;
}
function teaNotInstalled(): never {
throw axiError("tea is not installed", "TEA_NOT_INSTALLED", [
"Install tea (https://gitea.com/gitea/tea) — gitea-axi discovers credentials from tea's login store",
"Then run `tea login add` to configure a login",
]);
}
function failureDetail(result: { stderr: string; code: number | null }): string {
return firstLine(result.stderr) || `exit code ${result.code}`;
}
export async function listLogins(deps: CliDeps): Promise<TeaLogin[]> {
const result = await runSubprocess("tea", ["login", "list", "--output", "json"], {
env: deps.env,
});
if (result.enoent) {
teaNotInstalled();
}
if (result.code !== 0) {
throw axiError(`\`tea login list\` failed: ${failureDetail(result)}`, "UNKNOWN");
}
let raw: unknown;
try {
raw = JSON.parse(result.stdout);
} catch {
throw axiError("`tea login list --output json` returned invalid JSON", "UNKNOWN");
}
if (!Array.isArray(raw)) {
throw axiError("`tea login list --output json` returned unexpected output", "UNKNOWN");
}
return (raw as RawTeaLogin[]).map((entry) => ({
name: entry.name ?? "",
url: entry.url ?? "",
sshHost: entry.ssh_host ?? "",
isDefault: entry.default === "true",
}));
}
// The list output carries no token (its columns are name, url, ssh_host, user,
// default), so the token comes from tea's git-credential interface, which also
// refreshes OAuth tokens transparently.
export async function getToken(deps: CliDeps, login: TeaLogin, host: string): Promise<string> {
let protocol = "https";
try {
protocol = new URL(login.url).protocol.replace(/:$/, "");
} catch {
// Fall back to https; the helper only requires the host.
}
const result = await runSubprocess(
"tea",
["login", "helper", "get", "--login", login.name],
{
env: deps.env,
stdin: `protocol=${protocol}\nhost=${host}\n\n`,
},
);
if (result.enoent) {
teaNotInstalled();
}
if (result.code !== 0) {
throw axiError(
`tea could not provide a token for login "${login.name}": ${failureDetail(result)}`,
"AUTH_REQUIRED",
[`Run \`tea login edit ${login.name}\` to repair the login`],
);
}
for (const line of result.stdout.split("\n")) {
if (line.startsWith("password=")) {
const token = line.slice("password=".length).trim();
if (token) {
return token;
}
}
}
throw axiError(
`tea returned no token for login "${login.name}"`,
"AUTH_REQUIRED",
[`Run \`tea login edit ${login.name}\` to repair the login`],
);
}
function firstLine(text: string): string {
return text.split("\n", 1)[0]?.trim() ?? "";
}

32
src/time.ts Normal file
View File

@@ -0,0 +1,32 @@
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
export function relativeTime(iso: string | undefined, now: Date): string {
if (!iso) {
return "unknown";
}
const then = Date.parse(iso);
if (Number.isNaN(then)) {
return "unknown";
}
const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
if (seconds < MINUTE) {
return "just now";
}
if (seconds < HOUR) {
return `${Math.floor(seconds / MINUTE)}m ago`;
}
if (seconds < DAY) {
return `${Math.floor(seconds / HOUR)}h ago`;
}
if (seconds < MONTH) {
return `${Math.floor(seconds / DAY)}d ago`;
}
if (seconds < YEAR) {
return `${Math.floor(seconds / MONTH)}mo ago`;
}
return `${Math.floor(seconds / YEAR)}y ago`;
}