diff --git a/apps/docs/content/docs/cli/cli-reference.mdx b/apps/docs/content/docs/cli/cli-reference.mdx
index 4964e8bd371..40872a25677 100644
--- a/apps/docs/content/docs/cli/cli-reference.mdx
+++ b/apps/docs/content/docs/cli/cli-reference.mdx
@@ -635,6 +635,54 @@ superset workspaces create \
```
+", required: true, description: "Project ID." },
+ { flag: "--path ", required: true, description: "Absolute path of the existing worktree on the target host." },
+ { flag: "--branch ", required: true, description: "Branch checked out in that worktree." },
+ { flag: "--name ", description: "Workspace name. Defaults to the worktree directory name." },
+ { flag: "--base-branch ", description: "Branch to record as the compare base for this workspace." },
+ { flag: "--host ", description: "Target host machineId. Mutually exclusive with `--local`; one is required." },
+ { flag: "--local", description: "Target this machine. Mutually exclusive with `--host`; one is required." },
+ ]}
+ output={`{
+ workspace: Workspace;
+ terminals: Array<{ terminalId: string; label?: string }>;
+ warnings: string[];
+ branchMismatch: boolean;
+}`}
+>
+Register a git worktree that **already exists** on the target host as a
+workspace, instead of creating a new one. Use this when a script prepared the
+branch and directory under its own naming rules and Superset only needs to
+manage sessions for it — the CLI counterpart of Project Settings → Import in
+the desktop app.
+
+`--path` is interpreted on the target host, so it must be absolute, and the
+worktree must already be registered in that project's `git worktree list`
+(run `git worktree add` first). A path that belongs to a different project's
+repo is rejected.
+
+The host reads the branch actually checked out at `--path` rather than
+trusting `--branch`. If the two disagree, the returned workspace keeps the
+real branch and `branchMismatch` is `true`.
+
+Importing is idempotent: running it again for the same path reuses the
+existing workspace, and a workspace whose branch was renamed in place is
+re-pointed at the new branch rather than duplicated.
+
+```bash
+git -C ~/code/repo worktree add -b eng-123/login-fix ~/code/repo/.worktrees/eng-123
+
+superset workspaces import \
+ --project prj_… \
+ --path ~/code/repo/.worktrees/eng-123 \
+ --branch eng-123/login-fix \
+ --local
+```
+
+
| undefined;
+let adoptedBranch = "feature/from-linear";
+
+mock.module("../../../lib/host-target", () => ({
+ requireHostTarget: () => "host-1",
+ resolveHostTarget: () => ({
+ hostId: "host-1",
+ client: {
+ workspaceCreation: {
+ adopt: {
+ mutate: async (input: Record) => {
+ adoptInput = input;
+ return {
+ workspace: {
+ id: "ws-1",
+ name: input.workspaceName,
+ branch: adoptedBranch,
+ },
+ terminals: [],
+ warnings: [],
+ };
+ },
+ },
+ },
+ },
+ }),
+}));
+
+const { default: importWorkspaceCommand } = await import("./command");
+
+interface ImportResult {
+ data: {
+ workspace: { id: string; name: string; branch: string };
+ branchMismatch: boolean;
+ };
+ message: string;
+}
+
+function invoke(
+ overrides: {
+ path?: string;
+ branch?: string;
+ name?: string;
+ baseBranch?: string;
+ } = {},
+): Promise {
+ return importWorkspaceCommand.run({
+ ctx: {
+ config: { organizationId: "org-1" },
+ bearer: "bearer",
+ } as never,
+ args: {} as never,
+ options: {
+ local: true,
+ project: "project-1",
+ path: "/repo/.worktrees/from-linear",
+ branch: "feature/from-linear",
+ ...overrides,
+ } as never,
+ signal: new AbortController().signal,
+ }) as Promise;
+}
+
+afterEach(() => {
+ adoptInput = undefined;
+ adoptedBranch = "feature/from-linear";
+});
+
+describe("workspaces import", () => {
+ test("adopts a pre-existing worktree by path on the target host", async () => {
+ const result = await invoke();
+
+ expect(adoptInput).toMatchObject({
+ projectId: "project-1",
+ worktreePath: "/repo/.worktrees/from-linear",
+ branch: "feature/from-linear",
+ });
+ expect(result.data).toMatchObject({
+ workspace: { id: "ws-1", branch: "feature/from-linear" },
+ });
+ });
+
+ test("defaults the workspace name to the worktree directory name", async () => {
+ await invoke();
+
+ expect(adoptInput).toMatchObject({ workspaceName: "from-linear" });
+ });
+
+ test("honours an explicit --name", async () => {
+ await invoke({ name: "Linear ENG-123" });
+
+ expect(adoptInput).toMatchObject({ workspaceName: "Linear ENG-123" });
+ });
+
+ test("forwards --base-branch so the host records the compare base", async () => {
+ await invoke({ baseBranch: "main" });
+
+ expect(adoptInput).toMatchObject({ baseBranch: "main" });
+ });
+
+ test("rejects a relative --path instead of resolving it against the CLI cwd", async () => {
+ await expect(invoke({ path: ".worktrees/from-linear" })).rejects.toThrow(
+ /absolute path/i,
+ );
+ expect(adoptInput).toBeUndefined();
+ });
+
+ test("reports a branch mismatch when the worktree has another branch checked out", async () => {
+ adoptedBranch = "feature/actually-this-one";
+
+ const result = await invoke({ branch: "feature/from-linear" });
+
+ expect(result.data).toMatchObject({ branchMismatch: true });
+ expect(result.message).toContain("feature/actually-this-one");
+ });
+});
diff --git a/packages/cli/src/commands/workspaces/import/command.ts b/packages/cli/src/commands/workspaces/import/command.ts
new file mode 100644
index 00000000000..b90b2c1688b
--- /dev/null
+++ b/packages/cli/src/commands/workspaces/import/command.ts
@@ -0,0 +1,86 @@
+import { posix } from "node:path";
+import { boolean, CLIError, string } from "@superset/cli-framework";
+import { command } from "../../../lib/command";
+import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target";
+
+export default command({
+ description: "Import an existing git worktree on a host as a workspace",
+ options: {
+ host: string().desc("Target host machineId"),
+ local: boolean().desc("Target this machine"),
+ project: string().required().desc("Project ID"),
+ path: string()
+ .required()
+ .desc("Absolute path of the existing worktree on the target host"),
+ branch: string().required().desc("Branch checked out in that worktree"),
+ name: string().desc(
+ "Workspace name (defaults to the worktree directory name)",
+ ),
+ baseBranch: string().desc("Branch to record as the compare base"),
+ },
+ run: async ({ ctx, options }) => {
+ const organizationId = ctx.config.organizationId;
+ if (!organizationId) {
+ throw new CLIError("No active organization", "Run: superset auth login");
+ }
+
+ // The path is interpreted on the target host, which may not be this
+ // machine — resolving it against the CLI's cwd would silently point
+ // at the wrong directory.
+ if (!posix.isAbsolute(options.path)) {
+ throw new CLIError(
+ `--path must be an absolute path on the target host: ${options.path}`,
+ "Pass the full worktree path, e.g. --path /Users/me/code/repo/.worktrees/my-branch",
+ );
+ }
+ const worktreePath = posix.normalize(options.path).replace(/\/+$/, "");
+
+ const branch = options.branch.trim();
+ if (!branch) {
+ throw new CLIError(
+ "--branch is empty",
+ "Pass the branch checked out in the worktree",
+ );
+ }
+
+ const workspaceName = options.name ?? posix.basename(worktreePath);
+ if (!workspaceName) {
+ throw new CLIError(
+ "Could not derive a workspace name from --path",
+ "Pass --name ",
+ );
+ }
+
+ const hostId = requireHostTarget({
+ host: options.host ?? undefined,
+ local: options.local ?? undefined,
+ });
+
+ const target = resolveHostTarget({
+ requestedHostId: hostId,
+ organizationId,
+ userJwt: ctx.bearer,
+ });
+
+ const result = await target.client.workspaceCreation.adopt.mutate({
+ projectId: options.project,
+ workspaceName,
+ branch,
+ worktreePath,
+ baseBranch: options.baseBranch ?? undefined,
+ });
+
+ // The host reads the branch actually checked out at the path rather
+ // than trusting `--branch`, so surface a disagreement instead of
+ // silently registering a different branch than the caller asked for.
+ const adoptedBranch = result.workspace.branch;
+ const branchMismatch = adoptedBranch !== branch;
+
+ return {
+ data: { ...result, branchMismatch },
+ message: branchMismatch
+ ? `Imported worktree ${worktreePath} as workspace "${result.workspace.name}" on host ${target.hostId} — it has "${adoptedBranch}" checked out, not "${branch}"`
+ : `Imported worktree ${worktreePath} as workspace "${result.workspace.name}" on host ${target.hostId}`,
+ };
+ },
+});
diff --git a/packages/mcp-v2/src/tools/register.ts b/packages/mcp-v2/src/tools/register.ts
index 94062e5bfbf..8e651edfe3b 100644
--- a/packages/mcp-v2/src/tools/register.ts
+++ b/packages/mcp-v2/src/tools/register.ts
@@ -33,6 +33,7 @@ import * as terminalsRead from "./terminals/read";
import * as terminalsSend from "./terminals/send";
import * as workspacesCreate from "./workspaces/create";
import * as workspacesDelete from "./workspaces/delete";
+import * as workspacesImport from "./workspaces/import";
import * as workspacesList from "./workspaces/list";
import * as workspacesUpdate from "./workspaces/update";
@@ -57,6 +58,7 @@ const REGISTRARS = [
automationsLogs,
workspacesList,
workspacesCreate,
+ workspacesImport,
workspacesUpdate,
workspacesDelete,
agentsCreate,
diff --git a/packages/mcp-v2/src/tools/workspaces/import.test.ts b/packages/mcp-v2/src/tools/workspaces/import.test.ts
new file mode 100644
index 00000000000..fb3de3cbbc3
--- /dev/null
+++ b/packages/mcp-v2/src/tools/workspaces/import.test.ts
@@ -0,0 +1,132 @@
+import { afterEach, describe, expect, mock, test } from "bun:test";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type { McpContext } from "../../auth";
+
+interface CapturedTool {
+ name: string;
+ description: string;
+ inputSchema: Record;
+ handler: (input: never, ctx: McpContext) => Promise;
+}
+
+let tool: CapturedTool | undefined;
+let hostCall:
+ | { procedure: string; method: string; input: Record }
+ | undefined;
+
+// `define-tool` and `host-service-client` are the seams: the real
+// `define-tool` pulls the cloud auth/db env in at import time, and the real
+// client would issue a relay fetch. Mocking both leaves the tool's own
+// wiring — name, schema, path handling, target procedure — under test.
+mock.module("../../define-tool", () => ({
+ defineTool: (_server: McpServer, def: CapturedTool) => {
+ tool = def;
+ },
+}));
+
+mock.module("../../host-service-client", () => ({
+ hostServiceCall: async (
+ _options: unknown,
+ procedure: string,
+ method: string,
+ input: Record,
+ ) => {
+ hostCall = { procedure, method, input };
+ return {
+ workspace: {
+ id: "ws-1",
+ projectId: input.projectId,
+ name: input.workspaceName,
+ branch: input.branch,
+ },
+ terminals: [],
+ warnings: [],
+ };
+ },
+}));
+
+const { register } = await import("./import");
+
+const ctx: McpContext = {
+ userId: "user-1",
+ email: "dev@example.com",
+ organizationId: "org-1",
+ organizationIds: ["org-1"],
+ source: "api-key",
+ clientLabel: null,
+ requestId: "req-1",
+ bearerToken: "jwt-1",
+ relayUrl: "https://relay.example.com",
+};
+
+function registerTool(): CapturedTool {
+ tool = undefined;
+ register({} as McpServer);
+ if (!tool) throw new Error("tool was not registered");
+ return tool;
+}
+
+function invoke(args: Record = {}) {
+ const registered = registerTool();
+ return registered.handler(
+ {
+ projectId: "11111111-1111-1111-1111-111111111111",
+ hostId: "host-1",
+ worktreePath: "/repo/.worktrees/from-linear",
+ branch: "feature/from-linear",
+ ...args,
+ } as never,
+ ctx,
+ );
+}
+
+afterEach(() => {
+ hostCall = undefined;
+});
+
+describe("workspaces_import", () => {
+ test("is exposed as an MCP tool taking a worktree path", () => {
+ const registered = registerTool();
+
+ expect(registered.name).toBe("workspaces_import");
+ expect(Object.keys(registered.inputSchema)).toEqual(
+ expect.arrayContaining(["projectId", "hostId", "worktreePath", "branch"]),
+ );
+ });
+
+ test("adopts the existing worktree on the host instead of creating one", async () => {
+ await invoke();
+
+ expect(hostCall).toMatchObject({
+ procedure: "workspaceCreation.adopt",
+ method: "mutation",
+ input: {
+ projectId: "11111111-1111-1111-1111-111111111111",
+ worktreePath: "/repo/.worktrees/from-linear",
+ branch: "feature/from-linear",
+ workspaceName: "from-linear",
+ },
+ });
+ });
+
+ test("honours an explicit name", async () => {
+ await invoke({ name: "Linear ENG-123" });
+
+ expect(hostCall?.input).toMatchObject({
+ workspaceName: "Linear ENG-123",
+ });
+ });
+
+ test("forwards baseBranch so the host records the compare base", async () => {
+ await invoke({ baseBranch: "main" });
+
+ expect(hostCall?.input).toMatchObject({ baseBranch: "main" });
+ });
+
+ test("rejects a relative worktreePath without calling the host", async () => {
+ await expect(
+ invoke({ worktreePath: ".worktrees/from-linear" }),
+ ).rejects.toThrow(/absolute path/i);
+ expect(hostCall).toBeUndefined();
+ });
+});
diff --git a/packages/mcp-v2/src/tools/workspaces/import.ts b/packages/mcp-v2/src/tools/workspaces/import.ts
new file mode 100644
index 00000000000..e09a98e9048
--- /dev/null
+++ b/packages/mcp-v2/src/tools/workspaces/import.ts
@@ -0,0 +1,80 @@
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import { defineTool } from "../../define-tool";
+import { hostServiceCall } from "../../host-service-client";
+
+export function register(server: McpServer): void {
+ defineTool(server, {
+ name: "workspaces_import",
+ annotations: { destructiveHint: false },
+ description:
+ "Import a git worktree that already exists on a host into a Superset workspace, without creating a new worktree. Use this when something outside Superset (a script, a CI job, a manual `git worktree add`) already prepared the branch and directory and you only need Superset to manage sessions for it. `worktreePath` must be an absolute path on that host and must already be registered in the project's `git worktree list` — otherwise the call fails. Safe to repeat: importing the same path again reuses the existing workspace rather than duplicating it. To have Superset create the worktree itself, use workspaces_create instead. Use projects_list and hosts_list first to get the projectId and hostId.",
+ inputSchema: {
+ projectId: z.string().uuid().describe("Project UUID."),
+ hostId: z
+ .string()
+ .min(1)
+ .describe("Host machineId the worktree lives on."),
+ worktreePath: z
+ .string()
+ .min(1)
+ .describe(
+ "Absolute path of the existing worktree on the host, e.g. /Users/me/code/repo/.worktrees/my-branch.",
+ ),
+ branch: z
+ .string()
+ .min(1)
+ .describe(
+ "Branch checked out in that worktree. The host reads the branch actually checked out at `worktreePath` and returns it, so compare the returned `workspace.branch` against what you sent.",
+ ),
+ name: z
+ .string()
+ .min(1)
+ .optional()
+ .describe(
+ "Workspace name (display). Defaults to the worktree directory name.",
+ ),
+ baseBranch: z
+ .string()
+ .optional()
+ .describe("Branch to record as the compare base for this workspace."),
+ },
+ handler: async (input, ctx) => {
+ const worktreePath = input.worktreePath.replace(/\/+$/, "");
+ if (!worktreePath.startsWith("/")) {
+ throw new Error(
+ `worktreePath must be an absolute path on the host: ${input.worktreePath}`,
+ );
+ }
+ const workspaceName =
+ input.name ?? (worktreePath.split("/").pop() as string);
+
+ return hostServiceCall<{
+ workspace: {
+ id: string;
+ projectId: string;
+ name: string;
+ branch: string;
+ };
+ terminals: Array<{ terminalId: string; label?: string }>;
+ warnings: string[];
+ }>(
+ {
+ relayUrl: ctx.relayUrl,
+ organizationId: ctx.organizationId,
+ hostId: input.hostId,
+ jwt: ctx.bearerToken,
+ },
+ "workspaceCreation.adopt",
+ "mutation",
+ {
+ projectId: input.projectId,
+ workspaceName,
+ branch: input.branch,
+ worktreePath,
+ baseBranch: input.baseBranch,
+ },
+ );
+ },
+ });
+}