@quonfig/cli 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,14 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { SKILL_NAME } from '../../agent/skill-bundle.generated.js';
4
5
  import { APICommand } from '../../index.js';
5
6
  import { getAppUrl } from '../../util/domain-urls.js';
6
7
  import nameArg from '../../util/name-arg.js';
7
- const SKILL_NAME = 'qfg-flag-cleanup';
8
+ // The public Quonfig agent skill (github.com/quonfig/skills, installed with
9
+ // `qfg agent install-skill` or the Claude Code plugin). Its
10
+ // references/retire-a-flag.md is the code-removal half of this workflow.
11
+ const SKILL_REFERENCE = 'references/retire-a-flag.md';
8
12
  const GITIGNORE_ENTRY = '.qf/cleanup/';
9
13
  // SDK client method names worth grepping for in the customer's codebase.
10
14
  // Based on the surfaces the official Quonfig SDKs expose (sdk-node /
@@ -121,14 +125,16 @@ function ensureGitignoreEntry(cwd) {
121
125
  }
122
126
  export default class CleanupRemove extends APICommand {
123
127
  static args = { ...nameArg };
124
- static description = `Write a cleanup payload for a ready-for-cleanup flag and hand off to the qfg-flag-cleanup Claude skill.
128
+ static description = `Write a cleanup payload for a ready-for-cleanup flag and hand off to the Quonfig agent skill.
125
129
 
126
130
  Modeled on \`qfg migrate my-code\` — this command never edits source files
127
131
  itself. It validates that the flag is marked readyForCleanup=true, refuses to
128
132
  proceed if there are still evals_2d > 0 (use --force to override), writes
129
133
  \`.qf/cleanup/<key>.json\` describing the flag's current rule shape +
130
- telemetry, and prints instructions to invoke the qfg-flag-cleanup skill which
131
- asks the engineer which value should "win" and applies the inlining.
134
+ telemetry, and prints how to have your coding agent remove the call sites with
135
+ the quonfig agent skill (its references/retire-a-flag.md), which asks the
136
+ engineer which value should "win" and applies the inlining. Install the skill
137
+ with \`qfg agent install-skill\` or \`claude plugin install quonfig@quonfig\`.
132
138
 
133
139
  The payload deliberately does NOT suggest a winning value; that's the
134
140
  engineer's call. Run \`qfg cleanup status <key>\` first if you want to inspect
@@ -208,9 +214,14 @@ telemetry before retiring.`;
208
214
  this.log(`Payload: ${relPayloadPath}`);
209
215
  this.log(`Evals (2d): ${totals.evals_2d}${payload.forced ? ' (--force overrode the safety gate)' : ''}`);
210
216
  this.log('');
211
- this.log(`To remove this flag's call sites, invoke Claude with the ${SKILL_NAME} skill:`);
217
+ this.log(`To remove this flag's call sites, ask your coding agent to retire it with the quonfig agent skill`);
218
+ this.log(`(${SKILL_REFERENCE} covers this workflow), e.g. in Claude Code:`);
212
219
  this.log('');
213
- this.log(` claude "/${SKILL_NAME} ${key}"`);
220
+ this.log(` claude "Retire the Quonfig flag ${key} using ${relPayloadPath}"`);
221
+ this.log('');
222
+ this.log(`No quonfig skill in this repo yet? Install it first:`);
223
+ this.log(` qfg agent install-skill # writes .claude/skills/quonfig/`);
224
+ this.log(` # or: claude plugin marketplace add quonfig/skills && claude plugin install quonfig@quonfig`);
214
225
  this.log('');
215
226
  this.log(`The skill will:`);
216
227
  this.log(` 1. Read ${relPayloadPath}`);
@@ -42,13 +42,13 @@ export default class CleanupStatus extends APICommand {
42
42
  static description = `Drill into one ready-for-cleanup flag — show telemetry across all environments and the current rule shape.
43
43
 
44
44
  Use this after \`qfg cleanup list\` to inspect a specific flag before handing
45
- removal off to the qfg-flag-cleanup Claude skill. The eval counts come from
45
+ removal off to the Quonfig agent skill. The eval counts come from
46
46
  analytics.configSparklines (the same backing data the per-flag sparklines on
47
47
  the flag detail page use), summed into 24h/2d/7d/30d windows so you can decide
48
48
  whether it's safe to retire.
49
49
 
50
50
  Pass --json for the structured object including the full rule shape per
51
- environment — the cleanup skill consumes this directly.`;
51
+ environment — the agent skill consumes this directly.`;
52
52
  static examples = [
53
53
  '<%= config.bin %> <%= command.id %> my.flag.key',
54
54
  '<%= config.bin %> <%= command.id %> my.flag.key --json',
@@ -4,14 +4,15 @@ export default class Cleanup extends BaseCommand {
4
4
 
5
5
  The cleanup workflow turns a flag's "Ready for cleanup" marker into an end-to-end
6
6
  removal flow: surface the flags that are safe to retire, hand the actual code
7
- removal to the qfg-flag-cleanup Claude skill, then delete the flag definition
8
- once the call sites are gone.
7
+ removal to your coding agent with the Quonfig agent skill (install it with
8
+ \`qfg agent install-skill\`), then delete the flag definition once the call
9
+ sites are gone.
9
10
 
10
11
  Lifecycle (high-level):
11
12
  1. Owner flips readyForCleanup=true in the UI (already exists)
12
13
  2. qfg cleanup list see candidates + telemetry
13
14
  3. qfg cleanup status <key> drill into one flag
14
- 4. qfg cleanup remove <key> handoff to the cleanup skill
15
+ 4. qfg cleanup remove <key> handoff to the agent skill
15
16
  5. PR merges, SDK redeploys, telemetry confirms 0 evals
16
17
  6. qfg cleanup verify <key> (optional) confirm safe
17
18
  7. qfg delete <key> remove the flag definition`;
@@ -89,6 +89,9 @@ CRITERION FIELDS
89
89
  Each criterion object has:
90
90
  operator (required) — see operator table below
91
91
  propertyName (required for PROP_* operators) — user property, e.g. "user.key", "user.plan"
92
+ To target specific customers or users, match on the context KEY
93
+ ("organization.key", "user.key"), not a slug, email or name.
94
+ Find a key from a name with: qfg contexts search organization <name>
92
95
  valueToMatch (required for all except ALWAYS_TRUE / IS_PRESENT / IS_NOT_PRESENT) — typed value to compare against
93
96
 
94
97
  ────────────────────────────────────────────────────────────
@@ -0,0 +1,15 @@
1
+ import type { JsonObj } from '../../result.js';
2
+ import { APICommand } from '../../index.js';
3
+ export default class ContextsSearch extends APICommand {
4
+ static args: {
5
+ contextType: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
6
+ query: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static description: string;
9
+ static examples: string[];
10
+ static flags: {
11
+ environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
+ limit: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
+ };
14
+ run(): Promise<JsonObj | void>;
15
+ }
@@ -0,0 +1,78 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { APICommand } from '../../index.js';
3
+ export default class ContextsSearch extends APICommand {
4
+ static args = {
5
+ contextType: Args.string({
6
+ description: 'Context type to search, e.g. organization or user (the part before ".key" in a rule)',
7
+ required: true,
8
+ }),
9
+ query: Args.string({
10
+ description: 'Case-insensitive text matched against each context key and its reported properties (e.g. a name)',
11
+ required: true,
12
+ }),
13
+ };
14
+ static description = `Find a context's key from a name, to target it in a rule.
15
+
16
+ Targeting rules should match a context by its KEY (<contextType>.key, e.g.
17
+ organization.key or user.key), not by a slug, email or name. This searches the
18
+ contexts your SDKs have reported through telemetry, matching the query against
19
+ each context's key and properties, and prints each match's key, its name and
20
+ its email (blank when the context reported none), most recently seen first.
21
+
22
+ Only contexts your SDKs have reported are searchable. If nothing matches, the key
23
+ is unknown: get it from whoever owns that customer rather than targeting a slug
24
+ or email instead.
25
+
26
+ Pass a key as the query to see its name and email.
27
+
28
+ --json prints {contexts: [{key, name, email?}]}: name is null when the context
29
+ reported no name, and email is present only when it reported one.`;
30
+ static examples = [
31
+ '<%= config.bin %> <%= command.id %> organization formhealth',
32
+ '<%= config.bin %> <%= command.id %> user barry@example.com --environment production',
33
+ '<%= config.bin %> <%= command.id %> organization formhealth --json',
34
+ ];
35
+ static flags = {
36
+ environment: Flags.string({
37
+ description: 'Only contexts reported from this environment (default: every environment)',
38
+ }),
39
+ limit: Flags.integer({
40
+ default: 20,
41
+ description: 'Maximum number of matches (1-100)',
42
+ max: 100,
43
+ min: 1,
44
+ }),
45
+ };
46
+ async run() {
47
+ const { args, flags } = await this.parse(ContextsSearch);
48
+ const request = await this.apiClient.post('/api/v1/contexts/searchKeys', {
49
+ contextType: args.contextType,
50
+ environment: flags.environment,
51
+ limit: flags.limit,
52
+ query: args.query,
53
+ workspaceId: this.workspaceId,
54
+ });
55
+ if (!request.ok) {
56
+ const serverError = request.error;
57
+ const errorMsg = serverError?.error || serverError?.json?.message || `Failed to search contexts: ${request.status}`;
58
+ return this.err(errorMsg, { serverError: request.error });
59
+ }
60
+ const contexts = (request.json?.contexts ?? []);
61
+ if (contexts.length === 0) {
62
+ this.log(`No context seen via telemetry matched "${args.query}" (type ${args.contextType}), so the key is unknown.\n` +
63
+ 'Only contexts your SDKs have reported are searchable. Get the key from whoever owns that ' +
64
+ 'customer; do not target a slug or email instead.');
65
+ return { contexts: [] };
66
+ }
67
+ const keyWidth = Math.max(...contexts.map((c) => c.key.length), 'KEY'.length);
68
+ const nameWidth = Math.max(...contexts.map((c) => (c.name ?? '').length), 'NAME'.length);
69
+ const row = (key, name, email) => `${key.padEnd(keyWidth)} ${name.padEnd(nameWidth)} ${email}`.trimEnd();
70
+ const lines = [row('KEY', 'NAME', 'EMAIL')];
71
+ for (const context of contexts) {
72
+ lines.push(row(context.key, context.name ?? '', context.email ?? ''));
73
+ }
74
+ lines.push('', `Target these in a rule with propertyName "${args.contextType}.key".`);
75
+ this.log(lines.join('\n'));
76
+ return { contexts };
77
+ }
78
+ }
@@ -0,0 +1,6 @@
1
+ import { BaseCommand } from '../index.js';
2
+ export default class Contexts extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,10 @@
1
+ import { BaseCommand } from '../index.js';
2
+ export default class Contexts extends BaseCommand {
3
+ static description = 'Look up contexts (organizations, users, ...) your SDKs have reported, to target them by key.';
4
+ static examples = ['<%= config.bin %> contexts search organization formhealth'];
5
+ async run() {
6
+ this.log('Use one of the contexts subcommands:');
7
+ this.log(' qfg contexts search CONTEXT_TYPE QUERY [--environment ENV] [--limit N] [--json]');
8
+ this.log(' # find a context key by name, e.g. qfg contexts search organization formhealth');
9
+ }
10
+ }
@@ -32,7 +32,11 @@ To set a percentage rollout (gradual rollout / A/B test / canary deploy) instead
32
32
  qfg set-rollout my.flag --environment production --true-percent 20
33
33
 
34
34
  To see all current values and rules for a flag:
35
- qfg info my.flag`;
35
+ qfg info my.flag
36
+
37
+ To target specific organizations or users, write rules on their KEY
38
+ (organization.key, user.key), not a slug or email; find a key from a name with:
39
+ qfg contexts search organization formhealth`;
36
40
  static examples = [
37
41
  '<%= config.bin %> <%= command.id %> my.flag.name # prompts for value and env',
38
42
  '<%= config.bin %> <%= command.id %> my.flag.name --value=true --environment=staging',
@@ -5,6 +5,16 @@
5
5
  * user-editable. Each starts with a comment so users know not to
6
6
  * hand-edit them.
7
7
  */
8
+ import { SKILL_FILES } from '../agent/skill-bundle.generated.js';
9
+ /**
10
+ * The qfg-vs-MCP steering and "setting a value keeps targeting" rules are
11
+ * shared with the Quonfig agent skill (github.com/quonfig/skills,
12
+ * references/writes.md). The skill is the single source; the cli vendors it
13
+ * at build time (scripts/sync-agent-skill.mjs) and AGENTS.md embeds it here.
14
+ */
15
+ const SHARED_WRITES_SECTION = SKILL_FILES.find((f) => f.path === 'references/writes.md')
16
+ .content.trim()
17
+ .replaceAll(/^## /gm, '### ');
8
18
  const MANAGED_HEADER = '<!-- Managed by `qfg init` — do not edit manually. Run `qfg init` to update. -->';
9
19
  // ── README.md ──────────────────────────────────────────────────────────
10
20
  export function readmeTemplate() {
@@ -196,48 +206,19 @@ This is a Quonfig workspace repository. See \`CLAUDE.md\` for the complete schem
196
206
  5. Files must be in the correct directory for their \`type\`.
197
207
  6. See \`CLAUDE.md\` for the full list of value types, operators, and constraints.
198
208
 
199
- ## \`qfg\` or the Quonfig MCP?
209
+ ## Hosted workspaces: \`qfg\`, the MCP, and how writes behave
200
210
 
201
211
  Only relevant if this workspace is *also* hosted on a Quonfig account. On the
202
212
  fully-local path, editing the JSON in this repo is the whole story.
203
213
 
204
- - **This repo is checked out and you have a shell** -> use \`qfg\`. The files are
205
- already on disk, so reading them costs nothing.
206
- - **Slack, claude.ai, or anywhere without a checkout** -> use the Quonfig MCP
207
- server. It reads and writes the hosted workspace directly. Do not try to shell
208
- out to \`qfg\` from a surface that has no repo.
209
-
210
- Both surfaces cover the same everyday loop — create a flag or config, change what
211
- an environment serves, set a log level. (\`qfg\` has all of it today; the MCP's
212
- create/config/log-level verbs land with its v2 write surface.)
213
-
214
- ## Setting a value KEEPS that environment's targeting rules
215
-
216
- Both surfaces write the same way, so learn it once:
217
-
218
- \`qfg set-default\` (alias \`toggle\`), \`qfg set-rollout\`, and the MCP
219
- \`set_flag\`/\`set_config\` verbs replace exactly ONE rule — the environment's
220
- **fallback**, the unconditional rule at the end of its rule list that decides
221
- what users receive when no targeting rule matches. Every targeting rule above
222
- it is kept, and both surfaces report how many. An environment with no rules of
223
- its own has them copied from default first, so inherited targeting is kept too.
224
-
225
- | Surface | By default | To set the value for EVERYONE |
226
- |---------|------------|-------------------------------|
227
- | \`qfg set-default\` / \`qfg set-rollout\` | Keeps targeting; reports "Kept N targeting rule(s)" | add \`--replace-targeting\` |
228
- | MCP \`set_flag\` / \`set_config\` | Keeps targeting; reports the kept count in its result | send \`replaceTargeting: true\` |
229
-
230
- Setting the value for everyone DELETES that environment's targeting rules. They
231
- stay in git history, and the write returns a \`previousCommitSha\` to restore from.
214
+ ${SHARED_WRITES_SECTION}
232
215
 
233
- **Still read before you write.** \`qfg info <key>\` (or MCP \`get_flag\`) shows the
234
- rules your value is about to sit next to. For anything a fallback cannot express —
235
- multi-rule targeting, reordering, editing one targeting rule — edit the JSON
236
- directly or use MCP \`set_document\`.
216
+ ## Application repos
237
217
 
238
- \`qfg log-level --target ...\` and MCP \`set_log_level\` are **surgical** in the same
239
- way: they upsert the level for the loggers you name and leave every other rule in
240
- place.
218
+ This file covers the workspace (config) repo. In an application repo that
219
+ *reads* these flags through a Quonfig SDK, install the Quonfig agent skill:
220
+ \`qfg agent install-skill\` (or \`claude plugin install quonfig@quonfig\`).
221
+ It covers SDK setup, creating and rolling out flags, and the MCP tools.
241
222
  `;
242
223
  }
243
224
  // ── Pre-commit hook ────────────────────────────────────────────────────
@@ -33,7 +33,9 @@ export function decrypt(encryptedString, keyStringHex) {
33
33
  const encryptedBytes = forge.util.hexToBytes(encryptedDataPart);
34
34
  const ivBytes = forge.util.hexToBytes(ivPart);
35
35
  const tagBytes = forge.util.hexToBytes(authTagPart);
36
- // Create decipher
36
+ // Create decipher. node-forge AES-GCM with the IV passed to start() below, not
37
+ // Node's deprecated IV-less crypto.createDecipher that this rule targets.
38
+ // nosemgrep: javascript.node-crypto.security.create-de-cipher-no-iv.create-de-cipher-no-iv
37
39
  const decipher = forge.cipher.createDecipher('AES-GCM', keyBytes);
38
40
  decipher.start({
39
41
  iv: ivBytes,
@@ -59,6 +59,11 @@ export declare function findOneOfTag(branches: unknown[], root: unknown): OneOfT
59
59
  * a single `oneOfTag` error at the union's position.
60
60
  */
61
61
  export declare function reshapeOneOfErrors<E extends SchemaErrorLike>(errors: readonly E[], rootSchema: unknown, value: unknown): (E | SchemaErrorLike)[];
62
+ /**
63
+ * The two 0-based indexes a duplicate-items violation names, lower first,
64
+ * or undefined for any other message. Lets a caller mark both rows.
65
+ */
66
+ export declare function duplicateItemIndexes(message: string): [number, number] | undefined;
62
67
  /** One error -> message. Undefined means "skip it, a nested error says why". */
63
68
  export declare function describeSchemaError(error: SchemaErrorLike): string | undefined;
64
69
  /**
@@ -207,6 +207,25 @@ export function reshapeOneOfErrors(errors, rootSchema, value) {
207
207
  });
208
208
  return result;
209
209
  }
210
+ /**
211
+ * `uniqueItems` wording. Ajv says `(items ## 3 and 1 are identical)`:
212
+ * 0-based, later item first, with a stray `##`. Lists are numbered from 1
213
+ * everywhere the user reads them, so name the pair by row number, lower
214
+ * first: `(items 2 and 4 are identical)`.
215
+ */
216
+ function duplicateItemsMessage(first, second) {
217
+ const [a, b] = [first, second].sort((x, y) => x - y);
218
+ return `must NOT have duplicate items (items ${a + 1} and ${b + 1} are identical)`;
219
+ }
220
+ const DUPLICATE_ITEMS_MESSAGE = /^must NOT have duplicate items \(items (\d+) and (\d+) are identical\)$/;
221
+ /**
222
+ * The two 0-based indexes a duplicate-items violation names, lower first,
223
+ * or undefined for any other message. Lets a caller mark both rows.
224
+ */
225
+ export function duplicateItemIndexes(message) {
226
+ const match = DUPLICATE_ITEMS_MESSAGE.exec(message);
227
+ return match ? [Number(match[1]) - 1, Number(match[2]) - 1] : undefined;
228
+ }
210
229
  function stringifyAllowed(entry) {
211
230
  return typeof entry === 'string' ? entry : JSON.stringify(entry);
212
231
  }
@@ -223,6 +242,10 @@ export function describeSchemaError(error) {
223
242
  return Array.isArray(params.allowedValues)
224
243
  ? `${message}: ${params.allowedValues.map(stringifyAllowed).join(', ')}`
225
244
  : message;
245
+ case 'uniqueItems':
246
+ return typeof params.i === 'number' && typeof params.j === 'number'
247
+ ? duplicateItemsMessage(params.i, params.j)
248
+ : message;
226
249
  case 'propertyNames':
227
250
  // The nested keyword error (which carries `propertyName`) says why.
228
251
  return undefined;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.2.3";
1
+ declare const _default: "0.3.0";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.2.3';
2
+ export default '0.3.0';