Skip to content

Commit 7d2ccf8

Browse files
Add experimental feature config support to the CLI (#3126)
## Summary - Adds a persisted CLI user config file for experimental feature flags, artifact directory overrides, and experimental subagent target selection. - Wires config-aware command visibility into root command construction, help text, and validation error handling so experimental commands only appear when enabled. - Updates runtime helpers and session artifact resolution to honor the new CLI config values. - Expands installation workflow checks and adds tests for the new CLI config service. ## Testing - `pnpm test ts/packages/cli/test/src/services/cli-user-config.test.ts` - `pnpm test ts/packages/cli/test/src/commands/$default.cmd.test.ts` - Not run: full repo test suite - Not run: install workflow end-to-end in this environment --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a1f8b32 commit 7d2ccf8

File tree

15 files changed

+565
-134
lines changed

15 files changed

+565
-134
lines changed

.github/workflows/cli.test-installation.yml

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -316,33 +316,27 @@ jobs:
316316
echo "Checking shell configuration updates..."
317317
318318
if [[ "${{ matrix.shell }}" == "zsh" ]]; then
319-
if grep -q "COMPOSIO_INSTALL_DIR" ~/.zshrc; then
320-
echo "✅ .zshrc updated with COMPOSIO_INSTALL"
321-
else
322-
echo "❌ .zshrc not updated"
323-
exit 1
324-
fi
325-
326-
if grep -q 'export PATH="\\$COMPOSIO_INSTALL_DIR:\\$PATH"' ~/.zshrc; then
327-
echo "✅ .zshrc updated with PATH"
328-
else
329-
echo "❌ .zshrc PATH not updated"
330-
exit 1
331-
fi
319+
config_file=~/.zshrc
332320
else
333-
if grep -q "COMPOSIO_INSTALL_DIR" ~/.bashrc; then
334-
echo "✅ .bashrc updated with COMPOSIO_INSTALL"
335-
else
336-
echo "❌ .bashrc not updated"
337-
exit 1
338-
fi
339-
340-
if grep -q 'export PATH="\\$COMPOSIO_INSTALL_DIR:\\$PATH"' ~/.bashrc; then
341-
echo "✅ .bashrc updated with PATH"
342-
else
343-
echo "❌ .bashrc PATH not updated"
344-
exit 1
345-
fi
321+
config_file=~/.bashrc
322+
fi
323+
324+
if grep -q "COMPOSIO_INSTALL_DIR" "$config_file"; then
325+
echo "✅ $config_file updated with COMPOSIO_INSTALL_DIR"
326+
else
327+
echo "❌ $config_file not updated with COMPOSIO_INSTALL_DIR"
328+
echo "=== $config_file contents ==="
329+
cat "$config_file"
330+
exit 1
331+
fi
332+
333+
if grep -q 'export PATH=.*COMPOSIO_INSTALL_DIR.*PATH' "$config_file"; then
334+
echo "✅ $config_file updated with PATH"
335+
else
336+
echo "❌ $config_file PATH not updated"
337+
echo "=== $config_file contents ==="
338+
cat "$config_file"
339+
exit 1
346340
fi
347341
348342
- name: Test custom install directory

ts/packages/cli/src/cli-main.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { CliConfig, CommandDescriptor, HelpDoc, Usage, ValidationError } from '@
55
import { FetchHttpClient } from '@effect/platform';
66
import { BunContext, BunRuntime, BunFileSystem } from '@effect/platform-bun';
77
import type { Teardown } from '@effect/platform/Runtime';
8-
import { rootCommand, runWithConfig } from 'src/commands';
8+
import { buildRootCommand, runWithConfig } from 'src/commands';
99
import { matchCommandFromArgv, getCommandHelpText } from 'src/commands/root-help';
1010
import * as constants from 'src/constants';
1111
import { ComposioCliConfig } from 'src/cli-config';
@@ -19,6 +19,7 @@ import { ComposioToolkitsRepositoryCached } from 'src/services/composio-clients-
1919
import { NodeOs } from 'src/services/node-os';
2020
import { NodeProcess } from 'src/services/node-process';
2121
import { JsPackageManagerDetector } from 'src/services/js-package-manager-detector';
22+
import { ComposioCliUserConfigLive, ComposioCliUserConfig } from 'src/services/cli-user-config';
2223
import { ComposioUserContextLive as _ComposioUserContextLive } from 'src/services/user-context';
2324
import { UpgradeBinary } from 'src/services/upgrade-binary';
2425
import { TerminalUILive } from 'src/services/terminal-ui';
@@ -48,6 +49,11 @@ export const ComposioUserContextLive = Layer.provide(
4849
Layer.mergeAll(BunFileSystem.layer, NodeOs.Default)
4950
) satisfies RequiredLayer;
5051

52+
export const ComposioCliUserConfigLayer = Layer.provide(
53+
ComposioCliUserConfigLive,
54+
Layer.mergeAll(BunFileSystem.layer, NodeOs.Default)
55+
);
56+
5157
export const ComposioSessionRepositoryLive = Layer.provide(
5258
ComposioSessionRepository.Default,
5359
Layer.mergeAll(BunFileSystem.layer, NodeOs.Default)
@@ -93,6 +99,7 @@ const layers = Layer.mergeAll(
9399
NodeOs.Default,
94100
NodeProcess.Default,
95101
UpgradeBinaryLive,
102+
ComposioCliUserConfigLayer,
96103
ComposioUserContextLive,
97104
ComposioSessionRepositoryLive,
98105
ComposioClientSingletonLive,
@@ -161,7 +168,7 @@ const collectValueOptionNamesFromUsage = (usage: Usage.Usage, acc: Set<string>)
161168
}
162169
};
163170

164-
const valueOptionNames = (() => {
171+
const collectValueOptionNames = (rootCommand: ReturnType<typeof buildRootCommand>) => {
165172
const names = new Set<string>();
166173
const visited = new Set<CommandDescriptor.Command<unknown>>();
167174
const visit = (command: CommandDescriptor.Command<unknown>) => {
@@ -176,7 +183,7 @@ const valueOptionNames = (() => {
176183
};
177184
visit(rootCommand.descriptor);
178185
return names;
179-
})();
186+
};
180187

181188
showUpdateNotice();
182189
checkForUpdateInBackground();
@@ -194,23 +201,31 @@ runWithArgs.pipe(
194201
)
195202
),
196203
Effect.catchIf(ValidationError.isValidationError, error => {
197-
const text = HelpDoc.toAnsiText(error.error).trim();
198-
const errorEffect = text.length > 0 ? Console.error(text) : Effect.void;
199-
const flagMatch = text.match(/Received unknown argument: '(-{1,2}[\w-]+)'/);
200-
const tipEffect =
201-
flagMatch && valueOptionNames.has(flagMatch[1])
202-
? Console.error(`Tip: ${flagMatch[1]} requires a value, e.g. ${flagMatch[1]} "value"`)
203-
: Effect.void;
204-
const cmdName = matchCommandFromArgv(process.argv);
205-
const helpText = cmdName ? getCommandHelpText(cmdName) : undefined;
206-
const helpEffect = helpText ? Console.error(helpText) : Effect.void;
207-
return Effect.all([errorEffect, tipEffect, helpEffect], { discard: true }).pipe(
208-
Effect.tap(() =>
209-
Effect.sync(() => {
210-
process.exitCode = 1;
211-
})
212-
)
213-
);
204+
return Effect.gen(function* () {
205+
const cliUserConfig = yield* ComposioCliUserConfig;
206+
const visibility = {
207+
isExperimentalFeatureEnabled: (feature: string) =>
208+
cliUserConfig.isExperimentalFeatureEnabled(feature),
209+
};
210+
const valueOptionNames = collectValueOptionNames(buildRootCommand(visibility));
211+
const text = HelpDoc.toAnsiText(error.error).trim();
212+
const errorEffect = text.length > 0 ? Console.error(text) : Effect.void;
213+
const flagMatch = text.match(/Received unknown argument: '(-{1,2}[\w-]+)'/);
214+
const tipEffect =
215+
flagMatch && valueOptionNames.has(flagMatch[1])
216+
? Console.error(`Tip: ${flagMatch[1]} requires a value, e.g. ${flagMatch[1]} "value"`)
217+
: Effect.void;
218+
const cmdName = matchCommandFromArgv(process.argv, visibility);
219+
const helpText = cmdName ? getCommandHelpText(cmdName, visibility) : undefined;
220+
const helpEffect = helpText ? Console.error(helpText) : Effect.void;
221+
return yield* Effect.all([errorEffect, tipEffect, helpEffect], { discard: true }).pipe(
222+
Effect.tap(() =>
223+
Effect.sync(() => {
224+
process.exitCode = 1;
225+
})
226+
)
227+
);
228+
});
214229
}),
215230
Effect.withSpan('composio-cli', {
216231
attributes: {
@@ -238,8 +253,13 @@ runWithArgs.pipe(
238253
).trim();
239254
if (message.length > 0) {
240255
yield* Console.error(message);
241-
const cmdName = matchCommandFromArgv(process.argv);
242-
const helpText = cmdName ? getCommandHelpText(cmdName) : undefined;
256+
const cliUserConfig = yield* ComposioCliUserConfig;
257+
const visibility = {
258+
isExperimentalFeatureEnabled: (feature: string) =>
259+
cliUserConfig.isExperimentalFeatureEnabled(feature),
260+
};
261+
const cmdName = matchCommandFromArgv(process.argv, visibility);
262+
const helpText = cmdName ? getCommandHelpText(cmdName, visibility) : undefined;
243263
if (helpText) {
244264
yield* Console.error(helpText);
245265
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export type CliFeatureTag = string;
2+
3+
export type TaggedValue<T> = {
4+
readonly value: T;
5+
readonly tags?: ReadonlyArray<CliFeatureTag>;
6+
};
7+
8+
export type CommandVisibility = {
9+
readonly isExperimentalFeatureEnabled: (feature: string) => boolean;
10+
};
11+
12+
export const tagged = <T>(value: T, tags?: ReadonlyArray<CliFeatureTag>): TaggedValue<T> => ({
13+
value,
14+
tags,
15+
});
16+
17+
export const experimental = <T>(feature: string, value: T): TaggedValue<T> => ({
18+
value,
19+
tags: [feature],
20+
});
21+
22+
export const isTaggedValueVisible = <T>(
23+
entry: TaggedValue<T>,
24+
visibility: CommandVisibility
25+
): boolean => {
26+
if (!entry.tags || entry.tags.length === 0) {
27+
return true;
28+
}
29+
30+
return entry.tags.every(tag => visibility.isExperimentalFeatureEnabled(tag));
31+
};
32+
33+
export const visibleValues = <T>(
34+
entries: ReadonlyArray<TaggedValue<T>>,
35+
visibility: CommandVisibility
36+
): Array<T> =>
37+
entries.filter(entry => isTaggedValueVisible(entry, visibility)).map(entry => entry.value);

ts/packages/cli/src/commands/index.ts

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,37 +29,49 @@ import { rootConnectedAccountsCmd$Link } from './connected-accounts/commands/con
2929
import { orgsCmd } from './orgs/orgs.cmd';
3030
import { renderCommandHintGraph } from 'src/services/command-hints';
3131
import { resetRuntimeDebugFlags, setRuntimeDebugFlags } from 'src/services/runtime-debug-flags';
32+
import { ComposioCliUserConfig } from 'src/services/cli-user-config';
3233
import { ComposioUserContext } from 'src/services/user-context';
3334
import { TerminalUI } from 'src/services/terminal-ui';
3435
import { detectMaster } from 'src/services/master-detector';
3536
import {
3637
formatResolveCommandProjectError,
3738
resolveCommandProject,
3839
} from 'src/services/command-project';
40+
import {
41+
type CommandVisibility,
42+
experimental,
43+
type TaggedValue,
44+
tagged,
45+
visibleValues,
46+
} from './feature-tags';
47+
48+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49+
const ROOT_COMMANDS: ReadonlyArray<TaggedValue<Command.Command<any, any, any, any>>> = [
50+
tagged(versionCmd),
51+
tagged(upgradeCmd),
52+
tagged(whoamiCmd),
53+
tagged(loginCmd),
54+
experimental('listen', listenCmd),
55+
tagged(logoutCmd),
56+
tagged(runCmd),
57+
tagged(proxyCmd),
58+
tagged(artifactsCmd),
59+
tagged(installCmd),
60+
tagged(devCmd),
61+
tagged(rootToolsCmd),
62+
tagged(rootTriggersCmd),
63+
tagged(rootToolsCmd$Search),
64+
tagged(rootConnectedAccountsCmd$Link),
65+
tagged(rootToolsCmd$Execute),
66+
tagged(generateCmd),
67+
tagged(orgsCmd),
68+
];
3969

40-
const $cmd = $defaultCmd.pipe(
41-
Command.withSubcommands([
42-
versionCmd,
43-
upgradeCmd,
44-
whoamiCmd,
45-
loginCmd,
46-
listenCmd,
47-
logoutCmd,
48-
runCmd,
49-
proxyCmd,
50-
artifactsCmd,
51-
installCmd,
52-
devCmd,
53-
rootToolsCmd,
54-
rootTriggersCmd,
55-
rootToolsCmd$Search,
56-
rootConnectedAccountsCmd$Link,
57-
rootToolsCmd$Execute,
58-
generateCmd,
59-
orgsCmd,
60-
])
61-
);
62-
export const rootCommand = $cmd;
70+
export const buildRootCommand = (visibility: CommandVisibility) => {
71+
const subcommands = visibleValues(ROOT_COMMANDS, visibility);
72+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73+
return $defaultCmd.pipe(Command.withSubcommands(subcommands as any));
74+
};
6375

6476
const parseExecuteInputHelpSlug = (argv: ReadonlyArray<string>): string | undefined => {
6577
const args = argv.slice(2);
@@ -239,8 +251,13 @@ const isDebugWhoIsMyMaster = (argv: ReadonlyArray<string>): boolean => {
239251
};
240252

241253
export const runWithConfig = Effect.gen(function* () {
254+
const cliUserConfig = yield* ComposioCliUserConfig;
255+
const visibility: CommandVisibility = {
256+
isExperimentalFeatureEnabled: feature => cliUserConfig.isExperimentalFeatureEnabled(feature),
257+
};
242258
const version = yield* getVersion;
243-
const run = Command.run($cmd, {
259+
const rootCommand = buildRootCommand(visibility);
260+
const run = Command.run(rootCommand, {
244261
name: 'composio',
245262
executable: 'composio',
246263
version,
@@ -251,11 +268,11 @@ export const runWithConfig = Effect.gen(function* () {
251268
normalizeListenStreamFlag(normalizeVersionShortFlag(argv))
252269
);
253270
if (isRootHelp(normalizedArgv)) {
254-
return printRootHelp();
271+
return printRootHelp(visibility);
255272
}
256-
const subHelp = matchSubcommandHelp(normalizedArgv);
273+
const subHelp = matchSubcommandHelp(normalizedArgv, visibility);
257274
if (subHelp) {
258-
return printSubcommandHelp(subHelp);
275+
return printSubcommandHelp(subHelp, visibility);
259276
}
260277
const parallelExecute = runParallelToolsExecuteFromArgv(normalizedArgv);
261278
if (parallelExecute) {
@@ -316,9 +333,3 @@ export const runWithConfig = Effect.gen(function* () {
316333
return run(normalizedArgv);
317334
};
318335
});
319-
320-
export const run = Command.run($cmd, {
321-
name: 'composio',
322-
version: constants.APP_VERSION,
323-
executable: 'composio',
324-
});

ts/packages/cli/src/commands/install.cmd.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Command, Options } from '@effect/cli';
44
import { FileSystem } from '@effect/platform';
55
import type { PlatformError } from '@effect/platform/Error';
66
import { Array as Arr, Effect } from 'effect';
7+
import { ComposioCliUserConfig } from 'src/services/cli-user-config';
78
import { NodeOs } from 'src/services/node-os';
89
import { TerminalUI } from 'src/services/terminal-ui';
910
import { getCompletionScript } from 'src/effects/shell-completions';
@@ -128,7 +129,11 @@ const tildify = (p: string, homedir: string): string =>
128129

129130
export const installShellIntegration = (params: {
130131
readonly completions: boolean;
131-
}): Effect.Effect<void, PlatformError, TerminalUI | NodeOs | FileSystem.FileSystem> =>
132+
}): Effect.Effect<
133+
void,
134+
PlatformError,
135+
TerminalUI | NodeOs | FileSystem.FileSystem | ComposioCliUserConfig
136+
> =>
132137
Effect.gen(function* () {
133138
const ui = yield* TerminalUI;
134139
const os = yield* NodeOs;
@@ -168,8 +173,15 @@ export const installShellIntegration = (params: {
168173
// (index.ts → install.cmd.ts → index.ts).
169174
let completionScript: string | undefined;
170175
if (params.completions && shell !== 'zsh') {
176+
const cliUserConfig = yield* ComposioCliUserConfig;
171177
const mod = yield* Effect.promise(() => import('src/commands'));
172-
const lines = yield* getCompletionScript(mod.rootCommand, shell);
178+
const lines = yield* getCompletionScript(
179+
mod.buildRootCommand({
180+
isExperimentalFeatureEnabled: feature =>
181+
cliUserConfig.isExperimentalFeatureEnabled(feature),
182+
}),
183+
shell
184+
);
173185
completionScript = lines.length > 0 ? Arr.join(lines, '\n') : undefined;
174186
}
175187

0 commit comments

Comments
 (0)