@f5-sales-demo/xcsh 21.45.2 → 21.45.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.
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "21.45.2",
21
- "commit": "03750851e444f62aa128f72454749f7ab824afdd",
22
- "shortCommit": "0375085",
20
+ "version": "21.45.3",
21
+ "commit": "f2bcda523f9298bf318b85466aff6e44e8ff5872",
22
+ "shortCommit": "f2bcda5",
23
23
  "branch": "main",
24
- "tag": "v21.45.2",
25
- "commitDate": "2026-09-25T14:32:18Z",
26
- "buildDate": "2026-09-25T15:08:16.358Z",
24
+ "tag": "v21.45.3",
25
+ "commitDate": "2026-09-25T15:11:11Z",
26
+ "buildDate": "2026-09-25T15:52:20.005Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/03750851e444f62aa128f72454749f7ab824afdd",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.45.2"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/f2bcda523f9298bf318b85466aff6e44e8ff5872",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.45.3"
33
33
  };
@@ -159,11 +159,12 @@
159
159
  "commands.fork.description": "Create a new fork from a previous message",
160
160
  "commands.tree.description": "Navigate session tree (switch branches)",
161
161
  "commands.login.description": "Login with OAuth provider",
162
- "commands.login.inlineHint": "[provider|redirect URL]",
162
+ "commands.login.inlineHint": "[provider|redirect URL|copy]",
163
163
  "commands.login.alreadyInProgressFor": "OAuth login already in progress for {provider}. Paste the redirect URL with /login <url>.",
164
164
  "commands.login.alreadyInProgress": "OAuth login already in progress. Paste the redirect URL with /login <url>.",
165
165
  "commands.login.callbackReceived": "OAuth callback received; completing login…",
166
166
  "commands.login.noCallbackWaiting": "No OAuth login is waiting for a manual callback.",
167
+ "commands.login.noUrlToCopy": "No OAuth sign-in URL is waiting to copy.",
167
168
  "commands.logout.description": "Logout from OAuth provider",
168
169
  "commands.mcp.description": "Manage MCP servers (add, list, remove, test)",
169
170
  "commands.mcp.sub.add.description": "Add a new MCP server",
@@ -1,33 +1,60 @@
1
- import { type Container, Text } from "@f5-sales-demo/pi-tui";
2
- import { recoveryUrlHyperlink } from "../../tui/hyperlink";
1
+ import { type Component, type Container, TERMINAL, Text, visibleWidth, wrapTextWithAnsi } from "@f5-sales-demo/pi-tui";
2
+ import { recoveryUrlHyperlink, safeRecoveryUrl } from "../../tui/hyperlink";
3
3
  import { theme } from "../theme/theme";
4
4
 
5
5
  type AuthLinkContainer = Pick<Container, "addChild">;
6
6
 
7
+ /** Wrap plain glyphs first, then attach the complete target to each physical row. */
8
+ class RecoveryLinkText implements Component {
9
+ constructor(
10
+ private readonly url: string,
11
+ private readonly label: string,
12
+ ) {}
13
+ invalidate(): void {}
14
+
15
+ render(width: number): string[] {
16
+ const margin = width >= 3 ? " " : "";
17
+ const contentWidth = Math.max(1, width - margin.length * 2);
18
+ return wrapTextWithAnsi(this.label, contentWidth).map(segment => {
19
+ const linked = theme.fg("accent", recoveryUrlHyperlink(this.url, segment));
20
+ const line = `${margin}${linked}${margin}`;
21
+ return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
22
+ });
23
+ }
24
+ }
25
+
7
26
  export interface AuthLinkPresenterOptions {
8
27
  platform?: NodeJS.Platform;
9
28
  }
10
29
 
11
- /** Present a browser authorization target without exposing its long URL as visible terminal text. */
30
+ /** Present a browser authorization target with exact links on every wrapped row. */
12
31
  export function presentAuthLink(
13
32
  container: AuthLinkContainer,
14
33
  url: string,
15
34
  options: AuthLinkPresenterOptions = {},
16
35
  ): void {
36
+ const target = safeRecoveryUrl(url);
37
+ if (!target) {
38
+ container.addChild(new Text(theme.fg("warning", "Sign-in URL unavailable: invalid HTTP(S) target."), 1, 0));
39
+ return;
40
+ }
17
41
  const platform = options.platform ?? process.platform;
18
42
  const clickHint = platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
19
- const hyperlink = recoveryUrlHyperlink(url, "Open sign-in page");
20
- container.addChild(new Text(`${theme.fg("accent", hyperlink)} ${theme.fg("dim", `(${clickHint})`)}`, 1, 0));
21
- if (hyperlink === "Open sign-in page") container.addChild(new Text(theme.fg("accent", url), 1, 0));
22
- container.addChild(
23
- new Text(theme.fg("dim", "The full URL remains visible when terminal hyperlinks are unavailable."), 1, 0),
24
- );
43
+ const hyperlink = recoveryUrlHyperlink(target, "Open sign-in page");
44
+ container.addChild(new RecoveryLinkText(target, "Open sign-in page"));
45
+ container.addChild(new Text(theme.fg("dim", `(${clickHint})`), 1, 0));
46
+ if (!TERMINAL.hyperlinks || hyperlink === "Open sign-in page") {
47
+ container.addChild(new RecoveryLinkText(target, target));
48
+ container.addChild(new Text(theme.fg("dim", "The full URL is visible above for manual recovery."), 1, 0));
49
+ }
25
50
  }
26
51
 
27
52
  /** Render device verification details so they remain usable without hyperlink or clipboard support. */
28
53
  export function presentDeviceCode(container: AuthLinkContainer, url: string, userCode: string): void {
29
- const hyperlink = recoveryUrlHyperlink(url, url);
30
- container.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
54
+ const target = safeRecoveryUrl(url);
55
+ if (target) container.addChild(new RecoveryLinkText(target, target));
56
+ else
57
+ container.addChild(new Text(theme.fg("warning", "Verification URL unavailable: invalid HTTP(S) target."), 1, 0));
31
58
  container.addChild(new Text(theme.fg("warning", `One-time code: ${theme.bold(userCode)}`), 1, 0));
32
59
  container.addChild(
33
60
  new Text(theme.fg("dim", "Press c at the prompt to copy; the code remains readable above."), 1, 0),
@@ -40,7 +40,7 @@ import { createContextEnv } from "../../services/context-env";
40
40
  import { type SessionInfo, SessionManager } from "../../session/session-manager";
41
41
  import { FileSessionStorage } from "../../session/session-storage";
42
42
  import { isSearchProviderPreference, setPreferredImageProvider, setPreferredSearchProvider } from "../../tools";
43
- import { applyHyperlinkSetting } from "../../tui/hyperlink";
43
+ import { applyHyperlinkSetting, safeRecoveryUrl } from "../../tui/hyperlink";
44
44
  import { setSessionTerminalTitle } from "../../utils/title-generator";
45
45
  import { AgentDashboard } from "../components/agent-dashboard";
46
46
  import { AssistantMessageComponent } from "../components/assistant-message";
@@ -1980,6 +1980,8 @@ export class SelectorController {
1980
1980
  CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider) || providerId === "openai-codex";
1981
1981
  const shouldOpenBrowser = providerId !== "openai-codex" || openAICodexMethod === "browser";
1982
1982
  let endAuthorizationWait: (() => void) | undefined;
1983
+ let authorizationUrl: string | undefined;
1984
+ let authorizationKind: "browser" | "device" = "browser";
1983
1985
 
1984
1986
  const abort = new AbortController();
1985
1987
  let closeAuthFrame: (() => void) | undefined;
@@ -1997,10 +1999,22 @@ export class SelectorController {
1997
1999
  });
1998
2000
  input.onEscape = cancel;
1999
2001
  input.onSubmit = () => {
2000
- const value = input
2001
- .getValue()
2002
- .trim()
2003
- .replace(/^\/login\s+/, "");
2002
+ const typed = input.getValue().trim();
2003
+ if (authorizationKind !== "device" && authorizationUrl && /^(?:c|copy)$/iu.test(typed)) {
2004
+ const url = authorizationUrl;
2005
+ input.setValue("");
2006
+ void reviewClipboardAction(this.ctx, {
2007
+ title: "sign-in URL copy",
2008
+ identity: `login:${providerId}:authorization-url`,
2009
+ label: "Current sign-in URL",
2010
+ success: "Sign-in URL copied to the local clipboard.",
2011
+ reopen: "Enter c at the login prompt to review it again.",
2012
+ current: () => !abort.signal.aborted && authFrame?.input === input && authorizationUrl === url,
2013
+ resolveText: () => (authorizationUrl === url ? url : undefined),
2014
+ });
2015
+ return;
2016
+ }
2017
+ const value = typed.replace(/^\/login\s+/, "");
2004
2018
  if (value) manualInput.submit(value);
2005
2019
  input.setValue("");
2006
2020
  };
@@ -2030,11 +2044,21 @@ export class SelectorController {
2030
2044
  kind?: "browser" | "device";
2031
2045
  userCode?: string;
2032
2046
  }) => {
2047
+ authorizationUrl = safeRecoveryUrl(info.url);
2048
+ authorizationKind = info.kind ?? "browser";
2049
+ if (useManualInput) {
2050
+ manualInput.clearAuthorizationUrl(providerId);
2051
+ if (authorizationUrl) manualInput.setAuthorizationUrl(providerId, authorizationUrl);
2052
+ }
2033
2053
  const frame = showAuthFrame();
2034
2054
  frame.content.clear();
2035
2055
  if (info.kind === "device" && info.userCode) presentDeviceCode(frame.content, info.url, info.userCode);
2036
2056
  else presentAuthLink(frame.content, info.url);
2037
2057
  if (info.instructions) frame.content.addChild(new Text(theme.fg("muted", info.instructions), 0, 0));
2058
+ if (info.kind !== "device" && authorizationUrl)
2059
+ frame.content.addChild(
2060
+ new Text(theme.fg("dim", "Type c at the prompt to copy the exact sign-in URL."), 0, 0),
2061
+ );
2038
2062
  if (useManualInput) frame.content.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 0, 0));
2039
2063
  this.ctx.ui.requestRender();
2040
2064
  if (shouldOpenBrowser)
@@ -2055,17 +2079,24 @@ export class SelectorController {
2055
2079
  };
2056
2080
  input.onSubmit = () => {
2057
2081
  const value = input.getValue();
2058
- if (prompt.copyText && /^(?:c|copy)$/i.test(value.trim())) {
2082
+ const copyText = prompt.copyText ?? (authorizationKind === "device" ? undefined : authorizationUrl);
2083
+ if (copyText && /^(?:c|copy)$/i.test(value.trim())) {
2084
+ const copyingCode = Boolean(prompt.copyText);
2059
2085
  void reviewClipboardAction(this.ctx, {
2060
2086
  title: "login recovery copy",
2061
2087
  identity: `login:${providerId}:prompt`,
2062
- label: "One-time login recovery value",
2063
- success: "One-time login recovery value copied to the local clipboard.",
2088
+ label: copyingCode ? "One-time login recovery value" : "Current sign-in URL",
2089
+ success: copyingCode
2090
+ ? "One-time login recovery value copied to the local clipboard."
2091
+ : "Sign-in URL copied to the local clipboard.",
2064
2092
  reopen: "Enter copy at the login prompt to review it again.",
2065
- current: () => !abort.signal.aborted && frame.input === input,
2066
- resolveText: () => prompt.copyText,
2093
+ current: () =>
2094
+ !abort.signal.aborted &&
2095
+ frame.input === input &&
2096
+ (copyingCode || authorizationUrl === copyText),
2097
+ resolveText: () => (copyingCode || authorizationUrl === copyText ? copyText : undefined),
2067
2098
  }).then(outcome => {
2068
- if (outcome === "copied" || outcome === "requested") {
2099
+ if (copyingCode && (outcome === "copied" || outcome === "requested")) {
2069
2100
  restoreWaiting();
2070
2101
  resolve("");
2071
2102
  } else {
@@ -2108,6 +2139,7 @@ export class SelectorController {
2108
2139
  } else this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
2109
2140
  } finally {
2110
2141
  closeAuthFrame?.();
2142
+ manualInput.clearAuthorizationUrl(providerId);
2111
2143
  if (useManualInput) {
2112
2144
  manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
2113
2145
  }
@@ -2161,7 +2193,15 @@ export class SelectorController {
2161
2193
  try {
2162
2194
  await authStorage.login("google-vertex", {
2163
2195
  onAuth: info => {
2196
+ const authorizationUrl = safeRecoveryUrl(info.url);
2197
+ this.ctx.oauthManualInput.clearAuthorizationUrl("google-vertex");
2198
+ if (authorizationUrl)
2199
+ this.ctx.oauthManualInput.setAuthorizationUrl("google-vertex", authorizationUrl);
2164
2200
  presentAuthLink(this.ctx.chatContainer, info.url);
2201
+ if (authorizationUrl)
2202
+ this.ctx.chatContainer.addChild(
2203
+ new Text(theme.fg("dim", "Tip: Use /login copy to copy the exact sign-in URL."), 1, 0),
2204
+ );
2165
2205
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", VERTEX_MANUAL_LOGIN_TIP), 1, 0));
2166
2206
  if (!isHeadlessTerminal(runtime.environment)) {
2167
2207
  const launch = this.#launchHttpUrl(info.url);
@@ -2182,6 +2222,7 @@ export class SelectorController {
2182
2222
  onManualCodeInput: () => this.ctx.oauthManualInput.waitForInput("google-vertex"),
2183
2223
  });
2184
2224
  } finally {
2225
+ this.ctx.oauthManualInput.clearAuthorizationUrl("google-vertex");
2185
2226
  endAuthorizationWait?.();
2186
2227
  }
2187
2228
  accessToken = await authStorage.getApiKey("google-vertex");
@@ -6,11 +6,25 @@ type PendingInput = {
6
6
 
7
7
  export class OAuthManualInputManager {
8
8
  #pending?: PendingInput;
9
+ #authorization?: { providerId: string; url: string };
10
+
11
+ setAuthorizationUrl(providerId: string, url: string): void {
12
+ this.#authorization = { providerId, url };
13
+ }
14
+
15
+ get authorizationUrl(): string | undefined {
16
+ return this.#pending?.providerId === this.#authorization?.providerId ? this.#authorization?.url : undefined;
17
+ }
18
+
19
+ clearAuthorizationUrl(providerId: string): void {
20
+ if (this.#authorization?.providerId === providerId) this.#authorization = undefined;
21
+ }
9
22
 
10
23
  waitForInput(providerId: string): Promise<string> {
11
24
  if (this.#pending) {
12
25
  this.clear("Manual OAuth input superseded by a new login");
13
26
  }
27
+ if (this.#authorization?.providerId !== providerId) this.#authorization = undefined;
14
28
 
15
29
  const { promise, resolve, reject } = Promise.withResolvers<string>();
16
30
  this.#pending = { providerId, resolve, reject };
@@ -21,11 +35,13 @@ export class OAuthManualInputManager {
21
35
  if (!this.#pending) return false;
22
36
  const { resolve } = this.#pending;
23
37
  this.#pending = undefined;
38
+ this.#authorization = undefined;
24
39
  resolve(input);
25
40
  return true;
26
41
  }
27
42
 
28
43
  clear(reason = "Manual OAuth input cleared"): void {
44
+ this.#authorization = undefined;
29
45
  if (!this.#pending) return;
30
46
  const { reject } = this.#pending;
31
47
  this.#pending = undefined;
@@ -35,6 +35,7 @@ import { runReviewedAction } from "../modes/components/reviewed-action-dialog";
35
35
  import { getLoginOptions } from "../modes/controllers/login-options";
36
36
  import { theme } from "../modes/theme/theme";
37
37
  import type { InteractiveModeContext } from "../modes/types";
38
+ import { reviewClipboardAction } from "../modes/utils/clipboard-action";
38
39
  import { personProfileService } from "../person-profile/service";
39
40
  import { resolveRemoteThreadId } from "../remote-control/thread-identity";
40
41
  import { createContextEnv } from "../services/context-env";
@@ -893,9 +894,25 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
893
894
  description: t("commands.login.description"),
894
895
  inlineHint: t("commands.login.inlineHint"),
895
896
  allowArgs: true,
896
- handle: (command, runtime) => {
897
+ handle: async (command, runtime) => {
897
898
  const manualInput = runtime.ctx.oauthManualInput;
898
899
  const args = command.args.trim();
900
+ if (args.toLowerCase() === "copy") {
901
+ const url = manualInput.authorizationUrl;
902
+ if (url) {
903
+ await reviewClipboardAction(runtime.ctx, {
904
+ title: "sign-in URL copy",
905
+ identity: `login:${manualInput.pendingProviderId}:authorization-url`,
906
+ label: "Current sign-in URL",
907
+ success: "Sign-in URL copied to the local clipboard.",
908
+ reopen: "Run /login copy while this login is waiting to review it again.",
909
+ current: () => manualInput.authorizationUrl === url,
910
+ resolveText: () => manualInput.authorizationUrl,
911
+ });
912
+ } else runtime.ctx.showWarning(t("commands.login.noUrlToCopy"));
913
+ runtime.ctx.editor.setText("");
914
+ return;
915
+ }
899
916
  if (args.length > 0) {
900
917
  const matchedProvider = getLoginOptions().find(provider => provider.id === args);
901
918
  if (matchedProvider) {
@@ -5,6 +5,7 @@ import { settings } from "../config/settings";
5
5
 
6
6
  const DETECTED_TERMINAL_HYPERLINKS = TERMINAL.hyperlinks;
7
7
  let policyGeneration = 0;
8
+ let activeMode: HyperlinkMode = "auto";
8
9
  const OSC8_RE = /\x1b\]8;[^\x07\x1b]*(?:\x07|\x1b\\)/gu;
9
10
  const UNSAFE_RE = /[\x00-\x1f\x7f-\x9f]/u;
10
11
  type HyperlinkMode = "off" | "auto" | "always";
@@ -28,6 +29,7 @@ export function isHyperlinkEnabled(): boolean {
28
29
 
29
30
  export function applyHyperlinkSetting(mode?: unknown): void {
30
31
  const resolved = mode === "off" || mode === "auto" || mode === "always" ? mode : settings.get("tui.hyperlinks");
32
+ activeMode = resolved;
31
33
  const enabled = resolveHyperlinkMode(resolved);
32
34
  if (TERMINAL.hyperlinks !== enabled) policyGeneration++;
33
35
  setTerminalHyperlinks(enabled);
@@ -62,8 +64,19 @@ export function urlHyperlink(target: string, label: string): string {
62
64
  }
63
65
 
64
66
  export function recoveryUrlHyperlink(target: string, label: string): string {
67
+ const uri = safeRecoveryUrl(target);
68
+ // Authentication links must remain actionable in nested terminals whose
69
+ // capability detection is incomplete. Preserve the explicit user opt-out.
70
+ if (activeMode === "off" || !uri) return label;
71
+ return `\x1b]8;;${uri}\x07${label.replace(OSC8_RE, "")}\x1b]8;;\x07`;
72
+ }
73
+
74
+ export function safeRecoveryUrl(target: string): string | undefined {
65
75
  const normalized = /^www\./iu.test(target) ? `https://${target}` : target;
66
- return wrap(normalized, label);
76
+ const uri = safeAbsoluteUri(normalized);
77
+ if (!uri) return undefined;
78
+ const protocol = new URL(uri).protocol;
79
+ return protocol === "http:" || protocol === "https:" ? uri : undefined;
67
80
  }
68
81
 
69
82
  export function fileHyperlink(target: string, label: string, cwd = process.cwd()): string {