Skip to content

Commit 6a29662

Browse files
bespoyasovclaude
andauthored
feat(cli): introduce flags commands (#99)
* chore: extend allowed methods for api client * feat(cli): introduce `flags` commands * fix(cli): merge existing variants when adding new ones via `flags update` `--add-variant` sent only the new variants with `update_mask=variants`, which replaced the entire list. Now fetches the flag first and appends new variants to the existing set before patching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): respect `--json` / `--output` format in `flags update` Output was always rendered as a table. Now checks `resolveFormat` and emits JSON when requested, matching `get`, `list`, and `target`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(cli): split input resolver into focused modules Extract file-reading logic into `read-file.ts` and key-alias mapping into `aliases.ts`, keeping `resolve.ts` as the orchestrator. Also accept `variants` as an alias for `variant` in `--from-file` JSON so `{"variants": ["on","off"]}` is no longer silently dropped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): filter `--from-file` keys and warn on unknown entries File data was spread into the result unfiltered, so typos and extra keys passed through silently. Now applies `pickDefined` to file data and emits a stderr warning for any dropped keys. Adds a `warn` helper to the output module. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): return raw flag resources in `flags list --json` JSON mode was returning the trimmed table rows instead of the full API resources, inconsistent with `flags get --json`. Now returns `result.data.flags` directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): validate allocation percentages in `flags target` Add `validateAllocations` to the shared validation utils and use it in both the `--add` and `--from-file` paths to reject negative values, values over 100, and totals exceeding the target. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): encode flag key in URL path segments Flag keys containing `/` or `?` could hit the wrong endpoint. Now uses `encodeURIComponent` in `getFlag`, `updateFlag`, `toggleFlag`, and `archiveFlag`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d83032f commit 6a29662

37 files changed

Lines changed: 1534 additions & 71 deletions

‎packages/cli/__tests__/commands/flags.test.ts‎

Lines changed: 621 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/cli/src/commands/flags.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import type { Argv } from 'yargs';
22
import { QUICKSTART_BUILDER, launchQuickstart } from '@features/quickstart/index.js';
3+
import {
4+
listFlagsCmd,
5+
getFlagCmd,
6+
createFlagCmd,
7+
updateFlagCmd,
8+
toggleFlagCmd,
9+
resolveFlagCmd,
10+
targetFlagCmd,
11+
archiveFlagCmd,
12+
} from '@features/flags/index.js';
13+
import { safely } from '../utils/safely.js';
314

415
export const flagsCommand = {
516
command: 'flags <action>',
@@ -14,6 +25,129 @@ export const flagsCommand = {
1425
await launchQuickstart(argv as Record<string, unknown>, { features: ['flags'] });
1526
},
1627
)
28+
.command(
29+
'list',
30+
'List all flags in project',
31+
(y: Argv) => y.option('page-token', { type: 'string', describe: 'Pagination token' }),
32+
safely(listFlagsCmd),
33+
)
34+
.command(
35+
'get <flag-key>',
36+
'Get flag details (variants, targeting, status)',
37+
(y: Argv) => y.positional('flag-key', { type: 'string', demandOption: true }),
38+
safely(getFlagCmd),
39+
)
40+
.command(
41+
'create <flag-key>',
42+
'Create a new flag',
43+
(y: Argv) =>
44+
y
45+
.positional('flag-key', { type: 'string', demandOption: true })
46+
.option('description', {
47+
alias: 'd',
48+
type: 'string',
49+
describe: 'Flag description',
50+
})
51+
.option('variant', {
52+
type: 'string',
53+
array: true,
54+
describe: 'Variant name (e.g. --variant on --variant off)',
55+
})
56+
.option('client', { type: 'string', describe: 'Client name for the flag' })
57+
.option('from-file', { type: 'string', describe: 'Read options from JSON file' }),
58+
safely(createFlagCmd),
59+
)
60+
.command(
61+
'update <flag-key>',
62+
'Update flag description or variants',
63+
(y: Argv) =>
64+
y
65+
.positional('flag-key', { type: 'string', demandOption: true })
66+
.option('description', {
67+
alias: 'd',
68+
type: 'string',
69+
describe: 'New description',
70+
})
71+
.option('add-variant', {
72+
type: 'string',
73+
array: true,
74+
describe: 'Variant name to add',
75+
})
76+
.option('from-file', { type: 'string', describe: 'Read options from JSON file' }),
77+
safely(updateFlagCmd),
78+
)
79+
.command(
80+
'toggle <flag-key>',
81+
'Enable or disable a flag',
82+
(y: Argv) =>
83+
y
84+
.positional('flag-key', { type: 'string', demandOption: true })
85+
.option('on', { type: 'boolean', describe: 'Enable the flag' })
86+
.option('off', { type: 'boolean', describe: 'Disable the flag' })
87+
.conflicts('on', 'off')
88+
.check((args) => {
89+
if (!args.on && !args.off) {
90+
throw new Error('Provide either --on or --off.');
91+
}
92+
return true;
93+
}),
94+
safely(toggleFlagCmd),
95+
)
96+
.command(
97+
'resolve <flag-key>',
98+
'Resolve flag value for given context',
99+
(y: Argv) =>
100+
y
101+
.positional('flag-key', { type: 'string', demandOption: true })
102+
.option('entity', {
103+
type: 'string',
104+
describe: 'Targeting key field (e.g. targeting_key)',
105+
demandOption: true,
106+
})
107+
.option('entity-value', {
108+
type: 'string',
109+
describe: 'Targeting key value (e.g. user-123)',
110+
demandOption: true,
111+
})
112+
.option('context', {
113+
type: 'string',
114+
array: true,
115+
describe: 'Additional context key=value pair',
116+
})
117+
.option('client', { type: 'string', describe: 'Client name for resolution' }),
118+
safely(resolveFlagCmd),
119+
)
120+
.command(
121+
'target <flag-key>',
122+
'View or update targeting rules',
123+
(y: Argv) =>
124+
y
125+
.positional('flag-key', { type: 'string', demandOption: true })
126+
.option('add', {
127+
type: 'string',
128+
describe: 'Add rule as variant:percentage pairs (e.g. on:80,off:20)',
129+
})
130+
.option('targeting-key', {
131+
type: 'string',
132+
describe: 'Targeting key for the rule (used with --add)',
133+
})
134+
.option('from-file', {
135+
type: 'string',
136+
describe: 'Read rule from JSON file',
137+
}),
138+
safely(targetFlagCmd),
139+
)
140+
.command(
141+
'archive <flag-key>',
142+
'Archive a flag',
143+
(y: Argv) =>
144+
y.positional('flag-key', { type: 'string', demandOption: true }).option('force', {
145+
alias: 'f',
146+
type: 'boolean',
147+
describe: 'Skip confirmation prompt',
148+
}),
149+
safely(archiveFlagCmd),
150+
)
17151
.demandCommand(1, 'Run "confidence flags --help" to see available actions.')
18152
.strict();
19153
},

‎packages/cli/src/features/events/create.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
import type { JsonObject } from '@spotify-confidence/shared-kernel';
12
import { createEventDefinition } from '@network/index.js';
2-
import { message, printMcpResult } from '@output/print.js';
3-
import { withAuth } from '../../utils/require-auth.js';
4-
import { resolveTargetFile } from '../../utils/read-json-file.js';
3+
import { message, printMcpResult } from '@output/index.js';
4+
import { withAuth } from '@utils/require-auth.js';
5+
import { resolveInput } from '@input/index.js';
56
import { parseFieldArg } from './field-spec.js';
67

78
export const createEvent = withAuth(async function createEvent(argv, token) {
89
const eventDefinitionId = argv.name as string;
910

10-
const schema = resolveTargetFile<Record<string, unknown>>(argv, () => {
11+
const schema = resolveInput<JsonObject>(argv, () => {
1112
const fieldSpecs = (argv.field as string[] | undefined) ?? [];
1213
if (fieldSpecs.length === 0) {
1314
throw new Error('Provide at least one --field or --from-file.');

‎packages/cli/src/features/events/field-spec.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import type { JsonObject } from '@spotify-confidence/shared-kernel';
2+
13
const FIELD_TYPES = ['string', 'int', 'double', 'bool', 'struct'] as const;
24

3-
export function parseFieldArg(spec: string): [string, Record<string, unknown>] {
5+
export function parseFieldArg(spec: string): [string, JsonObject] {
46
const colonIndex = spec.indexOf(':');
57
if (colonIndex === -1) {
68
throw new Error(`Invalid field format "${spec}". Expected "name:type" (e.g. "page:string").`);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import confirm from '@inquirer/confirm';
2+
import { archiveFlag } from '@network/index.js';
3+
import { message, fail } from '@output/index.js';
4+
import { withAuth } from '@utils/require-auth.js';
5+
6+
export const archiveFlagCmd = withAuth(async function archiveFlagCmd(argv, token) {
7+
const flagKey = argv['flag-key'] as string;
8+
9+
if (argv['dry-run']) {
10+
message(JSON.stringify({ action: 'archive', flagKey }, null, 2));
11+
return;
12+
}
13+
14+
if (!argv.force) {
15+
if (!process.stdin.isTTY) {
16+
fail('Cannot prompt for confirmation without a TTY. Use --force to skip.');
17+
return;
18+
}
19+
20+
const confirmed = await confirm({
21+
message: `Archive flag "${flagKey}"? This cannot be undone.`,
22+
default: false,
23+
});
24+
25+
if (!confirmed) {
26+
message('Aborted.');
27+
return;
28+
}
29+
}
30+
31+
const result = await archiveFlag(token, flagKey);
32+
33+
if (!result.ok) {
34+
fail(result.error.message);
35+
return;
36+
}
37+
38+
message(`Flag "${flagKey}" archived.`);
39+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createFlag } from '@network/index.js';
2+
import { message, printMcpResult } from '@output/index.js';
3+
import { withAuth } from '@utils/require-auth.js';
4+
import { resolveInput } from '@input/index.js';
5+
6+
export const createFlagCmd = withAuth(async function createFlagCmd(argv, token) {
7+
const flagKey = argv['flag-key'] as string;
8+
const opts = resolveInput(argv, ['description', 'variant', 'client']);
9+
const description = opts.description as string | undefined;
10+
const variants = (opts.variant as string[]) ?? [];
11+
const client = opts.client as string | undefined;
12+
13+
if (argv['dry-run']) {
14+
message(JSON.stringify({ flagKey, description, variants, client }, null, 2));
15+
return;
16+
}
17+
18+
const result = await createFlag(token, flagKey, {
19+
description,
20+
variants: variants.length > 0 ? variants : undefined,
21+
client,
22+
});
23+
printMcpResult(result, argv);
24+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { getFlag } from '@network/index.js';
2+
import { resolveFormat, formatJson, extractFlags, fail, message, print } from '@output/index.js';
3+
import { withAuth } from '@utils/require-auth.js';
4+
import { flagStatus } from './status.js';
5+
6+
export const getFlagCmd = withAuth(async function getFlagCmd(argv, token) {
7+
const flagKey = argv['flag-key'] as string;
8+
9+
const result = await getFlag(token, flagKey);
10+
11+
if (!result.ok) {
12+
fail(result.error.message);
13+
return;
14+
}
15+
16+
const flag = result.data;
17+
const flags = extractFlags(argv);
18+
const format = resolveFormat(flags);
19+
20+
if (format === 'json') {
21+
message(formatJson(flag));
22+
return;
23+
}
24+
25+
print({
26+
data: {
27+
'Flag Key': flag.flagId ?? flag.name.replace(/^flags\//, ''),
28+
Description: flag.description ?? '',
29+
Status: flagStatus(flag),
30+
Created: flag.createTime ?? '',
31+
Updated: flag.updateTime ?? '',
32+
},
33+
columns: [
34+
{ key: 'key', header: 'Field', width: 14 },
35+
{ key: 'value', header: 'Value' },
36+
],
37+
flags,
38+
});
39+
40+
if (flag.variants && flag.variants.length > 0) {
41+
message('');
42+
message('Variants:');
43+
print({
44+
data: flag.variants.map((v) => ({
45+
name: v.name.split('/').pop() ?? v.name,
46+
description: v.description ?? '',
47+
})),
48+
columns: [
49+
{ key: 'name', header: 'Name' },
50+
{ key: 'description', header: 'Description' },
51+
],
52+
flags,
53+
});
54+
}
55+
56+
if (flag.rules && flag.rules.length > 0) {
57+
message('');
58+
message('Targeting Rules:');
59+
print({
60+
data: flag.rules.map((r) => ({
61+
name: r.name.split('/').pop() ?? r.name,
62+
enabled: String(r.enabled ?? false),
63+
targetingKey: r.targetingKeySelector ?? '',
64+
})),
65+
columns: [
66+
{ key: 'name', header: 'Rule' },
67+
{ key: 'enabled', header: 'Enabled' },
68+
{ key: 'targetingKey', header: 'Targeting Key' },
69+
],
70+
flags,
71+
});
72+
}
73+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export { listFlagsCmd } from './list.js';
2+
export { getFlagCmd } from './get.js';
3+
export { createFlagCmd } from './create.js';
4+
export { updateFlagCmd } from './update.js';
5+
export { toggleFlagCmd } from './toggle.js';
6+
export { resolveFlagCmd } from './resolve.js';
7+
export { targetFlagCmd } from './target.js';
8+
export { archiveFlagCmd } from './archive.js';
9+
export { flagStatus } from './status.js';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { listFlags } from '@network/index.js';
2+
import { resolveFormat, formatJson, extractFlags, fail, message, print } from '@output/index.js';
3+
import { withAuth } from '@utils/require-auth.js';
4+
import { flagStatus } from './status.js';
5+
6+
export const listFlagsCmd = withAuth(async function listFlagsCmd(argv, token) {
7+
const result = await listFlags(token, {
8+
pageToken: argv['page-token'] as string | undefined,
9+
});
10+
11+
if (!result.ok) {
12+
fail(result.error.message);
13+
return;
14+
}
15+
16+
const flags = extractFlags(argv);
17+
const format = resolveFormat(flags);
18+
19+
if (format === 'json') {
20+
const meta = result.data.nextPageToken
21+
? { nextPageToken: result.data.nextPageToken }
22+
: undefined;
23+
message(formatJson(result.data.flags ?? [], meta));
24+
return;
25+
}
26+
27+
const rows = (result.data.flags ?? []).map((f) => ({
28+
key: f.flagId ?? f.name.replace(/^flags\//, ''),
29+
status: flagStatus(f),
30+
variants: String(f.variants?.length ?? 0),
31+
updated: f.updateTime ?? '',
32+
}));
33+
34+
print({
35+
data: rows,
36+
columns: [
37+
{ key: 'key', header: 'Flag Key' },
38+
{ key: 'status', header: 'Status' },
39+
{ key: 'variants', header: 'Variants' },
40+
{ key: 'updated', header: 'Updated' },
41+
],
42+
flags,
43+
empty: 'No flags found.',
44+
});
45+
46+
if (result.data.nextPageToken) {
47+
message(`\nNext page: --page-token ${result.data.nextPageToken}`);
48+
}
49+
});

0 commit comments

Comments
 (0)