Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .dev-os/notepad.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## PRIORITY
<!-- Keep urgent context here — injected into every session start (≤500 chars) -->

## WORKING MEMORY
<!-- Auto-written by precompact-guide on each compaction -->

## MANUAL
<!-- User-managed notes — never auto-modified -->
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules/
dist/
.DS_Store
.no-mistakes/evidence/
.dev-os/runtime/
product/runtime/tool-artifacts/
299 changes: 299 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

308 changes: 308 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ gh-axi gist clone <id|url> # clone a gist locally
gh-axi setup hooks # install optional agent session hooks
gh-axi update --check # check whether a newer release exists
gh-axi update # upgrade a global install
gh-axi --fix-ignore-conflicts # dashboard: explicitly repair tracked ignored entries
gh-axi pr create --title "..." --fix-ignore-conflicts
```

For multi-line issue, PR, review, or comment text, write Markdown to a UTF-8 file and pass `--body-file <path>` on the relevant command.
Expand All @@ -137,6 +139,8 @@ Cancelled, stale, timed-out, action-required, and startup-failure check runs cou
`gh-axi stack` is a strict, non-interactive adapter over the official `github/gh-stack` extension. It supports `view`, `init`, `add`, `checkout`, `push`, `submit`, `sync`, `rebase`, `link`, `unstack`, `merge`, and branch navigation. It intentionally excludes the interactive `modify` and `switch` TUIs and the human-only `alias` and `feedback` utilities.
Stack commands operate on local branches and `.git/gh-stack`, so run them from the target repository's working directory. They reject `-R`, `--repo`, and `GH_REPO` rather than pretending a remote repository is enough. `--hostname` remains available for authenticated GitHub Enterprise hosts.
Agent-safe behavior is automatic: `stack view` requests JSON, `stack submit` adds `--auto`, and `stack merge` requires an explicit stack or PR target and adds `--yes`. Rebase conflicts and other extension exits retain their original exit codes and include recovery guidance.
Gitignore tracking hygiene runs automatically on the local dashboard and before local-repository `pr create`. It reports tracked files matched by repository `.gitignore`; TTY sessions ask once with yes/no/always, while non-TTY sessions remain report-only unless `--fix-ignore-conflicts` is explicit. Repair only stages `git rm --cached`: local files are preserved, and hygiene never commits, pushes, or deletes worktree files.
If the canonical Git index lock is already held, hygiene returns `manual` and makes no mutation. Use normal Git `index.lock` recovery after confirming no Git process is active; gh-axi never auto-deletes an unknown lock.

`gh-axi secret set <name>` reads the value only from piped stdin because secret flags would be visible in the `gh-axi` process argv.
`gh-axi secret list` never prints values, matching `gh secret list`.
Expand Down
4 changes: 2 additions & 2 deletions skills/gh-axi/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: gh-axi
description: "Operate GitHub through the gh-axi CLI - issues, pull requests, stacked PRs, workflow runs, workflows, releases, repositories, labels, gists, Projects (v2), Actions secrets and variables, search, and raw API access. Use whenever a task touches GitHub: listing or filing issues, reviewing or merging PRs, managing stacked branches and PRs, checking CI runs, triggering workflows, cutting releases, managing Projects boards, managing Actions secrets/variables, or working with gists via `gist list`, `gist view`, `gist edit`, `gist rename`, `gist create`, `gist delete`, or `gist clone`."
description: "Operate GitHub through the gh-axi CLI - issues, pull requests, stacked PRs, workflow runs, workflows, releases, repositories, labels, gists, Projects (v2), Actions secrets and variables, search, raw API access, and repository Gitignore tracking hygiene. Use whenever a task touches GitHub or needs a safe preflight before publishing: listing or filing issues, reviewing or merging PRs, managing stacked branches and PRs, checking CI runs, triggering workflows, cutting releases, managing Projects boards, managing Actions secrets/variables, working with gists, or reviewing tracked files matched by repository `.gitignore` rules."
user-invocable: false
author: Kun Chen (kunchenguid)
metadata:
Expand All @@ -13,7 +13,7 @@ metadata:

Agent ergonomic wrapper around Github CLI. Prefer this over `gh` and other methods for Github operations.

Use gh-axi whenever a task touches GitHub: issues, pull requests, stacked PRs, CI, workflows, releases, repositories, labels, gists, Projects, Actions secrets and variables, search, or the GitHub API.
Use gh-axi whenever a task touches GitHub or needs repository Gitignore tracking hygiene before publication: issues, pull requests, stacked PRs, CI, workflows, releases, repositories, labels, gists, Projects, Actions secrets and variables, search, the GitHub API, or the shared push-preflight seam.

## Current guidance lives in the CLI

Expand Down
32 changes: 24 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ type MainOptions = {
export const TOP_HELP = `usage: gh-axi [command] [args] [flags]
commands[16]:
(none)=dashboard, issue, pr, stack, run, workflow, release, repo, label, gist, project, secret, variable, search, api, setup
flags[4]:
-R/--repo <OWNER/NAME> (after command), --hostname <host> (after command) or GH_HOST env, both flags accept space or equals form, --help, -v/-V/--version
flags[5]:
-R/--repo <OWNER/NAME> (after command), --hostname <host> (after command) or GH_HOST env, both flags accept space or equals form, --fix-ignore-conflicts (dashboard only), --help, -v/-V/--version
examples:
gh-axi
gh-axi issue list --state open
Expand All @@ -47,6 +47,7 @@ examples:
gh-axi stack view
gh-axi secret list
gh-axi setup hooks
gh-axi --fix-ignore-conflicts
`;

const COMMAND_HELP: Record<string, string> = {
Expand Down Expand Up @@ -90,17 +91,29 @@ const COMMANDS: Record<string, WrappedCommandFn> = {
search: withRepoContext("search", searchCommand),
api: withRepoContext("api", apiCommand),
setup: setupCommand,
stack: withLocalRepoContext(stackCommand),
stack: withLocalRepoContext("stack", stackCommand),
};

export async function main(options: MainOptions = {}): Promise<void> {
const argv = options.argv ?? process.argv.slice(2);
const dashboardExplicitFix =
argv.length === 1 && argv[0] === "--fix-ignore-conflicts";
await runAxiCli<CliContext | undefined>({
...(options.argv ? { argv: options.argv } : {}),
...(dashboardExplicitFix
? { argv: [] }
: options.argv
? { argv: options.argv }
: {}),
description: DESCRIPTION,
version: VERSION,
topLevelHelp: TOP_HELP,
...(options.stdout ? { stdout: options.stdout } : {}),
home: withRepoContext(undefined, homeCommand),
home: withRepoContext(undefined, (args, ctx) =>
homeCommand(
dashboardExplicitFix ? ["--fix-ignore-conflicts", ...args] : args,
ctx,
),
),
commands: COMMANDS,
getCommandHelp: (command) => COMMAND_HELP[command],
formatError: (error) => {
Expand Down Expand Up @@ -160,12 +173,15 @@ function withRepoContext(
);
}

function withLocalRepoContext(handler: CommandFn): WrappedCommandFn {
function withLocalRepoContext(
name: string,
handler: CommandFn,
): WrappedCommandFn {
return (args, ctx) => {
const parsed = parseRepoContextArgs("stack", args);
const parsed = parseRepoContextArgs(name, args);
if (parsed.repoFlag !== undefined || process.env["GH_REPO"]) {
throw new AxiError(
"stack commands operate on the repository in the current working directory and do not support -R, --repo, or GH_REPO",
`${name} commands operate on the repository in the current working directory and do not support -R, --repo, or GH_REPO`,
"VALIDATION_ERROR",
);
}
Expand Down
77 changes: 54 additions & 23 deletions src/commands/home.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { encode } from "@toon-format/toon";
import type { RepoContext } from "../context.js";
import { ghJson } from "../gh.js";
import { runGitignorePreflight } from "../gitignore-hygiene.js";
import { isStdinTTY } from "../stdin.js";
import { getSuggestions } from "../suggestions.js";
import {
field,
lower,
Expand All @@ -10,18 +14,14 @@ import {
renderOutput,
type FieldDef,
} from "../toon.js";
import { getSuggestions } from "../suggestions.js";
import { encode } from "@toon-format/toon";

export const HOME_HELP = "";

const issueSchema: FieldDef[] = [
field("number"),
field("title"),
lower("state"),
pluck("author", "login", "author"),
];

const prSchema: FieldDef[] = [
field("number"),
field("title"),
Expand All @@ -39,15 +39,29 @@ const prSchema: FieldDef[] = [
];

export async function homeCommand(
_args: string[],
args: string[],
ctx?: RepoContext,
): Promise<string> {
// Run queries in parallel
const [issues, prs] = await Promise.all([
const explicitFix = args.includes("--fix-ignore-conflicts");
const localRepo = !ctx || ctx.source === "git";
const hygienePromise = localRepo
? runGitignorePreflight({
policy: explicitFix
? "explicit-fix"
: isStdinTTY()
? "interactive"
: "report",
})
: Promise.resolve({
findings: [],
gitAvailable: false,
action: "none" as const,
});
const [issues, prs, hygiene] = await Promise.all([
ghJson<Record<string, unknown>[]>(
["issue", "list", "--json", "number,title,state,author", "--limit", "3"],
ctx,
).catch(() => [] as Record<string, unknown>[]),
).catch(() => []),
ghJson<Record<string, unknown>[]>(
[
"pr",
Expand All @@ -58,32 +72,49 @@ export async function homeCommand(
"3",
],
ctx,
).catch(() => [] as Record<string, unknown>[]),
).catch(() => []),
hygienePromise.catch(() => ({
findings: [],
gitAvailable: false,
action: "none" as const,
})),
]);

const blocks: string[] = [];

if (ctx) {
blocks.push(encode({ repo: ctx.nwo }));
}

if (ctx) blocks.push(encode({ repo: ctx.nwo }));
blocks.push(
issues.length
? renderList("issues", issues, issueSchema)
: "issues: 0 open",
);
blocks.push(prs.length ? renderList("prs", prs, prSchema) : "prs: 0 open");

blocks.push(
encode({
hygiene: {
action: !localRepo
? "skipped"
: hygiene.gitAvailable
? hygiene.findings.length > 0
? hygiene.action
: "none"
: "unavailable",
local_files: "preserved",
findings: hygiene.findings.length,
},
}),
);
const hints: string[] = [];
if (issues.length >= 3)
hints.push("Run `gh-axi issue list` for full issue list");
if (prs.length >= 3) hints.push("Run `gh-axi pr list` for full PR list");
const suggestions = getSuggestions({
domain: "home",
action: "home",
repo: ctx,
});
blocks.push(renderHelp([...hints, ...suggestions]));

if (hygiene.findings.length > 0)
hints.push(
"Run `gh-axi --fix-ignore-conflicts` to repair tracked .gitignore conflicts",
);
blocks.push(
renderHelp([
...hints,
...getSuggestions({ domain: "home", action: "home", repo: ctx }),
]),
);
return renderOutput(blocks);
}
37 changes: 30 additions & 7 deletions src/commands/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { takeBody, truncateBody } from "../body.js";
import { formatCountLine } from "../format.js";
import { fetchListTotal, type ListFilter } from "../totals.js";
import { getSuggestions } from "../suggestions.js";
import { isStdinTTY } from "../stdin.js";
import { runGitignorePreflight } from "../gitignore-hygiene.js";
import {
takeFlag,
takeBoolFlag,
Expand Down Expand Up @@ -276,6 +278,7 @@ const PR_FLAGS: Record<string, readonly string[]> = {
"--label",
"--milestone",
"--project",
"--fix-ignore-conflicts",
],
edit: [
"--title",
Expand Down Expand Up @@ -327,7 +330,7 @@ flags{list}:
flags{view}:
--comments, --reviews (show review submissions and inline review comments), --full (show complete body without truncation)
flags{create}:
--title <text> (required), --body <text> or --body-file <path>, --base, --head, --draft, --assignee <login> (repeatable), --reviewer <login> (repeatable), --label <name> (repeatable), --milestone, --project <name> (repeatable)
--title <text> (required), --body <text> or --body-file <path>, --base, --head, --draft, --assignee <login> (repeatable), --reviewer <login> (repeatable), --label <name> (repeatable), --milestone, --project <name> (repeatable), --fix-ignore-conflicts
flags{edit}:
--title <text>, --body <text> or --body-file <path>, --add-label <name> (repeatable), --remove-label <name> (repeatable), --add-assignee <login> (repeatable), --remove-assignee <login> (repeatable), --add-reviewer <login> (repeatable), --remove-reviewer <login> (repeatable), --milestone
flags{merge}:
Expand Down Expand Up @@ -518,7 +521,12 @@ async function prView(args: string[], ctx?: RepoContext): Promise<string> {
return renderOutput([renderDetail("pull_request", pr, schema)]);
}

async function prCreate(args: string[], ctx?: RepoContext): Promise<string> {
async function prCreate(
args: string[],
ctx?: RepoContext,
preflight: typeof runGitignorePreflight = runGitignorePreflight,
): Promise<string> {
const explicitFix = takeBoolFlag(args, "--fix-ignore-conflicts");
const title = takeFlag(args, "--title");
if (!title) throw new AxiError("--title is required", "VALIDATION_ERROR");
const body = takeBody(args);
Expand All @@ -542,13 +550,31 @@ async function prCreate(args: string[], ctx?: RepoContext): Promise<string> {
if (milestone) ghArgs.push("--milestone", milestone);
pushRepeated(ghArgs, "--project", projects);

const hygiene =
ctx?.source === "git"
? await preflight({
policy: explicitFix
? "explicit-fix"
: isStdinTTY()
? "interactive"
: "report",
})
: { findings: [], gitAvailable: false, action: "none" as const };

const stdout = await ghExec(ghArgs, ctx);
// Parse PR number from the emitted URL: https://<host>/OWNER/REPO/pull/123
const urlMatch = stdout.match(/\/pull\/(\d+)/);
const num = urlMatch ? Number(urlMatch[1]) : undefined;
const url = stdout.trim().split("\n").pop()?.trim() ?? "";

return renderOutput([
encode({
hygiene: {
action: hygiene.findings.length > 0 ? hygiene.action : "none",
local_files: "preserved",
findings: hygiene.findings.length,
},
}),
renderDetail("created", { number: num ?? url, url }, [
field("number"),
field("url"),
Expand Down Expand Up @@ -1026,13 +1052,10 @@ async function prRevert(args: string[], ctx?: RepoContext): Promise<string> {
]);
}

// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------

export async function prCommand(
args: string[],
ctx?: RepoContext,
preflight: typeof runGitignorePreflight = runGitignorePreflight,
): Promise<string> {
const sub = args[0];
const rest = args.slice(1);
Expand All @@ -1046,7 +1069,7 @@ export async function prCommand(
return prView(rest, ctx);
case "create":
rejectUnknownFlags(rest, PR_FLAGS.create, "pr", "create");
return prCreate(rest, ctx);
return prCreate(rest, ctx, preflight);
case "edit":
rejectUnknownFlags(rest, PR_FLAGS.edit, "pr", "edit");
return prEdit(rest, ctx);
Expand Down
Loading