diff --git a/packages/cli/README.md b/packages/cli/README.md index 644ad0f0..2ae99b60 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -84,14 +84,14 @@ Initialize a new Bucket configuration in your project. This creates a `bucket.config.json` file with your settings and prompts for any required information not provided via options. ```bash -bucket init [--overwrite] +npx bucket init [--overwrite] ``` Options: -- `--overwrite`: Overwrite existing configuration file if one exists -- `--app-id `: Set the application ID -- `--key-format `: Set the key format for features +- `--overwrite`: Overwrite existing configuration file if one exists. +- `--app-id `: Set the application ID. +- `--key-format `: Set the key format for features. ### `bucket new [featureName]` @@ -99,16 +99,16 @@ All-in-one command to get started quickly. This command combines `init`, feature and type generation in a single step. Use this for the fastest way to get up and running with Bucket. ```bash -bucket new "My Feature" [--key my-feature] [--app-id ap123456789] [--key-format custom] [--out gen/features.ts] [--format react] +npx bucket new "My Feature" [--key my-feature] [--app-id ap123456789] [--key-format custom] [--out gen/features.ts] [--format react] ``` Options: -- `--key`: Specific key for the feature -- `--app-id`: App ID to use -- `--key-format`: Format for feature keys (custom, snake, camel, etc.) -- `--out`: Path to generate TypeScript types -- `--format`: Format of the generated types (react or node) +- `--key`: Specific key for the feature. +- `--app-id`: App ID to use. +- `--key-format`: Format for feature keys (custom, snake, camel, etc.). +- `--out`: Path to generate TypeScript types. +- `--format`: Format of the generated types (react or node). If you prefer more control over each step, you can use the individual commands (`init`, `features create`, `features types`) instead. @@ -117,7 +117,7 @@ If you prefer more control over each step, you can use the individual commands ( Log in to your Bucket account. This will authenticate your CLI for subsequent operations and store credentials securely. ```bash -bucket login +npx bucket login ``` ### `bucket logout` @@ -125,7 +125,7 @@ bucket login Log out from your Bucket account, removing stored credentials. ```bash -bucket logout +npx bucket logout ``` ### `bucket features` @@ -138,14 +138,14 @@ Create a new feature in your Bucket app. The command guides you through the feature creation process with interactive prompts if options are not provided. ```bash -bucket features create "My Feature" [--key my-feature] [--app-id ap123456789] [--key-format custom] +npx bucket features create "My Feature" [--key my-feature] [--app-id ap123456789] [--key-format custom] ``` Options: -- `--key`: Specific key for the feature -- `--app-id`: App ID to use -- `--key-format`: Format for feature keys +- `--key`: Specific key for the feature. +- `--app-id`: App ID to use. +- `--key-format`: Format for feature keys. #### `bucket features list` @@ -153,12 +153,12 @@ List all features for the current app. This helps you visualize what features are available and their current configurations. ```bash -bucket features list [--app-id ap123456789] +npx bucket features list [--app-id ap123456789] ``` Options: -- `--app-id`: App ID to use +- `--app-id`: App ID to use. #### `bucket features types` @@ -166,52 +166,69 @@ Generate TypeScript types for your features. This ensures type safety when using Bucket features in your TypeScript/JavaScript applications. ```bash -bucket features types [--app-id ap123456789] [--out gen/features.ts] [--format react] +npx bucket features types [--app-id ap123456789] [--out gen/features.ts] [--format react] ``` Options: -- `--app-id`: App ID to use -- `--out`: Path to generate TypeScript types -- `--format`: Format of the generated types (react or node) +- `--app-id`: App ID to use. +- `--out`: Path to generate TypeScript types. +- `--format`: Format of the generated types (react or node). ### `bucket companies` -Manage company data and feature access with the following subcommands. +Commands for managing companies. #### `bucket companies list` -List all companies for the current app. -This helps you visualize the companies using your features and their basic metrics. +List all companies in your app. ```bash -bucket companies list [--app-id ap123456789] [--filter nameOrId] +npx bucket companies list [--filter ] [--app-id ap123456789] ``` Options: -- `--app-id`: App ID to use -- `--filter`: Filter companies by name or ID +- `--filter`: Filter companies by name or ID. +- `--app-id`: App ID to use. -#### `bucket companies features access` +The command outputs a table with the following columns: -Grant or revoke access to specific features for a company. +- `id`: Company ID. +- `name`: Company name (shows "(unnamed)" if not set). +- `users`: Number of users in the company. +- `lastSeen`: Date when the company was last active. + +### `bucket companies features access` + +Grant or revoke access to specific features for companies, segments, and users. If no feature key is provided, you'll be prompted to select one from a list. ```bash -bucket companies features access [featureKey] [--enable|--disable] [--app-id ap123456789] +npx bucket companies features access [featureKey] [--enable|--disable] [--companies ] [--segments ] [--users ] [--app-id ap123456789] ``` Arguments: -- `companyId`: ID of the company to manage -- `featureKey`: Key of the feature to grant/revoke access to (optional, interactive selection if omitted) +- `featureKey`: Key of the feature to grant/revoke access to (optional, interactive selection if omitted). Options: -- `--enable`: Enable the feature for this company -- `--disable`: Disable the feature for this company -- `--app-id`: App ID to use +- `--enable`: Enable the feature for the specified targets. +- `--disable`: Disable the feature for the specified targets. +- `--users`: User IDs to target. Can be specified multiple times. +- `--companies`: Company IDs to target. Can be specified multiple times. +- `--segments`: Segment IDs to target. Can be specified multiple times. +- `--app-id`: App ID to use. + +At least one target (companies, segments, or users) must be specified. You must also specify either `--enable` or `--disable`, but not both. + +Example: + +```bash +# Enable feature for multiple companies and users +npx bucket companies features access my-feature --enable --companies comp_123 --companies comp_456 --users user_789 +``` ### `bucket apps` @@ -221,10 +238,95 @@ Commands for managing Bucket apps. These options can be used with any command: -- `--debug`: Enable debug mode for verbose output -- `--base-url `: Set the base URL for Bucket API -- `--api-url `: Set the API URL directly (overrides base URL) -- `--help`: Display help information for a command +- `--debug`: Enable debug mode for verbose output. +- `--base-url `: Set the base URL for Bucket API. +- `--api-url `: Set the API URL directly (overrides base URL). +- `--help`: Display help information for a command. + +## AI-Assisted Development + +Bucket provides powerful AI-assisted development capabilities through rules and Model Context Protocol (MCP). These features help your AI development tools better understand your features and provide more accurate assistance. + +### Bucket Rules (Recommended) + +The `rules` command helps you set up AI-specific rules for your project. These rules enable AI tools to better understand how to work with Bucket and feature flags and how they should be used in your codebase. + +```bash +npx bucket rules [--format cursor|copilot] [--yes] +``` + +Options: + +- `--format`: Format to add rules in: + - `cursor`: Adds rules to `.cursor/rules/bucket.mdc` for Cursor IDE integration. + - `copilot`: Adds rules to `.github/copilot-instructions.md` for GitHub Copilot integration. +- `--yes`: Skip confirmation prompts and overwrite existing files without asking. + +This command will add rules to your project that provide AI tools with context about how to setup and use Bucket feature flags. For the copilot format, the rules will be added to a dedicated section in the file, allowing you to maintain other copilot instructions alongside Bucket's rules. + +## Model Context Protocol + +The Model Context Protocol (MCP) is an open protocol that provides a standardized way to connect AI models to different data sources and tools. In the context of Bucket, MCP enables your development environment to understand your feature flags, their states, and their relationships within your codebase. This creates a seamless bridge between your feature management workflow and AI-powered development tools. MCP is in a very early stage of development and changes are frequent, if something isn't working please check out the [Model Context Protocol Website](https://modelcontextprotocol.io/) and open an [issue ticket here](https://github.com/bucketco/bucket-javascript-sdk/issues). + +### Setting up MCP + +MCP servers currently run locally on your machine. To start the MCP server run the CLI command from your Bucket initialized project directory: + +```bash +npx bucket mcp [--port ] [--app-id ap123456789] +``` + +Options: + +- `--port`: Port to run the SSE server on (defaults to 8050, "auto" for random port). +- `--app-id`: App ID to use. + +This will start an SSE server at `http://localhost:8050/sse` by default which you can connect to using your [client of choice](https://modelcontextprotocol.io/clients). Below are examples that work for [Cursor IDE](https://www.cursor.com/) and [Claude Desktop](https://claude.ai/download). + +#### Server-Side Events (SSE) + +```json +{ + "mcpServers": { + "Bucket": { + "url": "http://localhost:8050/sse" + } + } +} +``` + +#### STDIO Proxy + +Some clients don't support SSE and can instead interface with the MCP server over a STDIO proxy. + +```json +{ + "mcpServers": { + "Bucket": { + "command": "npx", + "args": ["-y", "supergateway", "--sse", "http://localhost:8050/sse"] + } + } +} +``` + +### Cursor IDE + +To enable MCP features in [Cursor IDE](https://www.cursor.com/): + +1. Open Cursor IDE. +2. Go to `Settings > MCP`. +3. Click `Add new global MCP server` and paste the `SSE` config. +4. Save and go back to Cursor. + +### Claude Desktop + +To enable MCP features in [Claude Desktop](https://claude.ai/download): + +1. Open Claude Desktop. +2. Go to `Settings > Developer`. +3. Click `Edit config` and paste the `STDIO` config. +4. Save and restart Claude Desktop. ## Development diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index bba42458..e2711b3d 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -24,6 +24,7 @@ export function registerAppCommands(cli: Command) { appsCommand .command("list") + .alias("ls") .description("List all available apps.") .action(listAppsAction); diff --git a/packages/cli/commands/companies.ts b/packages/cli/commands/companies.ts index e2d46983..f1fc7d16 100644 --- a/packages/cli/commands/companies.ts +++ b/packages/cli/commands/companies.ts @@ -1,28 +1,16 @@ -import { select } from "@inquirer/prompts"; import chalk from "chalk"; -import { Argument, Command } from "commander"; +import { Command } from "commander"; import ora, { Ora } from "ora"; import { getApp } from "../services/bootstrap.js"; -import { - CompanyFeatureAccess, - companyFeatureAccess, - listCompanies, -} from "../services/companies.js"; -import { listFeatures } from "../services/features.js"; +import { listCompanies } from "../services/companies.js"; import { configStore } from "../stores/config.js"; import { handleError, MissingAppIdError, MissingEnvIdError, } from "../utils/errors.js"; -import { - appIdOption, - companyFilterOption, - companyIdArgument, - disableFeatureOption, - enableFeatureOption, -} from "../utils/options.js"; +import { appIdOption, companyFilterOption } from "../utils/options.js"; import { baseUrlSuffix } from "../utils/path.js"; export const listCompaniesAction = async (options: { filter?: string }) => { @@ -69,136 +57,19 @@ export const listCompaniesAction = async (options: { filter?: string }) => { } }; -export const companyFeatureAccessAction = async ( - companyId: string, - featureKey: string | undefined, - options: { enable: boolean; disable: boolean }, -) => { - const { baseUrl, appId } = configStore.getConfig(); - let spinner: Ora | undefined; - - if (!appId) { - return handleError(new MissingAppIdError(), "Company Feature Access"); - } - - const app = getApp(appId); - const production = app.environments.find((e) => e.isProduction); - if (!production) { - return handleError(new MissingEnvIdError(), "Company Feature Access"); - } - - // Validate conflicting options - if (options.enable && options.disable) { - return handleError( - "Cannot both enable and disable a feature.", - "Company Feature Access", - ); - } - - if (!options.enable && !options.disable) { - return handleError( - "Must specify either --enable or --disable.", - "Company Feature Access", - ); - } - - // If feature key is not provided, let user select one - if (!featureKey) { - try { - spinner = ora( - `Loading features for app ${chalk.cyan(app.name)}${baseUrlSuffix( - baseUrl, - )}...`, - ).start(); - - const featuresResponse = await listFeatures(appId, { - envId: production.id, - }); - - if (featuresResponse.data.length === 0) { - return handleError( - "No features found for this app.", - "Company Feature Access", - ); - } - - spinner.succeed( - `Loaded features for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, - ); - - featureKey = await select({ - message: "Select a feature to manage access:", - choices: featuresResponse.data.map((feature) => ({ - name: `${feature.name} (${feature.key})`, - value: feature.key, - })), - }); - } catch (error) { - spinner?.fail("Loading features failed."); - return handleError(error, "Company Feature Access"); - } - } - - // Determine if enabling or disabling - const isEnabled = options.enable; - - try { - spinner = ora( - `${isEnabled ? "Enabling" : "Disabling"} feature ${chalk.cyan(featureKey)} for company ${chalk.cyan(companyId)}...`, - ).start(); - - const request: CompanyFeatureAccess = { - envId: production.id, - companyId, - featureKey, - isEnabled, - }; - - await companyFeatureAccess(appId, request); - - spinner.succeed( - `${isEnabled ? "Enabled" : "Disabled"} feature ${chalk.cyan(featureKey)} for company ${chalk.cyan(companyId)}.`, - ); - } catch (error) { - spinner?.fail(`Feature access update failed.`); - void handleError(error, "Company Feature Access"); - } -}; - export function registerCompanyCommands(cli: Command) { const companiesCommand = new Command("companies").description( "Manage companies.", ); - const companyFeaturesCommand = new Command("features").description( - "Manage company features.", - ); - companiesCommand .command("list") + .alias("ls") .description("List all companies.") .addOption(appIdOption) .addOption(companyFilterOption) .action(listCompaniesAction); - // Feature access command - companyFeaturesCommand - .command("access") - .description("Grant or revoke feature access for a specific company.") - .addOption(appIdOption) - .addArgument(companyIdArgument) - .addArgument( - new Argument( - "[featureKey]", - "Feature key. If not provided, you'll be prompted to select one", - ), - ) - .addOption(enableFeatureOption) - .addOption(disableFeatureOption) - .action(companyFeatureAccessAction); - - companiesCommand.addCommand(companyFeaturesCommand); - // Update the config with the cli override values companiesCommand.hook("preAction", (_, command) => { const { appId } = command.opts(); diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 5dd8288f..12597996 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,11 +1,16 @@ -import { input } from "@inquirer/prompts"; +import { input, select } from "@inquirer/prompts"; import chalk from "chalk"; -import { Command } from "commander"; +import { Argument, Command } from "commander"; import { relative } from "node:path"; import ora, { Ora } from "ora"; import { getApp, getOrg } from "../services/bootstrap.js"; -import { createFeature, Feature, listFeatures } from "../services/features.js"; +import { + createFeature, + Feature, + listFeatures, + updateFeatureAccess, +} from "../services/features.js"; import { configStore } from "../stores/config.js"; import { handleError, @@ -21,13 +26,20 @@ import { } from "../utils/gen.js"; import { appIdOption, + companyIdsOption, + disableFeatureOption, + enableFeatureOption, featureKeyOption, featureNameArgument, + segmentIdsOption, typesFormatOption, typesOutOption, + userIdsOption, } from "../utils/options.js"; import { baseUrlSuffix, featureUrl } from "../utils/path.js"; +const lf = new Intl.ListFormat("en"); + type CreateFeatureArgs = { key?: string; }; @@ -173,6 +185,122 @@ export const generateTypesAction = async () => { } }; +export const featureAccessAction = async ( + featureKey: string | undefined, + options: { + enable: boolean; + disable: boolean; + companies?: string[]; + segments?: string[]; + users?: string[]; + }, +) => { + const { baseUrl, appId } = configStore.getConfig(); + let spinner: Ora | undefined; + + if (!appId) { + return handleError(new MissingAppIdError(), "Feature Access"); + } + + const app = getApp(appId); + const production = app.environments.find((e) => e.isProduction); + if (!production) { + return handleError(new MissingEnvIdError(), "Feature Access"); + } + + // Validate conflicting options + if (options.enable && options.disable) { + return handleError( + "Cannot both enable and disable a feature.", + "Feature Access", + ); + } + + if (!options.enable && !options.disable) { + return handleError( + "Must specify either --enable or --disable.", + "Feature Access", + ); + } + + // Validate at least one target is specified + if ( + !options.companies?.length && + !options.segments?.length && + !options.users?.length + ) { + return handleError( + "Must specify at least one target using --companies, --segments, or --users.", + "Feature Access", + ); + } + + // If feature key is not provided, let user select one + if (!featureKey) { + try { + spinner = ora( + `Loading features for app ${chalk.cyan(app.name)}${baseUrlSuffix( + baseUrl, + )}...`, + ).start(); + + const featuresResponse = await listFeatures(appId, { + envId: production.id, + }); + + if (featuresResponse.data.length === 0) { + return handleError("No features found for this app.", "Feature Access"); + } + + spinner.succeed( + `Loaded features for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, + ); + + featureKey = await select({ + message: "Select a feature to manage access:", + choices: featuresResponse.data.map((feature) => ({ + name: `${feature.name} (${feature.key})`, + value: feature.key, + })), + }); + } catch (error) { + spinner?.fail("Loading features failed."); + return handleError(error, "Feature Access"); + } + } + + // Determine if enabling or disabling + const isEnabled = options.enable; + + const targets = [ + ...(options.users?.map((id) => chalk.cyan(`user ID ${id}`)) ?? []), + ...(options.companies?.map((id) => chalk.cyan(`company ID ${id}`)) ?? []), + ...(options.segments?.map((id) => chalk.cyan(`segment ID ${id}`)) ?? []), + ]; + + try { + spinner = ora( + `${isEnabled ? "Enabling" : "Disabling"} feature ${chalk.cyan(featureKey)} for ${lf.format(targets)}...`, + ).start(); + + await updateFeatureAccess(appId, { + envId: production.id, + featureKey, + isEnabled, + companyIds: options.companies, + segmentIds: options.segments, + userIds: options.users, + }); + + spinner.succeed( + `${isEnabled ? "Enabled" : "Disabled"} feature ${chalk.cyan(featureKey)} for specified ${lf.format(targets)}.`, + ); + } catch (error) { + spinner?.fail(`Feature access update failed.`); + void handleError(error, "Feature Access"); + } +}; + export function registerFeatureCommands(cli: Command) { const featuresCommand = new Command("features").description( "Manage features.", @@ -188,6 +316,7 @@ export function registerFeatureCommands(cli: Command) { featuresCommand .command("list") + .alias("ls") .description("List all features.") .addOption(appIdOption) .action(listFeaturesAction); @@ -200,6 +329,26 @@ export function registerFeatureCommands(cli: Command) { .addOption(typesFormatOption) .action(generateTypesAction); + // Add feature access command + featuresCommand + .command("access") + .description( + "Grant or revoke feature access for companies, segments, and users.", + ) + .addOption(appIdOption) + .addArgument( + new Argument( + "[featureKey]", + "Feature key. If not provided, you'll be prompted to select one", + ), + ) + .addOption(enableFeatureOption) + .addOption(disableFeatureOption) + .addOption(companyIdsOption) + .addOption(segmentIdsOption) + .addOption(userIdsOption) + .action(featureAccessAction); + // Update the config with the cli override values featuresCommand.hook("preAction", (_, command) => { const { appId, out, format } = command.opts(); diff --git a/packages/cli/commands/mcp.ts b/packages/cli/commands/mcp.ts index bc5b3b25..fe4c18d1 100644 --- a/packages/cli/commands/mcp.ts +++ b/packages/cli/commands/mcp.ts @@ -8,26 +8,32 @@ import { readFile } from "node:fs/promises"; import ora, { Ora } from "ora"; import { registerMcpTools } from "../mcp/tools.js"; -import { handleError } from "../utils/errors.js"; -import { appIdOption } from "../utils/options.js"; +import { configStore } from "../stores/config.js"; +import { handleError, MissingAppIdError } from "../utils/errors.js"; +import { appIdOption, mcpSsePortOption } from "../utils/options.js"; type MCPArgs = { port?: "auto" | number; - appId?: string; }; -export const mcpAction = async ({ appId, port = 8050 }: MCPArgs) => { +export const mcpAction = async ({ port = 8050 }: MCPArgs) => { + const { appId } = configStore.getConfig(); let spinner: Ora | undefined; + + if (!appId) { + return handleError(new MissingAppIdError(), "MCP"); + } + try { const packageJSONPath = await findUp("package.json"); if (!packageJSONPath) { - throw new Error("Unable to determine version using package.json"); + throw new Error("Unable to determine version using package.json."); } const { version } = JSON.parse( (await readFile(packageJSONPath, "utf-8")) ?? "{}", ); if (!version) { - throw new Error("Unable to determine version using package.json"); + throw new Error("Unable to determine version using package.json."); } // Create an MCP server @@ -36,6 +42,18 @@ export const mcpAction = async ({ appId, port = 8050 }: MCPArgs) => { version: version, }); + setInterval(async () => { + try { + await mcp.server.ping(); + } catch (error) { + if (error instanceof Error) { + console.error(`MCP server ping failed: ${error.message}`); + } else { + console.error(`MCP server ping failed: ${error}`); + } + } + }, 10000); + const app = express(); const transportMap = new Map(); @@ -49,15 +67,20 @@ export const mcpAction = async ({ appId, port = 8050 }: MCPArgs) => { console.log(`Transport ${sessionId} has been closed.`); }; + // Set the onerror handler to log the error + transport.onerror = (error) => { + console.error(`Transport ${sessionId} error:`, error); + }; + transportMap.set(sessionId, transport); await mcp.connect(transport); - spinner?.succeed("Client connected to MCP server"); + spinner?.succeed("Client connected to MCP server."); }); app.post("/messages", async (req, res) => { const sessionId = req.query.sessionId?.toString(); if (!sessionId) { - res.status(400).json({ error: "SessionId is not found" }); + res.status(400).json({ error: "SessionId is not found." }); return; } @@ -80,12 +103,12 @@ export const mcpAction = async ({ appId, port = 8050 }: MCPArgs) => { const assignedPort = !!address && typeof address === "object" ? address.port : port; console.log( - `\nMCP server listening at ${chalk.cyan(`http://localhost:${assignedPort}/sse`)}`, + `\nMCP server listening at ${chalk.cyan(`http://localhost:${assignedPort}/sse.`)}`, ); spinner = ora(`Waiting for connections...`).start(); }); } catch (error) { - spinner?.fail("MCP server failed to start"); + spinner?.fail("MCP server failed to start."); void handleError(error, "MCP"); } }; @@ -97,5 +120,14 @@ export function registerMcpCommand(cli: Command) { "Create an model context protocol (MCP) server between your AI assistant and the Bucket API (alpha).", ) .action(mcpAction) - .addOption(appIdOption); + .addOption(appIdOption) + .addOption(mcpSsePortOption); + + // Update the config with the cli override values + cli.hook("preAction", (_, command) => { + const { appId } = command.opts(); + configStore.setConfig({ + appId, + }); + }); } diff --git a/packages/cli/commands/rules.ts b/packages/cli/commands/rules.ts new file mode 100644 index 00000000..bfd9dd6c --- /dev/null +++ b/packages/cli/commands/rules.ts @@ -0,0 +1,132 @@ +import { confirm } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; +import { + access, + constants, + mkdir, + readFile, + writeFile, +} from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import ora from "ora"; + +import { getCopilotInstructions, getCursorRules } from "../services/rules.js"; +import { configStore } from "../stores/config.js"; +import { handleError } from "../utils/errors.js"; +import { rulesFormatOption, yesOption } from "../utils/options.js"; + +type RulesArgs = { + format?: string; + yes?: boolean; +}; + +const BUCKET_SECTION_START = ""; +const BUCKET_SECTION_END = ""; + +async function fileExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function confirmOverwrite( + filePath: string, + yes: boolean, + append: boolean = false, +): Promise { + if (yes) return true; + + if (await fileExists(filePath)) { + const projectPath = configStore.getProjectPath(); + const relativePath = relative(projectPath, filePath); + + return await confirm({ + message: `Rules ${chalk.cyan(relativePath)} already exists. ${ + append ? "Append rules?" : "Overwrite rules?" + }`, + default: false, + }); + } + + return true; +} + +function wrapInMarkers(content: string): string { + return `${BUCKET_SECTION_START}\n\n${content}\n\n${BUCKET_SECTION_END}`; +} + +function replaceOrAppendSection( + existingContent: string, + newContent: string, +): string { + const wrappedContent = wrapInMarkers(newContent); + const sectionRegex = new RegExp( + `${BUCKET_SECTION_START}[\\s\\S]*?${BUCKET_SECTION_END}`, + "g", + ); + + if (sectionRegex.test(existingContent)) { + return existingContent.replace(sectionRegex, wrappedContent); + } + + return `${existingContent}\n\n${wrappedContent}`; +} + +export const rulesAction = async ({ + format = "cursor", + yes = false, +}: RulesArgs = {}) => { + const projectPath = configStore.getProjectPath(); + const appendFormats = ["copilot"]; + let destPath: string; + let content: string; + + // Determine destination and content based on format + if (format === "cursor") { + destPath = join(projectPath, ".cursor", "rules", "bucket.mdc"); + content = getCursorRules(); + } else if (format === "copilot") { + destPath = join(projectPath, ".github", "copilot-instructions.md"); + content = getCopilotInstructions(); + } else { + console.error(`No rules added. Invalid format ${chalk.cyan(format)}.`); + return; + } + + // Check for overwrite and write file + if (await confirmOverwrite(destPath, yes, appendFormats.includes(format))) { + const spinner = ora("Adding rules...").start(); + try { + await mkdir(dirname(destPath), { recursive: true }); + + if (appendFormats.includes(format) && (await fileExists(destPath))) { + const existingContent = await readFile(destPath, "utf-8"); + content = replaceOrAppendSection(existingContent, content); + } + + await writeFile(destPath, content); + spinner.succeed( + `Rules added to ${chalk.cyan(relative(projectPath, destPath))}. +${chalk.grey("These rules should be committed to your project's version control.")}`, + ); + } catch (error) { + spinner.fail("Failed to add rules."); + void handleError(error, "Rules"); + } + } else { + console.log("Skipping adding rules."); + } +}; + +export function registerRulesCommand(cli: Command) { + cli + .command("rules") + .description("Add Bucket LLM rules to your project.") + .addOption(rulesFormatOption) + .addOption(yesOption) + .action(rulesAction); +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 264645b6..43e22394 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -10,7 +10,8 @@ import { registerFeatureCommands } from "./commands/features.js"; import { registerInitCommand } from "./commands/init.js"; import { registerMcpCommand } from "./commands/mcp.js"; import { registerNewCommand } from "./commands/new.js"; -import { bootstrap, getUser } from "./services/bootstrap.js"; +import { registerRulesCommand } from "./commands/rules.js"; +import { bootstrap, getBucketUser } from "./services/bootstrap.js"; import { authStore } from "./stores/auth.js"; import { configStore } from "./stores/config.js"; import { handleError } from "./utils/errors.js"; @@ -60,7 +61,7 @@ async function main() { if (debug) { console.debug(chalk.cyan("\nDebug mode enabled.")); - const user = getUser(); + const user = getBucketUser(); console.debug(`Logged in as ${chalk.cyan(user.name ?? user.email)}.`); console.debug( "Reading config from:", @@ -78,6 +79,7 @@ async function main() { registerFeatureCommands(program); registerCompanyCommands(program); registerMcpCommand(program); + registerRulesCommand(program); program.parse(process.argv); } diff --git a/packages/cli/mcp/responses.ts b/packages/cli/mcp/responses.ts new file mode 100644 index 00000000..27c702b1 --- /dev/null +++ b/packages/cli/mcp/responses.ts @@ -0,0 +1,73 @@ +import { FlagVersion } from "../services/features.js"; +import { JSONPrimitive } from "../utils/json.js"; + +export function textResponse(message: string, data?: JSONPrimitive) { + let response = message.trim(); + if (data) { + response += `"\n\n--- Raw JSON Response ---\n ${JSON.stringify(data, null, 2)}`; + } + return { + content: [ + { + type: "text" as const, + text: response, + }, + ], + }; +} + +export function featuresResponse(data: JSONPrimitive) { + return textResponse("List of features.", data); +} + +export function feedbackResponse(data: JSONPrimitive) { + return textResponse( + "Feedback is returned in a JSON format with pages. Feedback score is between 1 and 5, where higher means better. A score of 0 indicates that no score was provided with the feedback.", + data, + ); +} + +export function featureCreateResponse( + key: string, + link: string, + data: JSONPrimitive, +) { + return textResponse( + ` +Feature created successfully. Show this link of the feature on Bucket website to the user: ${link}. Now run the Bucket CLI in a terminal: +\`shell +$ npx bucket features types +\` +Make sure to feature flag the code with the new feature key: ${key}. +`, + data, + ); +} + +export function companiesResponse(data: JSONPrimitive) { + return textResponse("List of companies.", data); +} + +export function usersResponse(data: JSONPrimitive) { + return textResponse("List of users.", data); +} + +export function companyFeatureAccessResponse( + isEnabled: boolean, + featureKey: string, + companyId: string, +) { + return textResponse( + `${isEnabled ? "Granted" : "Revoked"} access to feature '${featureKey}' for company ID '${companyId}'.`, + ); +} + +export function updateFeatureStageResponse(featureKey: string) { + return textResponse( + `Updated flag targeted audience for feature '${featureKey}'.`, + ); +} + +export function updateFeatureAccessResponse(flagVersions: FlagVersion[]) { + return textResponse(flagVersions.map((v) => v.changeDescription).join("\n")); +} diff --git a/packages/cli/mcp/tools.ts b/packages/cli/mcp/tools.ts index 63de23b4..17db254f 100644 --- a/packages/cli/mcp/tools.ts +++ b/packages/cli/mcp/tools.ts @@ -1,52 +1,55 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import ora from "ora"; import { z } from "zod"; -import { getApp, getOrg } from "../services/bootstrap.js"; -import { - CompaniesQuerySchema, - companyFeatureAccess, - CompanyFeatureAccessSchema, - listCompanies, -} from "../services/companies.js"; +import { getApp, getOrg, listSegments } from "../services/bootstrap.js"; +import { CompaniesQuerySchema, listCompanies } from "../services/companies.js"; import { createFeature, + FeatureAccessSchema, FeatureCreateSchema, listFeatureNames, + updateFeatureAccess, } from "../services/features.js"; import { FeedbackQuerySchema, listFeedback } from "../services/feedback.js"; -import { listStages, UpdateFeatureStage } from "../services/stages.js"; -import { configStore } from "../stores/config.js"; import { - handleMcpError, - MissingAppIdError, - MissingEnvIdError, -} from "../utils/errors.js"; + listStages, + updateFeatureStage, + UpdateFeatureStageSchema, +} from "../services/stages.js"; +import { listUsers, UsersQuerySchema } from "../services/users.js"; +import { configStore } from "../stores/config.js"; +import { handleMcpError, MissingEnvIdError } from "../utils/errors.js"; import { KeyFormatPatterns } from "../utils/gen.js"; import { featureUrl } from "../utils/path.js"; +import { withDefaults, withDescriptions } from "../utils/schemas.js"; + import { - EnvironmentQuerySchema, - withDefaults, - withDescriptions, -} from "../utils/schemas.js"; + companiesResponse, + featureCreateResponse, + featuresResponse, + feedbackResponse, + updateFeatureAccessResponse, + updateFeatureStageResponse, + usersResponse, +} from "./responses.js"; export async function registerMcpTools( mcp: McpServer, - { appId }: { appId?: string }, + { appId }: { appId: string }, ) { - // const projectPath = configStore.getProjectPath(); - const { appId: configAppId, typesOutput: _ } = configStore.getConfig(); - appId = appId || configAppId; - if (!appId) { - throw new MissingAppIdError(); - } const org = getOrg(); const app = getApp(appId); + const segments = listSegments(appId); const production = app.environments.find((e) => e.isProduction); if (!production) { throw new MissingEnvIdError(); } + // Extend bootstrap spinner for loading stages + const spinner = ora("Bootstrapping...").start(); const stages = await listStages(appId); + spinner.stop(); // Add features tool mcp.tool( @@ -55,17 +58,7 @@ export async function registerMcpTools( async () => { try { const data = await listFeatureNames(appId); - return { - content: [ - { - type: "text", - text: ` -List of features. ->>> JSON Response >>> - ${JSON.stringify(data, null, 2)}`, - }, - ], - }; + return featuresResponse(data); } catch (error) { return await handleMcpError(error); } @@ -82,18 +75,7 @@ List of features. async (args) => { try { const data = await listFeedback(appId, args); - return { - content: [ - { - type: "text", - text: ` -Feedback is returned in a JSON format with pages. -Feedback score is between 1 and 5, with 0 being unknown. ->>> JSON Response >>> -${JSON.stringify(data, null, 2)}`, - }, - ], - }; + return feedbackResponse(data); } catch (error) { return await handleMcpError(error); } @@ -111,55 +93,12 @@ ${JSON.stringify(data, null, 2)}`, async (args) => { try { const feature = await createFeature(appId, args); - const featureLink = featureUrl( configStore.getConfig("baseUrl"), production, feature, ); - return { - content: [ - { - type: "text", - text: `Feature created successfully. Show this link to the feature Bucket: ${featureLink}. Now run the Bucket CLI in a terminal: -\`shell -$ npx bucket features types -\` - -After that we can feature flag some code. Use the following pattern for React: - -\`\`\`typescript -import { useFeature } from "@bucketco/react-sdk"; -function MyComponent() { - const { isEnabled } = useFeature("${feature.key}"); - if (!isEnabled) { - // feature is disabled - return null; - } - return
Feature is disabled.
; -} -\`\`\` - -For Node.js, the pattern is similar: -\`\`\` -// import the initialized bucket client -import { bucketClient } from "./bucket"; - -function myFunction() { - const { isEnabled } = bucketClient.getFeature("${feature.key}"); - - if (!isEnabled) { - // feature is disabled - return; - } - - console.log("Feature is enabled!") -} -\`\`\` -`, - }, - ], - }; + return featureCreateResponse(feature.key, featureLink, feature); } catch (error) { return await handleMcpError(error); } @@ -176,63 +115,80 @@ function myFunction() { async (args) => { try { const data = await listCompanies(appId, args); - return { - content: [ - { - type: "text", - text: ` -List of companies. ->>> JSON Response >>> -${JSON.stringify(data, null, 2)}`, - }, - ], - }; + return companiesResponse(data); } catch (error) { return await handleMcpError(error); } }, ); - // Add company feature access tool + // Add users tool mcp.tool( - "companyFeatureAccess", - "Grant or revoke feature access for a specific company of the Bucket feature management service.", - withDefaults(CompanyFeatureAccessSchema, { + "users", + "List of users in your application that use the Bucket feature management service.", + withDefaults(UsersQuerySchema, { envId: production.id, }).shape, async (args) => { try { - await companyFeatureAccess(appId, args); - return { - content: [ - { - type: "text", - text: `${args.isEnabled ? "Granted" : "Revoked"} access to feature '${args.featureKey}' for company ID '${args.companyId}'.`, - }, - ], - }; + const data = await listUsers(appId, args); + return usersResponse(data); + } catch (error) { + return await handleMcpError(error); + } + }, + ); + + // Add company feature access tool + const segmentNames = segments.map((s) => s.name); + mcp.tool( + "featureAccess", + "Grant or revoke feature access to specific users, companies, or segments of the Bucket feature management service.", + withDefaults( + FeatureAccessSchema.omit({ segmentIds: true }).extend({ + segmentNames: z + .array(z.enum(segmentNames as [string, ...string[]])) + .optional() + .describe( + `Segment names to target. Must be one of the following: ${segmentNames.join(", ")}`, + ), + }), + { + envId: production.id, + }, + ).shape, + async (args) => { + try { + const { segmentNames: selectedSegmentNames, ...rest } = args; + const segmentIds = + selectedSegmentNames + ?.map((name) => segments.find((s) => s.name === name)?.id) + .filter((id) => id !== undefined) ?? []; + const { flagVersions } = await updateFeatureAccess(appId, { + ...rest, + segmentIds, + }); + return updateFeatureAccessResponse(flagVersions); } catch (error) { return await handleMcpError(error); } }, ); + // Add update feature stage tool + const stageNames = stages.map((s) => s.name); mcp.tool( "updateFeatureStage", - "Update feature stage.", + "Update the release stage of a feature in the Bucket feature management service.", withDefaults( - EnvironmentQuerySchema.extend({ - featureId: z.string(), + UpdateFeatureStageSchema.omit({ + stageId: true, + }).extend({ stageName: z - .enum(stages.map((s) => s.name) as [string, ...string[]]) + .enum(stageNames as [string, ...string[]]) .describe( - "The name of the stage. Must be one of the following: " + - stages.map((s) => s.name).join(", "), + `The name of the stage. Must be one of the following: ${stageNames.join(", ")}`, ), - targeting: z - .enum(["none", "some", "everyone"]) - .describe("The overarching targeting mode for the feature."), - changeDescription: z.string().describe("The reason for the change"), }), { envId: production.id, @@ -245,21 +201,14 @@ ${JSON.stringify(data, null, 2)}`, } try { - const { feature } = await UpdateFeatureStage(appId, { + const { feature } = await updateFeatureStage(appId, { featureId: args.featureId, stageId: stage.id, - targetingMode: args.targeting, + targetingMode: args.targetingMode, envId: args.envId, changeDescription: args.changeDescription, }); - return { - content: [ - { - type: "text", - text: `Updated flag targeting for feature '${feature.key}'.`, - }, - ], - }; + return updateFeatureStageResponse(feature.key); } catch (error) { return await handleMcpError(error); } diff --git a/packages/cli/package.json b/packages/cli/package.json index 07b67f22..1745601d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/cli", - "version": "0.4.1", + "version": "0.5.0", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", "main": "./dist/index.js", diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts index 7630087d..8dfaf302 100644 --- a/packages/cli/services/bootstrap.ts +++ b/packages/cli/services/bootstrap.ts @@ -3,7 +3,8 @@ import { KeyFormat } from "../utils/gen.js"; export type BootstrapResponse = { org: Org; - user: User; + user: BucketUser; + segments: { [appId: string]: Segment[] }; }; export type Org = { @@ -37,7 +38,7 @@ export type App = { environments: Environment[]; }; -export type User = { +export type BucketUser = { id: string; email: string; name: string; @@ -47,6 +48,13 @@ export type User = { isAdmin: boolean; }; +export type Segment = { + id: string; + name: string; + system: boolean; + isAllSegment: boolean; +}; + let bootstrapResponse: BootstrapResponse | null = null; export async function bootstrap(): Promise { @@ -89,7 +97,7 @@ export function getApp(id: string): App { return app; } -export function getUser(): User { +export function getBucketUser(): BucketUser { if (!bootstrapResponse) { throw new Error("CLI has not been bootstrapped."); } @@ -98,3 +106,10 @@ export function getUser(): User { } return bootstrapResponse.user; } + +export function listSegments(appId: string): Segment[] { + if (!bootstrapResponse) { + throw new Error("CLI has not been bootstrapped."); + } + return bootstrapResponse.segments[appId]; +} diff --git a/packages/cli/services/companies.ts b/packages/cli/services/companies.ts index dfd2e6e1..8684be63 100644 --- a/packages/cli/services/companies.ts +++ b/packages/cli/services/companies.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { authRequest } from "../utils/auth.js"; import { - booleanish, EnvironmentQuerySchema, PaginationQueryBaseSchema, } from "../utils/schemas.js"; @@ -79,30 +78,3 @@ export async function listCompanies( body: JSON.stringify(body), }); } - -export const CompanyFeatureAccessSchema = EnvironmentQuerySchema.extend({ - companyId: z.string().describe("Company ID"), - featureKey: z.string().describe("Feature key"), - isEnabled: booleanish.describe( - "Set feature to enabled or disabled for the company.", - ), -}).strict(); - -export type CompanyFeatureAccess = z.input; - -export async function companyFeatureAccess( - appId: string, - query: CompanyFeatureAccess, -): Promise { - const { envId, companyId, ...body } = CompanyFeatureAccessSchema.parse(query); - return authRequest(`/apps/${appId}/companies/${companyId}/features`, { - method: "PATCH", - params: { - envId, - }, - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ features: [body] }), - }); -} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 53c90a86..236daa1a 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -150,3 +150,74 @@ export async function createFeature(appId: string, featureData: FeatureCreate) { }), }).then(({ feature }) => feature); } + +export const FeatureAccessSchema = EnvironmentQuerySchema.extend({ + featureKey: z.string().describe("Feature key"), + isEnabled: booleanish.describe( + "Set feature to enabled or disabled for the targeted users, companies and segments.", + ), + userIds: z.array(z.string()).optional().describe("User IDs to target"), + companyIds: z.array(z.string()).optional().describe("Company IDs to target"), + segmentIds: z.array(z.string()).optional().describe("Segment IDs to target"), +}).strict(); + +export type FeatureAccess = z.input; + +export type FlagVersion = { + id: string; + version: number; + changeDescription: string; + targetingMode: string; + userIds: string[]; + companyIds: string[]; + segmentIds: string[]; +}; + +export type UpdateFeatureAccessResponse = { + flagVersions: FlagVersion[]; +}; + +export async function updateFeatureAccess(appId: string, query: FeatureAccess) { + const { + envId, + featureKey, + isEnabled, + companyIds = [], + segmentIds = [], + userIds = [], + } = FeatureAccessSchema.parse(query); + + const targets = [ + ...companyIds.map((id) => ({ + key: featureKey, + companyId: id, + enabled: isEnabled, + })), + ...segmentIds.map((id) => ({ + key: featureKey, + segmentId: id, + enabled: isEnabled, + })), + ...userIds.map((id) => ({ + key: featureKey, + userId: id, + enabled: isEnabled, + })), + ]; + + return authRequest( + `/apps/${appId}/features/targeting`, + { + method: "PATCH", + params: { + envId, + }, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + targets, + }), + }, + ); +} diff --git a/packages/cli/services/rules.ts b/packages/cli/services/rules.ts new file mode 100644 index 00000000..afb296ee --- /dev/null +++ b/packages/cli/services/rules.ts @@ -0,0 +1,302 @@ +export function getCursorRules() { + return ` +--- +description: Guidelines for implementing feature flagging using Bucket feature management service +globs: "**/*.ts, **/*.tsx, **/*.js, **/*.jsx" +--- + +${rules} +`.trim(); +} + +export function getCopilotInstructions() { + return rules; +} + +const rules = /* markdown */ ` +# Bucket Feature Management Service for LLMs + +Bucket is a comprehensive feature management service offering feature flags, user feedback collection, adoption tracking, and remote configuration for your applications across various JavaScript frameworks, particularly React, Next.js, Node.js, vanilla browser, CLI, and OpenFeature environments. Follow these best practices for feature flagging. + +## Follow Official Documentation + +- Refer to [Bucket's official documentation](mdc:https:/docs.bucket.co) for implementation details. +- Adhere to Bucket's recommended patterns for each framework. + +## Bucket SDK Usage + +- Configure \`BucketProvider\` or \`BucketClient\` properly at application entry points. +- Leverage Bucket CLI for generating type-safe feature definitions. +- Write clean, type-safe code when applying Bucket feature flags. +- Follow established patterns in the project. + +## Feature Flag Implementation + +- Create reusable hooks and utilities for consistent feature management. +- Write clear comments for usage and checks of a feature flag. +- Properly handle feature loading states to prevent UI flashing. +- Implement proper error fallbacks when feature flag services are unavailable. + +## Feature Targeting + +- Use release stages to manage feature rollout (for example, development, staging, production). +- Use targeting modes effectively: + - \`none\`: Feature is disabled for all targets. + - \`some\`: Feature is enabled only for specified targets. + - \`everyone\`: Feature is enabled for all targets. +- Target features to specific users, companies, or segments. + +## Analytics and Feedback + +- Track feature usage with Bucket analytics. +- Collect user feedback on features. +- Monitor feature adoption and health. + +## Common Concepts + +### Targeting Rules + +Targeting rules are entities used in Bucket to describe the target audience of a given feature. The target audience refers to the users who can interact with the feature within your application. Additionally, each targeting rule contains a value that is used for the target audience. + +### Feature Stages + +Release stages in Bucket allow setting up app-wide feature access targeting rules. Each release stage defines targeting rules for each available environment. Later, during the development of new features, you can apply all those rules automatically by selecting an available release stage. + +Release stages are useful tools when a standard release workflow is used in your organization. + +Predefined stages: + +- In development +- Internal +- Beta +- General Availability + +### Segments + +A segment entity in Bucket is a dynamic collection of companies. Segments' dynamic nature results from the fact that they use filters to evaluate which companies are included in them. + +#### Segment filters can be constructed using any combination of the following rules: + +- company attributes +- user feature access +- feature metrics +- other segments + +### Integrations + +Connect Bucket with your existing tools: + +- Linear +- Datadog +- Segment +- PostHog +- Amplitude +- Mixpanel +- AWS S3 +- Slack + +## React SDK Implementation + +### Installation + +\`\`\`bash +npm i @bucketco/react-sdk +\`\`\` + +### Key Features + +- Feature toggling with fine-grained targeting +- User feedback collection +- Feature usage tracking +- Remote configuration +- Type-safe feature management + +### Basic Setup + +1. Add the \`BucketProvider\` to wrap your application: + +\`\`\`jsx +import { BucketProvider } from "@bucketco/react-sdk"; + + + +; +\`\`\` + +1. Create a feature and generate type-safe definitions: + +\`\`\`bash +npm i --save-dev @bucketco/cli +npx bucket new "Feature name" +\`\`\` + +\`\`\`typescript +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. +// eslint-disable +// prettier-ignore +import "@bucketco/react-sdk"; + +declare module "@bucketco/react-sdk" { + export interface Features { + "feature-key": { + config: { + payload: { + tokens: number; + }; + }; + }; + } +} +\`\`\` + +1. Use features in your components: + +\`\`\`jsx +import { useFeature } from "@bucketco/react-sdk"; + +function StartHuddleButton() { + const { + isLoading, // true while features are being loaded + isEnabled, // boolean indicating if the feature is enabled + config: { + // feature configuration + key, // string identifier for the config variant + payload, // type-safe configuration object + }, + track, // function to track feature usage + requestFeedback, // function to request feedback for this feature + } = useFeature("huddle"); + + if (isLoading) { + return ; + } + + if (!isEnabled) { + return null; + } + + return ( + <> + + + + ); +} +\`\`\` + +### Core React Hooks + +- \`useFeature()\` - Access feature status, config, and tracking +- \`useTrack()\` - Send custom events to Bucket +- \`useRequestFeedback()\` - Open feedback dialog for a feature +- \`useSendFeedback()\` - Programmatically send feedback +- \`useUpdateUser()\` / \`useUpdateCompany()\` - Update user/company data +- \`useUpdateOtherContext()\` - Update session-only context data +- \`useClient()\` - Access the underlying Bucket client + +## Node.js SDK Implementation + +### Installation + +\`\`\`bash +npm i @bucketco/node-sdk +\`\`\` + +### Key Features + +- Server-side feature flag evaluation +- User and company context management +- Flexible integration options +- Event tracking + +### Basic Setup + +\`\`\`javascript +import { BucketClient } from "@bucketco/node-sdk"; + +const client = new BucketClient({ + secretKey: process.env.BUCKET_SECRET_KEY, +}); + +// Check if a feature is enabled +const isEnabled = await client.isEnabled("feature-key", { + user: { id: "user_123", role: "admin" }, + company: { id: "company_456", plan: "enterprise" }, +}); +\`\`\` + +### Context Management + +\`\`\`javascript +// Set user and company context +await client.setContext({ + user: { + id: "user_123", + email: "user@example.com", + role: "admin", + }, + company: { + id: "company_456", + name: "Acme Inc", + plan: "enterprise", + }, +}); + +// Check feature after setting context +const isEnabled = await client.isEnabled("feature-key"); +\`\`\` + +### Feature Configuration + +\`\`\`javascript +// Get feature configuration +const config = await client.getConfig("feature-key", { + user: { id: "user_123" }, + company: { id: "company_456" }, +}); + +// Use the configuration +console.log(config.payload.maxDuration); +\`\`\` + +### Event Tracking + +\`\`\`javascript +// Track feature usage +await client.track("feature-key", { + user: { id: "user_123" }, + company: { id: "company_456" }, + metadata: { action: "completed" }, +}); + +// Track custom events +await client.trackEvent("custom-event", { + user: { id: "user_123" }, + company: { id: "company_456" }, + metadata: { value: 42 }, +}); +\`\`\` + +## Further Resources + +- [Official Documentation](mdc:https:/docs.bucket.co) +- [Docs llms.txt](mdc:https:/docs.bucket.co/llms.txt) +- [GitHub Repository](mdc:https:/github.com/bucketco/bucket-javascript-sdk) +- [Example React App](mdc:https:/github.com/bucketco/bucket-javascript-sdk/tree/main/packages/react-sdk/dev) +`.trim(); diff --git a/packages/cli/services/stages.ts b/packages/cli/services/stages.ts index ce04b414..d365b0c8 100644 --- a/packages/cli/services/stages.ts +++ b/packages/cli/services/stages.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { authRequest } from "../utils/auth.js"; +import { EnvironmentQuerySchema } from "../utils/schemas.js"; import { getFeature, getFlag } from "./features.js"; @@ -21,35 +22,33 @@ export async function listStages(appId: string): Promise { export const FeatureTargetingModes = ["none", "some", "everyone"] as const; export type FeatureTargetingMode = (typeof FeatureTargetingModes)[number]; -export const UpdateFeatureStageSchema = z.object({ - featureKey: z.string(), - targetingMode: z.enum(FeatureTargetingModes).optional(), +export const UpdateFeatureStageSchema = EnvironmentQuerySchema.extend({ + featureId: z.string().describe("The ID of the feature to update."), + stageId: z + .string() + .describe("The ID of the stage to transition the feature to."), + targetingMode: z.enum(["none", "some", "everyone"]) + .describe(`The overarching targeting mode for the feature. + - none: Feature is disabled for all targets. + - some: Feature is enabled only for specified targets. + - everyone: Feature is enabled for all targets.`), + changeDescription: z.string().describe("The reason for the change."), }); +export type UpdateFeatureStageQuery = z.input; -export type UpdateFeatureStageArgs = { - stageId: string; - changeDescription: string; - targetingMode: FeatureTargetingMode; - featureId: string; - envId: string; -}; - -export async function UpdateFeatureStage( +export async function updateFeatureStage( appId: string, - { - stageId, - targetingMode, - featureId, - envId, - changeDescription, - }: UpdateFeatureStageArgs, + query: UpdateFeatureStageQuery, ) { + const { featureId, stageId, targetingMode, envId, changeDescription } = + UpdateFeatureStageSchema.parse(query); + const feature = await getFeature(appId, featureId, { envId, }); if (!feature) { - throw new Error(`Feature not found for ID ${featureId}`); + throw new Error(`Feature not found for ID ${featureId}.`); } const flag = await getFlag(appId, feature.flagId!, { @@ -61,7 +60,7 @@ export async function UpdateFeatureStage( (v) => v.environment.id === envId, ); if (!envFlagVersion) { - throw new Error(`Flag version not found for environment ${envId}`); + throw new Error(`Flag version not found for environment ${envId}.`); } envFlagVersion.targetingMode = targetingMode; const body = { @@ -81,14 +80,13 @@ export async function UpdateFeatureStage( await authRequest(`/apps/${appId}/flags/${flag.id}/versions`, { method: "POST", + headers: { + "Content-Type": "application/json", + }, params: { envId, "currentVersionIds[]": flag.currentVersions.map((v) => v.id), }, - - headers: { - "Content-Type": "application/json", - }, body: JSON.stringify(body), }); return { diff --git a/packages/cli/services/users.ts b/packages/cli/services/users.ts new file mode 100644 index 00000000..2ccb0456 --- /dev/null +++ b/packages/cli/services/users.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { authRequest } from "../utils/auth.js"; +import { + EnvironmentQuerySchema, + PaginationQueryBaseSchema, +} from "../utils/schemas.js"; +import { PaginatedResponse } from "../utils/types.js"; + +export type UserName = { + id: string; + name: string | null; + email: string | null; + avatarUrl: string | null; +}; + +export type User = UserName & { + firstSeen: string | null; + lastSeen: string | null; + eventCount: number; +}; + +export type UsersResponse = PaginatedResponse; + +export const UsersSortByColumns = [ + "id", + "name", + "email", + "avatarUrl", + "firstSeen", + "lastSeen", + "eventCount", +] as const; + +export const UsersSortBySchema = z + .enum(UsersSortByColumns) + .describe("Column to sort users by"); + +export const UsersQuerySchema = EnvironmentQuerySchema.merge( + PaginationQueryBaseSchema(), +) + .extend({ + sortBy: UsersSortBySchema.default("name"), + }) + .strict(); + +export type UsersQuery = z.input; + +export async function listUsers( + appId: string, + query: UsersQuery, +): Promise { + return authRequest(`/apps/${appId}/users`, { + params: UsersQuerySchema.parse(query), + }); +} diff --git a/packages/cli/stores/config.ts b/packages/cli/stores/config.ts index 8c8dead9..9bbc1ff2 100644 --- a/packages/cli/stores/config.ts +++ b/packages/cli/stores/config.ts @@ -67,7 +67,7 @@ class ConfigStore { // Using current config store file, resolve the schema.json path const filePath = fileURLToPath(import.meta.url); const schemaPath = join( - filePath.substring(0, filePath.indexOf("cli") + 3), + filePath.substring(0, filePath.lastIndexOf("cli") + 3), "schema.json", ); const content = await readFile(schemaPath, "utf-8"); diff --git a/packages/cli/utils/errors.ts b/packages/cli/utils/errors.ts index f3799a15..1be1bc44 100644 --- a/packages/cli/utils/errors.ts +++ b/packages/cli/utils/errors.ts @@ -72,7 +72,11 @@ export async function handleMcpError(error: unknown): Promise<{ if (error instanceof Response) { try { const data = await error.json(); - errorMessage = data.error?.message ?? data.error?.code ?? "API Error"; + errorMessage = `API Error: ${ + data.error?.message ?? + data.error?.code ?? + `${error.statusText} (${error.status})` + }`; if (data.validationErrors) { const validationDetails = data.validationErrors diff --git a/packages/cli/utils/json.ts b/packages/cli/utils/json.ts index b5e9480d..ed79f4be 100644 --- a/packages/cli/utils/json.ts +++ b/packages/cli/utils/json.ts @@ -1,4 +1,4 @@ -type JSONPrimitive = +export type JSONPrimitive = | number | string | boolean diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts index 76bf58c7..1ec847e1 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -2,7 +2,7 @@ import { Argument, Option } from "commander"; import { CONFIG_FILE_NAME } from "./constants.js"; -export const debugOption = new Option("--debug", "Enable debug mode"); +export const debugOption = new Option("--debug", "Enable debug mode."); export const baseUrlOption = new Option( "--base-url [url]", @@ -44,15 +44,14 @@ export const featureKeyOption = new Option( "Feature key. If not provided, a key is generated from the feature's name.", ); -export const mcpPortOption = new Option( +export const mcpSsePortOption = new Option( "-p, --port [port]", - "Port for the MCP server to listen on.", + "Port for the MCP server to listen on when using SSE transport with the --sse flag.", ).default(8050); -// Company related options export const companyFilterOption = new Option( "-f, --filter [name]", - "Filter companies by name or ID", + "Filter companies by name or ID.", ); export const companyIdArgument = new Argument("", "Company ID"); @@ -63,10 +62,37 @@ export const featureKeyArgument = new Argument( export const enableFeatureOption = new Option( "--enable", - "Enable the feature for this company", -); + "Enable the feature for the target.", +).conflicts("disable"); export const disableFeatureOption = new Option( "--disable", - "Disable the feature for this company", + "Disable the feature for the target.", +).conflicts("enable"); + +export const userIdsOption = new Option( + "--users ", + "User IDs to target. Can be specified multiple times.", +); + +export const companyIdsOption = new Option( + "--companies ", + "Company IDs to target. Can be specified multiple times.", +); + +export const segmentIdsOption = new Option( + "--segments ", + "Segment IDs to target. Can be specified multiple times.", +); + +export const rulesFormatOption = new Option( + "-f, --format [format]", + "Format to copy rules in", +) + .choices(["cursor", "copilot"]) + .default("cursor"); + +export const yesOption = new Option( + "-y, --yes", + "Skip confirmation prompts and overwrite existing files without asking.", );