@getpaseo/cli 0.7.0-beta.1 → 0.7.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,13 +80,16 @@ function toListItem(agent) {
80
80
  }
81
81
  function daemonConnectionFailure(host, cause) {
82
82
  const reason = cause instanceof Error ? cause.message : String(cause);
83
+ const isSsh = host.trim().startsWith("ssh://");
83
84
  return {
84
85
  code: "DAEMON_NOT_RUNNING",
85
86
  message: `Cannot reach the daemon at ${host}: ${reason}`,
86
- details: [
87
- "Start a local daemon with: paseo daemon start",
88
- "To use another daemon, pass --host <host:port> or set PASEO_HOST.",
89
- ].join("\n"),
87
+ details: isSsh
88
+ ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
89
+ : [
90
+ "Start a local daemon with: paseo daemon start",
91
+ "To use another daemon, pass --host <host:port> or set PASEO_HOST.",
92
+ ].join("\n"),
90
93
  };
91
94
  }
92
95
  function parseLabelFilters(labels) {
@@ -7,6 +7,8 @@ interface HubConnectOptions {
7
7
  apiKey?: string;
8
8
  host?: string;
9
9
  json?: boolean;
10
+ permission?: readonly string[];
11
+ permissions?: readonly string[];
10
12
  }
11
13
  interface HubConnectDependencies {
12
14
  env: Readonly<Record<string, string | undefined>>;
@@ -15,8 +15,14 @@ export async function runHubConnect(originInput, options, dependencies) {
15
15
  reportHubProgress(dependencies.reporter, options, `Connecting this daemon to ${origin}`);
16
16
  const credential = resolveHubCredential({ ...resolution, origin });
17
17
  const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
18
+ const permissions = options.permissions ?? options.permission ?? [];
18
19
  return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
19
- const response = await daemon.connectHub(origin, token);
20
+ const response = await daemon.connectHub(origin, token, permissions);
21
+ if (response.status.hubOrigin !== null &&
22
+ !samePermissions(response.status.permissions, permissions)) {
23
+ await daemon.disconnectHub(false).catch(() => undefined);
24
+ throw new Error("The daemon did not honor the requested Hub access. Update Paseo before connecting it.");
25
+ }
20
26
  return hubStatusResult(response.status);
21
27
  });
22
28
  }
@@ -25,10 +31,14 @@ export function addHubConnectCommand(parent, dependencies) {
25
31
  .command("connect")
26
32
  .description("Enroll this daemon with a Paseo Hub")
27
33
  .argument("[origin]", "Paseo Hub origin")
28
- .option("--api-key <secret>", "Organization API key"))).action(withOutput(async (...args) => {
34
+ .option("--api-key <secret>", "Organization API key")
35
+ .option("--permission <permission...>", "Grant daemon permission during connection"))).action(withOutput(async (...args) => {
29
36
  const origin = args[0];
30
37
  const options = args.at(-2);
31
38
  return runHubConnect(origin, options, dependencies);
32
39
  }));
33
40
  }
41
+ function samePermissions(actual, expected) {
42
+ return actual.length === expected.length && expected.every((scope) => actual.includes(scope));
43
+ }
34
44
  //# sourceMappingURL=connect.js.map
@@ -3,7 +3,7 @@ export interface HubStatus {
3
3
  state: string;
4
4
  daemonId: string | null;
5
5
  hubOrigin: string | null;
6
- scopes: string[];
6
+ permissions: string[];
7
7
  connectedAt: string | null;
8
8
  lastError: string | null;
9
9
  }
@@ -11,7 +11,13 @@ export interface HubProvidersSnapshotOptions {
11
11
  cwd?: string;
12
12
  }
13
13
  export interface HubDaemonClient {
14
- connectHub(url: string, token: string): Promise<{
14
+ connectHub(url: string, token: string, permissions?: readonly string[]): Promise<{
15
+ status: HubStatus;
16
+ }>;
17
+ updateHubPermissions(input: {
18
+ grant?: readonly string[];
19
+ revoke?: readonly string[];
20
+ }): Promise<{
15
21
  status: HubStatus;
16
22
  }>;
17
23
  getHubStatus(): Promise<{
@@ -0,0 +1,28 @@
1
+ import type { Command } from "commander";
2
+ import { type SingleResult } from "../../output/index.js";
3
+ import type { HubCredentialStore } from "./credentials.js";
4
+ import type { HubHttpClient } from "./hub-client/index.js";
5
+ import { type HubReporter } from "./reporter.js";
6
+ interface HubExportResult {
7
+ origin: string;
8
+ directory: string;
9
+ exported: number;
10
+ unchanged: number;
11
+ }
12
+ export interface HubExportOptions {
13
+ hub?: string;
14
+ apiKey?: string;
15
+ force?: boolean;
16
+ json?: boolean;
17
+ }
18
+ interface HubExportDependencies {
19
+ env: Readonly<Record<string, string | undefined>>;
20
+ credentials: HubCredentialStore;
21
+ hub: Pick<HubHttpClient, "listTriggers">;
22
+ reporter: HubReporter;
23
+ cwd(): string;
24
+ }
25
+ export declare function runHubExport(directoryInput: string | undefined, options: HubExportOptions, dependencies: HubExportDependencies): Promise<SingleResult<HubExportResult>>;
26
+ export declare function addHubExportCommand(parent: Command, dependencies: HubExportDependencies): void;
27
+ export {};
28
+ //# sourceMappingURL=export.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { withOutput } from "../../output/index.js";
4
+ import { addJsonOption } from "../../utils/command-options.js";
5
+ import { resolveHubCredential, resolveHubOrigin } from "./authority.js";
6
+ import { HubCommandError } from "./error.js";
7
+ import { addHubResolutionHelp } from "./help.js";
8
+ import { reportHubProgress } from "./reporter.js";
9
+ const schema = {
10
+ idField: "directory",
11
+ columns: [
12
+ { header: "DIRECTORY", field: "directory" },
13
+ { header: "EXPORTED", field: "exported" },
14
+ { header: "UNCHANGED", field: "unchanged" },
15
+ { header: "HUB", field: "origin" },
16
+ ],
17
+ };
18
+ export async function runHubExport(directoryInput, options, dependencies) {
19
+ const resolution = {
20
+ options: { origin: options.hub, apiKey: options.apiKey },
21
+ env: dependencies.env,
22
+ credentials: dependencies.credentials,
23
+ };
24
+ const origin = resolveHubOrigin(resolution);
25
+ const credential = resolveHubCredential({ ...resolution, origin });
26
+ const directory = path.resolve(dependencies.cwd(), directoryInput ?? ".paseo/triggers");
27
+ reportHubProgress(dependencies.reporter, options, `Exporting triggers from ${origin}`);
28
+ const triggers = await dependencies.hub.listTriggers(origin, credential);
29
+ await mkdir(directory, { recursive: true });
30
+ const files = await Promise.all(triggers.map(async (trigger) => {
31
+ const destination = path.join(directory, `${trigger.name}.yml`);
32
+ return { destination, trigger, existing: await readOptionalFile(destination) };
33
+ }));
34
+ const conflict = files.find(({ existing, trigger }) => existing !== undefined && existing !== trigger.yaml && options.force !== true);
35
+ if (conflict !== undefined) {
36
+ throw new HubCommandError("HUB_EXPORT_CONFLICT", `${conflict.destination} already exists with different contents. Pass --force to replace it.`);
37
+ }
38
+ for (const file of files) {
39
+ if (file.existing === file.trigger.yaml)
40
+ continue;
41
+ await writeFile(file.destination, file.trigger.yaml, file.existing === undefined ? { flag: "wx" } : undefined);
42
+ }
43
+ return {
44
+ type: "single",
45
+ data: {
46
+ origin,
47
+ directory,
48
+ exported: files.filter(({ existing, trigger }) => existing !== trigger.yaml).length,
49
+ unchanged: files.filter(({ existing, trigger }) => existing === trigger.yaml).length,
50
+ },
51
+ schema,
52
+ };
53
+ }
54
+ export function addHubExportCommand(parent, dependencies) {
55
+ addJsonOption(addHubResolutionHelp(parent
56
+ .command("export")
57
+ .description("Export active Hub triggers as one YAML file per trigger")
58
+ .argument("[directory]", "Destination directory", ".paseo/triggers")
59
+ .option("--hub <origin>", "Paseo Hub origin")
60
+ .option("--api-key <secret>", "Organization API key")
61
+ .option("--force", "Replace trigger files with different contents"))).action(withOutput(async (...args) => {
62
+ const directory = args[0];
63
+ const options = args.at(-2);
64
+ return runHubExport(directory, options, dependencies);
65
+ }));
66
+ }
67
+ async function readOptionalFile(filePath) {
68
+ try {
69
+ return await readFile(filePath, "utf8");
70
+ }
71
+ catch (error) {
72
+ if (isMissingFile(error))
73
+ return undefined;
74
+ throw error;
75
+ }
76
+ }
77
+ function isMissingFile(error) {
78
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
79
+ }
80
+ //# sourceMappingURL=export.js.map
@@ -1,6 +1,6 @@
1
1
  import type { HubBundleFile } from "../deploy-bundle.js";
2
- import { type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, type HubSetupResources, type HubProject, type HubValidationResult } from "./internal/contracts.js";
3
- export type { CliAuthorization, CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, HubSetupResources, HubProject, HubValidationResult, } from "./internal/contracts.js";
2
+ import { type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, type HubSetupResources, type HubProject, type HubTrigger, type HubValidationResult } from "./internal/contracts.js";
3
+ export type { CliAuthorization, CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, HubSetupResources, HubProject, HubTrigger, HubValidationResult, } from "./internal/contracts.js";
4
4
  interface HubConfigurationInput {
5
5
  origin: string;
6
6
  apiKey: string;
@@ -11,6 +11,7 @@ export declare class HubHttpClient {
11
11
  startCliAuthorization(origin: string): Promise<CliAuthorization>;
12
12
  pollCliAuthorization(origin: string, deviceCode: string, timeoutMilliseconds: number): Promise<CliAuthorizationPoll>;
13
13
  listProjects(origin: string, apiKey: string): Promise<HubProject[]>;
14
+ listTriggers(origin: string, apiKey: string): Promise<HubTrigger[]>;
14
15
  listConfigurationResources(origin: string, apiKey: string): Promise<HubConfigurationResources>;
15
16
  listSetupResources(origin: string, apiKey: string): Promise<HubSetupResources>;
16
17
  installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult>;
@@ -1,5 +1,5 @@
1
1
  import { HubCommandError } from "../error.js";
2
- import { authorizationPollSchema, authorizationSchema, configurationResourcesSchema, enrollmentTokenSchema, installResponseSchema, projectsResponseSchema, validationResponseSchema, setupResourcesSchema, } from "./internal/contracts.js";
2
+ import { authorizationPollSchema, authorizationSchema, configurationResourcesSchema, enrollmentTokenSchema, installResponseSchema, projectsResponseSchema, triggersResponseSchema, validationResponseSchema, setupResourcesSchema, } from "./internal/contracts.js";
3
3
  import { requestHub } from "./internal/transport.js";
4
4
  export class HubHttpClient {
5
5
  startCliAuthorization(origin) {
@@ -45,6 +45,18 @@ export class HubHttpClient {
45
45
  });
46
46
  return response.projects;
47
47
  }
48
+ async listTriggers(origin, apiKey) {
49
+ const response = await requestHub({
50
+ origin,
51
+ path: "/api/v1/triggers",
52
+ method: "GET",
53
+ apiKey,
54
+ successStatus: 200,
55
+ schema: triggersResponseSchema,
56
+ failureMessage: "Hub trigger export failed",
57
+ });
58
+ return response.triggers;
59
+ }
48
60
  listConfigurationResources(origin, apiKey) {
49
61
  return requestHub({
50
62
  origin,
@@ -42,6 +42,28 @@ export declare const projectsResponseSchema: z.ZodObject<{
42
42
  name: z.ZodString;
43
43
  }, z.core.$strict>>;
44
44
  }, z.core.$strict>;
45
+ declare const triggerSchema: z.ZodObject<{
46
+ id: z.ZodString;
47
+ name: z.ZodString;
48
+ enabled: z.ZodBoolean;
49
+ format: z.ZodEnum<{
50
+ single_run: "single_run";
51
+ legacy_multistep: "legacy_multistep";
52
+ }>;
53
+ yaml: z.ZodString;
54
+ }, z.core.$strict>;
55
+ export declare const triggersResponseSchema: z.ZodObject<{
56
+ triggers: z.ZodArray<z.ZodObject<{
57
+ id: z.ZodString;
58
+ name: z.ZodString;
59
+ enabled: z.ZodBoolean;
60
+ format: z.ZodEnum<{
61
+ single_run: "single_run";
62
+ legacy_multistep: "legacy_multistep";
63
+ }>;
64
+ yaml: z.ZodString;
65
+ }, z.core.$strict>>;
66
+ }, z.core.$strict>;
45
67
  export declare const configurationResourcesSchema: z.ZodObject<{
46
68
  daemons: z.ZodArray<z.ZodObject<{
47
69
  id: z.ZodString;
@@ -95,6 +117,7 @@ export declare const enrollmentTokenSchema: z.ZodObject<{
95
117
  export type CliAuthorization = z.infer<typeof authorizationSchema>;
96
118
  export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
97
119
  export type HubProject = z.infer<typeof projectSchema>;
120
+ export type HubTrigger = z.infer<typeof triggerSchema>;
98
121
  export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
99
122
  export type HubSetupResources = z.infer<typeof setupResourcesSchema>;
100
123
  export type HubInstallResult = z.infer<typeof installResponseSchema>;
@@ -30,6 +30,16 @@ const projectSchema = z
30
30
  .object({ id: z.string().uuid(), slug: z.string().min(1), name: z.string().min(1) })
31
31
  .strict();
32
32
  export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
33
+ const triggerSchema = z
34
+ .object({
35
+ id: z.string().uuid(),
36
+ name: z.string().regex(/^[a-z][a-z0-9_-]*$/u),
37
+ enabled: z.boolean(),
38
+ format: z.enum(["single_run", "legacy_multistep"]),
39
+ yaml: z.string(),
40
+ })
41
+ .strict();
42
+ export const triggersResponseSchema = z.object({ triggers: z.array(triggerSchema) }).strict();
33
43
  export const configurationResourcesSchema = z
34
44
  .object({
35
45
  daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()),
@@ -11,10 +11,12 @@ import { createCliLoginFlow } from "./login-flow.js";
11
11
  import { addHubLoginCommand } from "./login.js";
12
12
  import { addHubLogoutCommand, productionLogoutPrompt } from "./logout.js";
13
13
  import { addHubProjectsCommand } from "./projects.js";
14
+ import { addHubExportCommand } from "./export.js";
14
15
  import { processHubReporter } from "./reporter.js";
15
16
  import { hubStatusResult } from "./status-output.js";
16
17
  import { addHubResolutionHelp } from "./help.js";
17
18
  import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
19
+ import { addHubPermissionsCommand } from "./permissions.js";
18
20
  function productionEnvironment() {
19
21
  const env = process.env;
20
22
  const hub = new HubHttpClient();
@@ -56,12 +58,23 @@ export function createHubCommand(overrides = {}) {
56
58
  daemon: environment.daemon,
57
59
  reporter: environment.reporter,
58
60
  });
61
+ addHubPermissionsCommand(hub, {
62
+ daemon: environment.daemon,
63
+ reporter: environment.reporter,
64
+ });
59
65
  addHubProjectsCommand(hub, {
60
66
  env: environment.env,
61
67
  credentials: environment.credentials,
62
68
  hub: environment.hub,
63
69
  reporter: environment.reporter,
64
70
  });
71
+ addHubExportCommand(hub, {
72
+ env: environment.env,
73
+ credentials: environment.credentials,
74
+ hub: environment.hub,
75
+ reporter: environment.reporter,
76
+ cwd: environment.cwd,
77
+ });
65
78
  addHubDeployCommand(hub, {
66
79
  env: environment.env,
67
80
  credentials: environment.credentials,
@@ -112,31 +112,28 @@ export async function runHubGuidedSetup(environment, state = {}) {
112
112
  outro(deploy ? "Hub is ready" : "Hub bundle is ready");
113
113
  }
114
114
  export async function continueHubGuidedSetup(origin, environment) {
115
- if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
116
- reportMessage(environment, `Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`);
117
- return;
118
- }
119
- const daemonId = await ensureDaemonConnection(origin, environment, true);
120
- if (!(await requiredConfirm(environment, "Initialize and deploy a starter workflow?", true))) {
121
- reportMessage(environment, `Skipped starter workflow. Run: ${hubLoginResumeCommand("init", origin)}`);
122
- return;
123
- }
124
- try {
125
- await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
126
- }
127
- catch (error) {
128
- if (error instanceof HubInitCancelledError) {
129
- if (environment.prompts === undefined) {
130
- log.message(error.message);
131
- outro("Connect an app to continue");
132
- }
133
- else {
134
- environment.prompts.message(error.message);
135
- }
136
- return;
115
+ const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) => daemon.getHubStatus().then((response) => response.status));
116
+ const current = resolveHubInitConnection(currentStatus, origin);
117
+ if (current.kind === "connected") {
118
+ reportMessage(environment, `This daemon is already connected to ${origin}. Permissions: ${currentStatus.permissions.join(", ") || "None"}.`);
119
+ }
120
+ else if (current.kind === "pending") {
121
+ await waitForDaemonReady(origin, environment.daemon);
122
+ }
123
+ else if (current.kind === "conflict") {
124
+ reportMessage(environment, `This daemon is connected to ${current.origin}. Disconnect it before connecting to ${origin}.`);
125
+ }
126
+ else if (await requiredConfirm(environment, "Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.", true)) {
127
+ const grantExecution = await requiredConfirm(environment, "Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.", false);
128
+ await ensureDaemonConnection(origin, environment, true, grantExecution ? ["hub.execute"] : []);
129
+ if (!grantExecution) {
130
+ reportMessage(environment, "Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute");
137
131
  }
138
- throw error;
139
132
  }
133
+ else {
134
+ reportMessage(environment, `Skipped daemon connection. Connect later with: ${hubLoginResumeCommand("connect", origin)}`);
135
+ }
136
+ reportMessage(environment, `Configure triggers in Hub: ${new URL("/triggers", origin).toString()}\nOr scaffold triggers as code: ${hubLoginResumeCommand("init", origin)}`);
140
137
  }
141
138
  async function ensureLogin(activeOrigin, environment) {
142
139
  const endpoint = await requiredSelect(environment, {
@@ -175,10 +172,13 @@ async function ensureLogin(activeOrigin, environment) {
175
172
  log.success(`Logged in to ${normalizedOrigin}`);
176
173
  return normalizedOrigin;
177
174
  }
178
- async function ensureDaemonConnection(origin, environment, confirmed = false) {
175
+ async function ensureDaemonConnection(origin, environment, confirmed = false, permissions = ["hub.execute"]) {
179
176
  const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => daemon.getHubStatus().then((response) => response.status));
180
177
  const connection = resolveHubInitConnection(status, origin);
181
178
  if (connection.kind === "connected") {
179
+ if (permissions.includes("hub.execute") && !status.permissions.includes("hub.execute")) {
180
+ throw new HubCommandError("HUB_DAEMON_EXECUTION_NOT_ALLOWED", "This daemon is connected to Hub but cannot run Hub automations. Run `paseo hub permissions grant hub.execute`, then run Hub init again.");
181
+ }
182
182
  return connection.daemonId;
183
183
  }
184
184
  if (connection.kind === "pending") {
@@ -188,13 +188,13 @@ async function ensureDaemonConnection(origin, environment, confirmed = false) {
188
188
  throw new HubCommandError("HUB_DAEMON_ALREADY_CONNECTED", `This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`);
189
189
  }
190
190
  if (!confirmed &&
191
- !(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) {
191
+ !(await requiredConfirm(environment, `Connect this daemon to ${origin} and allow Hub workflows to create and control workspaces and agents?`, true))) {
192
192
  throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
193
193
  }
194
- return connectDaemon(origin, environment);
194
+ return connectDaemon(origin, environment, permissions);
195
195
  }
196
- async function connectDaemon(origin, environment) {
197
- await runHubConnect(origin, {}, {
196
+ async function connectDaemon(origin, environment, permissions = ["hub.execute"]) {
197
+ await runHubConnect(origin, { permissions }, {
198
198
  env: environment.env,
199
199
  credentials: environment.credentials,
200
200
  hub: environment.hub,
@@ -0,0 +1,21 @@
1
+ import type { Command } from "commander";
2
+ import { type ListResult } from "../../output/index.js";
3
+ import type { HubDaemonConnection } from "./daemon-client.js";
4
+ import { type HubReporter } from "./reporter.js";
5
+ interface HubPermissionsOptions {
6
+ host?: string;
7
+ json?: boolean;
8
+ }
9
+ interface HubPermissionsDependencies {
10
+ daemon: HubDaemonConnection;
11
+ reporter: HubReporter;
12
+ }
13
+ interface PermissionRow {
14
+ permission: string;
15
+ description: string;
16
+ }
17
+ export declare function runHubPermissionsList(options: HubPermissionsOptions, dependencies: HubPermissionsDependencies): Promise<ListResult<PermissionRow>>;
18
+ export declare function runHubPermissionChange(operation: "grant" | "revoke", permission: string, options: HubPermissionsOptions, dependencies: HubPermissionsDependencies): Promise<ListResult<import("./status-output.js").HubRow>>;
19
+ export declare function addHubPermissionsCommand(parent: Command, dependencies: HubPermissionsDependencies): void;
20
+ export {};
21
+ //# sourceMappingURL=permissions.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { withOutput } from "../../output/index.js";
2
+ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
3
+ import { withHubDaemon } from "./daemon-client.js";
4
+ import { reportHubProgress } from "./reporter.js";
5
+ import { hubStatusResult } from "./status-output.js";
6
+ const schema = {
7
+ idField: "permission",
8
+ columns: [
9
+ { header: "PERMISSION", field: "permission" },
10
+ { header: "DESCRIPTION", field: "description" },
11
+ ],
12
+ };
13
+ export function runHubPermissionsList(options, dependencies) {
14
+ return withHubDaemon(dependencies.daemon, options.host, async (client) => {
15
+ const status = (await client.getHubStatus()).status;
16
+ requireConnectedHub(status);
17
+ return {
18
+ type: "list",
19
+ data: status.permissions.map((permission) => ({
20
+ permission,
21
+ description: describePermission(permission),
22
+ })),
23
+ schema,
24
+ };
25
+ });
26
+ }
27
+ export function runHubPermissionChange(operation, permission, options, dependencies) {
28
+ return withHubDaemon(dependencies.daemon, options.host, async (client) => {
29
+ const current = (await client.getHubStatus()).status;
30
+ requireConnectedHub(current);
31
+ const response = await client.updateHubPermissions(operation === "grant" ? { grant: [permission] } : { revoke: [permission] });
32
+ reportHubProgress(dependencies.reporter, options, `${operation === "grant" ? "Granted" : "Revoked"} ${permission} ${operation === "grant" ? "to" : "from"} ${response.status.hubOrigin}`);
33
+ return hubStatusResult(response.status);
34
+ });
35
+ }
36
+ export function addHubPermissionsCommand(parent, dependencies) {
37
+ const permissions = parent
38
+ .command("permissions")
39
+ .description("Manage this Hub's daemon permissions");
40
+ addJsonAndDaemonHostOptions(permissions.command("list")).action(withOutput(async (...args) => {
41
+ const options = args.at(-2);
42
+ return runHubPermissionsList(options, dependencies);
43
+ }));
44
+ for (const operation of ["grant", "revoke"]) {
45
+ addJsonAndDaemonHostOptions(permissions.command(operation).argument("<permission>", "Daemon permission")).action(withOutput(async (...args) => {
46
+ const permission = args[0];
47
+ const options = args.at(-2);
48
+ return runHubPermissionChange(operation, permission, options, dependencies);
49
+ }));
50
+ }
51
+ }
52
+ function requireConnectedHub(status) {
53
+ if (status.hubOrigin === null ||
54
+ (status.state !== "connected" && status.state !== "reconnecting")) {
55
+ throw new Error("This daemon is not connected to a Hub");
56
+ }
57
+ }
58
+ function describePermission(permission) {
59
+ return permission === "hub.execute" ? "Run agents for Hub automations" : permission;
60
+ }
61
+ //# sourceMappingURL=permissions.js.map
@@ -4,7 +4,7 @@ export interface HubRow {
4
4
  state: string;
5
5
  daemonId: string | null;
6
6
  hub: string | null;
7
- scopes: string;
7
+ permissions: string;
8
8
  connectedAt: string | null;
9
9
  error: string | null;
10
10
  warning?: string;
@@ -4,7 +4,7 @@ const schema = {
4
4
  { header: "STATE", field: "state" },
5
5
  { header: "HUB", field: "hub" },
6
6
  { header: "DAEMON", field: "daemonId" },
7
- { header: "SCOPES", field: "scopes" },
7
+ { header: "PERMISSIONS", field: "permissions" },
8
8
  { header: "CONNECTED", field: "connectedAt" },
9
9
  { header: "ERROR", field: "error" },
10
10
  { header: "WARNING", field: "warning" },
@@ -18,7 +18,7 @@ export function hubStatusResult(status, warning, reportedHubOrigin = status.hubO
18
18
  state: status.state,
19
19
  daemonId: status.daemonId,
20
20
  hub: reportedHubOrigin,
21
- scopes: status.scopes.join(", "),
21
+ permissions: status.permissions.join(", "),
22
22
  connectedAt: status.connectedAt,
23
23
  error: status.lastError,
24
24
  warning,
@@ -55,6 +55,46 @@ const SDK_DECLARATIONS = `declare module "@getpaseo/plugin/server" {
55
55
  export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
56
56
  }
57
57
 
58
+ declare module "@getpaseo/plugin/react-native" {
59
+ import type { ComponentType, FunctionComponent, ReactNode } from "react";
60
+
61
+ export interface PluginIconProps {
62
+ name: string;
63
+ size?: number;
64
+ color?: string;
65
+ }
66
+
67
+ export interface ModalProps {
68
+ title: string;
69
+ icon?: ReactNode;
70
+ open: boolean;
71
+ onOpenChange(open: boolean): void;
72
+ children: ReactNode;
73
+ }
74
+
75
+ export interface ModalContentProps {
76
+ children: ReactNode;
77
+ }
78
+
79
+ export interface ModalComponent extends FunctionComponent<ModalProps> {
80
+ Content: ComponentType<ModalContentProps>;
81
+ }
82
+
83
+ export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
84
+ export interface ToastOptions {
85
+ variant?: ToastVariant;
86
+ durationMs?: number;
87
+ }
88
+ export interface ToastApi {
89
+ show(message: string, options?: ToastOptions): void;
90
+ error(message: string): void;
91
+ }
92
+
93
+ export const Icon: ComponentType<PluginIconProps>;
94
+ export const Modal: ModalComponent;
95
+ export function useToast(): ToastApi;
96
+ }
97
+
58
98
  declare module "@getpaseo/plugin" {
59
99
  import type { ComponentType } from "react";
60
100
  import type { PaseoApi } from "@getpaseo/client";
@@ -100,7 +140,15 @@ declare module "@getpaseo/plugin" {
100
140
  layout: { compact: boolean; platform: "ios" | "android" | "web" };
101
141
  }
102
142
 
103
- export interface PluginSurfaceProps extends PluginHostProps {}
143
+ interface PluginNavigableHostProps extends PluginHostProps {
144
+ /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
145
+ readonly navigation?: {
146
+ readonly openAgent: (input: { readonly agentId: string }) => void;
147
+ readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
148
+ };
149
+ }
150
+
151
+ export interface PluginSurfaceProps extends PluginNavigableHostProps {}
104
152
 
105
153
  export interface PluginIconProps {
106
154
  name: string;
@@ -143,19 +191,37 @@ declare module "@getpaseo/plugin" {
143
191
  readonly labels: Readonly<Record<string, string>>;
144
192
  }
145
193
 
146
- export interface PluginWorkspacePanelProps extends PluginHostProps {
194
+ export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
147
195
  context: "workspace";
148
196
  workspaceId: string;
149
197
  }
150
198
 
151
- export interface PluginAgentPanelProps extends PluginHostProps {
199
+ export interface PluginAgentPanelProps extends PluginNavigableHostProps {
152
200
  context: "agent";
153
201
  workspaceId: string;
154
202
  agentId: string;
155
203
  }
156
204
 
205
+ export interface PluginComposerPillProps extends PluginHostProps {
206
+ workspaceId: string;
207
+ agentId: string;
208
+ }
209
+
210
+ export interface PluginComposerPillContribution {
211
+ id: string;
212
+ title: string;
213
+ workspaceId: string;
214
+ agentId: string;
215
+ Component: ComponentType<PluginComposerPillProps>;
216
+ onPress(): void | Promise<void>;
217
+ }
218
+
157
219
  export type PluginPanelLocation = "workspace" | "explorer";
158
220
  export interface PluginOpenPanelOptions { location?: PluginPanelLocation; }
221
+ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
222
+ workspaceId: string;
223
+ agentId?: string;
224
+ }
159
225
 
160
226
  export type PluginWorkspacePanelContribution =
161
227
  | { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
@@ -240,6 +306,12 @@ declare module "@getpaseo/plugin" {
240
306
  openPanel(id: string, options?: PluginOpenPanelOptions): void;
241
307
  }
242
308
 
309
+ export interface PluginClientContext extends PluginCommandCapabilities {
310
+ addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
311
+ openPanel(id: string, options: PluginClientOpenPanelOptions): void;
312
+ }
313
+ export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
314
+
243
315
  export type PluginCommandCenterItemContribution =
244
316
  | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise<void> }
245
317
  | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
@@ -257,6 +329,7 @@ declare module "@getpaseo/plugin" {
257
329
  addSidebarItem(contribution: PluginSidebarContribution): void;
258
330
  addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
259
331
  addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
332
+ addClientSide(contribution: PluginClientContribution): void;
260
333
  addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
261
334
  addTheme(contribution: PluginThemeContribution): void;
262
335
  addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(contribution: PluginTimelineTransformerContribution<ItemType>): void;
@@ -0,0 +1,9 @@
1
+ import { type SshTransportTarget } from "@getpaseo/protocol/ssh-transport";
2
+ export interface SshTunnel {
3
+ endpoint: string;
4
+ close(): void;
5
+ failureDetail(): string | null;
6
+ }
7
+ export declare function resolveSshFailureDetail(failure: string | null, stderr: string): string | null;
8
+ export declare function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>;
9
+ //# sourceMappingURL=ssh-tunnel.d.ts.map
@@ -0,0 +1,79 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:net";
3
+ import { buildSshTunnelArgs } from "@getpaseo/protocol/ssh-transport";
4
+ const SSH_STDERR_LIMIT = 8192;
5
+ function formatSshFailure(stderr, code, signal) {
6
+ const detail = stderr.trim();
7
+ if (detail)
8
+ return detail;
9
+ if (signal)
10
+ return `ssh exited with signal ${signal}`;
11
+ return `ssh exited with code ${code ?? "unknown"}`;
12
+ }
13
+ export function resolveSshFailureDetail(failure, stderr) {
14
+ return failure ?? (stderr.trim() || null);
15
+ }
16
+ export function createSshTunnel(target) {
17
+ let server = null;
18
+ let socket = null;
19
+ let child = null;
20
+ let stderr = "";
21
+ let failure = null;
22
+ function close() {
23
+ server?.close();
24
+ server = null;
25
+ socket?.destroy();
26
+ socket = null;
27
+ if (child && !child.killed)
28
+ child.kill();
29
+ child = null;
30
+ }
31
+ return new Promise((resolve, reject) => {
32
+ server = createServer((acceptedSocket) => {
33
+ socket = acceptedSocket;
34
+ server?.close();
35
+ server = null;
36
+ child = spawn("ssh", buildSshTunnelArgs(target), {
37
+ stdio: ["pipe", "pipe", "pipe"],
38
+ windowsHide: true,
39
+ });
40
+ child.stderr.on("data", (chunk) => {
41
+ stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
42
+ });
43
+ child.on("error", (error) => {
44
+ failure = error.message;
45
+ acceptedSocket.destroy(error);
46
+ });
47
+ child.on("exit", (code, signal) => {
48
+ if (code !== 0 || signal)
49
+ failure = formatSshFailure(stderr, code, signal);
50
+ acceptedSocket.destroy(failure ? new Error(failure) : undefined);
51
+ });
52
+ acceptedSocket.on("error", () => undefined);
53
+ acceptedSocket.on("close", () => {
54
+ if (child && !child.killed)
55
+ child.kill();
56
+ });
57
+ acceptedSocket.pipe(child.stdin);
58
+ child.stdout.pipe(acceptedSocket);
59
+ });
60
+ server.once("error", (error) => {
61
+ close();
62
+ reject(error);
63
+ });
64
+ server.listen(0, "127.0.0.1", () => {
65
+ const address = server?.address();
66
+ if (!address || typeof address === "string") {
67
+ close();
68
+ reject(new Error("Failed to allocate the SSH tunnel port"));
69
+ return;
70
+ }
71
+ resolve({
72
+ endpoint: `127.0.0.1:${address.port}`,
73
+ close,
74
+ failureDetail: () => resolveSshFailureDetail(failure, stderr),
75
+ });
76
+ });
77
+ });
78
+ }
79
+ //# sourceMappingURL=ssh-tunnel.js.map
@@ -2,11 +2,13 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
3
3
  import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, normalizeHostPort, parseConnectionUri, shouldUseTlsForDefaultHostedRelay, } from "@getpaseo/protocol/daemon-endpoints";
4
4
  import { parseConnectionOfferFromUrl, } from "@getpaseo/protocol/connection-offer";
5
+ import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
5
6
  import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
6
7
  import path from "node:path";
7
8
  import { WebSocket } from "ws";
8
9
  import { getOrCreateCliClientId } from "./client-id.js";
9
10
  import { resolveCliVersion } from "../version.js";
11
+ import { createSshTunnel } from "../ssh/ssh-tunnel.js";
10
12
  const DEFAULT_HOST = "localhost:6767";
11
13
  const DEFAULT_TIMEOUT = 15000;
12
14
  const PID_FILENAME = "paseo.pid";
@@ -22,7 +24,9 @@ export function buildDaemonConnectionCommandError(options) {
22
24
  return {
23
25
  code: "DAEMON_NOT_RUNNING",
24
26
  message: `Cannot connect to daemon at ${host}: ${message}`,
25
- details: "Start the daemon with: paseo daemon start",
27
+ details: host.trim().startsWith("ssh://")
28
+ ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
29
+ : "Start the daemon with: paseo daemon start",
26
30
  };
27
31
  }
28
32
  export function normalizeDaemonHost(raw) {
@@ -266,6 +270,19 @@ export async function connectToDaemon(options) {
266
270
  const clientId = await getOrCreateCliClientId();
267
271
  const nodeWebSocketFactory = createNodeWebSocketFactory();
268
272
  const explicitHost = options?.host ?? process.env.PASEO_HOST;
273
+ if (explicitHost?.trim().startsWith("ssh://")) {
274
+ const target = parseSshTransportUri(explicitHost.trim());
275
+ const tunnel = await createSshTunnel(target);
276
+ const password = resolveDaemonPassword(explicitHost);
277
+ const result = await tryConnectHost(tunnel.endpoint, password, clientId, timeout, nodeWebSocketFactory);
278
+ if ("client" in result)
279
+ return result.client;
280
+ const failure = tunnel.failureDetail();
281
+ tunnel.close();
282
+ if (failure)
283
+ throw new Error(`SSH connection failed: ${failure}`, { cause: result.error });
284
+ throw result.error;
285
+ }
269
286
  const offer = parseHostOfferOrNull(explicitHost);
270
287
  if (offer) {
271
288
  return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
@@ -1,5 +1,5 @@
1
1
  const JSON_OPTION_DESCRIPTION = "Output in JSON format";
2
- const DAEMON_HOST_OPTION_DESCRIPTION = "Daemon host target: host:port or tcp://host:port?ssl=true&password=secret (default: local socket/pipe, then localhost:6767)";
2
+ const DAEMON_HOST_OPTION_DESCRIPTION = "Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)";
3
3
  export function collectMultiple(value, previous) {
4
4
  return previous.concat([value]);
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpaseo/cli",
3
- "version": "0.7.0-beta.1",
3
+ "version": "0.7.0-beta.3",
4
4
  "description": "Paseo CLI - control your AI coding agents from the command line",
5
5
  "bin": {
6
6
  "paseo": "bin/paseo"
@@ -28,9 +28,9 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@clack/prompts": "^1.0.0",
31
- "@getpaseo/client": "0.7.0-beta.1",
32
- "@getpaseo/protocol": "0.7.0-beta.1",
33
- "@getpaseo/server": "0.7.0-beta.1",
31
+ "@getpaseo/client": "0.7.0-beta.3",
32
+ "@getpaseo/protocol": "0.7.0-beta.3",
33
+ "@getpaseo/server": "0.7.0-beta.3",
34
34
  "chalk": "^5.3.0",
35
35
  "commander": "^12.0.0",
36
36
  "mime-types": "^2.1.35",