chore: set goose binaries as executable in package.json (#8589)

This commit is contained in:
Jack Amadeo 2026-04-16 11:36:53 -04:00 committed by GitHub
parent fd93865951
commit 67b90205ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 452 additions and 390 deletions

View file

@ -7,6 +7,9 @@
"type": "git",
"url": "git+https://github.com/aaif-goose/goose.git"
},
"bin": {
"goose": "bin/goose"
},
"keywords": [
"goose",
"ai",

View file

@ -7,6 +7,9 @@
"type": "git",
"url": "git+https://github.com/aaif-goose/goose.git"
},
"bin": {
"goose": "bin/goose"
},
"keywords": [
"goose",
"ai",

View file

@ -7,6 +7,9 @@
"type": "git",
"url": "git+https://github.com/aaif-goose/goose.git"
},
"bin": {
"goose": "bin/goose"
},
"keywords": [
"goose",
"ai",

View file

@ -7,6 +7,9 @@
"type": "git",
"url": "git+https://github.com/aaif-goose/goose.git"
},
"bin": {
"goose": "bin/goose"
},
"keywords": [
"goose",
"ai",

View file

@ -7,6 +7,9 @@
"type": "git",
"url": "git+https://github.com/aaif-goose/goose.git"
},
"bin": {
"goose": "bin/goose.exe"
},
"keywords": [
"goose",
"ai",

32
ui/pnpm-lock.yaml generated
View file

@ -667,6 +667,22 @@ importers:
typescript:
specifier: ~5.9.3
version: 5.9.3
optionalDependencies:
'@aaif/goose-binary-darwin-arm64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-darwin-arm64
'@aaif/goose-binary-darwin-x64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-darwin-x64
'@aaif/goose-binary-linux-arm64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-linux-arm64
'@aaif/goose-binary-linux-x64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-linux-x64
'@aaif/goose-binary-win32-x64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-win32-x64
text:
dependencies:
@ -716,22 +732,6 @@ importers:
typescript:
specifier: ^5.7.0
version: 5.9.3
optionalDependencies:
'@aaif/goose-binary-darwin-arm64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-darwin-arm64
'@aaif/goose-binary-darwin-x64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-darwin-x64
'@aaif/goose-binary-linux-arm64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-linux-arm64
'@aaif/goose-binary-linux-x64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-linux-x64
'@aaif/goose-binary-win32-x64':
specifier: workspace:*
version: link:../goose-binary/goose-binary-win32-x64
packages:

View file

@ -79,7 +79,6 @@ apt-get install -y -qq --no-install-recommends \
echo "==> Compiling goose (this takes a while)..."
cargo build --release --bin goose
cp /build/target/release/goose /output/goose
chmod +x /output/goose
echo "==> Done"
'
@ -123,8 +122,7 @@ WORKDIR /build
COPY . .
RUN mkdir -p /output && \
cargo build --release --bin goose && \
cp target/release/goose /output/goose && \
chmod +x /output/goose
cp target/release/goose /output/goose
DEOF
# Build in Docker and extract the binary
@ -141,7 +139,6 @@ DEOF
docker cp "${cid}:/output/goose" "${pkg_dir}/goose"
docker rm "${cid}" >/dev/null
docker rmi "${iid}" >/dev/null 2>&1 || true
chmod +x "${pkg_dir}/goose"
rm -rf "${ctx}"

View file

@ -17,6 +17,16 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./node": {
"types": "./dist/resolve-binary.d.ts",
"default": "./dist/resolve-binary.js"
}
},
"files": [
"dist"
],
@ -35,6 +45,13 @@
"peerDependencies": {
"@agentclientprotocol/sdk": "*"
},
"optionalDependencies": {
"@aaif/goose-binary-darwin-arm64": "workspace:*",
"@aaif/goose-binary-darwin-x64": "workspace:*",
"@aaif/goose-binary-linux-arm64": "workspace:*",
"@aaif/goose-binary-linux-x64": "workspace:*",
"@aaif/goose-binary-win32-x64": "workspace:*"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@hey-api/openapi-ts": "^0.92.3",

View file

@ -0,0 +1,43 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
const PLATFORMS: Record<string, string> = {
"darwin-arm64": "@aaif/goose-binary-darwin-arm64",
"darwin-x64": "@aaif/goose-binary-darwin-x64",
"linux-arm64": "@aaif/goose-binary-linux-arm64",
"linux-x64": "@aaif/goose-binary-linux-x64",
"win32-x64": "@aaif/goose-binary-win32-x64",
};
/**
* Resolves the path to the goose binary.
*
* Resolution order:
* 1. `GOOSE_BINARY` environment variable (explicit override)
* 2. Platform-specific `@aaif/goose-binary-*` optional dependency
*
* @throws if no binary can be found
*/
export function resolveGooseBinary(): string {
const envBinary = process.env.GOOSE_BINARY;
if (envBinary) return envBinary;
const key = `${process.platform}-${process.arch}`;
const pkg = PLATFORMS[key];
if (!pkg) {
throw new Error(
`No goose binary available for ${key}. Set GOOSE_BINARY to the path of a goose binary.`,
);
}
try {
const require = createRequire(import.meta.url);
const pkgDir = dirname(require.resolve(`${pkg}/package.json`));
const binName = process.platform === "win32" ? "goose.exe" : "goose";
return join(pkgDir, "bin", binName);
} catch {
throw new Error(
`goose binary package ${pkg} is not installed. Set GOOSE_BINARY or install the native package.`,
);
}
}

1
ui/text/.gitignore vendored
View file

@ -1,3 +1,2 @@
dist
node_modules
server-binary.json

View file

@ -19,15 +19,11 @@
"goose": "dist/tui.js"
},
"files": [
"dist",
"scripts/postinstall.mjs",
"server-binary.json"
"dist"
],
"scripts": {
"build": "tsc",
"dev:binary": "node scripts/dev-binary.mjs",
"start": "npm run dev:binary && tsx src/tui.tsx",
"postinstall": "node scripts/postinstall.mjs",
"start": "node scripts/dev-start.mjs",
"lint": "tsc --noEmit"
},
"dependencies": {
@ -41,13 +37,6 @@
"meow": "^13.2.0",
"react": "^19.2.4"
},
"optionalDependencies": {
"@aaif/goose-binary-darwin-arm64": "workspace:*",
"@aaif/goose-binary-darwin-x64": "workspace:*",
"@aaif/goose-binary-linux-arm64": "workspace:*",
"@aaif/goose-binary-linux-x64": "workspace:*",
"@aaif/goose-binary-win32-x64": "workspace:*"
},
"overrides": {
"react": "^19.2.4"
},

View file

@ -1,103 +0,0 @@
#!/usr/bin/env node
// For development: ensures the Rust binary is built from source and
// points server-binary.json to the local target/release/goose binary.
// Rebuilds if source files are newer than the binary.
import { writeFileSync, existsSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..", "..", "..");
const binaryName = process.platform === "win32" ? "goose.exe" : "goose";
const binaryPath = join(projectRoot, "target", "release", binaryName);
// Verify we're in a development environment with Cargo.toml
const cargoToml = join(projectRoot, "Cargo.toml");
if (!existsSync(cargoToml)) {
console.error("Error: Not in a Rust workspace (Cargo.toml not found)");
console.error("This script is for development only. In production, use the prebuilt binaries.");
process.exit(1);
}
function needsRebuild() {
if (!existsSync(binaryPath)) {
console.log("Binary not found, needs build");
return true;
}
const binaryMtime = statSync(binaryPath).mtimeMs;
// Check if any Rust source files are newer than the binary
const cargoLock = join(projectRoot, "Cargo.lock");
if (existsSync(cargoToml) && statSync(cargoToml).mtimeMs > binaryMtime) {
console.log("Cargo.toml changed, needs rebuild");
return true;
}
if (existsSync(cargoLock) && statSync(cargoLock).mtimeMs > binaryMtime) {
console.log("Cargo.lock changed, needs rebuild");
return true;
}
// Check if goose-acp crate sources are newer than the binary
const acpDir = join(projectRoot, "crates", "goose-acp");
if (existsSync(acpDir)) {
const result = spawnSync(
"find",
[acpDir, "-type", "f", "(", "-name", "*.rs", "-o", "-name", "Cargo.toml", ")", "-newer", binaryPath],
{ encoding: "utf-8" },
);
const changed = (result.stdout ?? "").trim();
if (changed) {
const first = changed.split("\n")[0];
console.log(`goose-acp changed (e.g. ${first}), needs rebuild`);
return true;
}
}
return false;
}
function buildBinary() {
console.log("Building goose-cli from source...");
const result = spawnSync(
"cargo",
["build", "--release", "-p", "goose-cli"],
{
cwd: projectRoot,
stdio: "inherit",
}
);
if (result.error) {
console.error(`Failed to build: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
console.error(`Build failed with exit code ${result.status}`);
process.exit(1);
}
console.log(`Built goose binary at ${binaryPath}`);
}
// Main logic
if (needsRebuild()) {
buildBinary();
} else {
console.log("Binary is up to date, skipping build");
}
// Write the server-binary.json to point to the local build
const outDir = join(__dirname, "..");
writeFileSync(
join(outDir, "server-binary.json"),
JSON.stringify({ binaryPath }, null, 2) + "\n",
);
console.log(`Using local goose binary at ${binaryPath}`);

View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
// Development entrypoint: ensures a goose binary is available, then launches
// the TUI via tsx. Skips the cargo build if GOOSE_BINARY is already set.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..", "..", "..");
if (!process.env.GOOSE_BINARY) {
const binName = process.platform === "win32" ? "goose.exe" : "goose";
const binaryPath = join(repoRoot, "target", "debug", binName);
console.log("Building goose (debug)…");
execFileSync("cargo", ["build", "-p", "goose-cli"], {
cwd: repoRoot,
stdio: "inherit",
});
if (!existsSync(binaryPath)) {
console.error(`Build succeeded but binary not found at ${binaryPath}`);
process.exit(1);
}
process.env.GOOSE_BINARY = binaryPath;
}
execFileSync("tsx", [join(__dirname, "..", "src", "tui.tsx"), ...process.argv.slice(2)], {
cwd: process.cwd(),
stdio: "inherit",
env: process.env,
});

View file

@ -1,63 +0,0 @@
#!/usr/bin/env node
// Resolves the path to the goose binary from the platform-specific
// optional dependency. Writes the result to a JSON file that the CLI reads at
// startup so it can spawn the server automatically.
import { writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const PLATFORMS = {
"darwin-arm64": "@aaif/goose-binary-darwin-arm64",
"darwin-x64": "@aaif/goose-binary-darwin-x64",
"linux-arm64": "@aaif/goose-binary-linux-arm64",
"linux-x64": "@aaif/goose-binary-linux-x64",
"win32-x64": "@aaif/goose-binary-win32-x64",
};
const key = `${process.platform}-${process.arch}`;
const pkg = PLATFORMS[key];
if (!pkg) {
console.warn(
`@aaif/goose: no prebuilt goose binary for ${key}. ` +
`You will need to provide a server URL manually with --server.`,
);
process.exit(0);
}
let binaryPath;
try {
// Resolve the package directory, then point at the binary inside it
const pkgDir = dirname(require.resolve(`${pkg}/package.json`));
const binName = process.platform === "win32" ? "goose.exe" : "goose";
binaryPath = join(pkgDir, "bin", binName);
} catch {
// The optional dependency wasn't installed (e.g. wrong platform). That's fine.
console.warn(
`@aaif/goose: optional dependency ${pkg} not installed. ` +
`You will need to provide a server URL manually with --server.`,
);
process.exit(0);
}
// Ensure the binary is executable (npm may strip permissions during packaging)
if (process.platform !== "win32") {
try {
chmodSync(binaryPath, 0o755);
} catch {}
}
const outDir = join(__dirname, "..");
mkdirSync(outDir, { recursive: true });
writeFileSync(
join(outDir, "server-binary.json"),
JSON.stringify({ binaryPath }, null, 2) + "\n",
);
console.log(`@aaif/goose: found native goose binary at ${binaryPath}`);

View file

@ -5,9 +5,6 @@ import { MultilineInput } from "ink-multiline-input";
import meow from "meow";
import { spawn } from "node:child_process";
import { Readable, Writable } from "node:stream";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type {
SessionNotification,
RequestPermissionRequest,
@ -19,6 +16,7 @@ import type {
} from "@agentclientprotocol/sdk";
import { ndJsonStream } from "@agentclientprotocol/sdk";
import { GooseClient } from "@aaif/goose-sdk";
import { resolveGooseBinary } from "@aaif/goose-sdk/node";
import Onboarding from "./onboarding.js";
import ConfigureScreen, { ConfigureIntent } from "./configure.js";
import ExtensionsManager from "./extensions.js";
@ -35,7 +33,15 @@ import {
import { Header } from "./components/Header.js";
import { Rule } from "./components/Rule.js";
import { isErrorStatus, formatError } from "./utils.js";
import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, RULE_COLOR } from "./colors.js";
import {
CRANBERRY,
TEAL,
GOLD,
TEXT_PRIMARY,
TEXT_SECONDARY,
TEXT_DIM,
RULE_COLOR,
} from "./colors.js";
import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
import {
PASTE_THRESHOLD,
@ -137,7 +143,9 @@ const InputBar = React.memo(function InputBar({
flexShrink={0}
>
<Box>
<Text color={CRANBERRY} bold>{" "}</Text>
<Text color={CRANBERRY} bold>
{" "}
</Text>
{isPasteMode ? (
<Box width={contentWidth} justifyContent="space-between">
<Box width={Math.max(contentWidth - 20, 10)}>
@ -145,10 +153,16 @@ const InputBar = React.memo(function InputBar({
{(() => {
const text = pastedFull;
const availableWidth = Math.max(contentWidth - 20, 10);
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
const flat = text
.replace(/\n/g, " ")
.replace(/\s+/g, " ")
.trim();
if (flat.length <= availableWidth) return flat;
const suffix = ` (${flat.length.toLocaleString()} chars)`;
const previewLen = Math.max(availableWidth - suffix.length - 1, 5);
const previewLen = Math.max(
availableWidth - suffix.length - 1,
5,
);
return flat.slice(0, previewLen) + "…" + suffix;
})()}
</Text>
@ -170,10 +184,13 @@ const InputBar = React.memo(function InputBar({
newline: (key) => key.return && key.ctrl,
}}
useCustomInput={(handler, isActive) => {
useInput((ch, key) => {
if (key.shift && (key.upArrow || key.downArrow)) return;
handler(ch, key);
}, { isActive });
useInput(
(ch, key) => {
if (key.shift && (key.upArrow || key.downArrow)) return;
handler(ch, key);
},
{ isActive },
);
}}
/>
{scrollHint && <Text color={TEXT_DIM}>shift+ history</Text>}
@ -227,36 +244,64 @@ function buildContentLines({
const safeWidth = Math.max(width, 20);
const turnId = String(turnIndex);
lines.push(...renderUserPrompt(turn.userText, safeWidth, turnId, (text: string, availableWidth: number) => {
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
const safeWidth = Math.max(availableWidth, 10);
const maxPreview = Math.max(safeWidth - 30, Math.min(SENT_PREVIEW_LEN, safeWidth - 10));
if (flat.length <= maxPreview + 10) {
return (
<Box width={safeWidth}>
<Text color={TEXT_PRIMARY} bold wrap="wrap">{flat}</Text>
</Box>
);
}
const preview = flat.slice(0, maxPreview) + "…";
const remaining = flat.length - maxPreview;
return (
<Box width={safeWidth}>
<Text color={TEXT_PRIMARY} bold wrap="wrap">{preview}</Text>
<Text color={TEXT_DIM}> ({remaining.toLocaleString()} more chars)</Text>
</Box>
);
}));
lines.push(
...renderUserPrompt(
turn.userText,
safeWidth,
turnId,
(text: string, availableWidth: number) => {
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
const safeWidth = Math.max(availableWidth, 10);
const maxPreview = Math.max(
safeWidth - 30,
Math.min(SENT_PREVIEW_LEN, safeWidth - 10),
);
if (flat.length <= maxPreview + 10) {
return (
<Box width={safeWidth}>
<Text color={TEXT_PRIMARY} bold wrap="wrap">
{flat}
</Text>
</Box>
);
}
const preview = flat.slice(0, maxPreview) + "…";
const remaining = flat.length - maxPreview;
return (
<Box width={safeWidth}>
<Text color={TEXT_PRIMARY} bold wrap="wrap">
{preview}
</Text>
<Text color={TEXT_DIM}>
{" "}
({remaining.toLocaleString()} more chars)
</Text>
</Box>
);
},
),
);
// Process response items
const hasToolCalls = turn.responseItems.some((it) => it.itemType === "tool_call");
const hasToolCalls = turn.responseItems.some(
(it) => it.itemType === "tool_call",
);
let tcIdx = 0;
for (let i = 0; i < turn.responseItems.length; i++) {
const item = turn.responseItems[i]!;
if (item.itemType === "tool_call") {
lines.push(...renderToolCallItem(item, i, safeWidth, toolCallsExpanded, tcIdx === 0, hasToolCalls));
lines.push(
...renderToolCallItem(
item,
i,
safeWidth,
toolCallsExpanded,
tcIdx === 0,
hasToolCalls,
),
);
tcIdx++;
} else if (item.itemType === "error") {
lines.push(...renderErrorItem(item, i, safeWidth));
@ -280,7 +325,12 @@ function buildContentLines({
const hRule = "─".repeat(Math.max(dialogWidth - 2, 0));
const permissionLines: React.ReactElement[] = [];
permissionLines.push(emptyLine(`pm-gap-${perm.toolTitle.slice(0, 10).replace(/[^a-zA-Z0-9]/g, '')}`, fullWidth));
permissionLines.push(
emptyLine(
`pm-gap-${perm.toolTitle.slice(0, 10).replace(/[^a-zA-Z0-9]/g, "")}`,
fullWidth,
),
);
permissionLines.push(
<Box key="pm-t" width={fullWidth} height={1}>
@ -292,15 +342,27 @@ function buildContentLines({
permissionLines.push(
<Box key={key} width={fullWidth} height={1}>
<Text color={GOLD}> </Text>
<Box width={innerWidth} height={1}>{content}</Box>
<Box width={innerWidth} height={1}>
{content}
</Box>
<Text color={GOLD}> </Text>
</Box>,
);
};
row("pm-title", <Text color={GOLD} bold>🔒 Permission required</Text>);
row(
"pm-title",
<Text color={GOLD} bold>
🔒 Permission required
</Text>,
);
row("pm-g1", <Text> </Text>);
row("pm-tool", <Text wrap="truncate-end" color={TEXT_PRIMARY}>{perm.toolTitle}</Text>);
row(
"pm-tool",
<Text wrap="truncate-end" color={TEXT_PRIMARY}>
{perm.toolTitle}
</Text>,
);
row("pm-g2", <Text> </Text>);
for (let i = 0; i < perm.options.length; i++) {
@ -308,18 +370,22 @@ function buildContentLines({
const k = PERMISSION_KEYS[opt.kind] ?? String(i + 1);
const label = PERMISSION_LABELS[opt.kind] ?? opt.name;
const active = i === selectedIdx;
row(`pm-o${i}`, (
row(
`pm-o${i}`,
<>
<Text color={active ? GOLD : RULE_COLOR}>{active ? "▸ " : " "}</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_SECONDARY} bold={active}>
[{k}] {label}
</Text>
</>
));
</>,
);
}
row("pm-g3", <Text> </Text>);
row("pm-help", <Text color={TEXT_DIM}> select · enter confirm · esc cancel</Text>);
row(
"pm-help",
<Text color={TEXT_DIM}> select · enter confirm · esc cancel</Text>,
);
permissionLines.push(
<Box key="pm-b" width={fullWidth} height={1}>
@ -367,9 +433,11 @@ const Viewport = React.memo(function Viewport({
const above = startIdx;
elements.push(
<Box key="si-up" width={width} height={1} justifyContent="center">
{above > 0
? <Text color={TEXT_DIM}> {above} more ()</Text>
: <Text> </Text>}
{above > 0 ? (
<Text color={TEXT_DIM}> {above} more ()</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}
@ -383,9 +451,11 @@ const Viewport = React.memo(function Viewport({
const below = total - endIdx;
elements.push(
<Box key="si-dn" width={width} height={1} justifyContent="center">
{below > 0
? <Text color={TEXT_DIM}> {below} more ()</Text>
: <Text> </Text>}
{below > 0 ? (
<Text color={TEXT_DIM}> {below} more ()</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}
@ -394,7 +464,11 @@ const Viewport = React.memo(function Viewport({
const constrainedHeight = Math.max(height, 1);
return (
<Box flexDirection="column" height={constrainedHeight} width={constrainedWidth}>
<Box
flexDirection="column"
height={constrainedHeight}
width={constrainedWidth}
>
{elements}
</Box>
);
@ -438,11 +512,15 @@ const SplashScreen = React.memo(function SplashScreen({
{topPad > 0 && <Box height={topPad} />}
<Box flexDirection="column" alignItems="center">
{frame.map((line, i) => (
<Text key={i} color={TEXT_PRIMARY}>{line}</Text>
<Text key={i} color={TEXT_PRIMARY}>
{line}
</Text>
))}
</Box>
<Box marginTop={1}>
<Text color={TEXT_PRIMARY} bold>goose</Text>
<Text color={TEXT_PRIMARY} bold>
goose
</Text>
</Box>
<Box alignItems="center">
<Text color={TEXT_DIM}>your on-machine AI agent</Text>
@ -484,7 +562,9 @@ function App({
const [scrollOffset, setScrollOffset] = useState(0);
const [pastedFull, setPastedFull] = useState<string | null>(null);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
type Overlay = { screen: "configure"; intent: ConfigureIntent } | { screen: "extensions" };
type Overlay =
| { screen: "configure"; intent: ConfigureIntent }
| { screen: "extensions" };
const [overlay, setOverlay] = useState<Overlay | null>(null);
const clientRef = useRef<GooseClient | null>(null);
@ -527,13 +607,22 @@ function App({
if (lastItem.content.type === "text") {
newItems[newItems.length - 1] = {
...lastItem,
content: { ...lastItem.content, text: lastItem.content.text + text },
content: {
...lastItem.content,
text: lastItem.content.text + text,
},
};
} else {
newItems.push({ itemType: "content_chunk", content: { type: "text", text } });
newItems.push({
itemType: "content_chunk",
content: { type: "text", text },
});
}
} else {
newItems.push({ itemType: "content_chunk", content: { type: "text", text } });
newItems.push({
itemType: "content_chunk",
content: { type: "text", text },
});
}
return [...prev.slice(0, -1), { ...last, responseItems: newItems }];
@ -605,7 +694,9 @@ function App({
if (option === "cancelled") {
resolve({ outcome: { outcome: "cancelled" } });
} else {
resolve({ outcome: { outcome: "selected", optionId: option.optionId } });
resolve({
outcome: { outcome: "selected", optionId: option.optionId },
});
}
setPendingPermission(null);
setPermissionIdx(0);
@ -665,29 +756,32 @@ function App({
[executePrompt, processQueue],
);
const createSession = useCallback(async (client: GooseClient) => {
setStatus("creating session…");
setLoading(true);
try {
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
sessionIdRef.current = session.sessionId;
setLoading(false);
setStatus("ready");
const createSession = useCallback(
async (client: GooseClient) => {
setStatus("creating session…");
setLoading(true);
try {
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
sessionIdRef.current = session.sessionId;
setLoading(false);
setStatus("ready");
if (initialPrompt && !sentInitialPrompt.current) {
sentInitialPrompt.current = true;
await sendPrompt(initialPrompt);
setTimeout(() => exit(), 100);
if (initialPrompt && !sentInitialPrompt.current) {
sentInitialPrompt.current = true;
await sendPrompt(initialPrompt);
setTimeout(() => exit(), 100);
}
} catch (e: unknown) {
const errorMsg = formatError(e);
setStatus(`failed: ${errorMsg}`);
setLoading(false);
}
} catch (e: unknown) {
const errorMsg = formatError(e);
setStatus(`failed: ${errorMsg}`);
setLoading(false);
}
}, [initialPrompt, sendPrompt, exit]);
},
[initialPrompt, sendPrompt, exit],
);
const handleOnboardingComplete = useCallback(() => {
setNeedsOnboarding(false);
@ -751,8 +845,11 @@ function App({
setStatus("checking provider…");
let hasProvider = false;
try {
const resp = await client.goose.GooseConfigRead({ key: "GOOSE_PROVIDER" });
hasProvider = resp.value != null && resp.value !== "" && resp.value !== "null";
const resp = await client.goose.GooseConfigRead({
key: "GOOSE_PROVIDER",
});
hasProvider =
resp.value != null && resp.value !== "" && resp.value !== "null";
} catch {
hasProvider = false;
}
@ -774,10 +871,17 @@ function App({
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [
serverConnection, initialPrompt, createSession,
appendAgent, handleToolCall, handleToolCallUpdate, exit,
serverConnection,
initialPrompt,
createSession,
appendAgent,
handleToolCall,
handleToolCallUpdate,
exit,
]);
const handleSubmit = useCallback(
@ -800,86 +904,118 @@ function App({
[loading, sendPrompt],
);
useInput((ch, key) => {
if (key.escape || (ch === "c" && key.ctrl)) {
if (pendingPermission) { resolvePermission("cancelled"); return; }
if (key.escape && pastedFull !== null) return;
exit();
}
useInput(
(ch, key) => {
if (key.escape || (ch === "c" && key.ctrl)) {
if (pendingPermission) {
resolvePermission("cancelled");
return;
}
if (key.escape && pastedFull !== null) return;
exit();
}
if (!loading && !pendingPermission && sessionIdRef.current) {
if (key.ctrl && (ch === "p" || ch === "P")) { setOverlay({ screen: "configure", intent: "provider" }); return; }
if (key.ctrl && (ch === "m" || ch === "M")) { setOverlay({ screen: "configure", intent: "model" }); return; }
if (key.ctrl && (ch === "e" || ch === "E")) { setOverlay({ screen: "extensions" }); return; }
if (ch === "g" && key.ctrl) { setOverlay({ screen: "configure", intent: "provider" }); return; }
}
if (!loading && !pendingPermission && sessionIdRef.current) {
if (key.ctrl && (ch === "p" || ch === "P")) {
setOverlay({ screen: "configure", intent: "provider" });
return;
}
if (key.ctrl && (ch === "m" || ch === "M")) {
setOverlay({ screen: "configure", intent: "model" });
return;
}
if (key.ctrl && (ch === "e" || ch === "E")) {
setOverlay({ screen: "extensions" });
return;
}
if (ch === "g" && key.ctrl) {
setOverlay({ screen: "configure", intent: "provider" });
return;
}
}
if (pendingPermission) {
const opts = pendingPermission.options;
if (key.upArrow) { setPermissionIdx((i) => (i - 1 + opts.length) % opts.length); return; }
if (key.downArrow) { setPermissionIdx((i) => (i + 1) % opts.length); return; }
if (key.return) {
const sel = opts[permissionIdx];
if (sel) resolvePermission({ optionId: sel.optionId });
if (pendingPermission) {
const opts = pendingPermission.options;
if (key.upArrow) {
setPermissionIdx((i) => (i - 1 + opts.length) % opts.length);
return;
}
if (key.downArrow) {
setPermissionIdx((i) => (i + 1) % opts.length);
return;
}
if (key.return) {
const sel = opts[permissionIdx];
if (sel) resolvePermission({ optionId: sel.optionId });
return;
}
const keyMap: Record<string, string> = {
y: "allow_once",
a: "allow_always",
n: "reject_once",
N: "reject_always",
};
const kind = keyMap[ch];
if (kind) {
const m = opts.find((o) => o.kind === kind);
if (m) resolvePermission({ optionId: m.optionId });
}
return;
}
const keyMap: Record<string, string> = {
y: "allow_once", a: "allow_always", n: "reject_once", N: "reject_always",
};
const kind = keyMap[ch];
if (kind) {
const m = opts.find((o) => o.kind === kind);
if (m) resolvePermission({ optionId: m.optionId });
const viewingHistory =
viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1;
const multilineOwnsArrows =
!pendingPermission &&
!initialPrompt &&
!viewingHistory &&
pastedFull === null;
if (key.tab) {
const idx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx;
const t = turns[idx];
if (t && t.responseItems.some((it) => it.itemType === "tool_call")) {
setToolCallsExpanded((prev) => !prev);
}
return;
}
return;
}
const viewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1;
const multilineOwnsArrows =
!pendingPermission && !initialPrompt && !viewingHistory && pastedFull === null;
if (key.tab) {
const idx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx;
const t = turns[idx];
if (t && t.responseItems.some((it) => it.itemType === "tool_call")) {
setToolCallsExpanded((prev) => !prev);
if (key.upArrow && !key.shift) {
if (!multilineOwnsArrows) setScrollOffset((prev) => prev + 3);
return;
}
if (key.downArrow && !key.shift) {
if (!multilineOwnsArrows)
setScrollOffset((prev) => Math.max(prev - 3, 0));
return;
}
return;
}
if (key.upArrow && !key.shift) {
if (!multilineOwnsArrows) setScrollOffset((prev) => prev + 3);
return;
}
if (key.downArrow && !key.shift) {
if (!multilineOwnsArrows) setScrollOffset((prev) => Math.max(prev - 3, 0));
return;
}
if (key.upArrow && key.shift) {
setTurns((cur) => {
if (cur.length <= 1) return cur;
setViewTurnIdx((prev) => {
const eff = prev === -1 ? cur.length - 1 : prev;
return Math.max(eff - 1, 0);
if (key.upArrow && key.shift) {
setTurns((cur) => {
if (cur.length <= 1) return cur;
setViewTurnIdx((prev) => {
const eff = prev === -1 ? cur.length - 1 : prev;
return Math.max(eff - 1, 0);
});
return cur;
});
return cur;
});
return;
}
if (key.downArrow && key.shift) {
setTurns((cur) => {
if (cur.length <= 1) return cur;
setViewTurnIdx((prev) => {
if (prev === -1) return -1;
const next = prev + 1;
return next >= cur.length ? -1 : next;
return;
}
if (key.downArrow && key.shift) {
setTurns((cur) => {
if (cur.length <= 1) return cur;
setViewTurnIdx((prev) => {
if (prev === -1) return -1;
const next = prev + 1;
return next >= cur.length ? -1 : next;
});
return cur;
});
return cur;
});
return;
}
}, { isActive: !needsOnboarding && !overlay });
return;
}
},
{ isActive: !needsOnboarding && !overlay },
);
const PAD_X = 2;
const PAD_Y = 1;
@ -891,7 +1027,8 @@ function App({
const currentTurn = turns[effectiveTurnIdx];
const isViewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1;
const isLatest = !isViewingHistory;
const showInputBar = !pendingPermission && !initialPrompt && !isViewingHistory;
const showInputBar =
!pendingPermission && !initialPrompt && !isViewingHistory;
const headerH = 2;
const isPasteMode = pastedFull !== null;
@ -924,11 +1061,7 @@ function App({
if (needsOnboarding && clientRef.current) {
return (
<Box
flexDirection="column"
width={safeTermWidth}
height={safeTermHeight}
>
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
<Onboarding
client={clientRef.current}
width={safeTermWidth}
@ -943,13 +1076,20 @@ function App({
if (overlay.screen === "configure") {
const intent = overlay.intent;
return (
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
<Box
flexDirection="column"
width={safeTermWidth}
height={safeTermHeight}
>
<ConfigureScreen
client={clientRef.current}
sessionId={sessionIdRef.current}
width={safeTermWidth}
height={safeTermHeight}
onComplete={() => { setOverlay(null); setStatus("ready"); }}
onComplete={() => {
setOverlay(null);
setStatus("ready");
}}
onCancel={() => setOverlay(null)}
initialIntent={intent}
/>
@ -957,7 +1097,11 @@ function App({
);
} else if (overlay.screen === "extensions") {
return (
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
<Box
flexDirection="column"
width={safeTermWidth}
height={safeTermHeight}
>
<ExtensionsManager
client={clientRef.current}
sessionId={sessionIdRef.current}
@ -1057,8 +1201,6 @@ const cli = meow(
},
);
let serverProcess: ReturnType<typeof spawn> | null = null;
async function runTextMode(serverConnection: Stream | string, prompt: string) {
@ -1077,7 +1219,9 @@ async function runTextMode(serverConnection: Stream | string, prompt: string) {
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> => {
// Auto-reject in text mode
const rejectOption = params.options.find(o => o.kind === "reject_once");
const rejectOption = params.options.find(
(o) => o.kind === "reject_once",
);
if (rejectOption) {
return {
outcome: { outcome: "selected", optionId: rejectOption.optionId },
@ -1119,29 +1263,7 @@ async function main() {
if (cli.flags.server) {
serverConnection = cli.flags.server;
} else {
const binary = (() => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(__dirname, "..", "server-binary.json"),
join(__dirname, "server-binary.json"),
];
for (const candidate of candidates) {
try {
const data = JSON.parse(readFileSync(candidate, "utf-8"));
return data.binaryPath ?? null;
} catch {
// not found here, try next
}
}
return null;
})();
if (!binary) {
console.error(
"No goose binary found. Use --server <url> or install the native package.",
);
process.exit(1);
}
const binary = resolveGooseBinary();
serverProcess = spawn(binary, ["acp"], {
stdio: ["pipe", "pipe", "ignore"],
detached: false,
@ -1152,8 +1274,12 @@ async function main() {
process.exit(1);
});
const output = Writable.toWeb(serverProcess.stdin!) as WritableStream<Uint8Array>;
const input = Readable.toWeb(serverProcess.stdout!) as ReadableStream<Uint8Array>;
const output = Writable.toWeb(
serverProcess.stdin!,
) as WritableStream<Uint8Array>;
const input = Readable.toWeb(
serverProcess.stdout!,
) as ReadableStream<Uint8Array>;
serverConnection = ndJsonStream(output, input);
}
@ -1180,8 +1306,14 @@ function cleanup() {
}
process.on("exit", cleanup);
process.on("SIGINT", () => { cleanup(); process.exit(0); });
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
main().catch((err) => {
console.error(err);