Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -14,6 +14,11 @@ export interface McpStdioServerConfig {
|
|||||||
command: string;
|
command: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, all tools from this server are always included in the prompt
|
||||||
|
* and never deferred behind tool search. Honored by the Claude provider.
|
||||||
|
*/
|
||||||
|
alwaysLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +28,11 @@ export interface McpHttpServerConfig {
|
|||||||
type: "http";
|
type: "http";
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, all tools from this server are always included in the prompt
|
||||||
|
* and never deferred behind tool search. Honored by the Claude provider.
|
||||||
|
*/
|
||||||
|
alwaysLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +42,11 @@ export interface McpSseServerConfig {
|
|||||||
type: "sse";
|
type: "sse";
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, all tools from this server are always included in the prompt
|
||||||
|
* and never deferred behind tool search. Honored by the Claude provider.
|
||||||
|
*/
|
||||||
|
alwaysLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -266,18 +266,21 @@ const McpStdioServerConfigSchema = z.object({
|
|||||||
command: z.string(),
|
command: z.string(),
|
||||||
args: z.array(z.string()).optional(),
|
args: z.array(z.string()).optional(),
|
||||||
env: z.record(z.string()).optional(),
|
env: z.record(z.string()).optional(),
|
||||||
|
alwaysLoad: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const McpHttpServerConfigSchema = z.object({
|
const McpHttpServerConfigSchema = z.object({
|
||||||
type: z.literal("http"),
|
type: z.literal("http"),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
|
alwaysLoad: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const McpSseServerConfigSchema = z.object({
|
const McpSseServerConfigSchema = z.object({
|
||||||
type: z.literal("sse"),
|
type: z.literal("sse"),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
|
alwaysLoad: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const McpServerConfigSchema = z.discriminatedUnion("type", [
|
const McpServerConfigSchema = z.discriminatedUnion("type", [
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export interface McpStdioServerConfig {
|
|||||||
command: string;
|
command: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, all tools from this server are always included in the prompt
|
||||||
|
* and never deferred behind tool search. Honored by the Claude provider.
|
||||||
|
*/
|
||||||
|
alwaysLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +29,11 @@ export interface McpHttpServerConfig {
|
|||||||
type: "http";
|
type: "http";
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, all tools from this server are always included in the prompt
|
||||||
|
* and never deferred behind tool search. Honored by the Claude provider.
|
||||||
|
*/
|
||||||
|
alwaysLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +43,11 @@ export interface McpSseServerConfig {
|
|||||||
type: "sse";
|
type: "sse";
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, all tools from this server are always included in the prompt
|
||||||
|
* and never deferred behind tool search. Honored by the Claude provider.
|
||||||
|
*/
|
||||||
|
alwaysLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ClaudeAgentClient,
|
ClaudeAgentClient,
|
||||||
convertClaudeHistoryEntry,
|
convertClaudeHistoryEntry,
|
||||||
normalizeClaudeAskUserQuestionUpdatedInput,
|
normalizeClaudeAskUserQuestionUpdatedInput,
|
||||||
|
toClaudeSdkMcpConfig,
|
||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
@@ -1674,3 +1675,63 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("toClaudeSdkMcpConfig", () => {
|
||||||
|
test("preserves alwaysLoad on stdio servers", () => {
|
||||||
|
expect(
|
||||||
|
toClaudeSdkMcpConfig({
|
||||||
|
type: "stdio",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "chrome-devtools-mcp@latest"],
|
||||||
|
alwaysLoad: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "stdio",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "chrome-devtools-mcp@latest"],
|
||||||
|
env: undefined,
|
||||||
|
alwaysLoad: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves alwaysLoad on http servers", () => {
|
||||||
|
expect(
|
||||||
|
toClaudeSdkMcpConfig({
|
||||||
|
type: "http",
|
||||||
|
url: "https://example.com/mcp",
|
||||||
|
headers: { Authorization: "Bearer x" },
|
||||||
|
alwaysLoad: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "http",
|
||||||
|
url: "https://example.com/mcp",
|
||||||
|
headers: { Authorization: "Bearer x" },
|
||||||
|
alwaysLoad: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves alwaysLoad on sse servers", () => {
|
||||||
|
expect(
|
||||||
|
toClaudeSdkMcpConfig({
|
||||||
|
type: "sse",
|
||||||
|
url: "https://example.com/sse",
|
||||||
|
alwaysLoad: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "sse",
|
||||||
|
url: "https://example.com/sse",
|
||||||
|
headers: undefined,
|
||||||
|
alwaysLoad: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves alwaysLoad undefined when not provided (preserves default deferral)", () => {
|
||||||
|
const result = toClaudeSdkMcpConfig({
|
||||||
|
type: "stdio",
|
||||||
|
command: "uvx",
|
||||||
|
args: ["markitdown-mcp"],
|
||||||
|
});
|
||||||
|
expect(result.type).toBe("stdio");
|
||||||
|
expect(result.alwaysLoad).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -800,7 +800,7 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServerConfig {
|
export function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServerConfig {
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case "stdio":
|
case "stdio":
|
||||||
return {
|
return {
|
||||||
@@ -808,18 +808,21 @@ function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServerConfig
|
|||||||
command: config.command,
|
command: config.command,
|
||||||
args: config.args,
|
args: config.args,
|
||||||
env: config.env,
|
env: config.env,
|
||||||
|
alwaysLoad: config.alwaysLoad,
|
||||||
};
|
};
|
||||||
case "http":
|
case "http":
|
||||||
return {
|
return {
|
||||||
type: "http",
|
type: "http",
|
||||||
url: config.url,
|
url: config.url,
|
||||||
headers: config.headers,
|
headers: config.headers,
|
||||||
|
alwaysLoad: config.alwaysLoad,
|
||||||
};
|
};
|
||||||
case "sse":
|
case "sse":
|
||||||
return {
|
return {
|
||||||
type: "sse",
|
type: "sse",
|
||||||
url: config.url,
|
url: config.url,
|
||||||
headers: config.headers,
|
headers: config.headers,
|
||||||
|
alwaysLoad: config.alwaysLoad,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error("Unhandled MCP server config type");
|
throw new Error("Unhandled MCP server config type");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createWorktree as createWorktreePrimitive,
|
createWorktree as createWorktreePrimitive,
|
||||||
deriveWorktreeProjectHash,
|
deriveWorktreeProjectHash,
|
||||||
deletePaseoWorktree,
|
deletePaseoWorktree,
|
||||||
|
InvalidGitBranchNameError,
|
||||||
getScriptConfigs,
|
getScriptConfigs,
|
||||||
getWorktreeSetupCommands,
|
getWorktreeSetupCommands,
|
||||||
getWorktreeTerminalSpecs,
|
getWorktreeTerminalSpecs,
|
||||||
@@ -270,6 +271,26 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" });
|
expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
|
||||||
|
execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir });
|
||||||
|
|
||||||
|
const result = await createLegacyWorktreeForTest({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "release-worktree",
|
||||||
|
source: { kind: "checkout-branch", branchName: "release/1.1.15" },
|
||||||
|
runSetup: true,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existsSync(result.worktreePath)).toBe(true);
|
||||||
|
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||||
|
cwd: result.worktreePath,
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
expect(currentBranch).toBe("release/1.1.15");
|
||||||
|
});
|
||||||
|
|
||||||
it("throws a typed error when checking out a branch already checked out in the main repo", async () => {
|
it("throws a typed error when checking out a branch already checked out in the main repo", async () => {
|
||||||
let caughtError: unknown;
|
let caughtError: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -340,6 +361,51 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||||
|
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
||||||
|
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
||||||
|
|
||||||
|
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
||||||
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
||||||
|
execFileSync("git", ["checkout", "-b", "Feature.X"], { cwd: remoteCloneDir });
|
||||||
|
writeFileSync(join(remoteCloneDir, "file.txt"), "from-uppercase-pr\n");
|
||||||
|
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
|
||||||
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "uppercase pr branch"], {
|
||||||
|
cwd: remoteCloneDir,
|
||||||
|
});
|
||||||
|
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
execFileSync("git", ["push", "origin", "Feature.X"], { cwd: remoteCloneDir });
|
||||||
|
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/43/head", prHead]);
|
||||||
|
|
||||||
|
const result = await createLegacyWorktreeForTest({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "pr-43",
|
||||||
|
source: {
|
||||||
|
kind: "checkout-github-pr",
|
||||||
|
githubPrNumber: 43,
|
||||||
|
headRef: "Feature.X",
|
||||||
|
baseRefName: "main",
|
||||||
|
},
|
||||||
|
runSetup: true,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe(
|
||||||
|
"from-uppercase-pr\n",
|
||||||
|
);
|
||||||
|
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||||
|
cwd: result.worktreePath,
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
expect(currentBranch).toBe("Feature.X");
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers origin/{branch} over local {branch} when both exist", async () => {
|
it("prefers origin/{branch} over local {branch} when both exist", async () => {
|
||||||
const remoteDir = join(tempDir, "remote.git");
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||||
@@ -425,6 +491,42 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
).rejects.toThrow("Invalid branch name");
|
).rejects.toThrow("Invalid branch name");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws a typed error when checking out an invalid existing branch name", async () => {
|
||||||
|
let caughtError: unknown;
|
||||||
|
try {
|
||||||
|
await createLegacyWorktreeForTest({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "invalid-existing-branch",
|
||||||
|
source: { kind: "checkout-branch", branchName: "bad..name" },
|
||||||
|
runSetup: true,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError);
|
||||||
|
expect((caughtError as InvalidGitBranchNameError).branchName).toBe("bad..name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a typed error when checking out a ref that is valid but not a branch name", async () => {
|
||||||
|
let caughtError: unknown;
|
||||||
|
try {
|
||||||
|
await createLegacyWorktreeForTest({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "invalid-option-like-branch",
|
||||||
|
source: { kind: "checkout-branch", branchName: "-bad" },
|
||||||
|
runSetup: true,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError);
|
||||||
|
expect((caughtError as InvalidGitBranchNameError).branchName).toBe("-bad");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles branch name collision by adding suffix", async () => {
|
it("handles branch name collision by adding suffix", async () => {
|
||||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||||
// Create a branch named "hello" first
|
// Create a branch named "hello" first
|
||||||
|
|||||||
@@ -206,6 +206,16 @@ export class UnknownBranchError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvalidGitBranchNameError extends Error {
|
||||||
|
readonly branchName: string;
|
||||||
|
|
||||||
|
constructor(branchName: string) {
|
||||||
|
super(`Invalid branch name: Git rejected ref name '${branchName}'`);
|
||||||
|
this.name = "InvalidGitBranchNameError";
|
||||||
|
this.branchName = branchName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ReadPaseoConfigResult =
|
export type ReadPaseoConfigResult =
|
||||||
| { ok: true; config: PaseoConfig | null }
|
| { ok: true; config: PaseoConfig | null }
|
||||||
| { ok: false; configPath: string; error: unknown };
|
| { ok: false; configPath: string; error: unknown };
|
||||||
@@ -1261,7 +1271,7 @@ async function resolveWorktreeSourcePlan({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "checkout-branch": {
|
case "checkout-branch": {
|
||||||
validateWorktreeBranchName(source.branchName);
|
await validateExistingWorktreeBranchName(cwd, source.branchName);
|
||||||
if (!(await localBranchExists(cwd, source.branchName))) {
|
if (!(await localBranchExists(cwd, source.branchName))) {
|
||||||
try {
|
try {
|
||||||
await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], {
|
await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], {
|
||||||
@@ -1284,7 +1294,7 @@ async function resolveWorktreeSourcePlan({
|
|||||||
}
|
}
|
||||||
case "checkout-github-pr": {
|
case "checkout-github-pr": {
|
||||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
||||||
validateWorktreeBranchName(localBranchCandidate);
|
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
|
||||||
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
||||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||||
await runGitCommand(
|
await runGitCommand(
|
||||||
@@ -1350,6 +1360,17 @@ function validateWorktreeBranchName(branchName: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateExistingWorktreeBranchName(cwd: string, branchName: string): Promise<void> {
|
||||||
|
const result = await runGitCommand(["check-ref-format", "--branch", branchName], {
|
||||||
|
cwd,
|
||||||
|
timeout: 30_000,
|
||||||
|
acceptExitCodes: [0, 1, 128],
|
||||||
|
});
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new InvalidGitBranchNameError(branchName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRequiredBaseBranch(baseBranch: string): string {
|
function normalizeRequiredBaseBranch(baseBranch: string): string {
|
||||||
const normalizedBaseBranch = normalizeBaseRefName(baseBranch);
|
const normalizedBaseBranch = normalizeBaseRefName(baseBranch);
|
||||||
if (!normalizedBaseBranch) {
|
if (!normalizedBaseBranch) {
|
||||||
|
|||||||
Reference in New Issue
Block a user