Repository: sfcompute/cli
Pull request #117: Add sf extend
Author: login: joshi4
Created: 2025-04-16T00:23:27Z | Merged: 2025-04-17T00:02:20Z



Diff:
diff --git a/src/index.ts b/src/index.ts
index 7c249978..bff96371 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,7 @@ import { getApiUrl } from "./helpers/urls.ts";
 import { getAppBanner } from "./lib/app-banner.ts";
 import { registerBalance } from "./lib/balance.ts";
 import { registerBuy } from "./lib/buy/index.tsx";
+import { registerExtend } from "./lib/extend/index.tsx";
 import { registerClusters } from "./lib/clusters/clusters.tsx";
 import { registerContracts } from "./lib/contracts/index.tsx";
 import { registerDev } from "./lib/dev.ts";
@@ -35,6 +36,7 @@ program
 // commands
 registerLogin(program);
 registerBuy(program);
+registerExtend(program);
 registerOrders(program);
 registerContracts(program);
 registerSell(program);
diff --git a/src/lib/buy/index.tsx b/src/lib/buy/index.tsx
index 866b6678..7469e742 100644
--- a/src/lib/buy/index.tsx
+++ b/src/lib/buy/index.tsx
@@ -32,9 +32,9 @@ import { analytics } from "../posthog.ts";
 dayjs.extend(relativeTime);
 dayjs.extend(duration);
 
-type SfBuyOptions = ReturnType<ReturnType<typeof _registerBuy>["opts"]>;
+export type SfBuyOptions = ReturnType<ReturnType<typeof _registerBuy>["opts"]>;
 
-function _registerBuy(program: Command) {
+export function _registerBuy(program: Command) {
   return program
     .command("buy")
     .description("Place a buy order")
@@ -141,7 +141,7 @@ function parseEnd(value: string) {
   return roundEndDate(parsed);
 }
 
-function parseDuration(duration?: string) {
+export function parseDuration(duration?: string) {
   if (!duration) {
     return 1 * 60 * 60; // 1 hour
   }
@@ -159,7 +159,7 @@ function parseDuration(duration?: string) {
   return parsed / 1000;
 }
 
-function parsePricePerGpuHour(price?: string) {
+export function parsePricePerGpuHour(price?: string) {
   if (!price) {
     return null;
   }
@@ -169,7 +169,7 @@ function parsePricePerGpuHour(price?: string) {
   return Number.parseFloat(priceWithoutDollar) * 100;
 }
 
-function QuoteComponent(props: { options: SfBuyOptions }) {
+export function QuoteComponent(props: { options: SfBuyOptions }) {
   const [quote, setQuote] = useState<Quote | null>(null);
   const [isLoading, setIsLoading] = useState(true);
 
@@ -193,7 +193,7 @@ function QuoteComponent(props: { options: SfBuyOptions }) {
     : <QuoteDisplay quote={quote} />;
 }
 
-function QuoteAndBuy(props: { options: SfBuyOptions }) {
+export function QuoteAndBuy(props: { options: SfBuyOptions }) {
   const [orderProps, setOrderProps] = useState<BuyOrderProps | null>(null);
 
   // submit a quote request, handle loading state
diff --git a/src/lib/contracts/ContractDisplay.tsx b/src/lib/contracts/ContractDisplay.tsx
index 0cd92ce6..5e3f101a 100644
--- a/src/lib/contracts/ContractDisplay.tsx
+++ b/src/lib/contracts/ContractDisplay.tsx
@@ -112,6 +112,13 @@ export function ContractDisplay(props: { contract: Contract }) {
             value={props.contract.colocate_with.join(", ")}
           />
         )}
+        {state === "Active" && (
+          <Row
+            headWidth={COLUMN_WIDTH}
+            head="Extend"
+            value={`sf extend --contract ${props.contract.id} --duration 1h`}
+          />
+        )}
 
         <Box flexDirection="column">
           {intervalData.map((data, index) => {
diff --git a/src/lib/contracts/utils.ts b/src/lib/contracts/utils.ts
index 5bb8579c..e073da86 100644
--- a/src/lib/contracts/utils.ts
+++ b/src/lib/contracts/utils.ts
@@ -1,12 +1,28 @@
 export type ContractState = "Upcoming" | "Active" | "Expired";
+export type ContractRange = { startsAt: Date; endsAt: Date };
+
+export function getContractRange(shape: {
+  intervals: string[];
+  quantities: number[];
+}): ContractRange {
+  const startsAt = new Date(shape.intervals[0]);
+  const endsAt = new Date(shape.intervals[shape.intervals.length - 1]);
+  return { startsAt, endsAt };
+}
+
+export function getContractAcceleratorQuantity(shape: {
+  intervals: string[];
+  quantities: number[];
+}): number {
+  return shape.quantities[0];
+}
 
 export function getContractState(shape: {
   intervals: string[];
   quantities: number[];
 }): ContractState {
   const now = new Date();
-  const startsAt = new Date(shape.intervals[0]);
-  const endsAt = new Date(shape.intervals[shape.intervals.length - 1]);
+  const { startsAt, endsAt } = getContractRange(shape);
 
   if (startsAt > now) {
     return "Upcoming";
diff --git a/src/lib/extend/index.tsx b/src/lib/extend/index.tsx
new file mode 100644
index 00000000..bd465f8e
--- /dev/null
+++ b/src/lib/extend/index.tsx
@@ -0,0 +1,98 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption(
+      "-c, --contract <contract>",
+      "Contract ID to extend e.g. cont_a9IcKaLesUBTHEY",
+    )
+    .requiredOption(
+      "-d, --duration <duration>",
+      "Extension duration (rounded up to the nearest hour)",
+      parseDuration,
+    )
+    .option("-p, --price <price>", "Price in dollars per GPU hour")
+    .option("-y, --yes", "Automatically confirm the extension")
+    .option(
+      "-q, --quote",
+      "Get a price quote without placing an extension order",
+    )
+    .configureHelp({
+      optionDescription: (option) => {
+        if (option.flags === "-h, --help") {
+          return "Display help for extend";
+        }
+        return option.description;
+      },
+    })
+    .addHelpText(
+      "after",
+      `
+Examples:
+  \x1b[2m# Get a Quote to extend a contract for 1 hour\x1b[0m]
+  $ sf extend --contract <contract_id> --duration 1h --quote
+
+  \x1b[2m# Auto confirm extending a contract by 1 hour at market price\x1b[0m]
+  $ sf extend -c <contract_id> -d 1h --yes
+
+  \x1b[2m# Extend a contract for 2 hours at a specific price\x1b[0m]
+  $ sf extend -c <contract_id> -d 2h --price 1.50
+`,
+    )
+    .action(async function extendAction(options) {
+      const contract = await getContract(options.contract);
+      if (!contract) {
+        logAndQuit(`Contract ${options.contract} not found`);
+      }
+
+      if (contract.status !== "active") {
+        logAndQuit(
+          `Contract ${contract.id} is ${contract.status}. Only active contracts can be extended.`,
+        );
+      }
+
+      const activeContract = contract as ActiveContract;
+      const activeContractRange = getContractRange(activeContract.shape);
+
+      const quoteOptions: SfBuyOptions = {
+        type: activeContract.instance_type,
+        accelerators: getContractAcceleratorQuantity(activeContract.shape) *
+          GPUS_PER_NODE,
+        colocate: [activeContract.id],
+        duration: options.duration,
+        price: options.price,
+        start: activeContractRange.endsAt,
+        quote: options.quote,
+        yes: options.yes,
+      };
+
+      if (options.quote) {
+        render(<QuoteComponent options={quoteOptions} />);
+      }
+      render(<QuoteAndBuy options={quoteOptions} />);
+    });
+}
+
+export function registerExtend(program: Command) {
+  _registerExtend(program);
+}


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR introduces a new 'extend' command to the CLI that enables users to extend existing active contracts, with support for duration and price specifications.

- Added new `extend` command in `/src/lib/extend/index.tsx` with required options for contract ID (`-c`) and duration (`-d`)
- Introduced `getContractRange` and `getContractAcceleratorQuantity` utility functions in `/src/lib/contracts/utils.ts` for better code organization
- Exported key components from `/src/lib/buy/index.tsx` (like `QuoteComponent`, `QuoteAndBuy`) to enable reuse in extend functionality
- Added validation to ensure only active contracts can be extended, using contract's end time as start time for extension

<sub>4 file(s) reviewed, 1 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=cli_117)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:29:03Z
user: login: joshi4
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T20:10:00Z
user: login: JohnPhamous
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:30:13Z
user: login: JohnPhamous
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:30:26Z
user: login: JohnPhamous
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:31:29Z
user: login: JohnPhamous
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:32:05Z
user: login: JohnPhamous
body: looks good!

non-blocking/nice to have: `sf clusters ls`, for active contracts, should show the extend command so the user can copy and paste it.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:33:58Z
user: login: sigmachirality
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:49:21Z

Review comments:
diff_hunk: @@ -0,0 +1,81 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption("-c, --contract <contract>", "Contract ID to extend")
+    .requiredOption(
+      "-d, --duration <duration>",
+      "Extension duration (rounded up to the nearest hour)",
+      parseDuration,
+    )
+    .option("-p, --price <price>", "Price in dollars per GPU hour")
+    .option("-y, --yes", "Automatically confirm the extension")
+    .option(
+      "-q, --quote",
+      "Get a price quote without placing an extension order",
+    )
+    .configureHelp({
+      optionDescription: (option) => {
+        if (option.flags === "-h, --help") {
+          return "Display help for extend";
+        }
+        return option.description;
+      },
+    })
+    .action(async function extendAction(options) {
+      const contract = await getContract(options.contract);
+      if (!contract) {
+        logAndQuit("Contract not found");
+      }
+
+      if (contract.status !== "active") {
+        logAndQuit(
+          `Contract ${contract.id} is ${contract.status}. An active contract is required to extend.`,
+        );
+      }
+
+      const activeContract = contract as ActiveContract;
+      const activeContractRange = getContractRange(activeContract.shape);
+
+      const quoteOptions: SfBuyOptions = {
+        type: activeContract.instance_type,
+        accelerators: getContractAcceleratorQuantity(activeContract.shape) *
+          GPUS_PER_NODE,
+        colocate: [activeContract.id],
+        duration: options.duration,
+        price: options.price,
+        start: activeContractRange.endsAt,
+        quote: options.quote,
+        yes: options.yes,
+      };
+
+      if (options.quote) {
+        render(<QuoteComponent options={quoteOptions} />);
+      }
+      render(<QuoteAndBuy options={quoteOptions} />);
path: src/lib/extend/index.tsx
user: login: greptile-apps[bot]
body: logic: Both QuoteComponent and QuoteAndBuy will render if quote=true. Remove lines 72-74 and use conditional rendering in line 75.

```suggestion
      render(options.quote ? <QuoteComponent options={quoteOptions} /> : <QuoteAndBuy options={quoteOptions} />);
```
created_at: 2025-04-16T19:28:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption("-c, --contract <contract>", "Contract ID to extend")
+    .requiredOption(
+      "-d, --duration <duration>",
+      "Extension duration (rounded up to the nearest hour)",
+      parseDuration,
+    )
+    .option("-p, --price <price>", "Price in dollars per GPU hour")
+    .option("-y, --yes", "Automatically confirm the extension")
+    .option(
+      "-q, --quote",
+      "Get a price quote without placing an extension order",
+    )
+    .configureHelp({
+      optionDescription: (option) => {
+        if (option.flags === "-h, --help") {
+          return "Display help for extend";
+        }
+        return option.description;
+      },
+    })
+    .action(async function extendAction(options) {
+      const contract = await getContract(options.contract);
+      if (!contract) {
+        logAndQuit("Contract not found");
+      }
+
+      if (contract.status !== "active") {
+        logAndQuit(
+          `Contract ${contract.id} is ${contract.status}. An active contract is required to extend.`,
+        );
+      }
+
+      const activeContract = contract as ActiveContract;
+      const activeContractRange = getContractRange(activeContract.shape);
+
+      const quoteOptions: SfBuyOptions = {
+        type: activeContract.instance_type,
+        accelerators: getContractAcceleratorQuantity(activeContract.shape) *
+          GPUS_PER_NODE,
+        colocate: [activeContract.id],
+        duration: options.duration,
+        price: options.price,
+        start: activeContractRange.endsAt,
+        quote: options.quote,
+        yes: options.yes,
+      };
+
+      if (options.quote) {
+        render(<QuoteComponent options={quoteOptions} />);
+      }
+      render(<QuoteAndBuy options={quoteOptions} />);
path: src/lib/extend/index.tsx
user: login: joshi4
body: not sure which is more readable - I think greptile's suggestion is less readable but not strongly opinionated 
created_at: 2025-04-16T20:10:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption("-c, --contract <contract>", "Contract ID to extend")
path: src/lib/extend/index.tsx
user: login: JohnPhamous
body: ```suggestion
    .requiredOption("-c, --contract <contract>", "Contract ID to extend e.g. cont_a9IcKaLesUBTHEY")
```
created_at: 2025-04-16T21:30:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption("-c, --contract <contract>", "Contract ID to extend")
path: src/lib/extend/index.tsx
user: login: JohnPhamous
body: makes it easier for the user to understand what thing to put here
created_at: 2025-04-16T21:30:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption("-c, --contract <contract>", "Contract ID to extend")
+    .requiredOption(
+      "-d, --duration <duration>",
+      "Extension duration (rounded up to the nearest hour)",
+      parseDuration,
+    )
+    .option("-p, --price <price>", "Price in dollars per GPU hour")
+    .option("-y, --yes", "Automatically confirm the extension")
+    .option(
+      "-q, --quote",
+      "Get a price quote without placing an extension order",
+    )
+    .configureHelp({
+      optionDescription: (option) => {
+        if (option.flags === "-h, --help") {
+          return "Display help for extend";
+        }
+        return option.description;
+      },
+    })
+    .action(async function extendAction(options) {
+      const contract = await getContract(options.contract);
+      if (!contract) {
+        logAndQuit("Contract not found");
+      }
path: src/lib/extend/index.tsx
user: login: JohnPhamous
body: ```suggestion
      if (!contract) {
        logAndQuit(`Contract ${options.contract} not found`);
      }
```

Reaffirm that the program did the right thing by reflecting the user input
created_at: 2025-04-16T21:31:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption("-c, --contract <contract>", "Contract ID to extend")
+    .requiredOption(
+      "-d, --duration <duration>",
+      "Extension duration (rounded up to the nearest hour)",
+      parseDuration,
+    )
+    .option("-p, --price <price>", "Price in dollars per GPU hour")
+    .option("-y, --yes", "Automatically confirm the extension")
+    .option(
+      "-q, --quote",
+      "Get a price quote without placing an extension order",
+    )
+    .configureHelp({
+      optionDescription: (option) => {
+        if (option.flags === "-h, --help") {
+          return "Display help for extend";
+        }
+        return option.description;
+      },
+    })
+    .action(async function extendAction(options) {
+      const contract = await getContract(options.contract);
+      if (!contract) {
+        logAndQuit("Contract not found");
+      }
+
+      if (contract.status !== "active") {
+        logAndQuit(
+          `Contract ${contract.id} is ${contract.status}. An active contract is required to extend.`,
path: src/lib/extend/index.tsx
user: login: JohnPhamous
body: ```suggestion
          `Contract ${contract.id} is ${contract.status}. Only active contracts can be extended.`,
```
created_at: 2025-04-16T21:32:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+import { type Command } from "@commander-js/extra-typings";
+import { render } from "ink";
+import dayjs from "npm:dayjs@1.11.13";
+import duration from "npm:dayjs@1.11.13/plugin/duration.js";
+import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
+import { parseDuration, SfBuyOptions } from "../buy/index.tsx";
+import React from "react";
+import { getContract } from "../../helpers/fetchers.ts";
+import { logAndQuit } from "../../helpers/errors.ts";
+import { QuoteAndBuy, QuoteComponent } from "../buy/index.tsx";
+import { ActiveContract } from "../contracts/types.ts";
+import { GPUS_PER_NODE } from "../constants.ts";
+import {
+  getContractAcceleratorQuantity,
+  getContractRange,
+} from "../contracts/utils.ts";
+
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+function _registerExtend(program: Command) {
+  return program
+    .command("extend")
+    .description("Extend an existing contract")
+    .requiredOption(
+      "-c, --contract <contract>",
+      "Contract ID to extend e.g. cont_a9IcKaLesUBTHEY",
+    )
+    .requiredOption(
+      "-d, --duration <duration>",
+      "Extension duration (rounded up to the nearest hour)",
+      parseDuration,
+    )
+    .option("-p, --price <price>", "Price in dollars per GPU hour")
+    .option("-y, --yes", "Automatically confirm the extension")
+    .option(
+      "-q, --quote",
+      "Get a price quote without placing an extension order",
+    )
+    .configureHelp({
+      optionDescription: (option) => {
+        if (option.flags === "-h, --help") {
+          return "Display help for extend";
+        }
+        return option.description;
+      },
+    })
+    .addHelpText(
+      "after",
+      `
+Examples:
+  \x1b[2m# Get a Quote to extend a contract for 1 hour\x1b[0m]
+  $ sf extend --contract <contract_id> --duration 1h --quote
+
+  \x1b[2m# Auto confirm extending a contract by 1 hour at market price\x1b[0m]
+  $ sf extend -c <contract_id> -d 1h --yes
+
+  \x1b[2m# Extend a contract for 2 hours at a specific price\x1b[0m]
+  $ sf extend -c <contract_id> -d 2h --price 1.50
+`,
+    )
+    .action(async function extendAction(options) {
+      const contract = await getContract(options.contract);
+      if (!contract) {
+        logAndQuit(`Contract ${options.contract} not found`);
+      }
+
+      if (contract.status !== "active") {
+        logAndQuit(
+          `Contract ${contract.id} is ${contract.status}. Only active contracts can be extended.`,
+        );
+      }
+
+      const activeContract = contract as ActiveContract;
+      const activeContractRange = getContractRange(activeContract.shape);
+
+      const quoteOptions: SfBuyOptions = {
+        type: activeContract.instance_type,
+        accelerators: getContractAcceleratorQuantity(activeContract.shape) *
+          GPUS_PER_NODE,
+        colocate: [activeContract.id],
+        duration: options.duration,
+        price: options.price,
+        start: activeContractRange.endsAt,
+        quote: options.quote,
+        yes: options.yes,
+      };
+
+      if (options.quote) {
+        render(<QuoteComponent options={quoteOptions} />);
+      }
+      render(<QuoteAndBuy options={quoteOptions} />);
+    });
+}
+
+export function registerExtend(program: Command) {
+  _registerExtend(program);
+}
path: src/lib/extend/index.tsx
user: login: sigmachirality
body: bit confused on why you didn't just export registerExtend directly here?
created_at: 2025-04-17T18:49:20Z
author_association: MEMBER

PR comments:
user: login: semanticdiff-com[bot]
created_at: 2025-04-16T00:23:29Z
body: Review changes with &nbsp;<a href="https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes"><picture><source media="(prefers-color-scheme: dark)" srcset="https://app.semanticdiff.com/images/github_button.svg?theme=dark&smaller=1"><source media="(prefers-color-scheme: light)" srcset="https://app.semanticdiff.com/images/github_button.svg?theme=light&smaller=1"><img src="https://app.semanticdiff.com/images/github_button.svg?theme=light&smaller=1" align="center" alt="SemanticDiff"></picture></a>
<details>
<summary>Changed Files</summary>
<br/>

| File | Status |
| :--- | :---   |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/buy/index.tsx)&nbsp;&nbsp;[src/lib/buy/index\.tsx](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/buy/index.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=15">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/buy/index.tsx) &nbsp;15% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/contracts/ContractDisplay.tsx)&nbsp;&nbsp;[src/lib/contracts/ContractDisplay\.tsx](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/contracts/ContractDisplay.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=4">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/contracts/ContractDisplay.tsx) &nbsp;4% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/typescript.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/index.ts)&nbsp;&nbsp;[src/index\.ts](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/index.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/index.ts) &nbsp;0% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/typescript.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/contracts/utils.ts)&nbsp;&nbsp;[src/lib/contracts/utils\.ts](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/contracts/utils.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/contracts/utils.ts) &nbsp;0% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/extend/index.tsx)&nbsp;&nbsp;[src/lib/extend/index\.tsx](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/extend/index.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/sfcompute/cli/pull/117/changes#src/lib/extend/index.tsx) &nbsp;0% smaller |

</details>
author_association: NONE
user: login: joshi4
created_at: 2025-04-16T19:22:10Z
body: ## User flow

<img width="1246" alt="image" src="https://github.com/user-attachments/assets/ad55b378-b4e8-4fee-8492-1313dc370829" />

author_association: CONTRIBUTOR
user: login: joshi4
created_at: 2025-04-16T22:15:45Z
body: @JohnPhamous  love the idea of putting the extend command in contracts ls - added that in. 

Will wait for the market to be back up to test. 
author_association: CONTRIBUTOR
user: login: joshi4
created_at: 2025-04-17T00:00:16Z
body: <img width="615" alt="image" src="https://github.com/user-attachments/assets/abf4951a-72d6-4af6-b3a7-a8129dd24dc1" />

author_association: CONTRIBUTOR