@sqaoss/flowy 0.1.1 → 1.0.2

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.
@@ -0,0 +1,308 @@
1
+ import { nanoid } from 'nanoid'
2
+ import type { FlowyDb } from './db.ts'
3
+
4
+ type Db = FlowyDb
5
+
6
+ interface NodeRow {
7
+ id: string
8
+ type: string
9
+ title: string
10
+ description: string | null
11
+ status: string
12
+ metadata: string | null
13
+ created_at: string
14
+ updated_at: string
15
+ }
16
+
17
+ export interface NodeGql {
18
+ id: string
19
+ type: string
20
+ title: string
21
+ description: string | null
22
+ status: string
23
+ metadata: string | null
24
+ createdAt: string
25
+ updatedAt: string
26
+ }
27
+
28
+ const NODE_COLS =
29
+ 'id, type, title, description, status, metadata, created_at, updated_at'
30
+
31
+ const PREFIX_MAP: Record<string, string> = {
32
+ project: 'proj',
33
+ feature: 'feat',
34
+ task: 'task',
35
+ }
36
+
37
+ const VALID_STATUSES = new Set([
38
+ 'draft',
39
+ 'pending_review',
40
+ 'approved',
41
+ 'in_progress',
42
+ 'done',
43
+ 'blocked',
44
+ 'cancelled',
45
+ ])
46
+
47
+ function generateId(type: string): string {
48
+ const prefix = PREFIX_MAP[type] ?? type
49
+ return `${prefix}_${nanoid(12)}`
50
+ }
51
+
52
+ function rowToNode(row: NodeRow): NodeGql {
53
+ return {
54
+ id: row.id,
55
+ type: row.type,
56
+ title: row.title,
57
+ description: row.description,
58
+ status: row.status,
59
+ metadata: row.metadata,
60
+ createdAt: row.created_at,
61
+ updatedAt: row.updated_at,
62
+ }
63
+ }
64
+
65
+ function selectNode(db: Db, id: string): NodeGql | null {
66
+ const row = db.raw
67
+ .query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ?`)
68
+ .get(id) as NodeRow | null
69
+ return row ? rowToNode(row) : null
70
+ }
71
+
72
+ function selectNodes(rows: NodeRow[]): NodeGql[] {
73
+ return rows.map(rowToNode)
74
+ }
75
+
76
+ function prefixedCols() {
77
+ return NODE_COLS.split(', ')
78
+ .map((c) => `n.${c}`)
79
+ .join(', ')
80
+ }
81
+
82
+ export function createResolvers(db: Db) {
83
+ return {
84
+ Query: {
85
+ node: (_: unknown, args: { id: string }) => {
86
+ return selectNode(db, args.id)
87
+ },
88
+
89
+ nodes: (_: unknown, args: { type?: string }) => {
90
+ const conditions: string[] = []
91
+ const params: string[] = []
92
+ if (args.type) {
93
+ conditions.push('type = ?')
94
+ params.push(args.type)
95
+ }
96
+ const where =
97
+ conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
98
+ const rows = db.raw
99
+ .query(`SELECT ${NODE_COLS} FROM nodes ${where}`)
100
+ .all(...params) as NodeRow[]
101
+ return selectNodes(rows)
102
+ },
103
+
104
+ descendants: (
105
+ _: unknown,
106
+ args: { nodeId: string; relation?: string; maxDepth?: number },
107
+ ) => {
108
+ const maxDepth = args.maxDepth ?? 100
109
+ if (maxDepth === 0) return []
110
+ let rows: NodeRow[]
111
+ if (args.relation) {
112
+ rows = db.raw
113
+ .query(
114
+ `WITH RECURSIVE tree(id, depth) AS (
115
+ SELECT source_id, 1 FROM edges WHERE target_id = ?1 AND relation = ?3
116
+ UNION ALL
117
+ SELECT e.source_id, t.depth + 1 FROM edges e
118
+ JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2 AND e.relation = ?3
119
+ )
120
+ SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
121
+ )
122
+ .all(args.nodeId, maxDepth, args.relation) as NodeRow[]
123
+ } else {
124
+ rows = db.raw
125
+ .query(
126
+ `WITH RECURSIVE tree(id, depth) AS (
127
+ SELECT source_id, 1 FROM edges WHERE target_id = ?1
128
+ UNION ALL
129
+ SELECT e.source_id, t.depth + 1 FROM edges e
130
+ JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2
131
+ )
132
+ SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
133
+ )
134
+ .all(args.nodeId, maxDepth) as NodeRow[]
135
+ }
136
+ return selectNodes(rows)
137
+ },
138
+
139
+ subtree: (_: unknown, args: { nodeId: string; maxDepth?: number }) => {
140
+ const maxDepth = args.maxDepth ?? 100
141
+ if (maxDepth === 0) return []
142
+ const rows = db.raw
143
+ .query(
144
+ `WITH RECURSIVE tree(id, depth) AS (
145
+ SELECT source_id, 1 FROM edges WHERE target_id = ?1
146
+ UNION ALL
147
+ SELECT e.source_id, t.depth + 1 FROM edges e
148
+ JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2
149
+ )
150
+ SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
151
+ )
152
+ .all(args.nodeId, maxDepth) as NodeRow[]
153
+ return selectNodes(rows)
154
+ },
155
+
156
+ search: (
157
+ _: unknown,
158
+ args: {
159
+ query: string
160
+ type?: string
161
+ status?: string
162
+ limit?: number
163
+ },
164
+ ) => {
165
+ if (args.query.trim().length < 3) {
166
+ throw new Error('Search query must be at least 3 characters')
167
+ }
168
+ const escaped = args.query.replace(/[%_\\]/g, '\\$&')
169
+ const conditions = [
170
+ "(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')",
171
+ ]
172
+ const params: (string | number)[] = [`%${escaped}%`, `%${escaped}%`]
173
+ if (args.type) {
174
+ conditions.push('type = ?')
175
+ params.push(args.type)
176
+ }
177
+ if (args.status) {
178
+ conditions.push('status = ?')
179
+ params.push(args.status)
180
+ }
181
+ const limit = args.limit ?? 50
182
+ const rows = db.raw
183
+ .query(
184
+ `SELECT ${NODE_COLS} FROM nodes WHERE ${conditions.join(' AND ')} LIMIT ?`,
185
+ )
186
+ .all(...params, limit) as NodeRow[]
187
+ return selectNodes(rows)
188
+ },
189
+ },
190
+
191
+ Mutation: {
192
+ createNode: (
193
+ _: unknown,
194
+ args: { type: string; title: string; description?: string },
195
+ ): NodeGql => {
196
+ if (!args.title.trim()) throw new Error('Title is required')
197
+ if (args.description != null && !args.description.trim()) {
198
+ throw new Error('Description cannot be empty')
199
+ }
200
+ const id = generateId(args.type)
201
+ const now = new Date().toISOString()
202
+ const description = args.description ?? null
203
+ db.raw.run(
204
+ 'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
205
+ [id, args.type, args.title, description, 'draft', null, now, now],
206
+ )
207
+ return {
208
+ id,
209
+ type: args.type,
210
+ title: args.title,
211
+ description,
212
+ status: 'draft',
213
+ metadata: null,
214
+ createdAt: now,
215
+ updatedAt: now,
216
+ }
217
+ },
218
+
219
+ updateNode: (_: unknown, args: { id: string; status?: string }) => {
220
+ const existing = db.raw
221
+ .query('SELECT * FROM nodes WHERE id = ?')
222
+ .get(args.id) as NodeRow | null
223
+ if (!existing) throw new Error(`Node ${args.id} not found`)
224
+ const newStatus = args.status ?? existing.status
225
+ if (args.status && !VALID_STATUSES.has(args.status)) {
226
+ throw new Error(
227
+ `Invalid status: ${args.status}. Must be one of: ${[...VALID_STATUSES].join(', ')}`,
228
+ )
229
+ }
230
+ const now = new Date().toISOString()
231
+ db.raw.run('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?', [
232
+ newStatus,
233
+ now,
234
+ args.id,
235
+ ])
236
+ return rowToNode({ ...existing, status: newStatus, updated_at: now })
237
+ },
238
+
239
+ approveNode: (_: unknown, args: { id: string }) => {
240
+ const existing = db.raw
241
+ .query('SELECT * FROM nodes WHERE id = ?')
242
+ .get(args.id) as NodeRow | null
243
+ if (!existing) throw new Error(`Node ${args.id} not found`)
244
+ if (existing.status !== 'pending_review') {
245
+ throw new Error(
246
+ `Cannot approve node with status "${existing.status}", must be "pending_review"`,
247
+ )
248
+ }
249
+ const now = new Date().toISOString()
250
+ db.raw.run('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?', [
251
+ 'approved',
252
+ now,
253
+ args.id,
254
+ ])
255
+ return rowToNode({ ...existing, status: 'approved', updated_at: now })
256
+ },
257
+
258
+ createEdge: (
259
+ _: unknown,
260
+ args: { sourceId: string; targetId: string; relation: string },
261
+ ) => {
262
+ const validRelations = new Set(['part_of', 'blocks'])
263
+ if (!validRelations.has(args.relation)) {
264
+ throw new Error(
265
+ `Invalid relation: ${args.relation}. Must be 'part_of' or 'blocks'`,
266
+ )
267
+ }
268
+ const sourceExists = db.raw
269
+ .query('SELECT id FROM nodes WHERE id = ?')
270
+ .get(args.sourceId)
271
+ if (!sourceExists) {
272
+ throw new Error(`Source node ${args.sourceId} not found`)
273
+ }
274
+ const targetExists = db.raw
275
+ .query('SELECT id FROM nodes WHERE id = ?')
276
+ .get(args.targetId)
277
+ if (!targetExists) {
278
+ throw new Error(`Target node ${args.targetId} not found`)
279
+ }
280
+ if (args.relation === 'blocks' && args.sourceId === args.targetId) {
281
+ throw new Error('A node cannot block itself')
282
+ }
283
+ const now = new Date().toISOString()
284
+ db.raw.run(
285
+ 'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
286
+ [args.sourceId, args.targetId, args.relation, now],
287
+ )
288
+ return {
289
+ sourceId: args.sourceId,
290
+ targetId: args.targetId,
291
+ relation: args.relation,
292
+ createdAt: now,
293
+ }
294
+ },
295
+
296
+ removeEdge: (
297
+ _: unknown,
298
+ args: { sourceId: string; targetId: string; relation: string },
299
+ ) => {
300
+ const result = db.raw.run(
301
+ 'DELETE FROM edges WHERE source_id = ? AND target_id = ? AND relation = ?',
302
+ [args.sourceId, args.targetId, args.relation],
303
+ )
304
+ return result.changes > 0
305
+ },
306
+ },
307
+ }
308
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { schema } from './schema.ts'
3
+
4
+ describe('schema', () => {
5
+ it('exports a valid GraphQL schema object', () => {
6
+ expect(schema).toBeDefined()
7
+ expect(typeof schema).toBe('object')
8
+ })
9
+
10
+ it('defines Node type with required fields', () => {
11
+ const nodeType = schema.getType(
12
+ 'Node',
13
+ ) as import('graphql').GraphQLObjectType
14
+ expect(nodeType).toBeDefined()
15
+ const fields = nodeType.getFields()
16
+ expect(fields.id).toBeDefined()
17
+ expect(fields.type).toBeDefined()
18
+ expect(fields.title).toBeDefined()
19
+ expect(fields.description).toBeDefined()
20
+ expect(fields.status).toBeDefined()
21
+ expect(fields.metadata).toBeDefined()
22
+ expect(fields.createdAt).toBeDefined()
23
+ expect(fields.updatedAt).toBeDefined()
24
+ })
25
+
26
+ it('uses String types instead of enums (no NodeType, Status, Relation enums)', () => {
27
+ expect(schema.getType('NodeType')).toBeUndefined()
28
+ expect(schema.getType('Status')).toBeUndefined()
29
+ expect(schema.getType('Relation')).toBeUndefined()
30
+ })
31
+
32
+ it('Node type does not have children, blockedBy, or blocking fields', () => {
33
+ const nodeType = schema.getType(
34
+ 'Node',
35
+ ) as import('graphql').GraphQLObjectType
36
+ const fields = nodeType.getFields()
37
+ expect(fields.children).toBeUndefined()
38
+ expect(fields.blockedBy).toBeUndefined()
39
+ expect(fields.blocking).toBeUndefined()
40
+ })
41
+
42
+ it('Edge type has sourceId, targetId, relation, createdAt as String fields', () => {
43
+ const edgeType = schema.getType(
44
+ 'Edge',
45
+ ) as import('graphql').GraphQLObjectType
46
+ const fields = edgeType.getFields()
47
+ expect(fields.sourceId).toBeDefined()
48
+ expect(fields.targetId).toBeDefined()
49
+ expect(fields.relation).toBeDefined()
50
+ expect(fields.createdAt).toBeDefined()
51
+ })
52
+
53
+ it('Query type has node, nodes, descendants, subtree, search', () => {
54
+ const queryType = schema.getType(
55
+ 'Query',
56
+ ) as import('graphql').GraphQLObjectType
57
+ const fields = queryType.getFields()
58
+ expect(fields.node).toBeDefined()
59
+ expect(fields.nodes).toBeDefined()
60
+ expect(fields.descendants).toBeDefined()
61
+ expect(fields.subtree).toBeDefined()
62
+ expect(fields.search).toBeDefined()
63
+ })
64
+
65
+ it('Query type does not have tree', () => {
66
+ const queryType = schema.getType(
67
+ 'Query',
68
+ ) as import('graphql').GraphQLObjectType
69
+ const fields = queryType.getFields()
70
+ expect(fields.tree).toBeUndefined()
71
+ })
72
+
73
+ it('Mutation type has createNode, updateNode, approveNode, createEdge, removeEdge', () => {
74
+ const mutationType = schema.getType(
75
+ 'Mutation',
76
+ ) as import('graphql').GraphQLObjectType
77
+ const fields = mutationType.getFields()
78
+ expect(fields.createNode).toBeDefined()
79
+ expect(fields.updateNode).toBeDefined()
80
+ expect(fields.approveNode).toBeDefined()
81
+ expect(fields.createEdge).toBeDefined()
82
+ expect(fields.removeEdge).toBeDefined()
83
+ })
84
+
85
+ it('Mutation type does not have deleteNode or deleteEdge', () => {
86
+ const mutationType = schema.getType(
87
+ 'Mutation',
88
+ ) as import('graphql').GraphQLObjectType
89
+ const fields = mutationType.getFields()
90
+ expect(fields.deleteNode).toBeUndefined()
91
+ expect(fields.deleteEdge).toBeUndefined()
92
+ })
93
+ })
@@ -0,0 +1,45 @@
1
+ import { createSchema } from 'graphql-yoga'
2
+
3
+ export const typeDefs = /* GraphQL */ `
4
+ type Node {
5
+ id: String!
6
+ type: String!
7
+ title: String!
8
+ description: String
9
+ status: String!
10
+ metadata: String
11
+ createdAt: String!
12
+ updatedAt: String!
13
+ }
14
+
15
+ type Edge {
16
+ sourceId: String!
17
+ targetId: String!
18
+ relation: String!
19
+ createdAt: String!
20
+ }
21
+
22
+ type Query {
23
+ node(id: String!): Node
24
+ nodes(type: String): [Node!]!
25
+ descendants(nodeId: String!, relation: String, maxDepth: Int): [Node!]!
26
+ subtree(nodeId: String!, maxDepth: Int): [Node!]!
27
+ search(query: String!, type: String, status: String, limit: Int): [Node!]!
28
+ }
29
+
30
+ type Mutation {
31
+ createNode(type: String!, title: String!, description: String): Node!
32
+ updateNode(id: String!, status: String): Node!
33
+ approveNode(id: String!): Node!
34
+ createEdge(sourceId: String!, targetId: String!, relation: String!): Edge!
35
+ removeEdge(sourceId: String!, targetId: String!, relation: String!): Boolean!
36
+ }
37
+ `
38
+
39
+ export const schema = createSchema({
40
+ typeDefs,
41
+ resolvers: {
42
+ Query: {},
43
+ Mutation: {},
44
+ },
45
+ })
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: using-flowy
3
+ description: Project management CLI for AI coding agents — create projects, features, tasks, track status and approvals
4
+ metadata:
5
+ sources:
6
+ - src/commands/project.ts
7
+ - src/commands/feature.ts
8
+ - src/commands/task.ts
9
+ - src/commands/status.ts
10
+ - src/commands/approve.ts
11
+ - src/commands/search.ts
12
+ - src/commands/tree.ts
13
+ - src/commands/setup.ts
14
+ - src/util/config.ts
15
+ ---
16
+
17
+ # Flowy — Project Management for AI Agents
18
+
19
+ Flowy is a hosted project management backend. You interact with it via the `flowy` CLI.
20
+
21
+ ## Setup
22
+
23
+ ```bash
24
+ # Install globally
25
+ bun add -g @sqaoss/flowy # or: npm i -g @sqaoss/flowy
26
+
27
+ # SaaS mode (hosted)
28
+ flowy setup --mode saas --email you@example.com
29
+ export FLOWY_API_KEY=flowy_xxx_yyy
30
+
31
+ # Self-hosted mode
32
+ flowy setup --mode local --api-url http://localhost:4000/graphql
33
+ ```
34
+
35
+ ## Hierarchy
36
+
37
+ Strict top-down: **client → project → feature → task**. No orphans at any level.
38
+
39
+ - A client has many projects
40
+ - A project has many features
41
+ - A feature has many tasks
42
+
43
+ ## Context
44
+
45
+ Flowy uses context to know which project and feature you're working in:
46
+
47
+ - **Project**: mapped to a directory via `flowy project set <name>`, or `FLOWY_PROJECT` env var
48
+ - **Feature**: set via `flowy feature set <name-or-id>`, or `FLOWY_FEATURE` env var
49
+
50
+ Features and tasks cannot be created without active project/feature context.
51
+
52
+ ## Commands
53
+
54
+ ### Identity
55
+ ```bash
56
+ flowy whoami # Show current user
57
+ ```
58
+
59
+ ### Client
60
+ ```bash
61
+ flowy client set name "Acme Corp" # Set client display name
62
+ ```
63
+
64
+ ### Projects
65
+ ```bash
66
+ flowy project create "Auth System" # Create a project
67
+ flowy project set "Auth System" # Map current dir to project
68
+ flowy project list # List all projects
69
+ flowy project show # Show active project details
70
+ flowy project show <id> # Show specific project
71
+ ```
72
+
73
+ ### Features (requires active project)
74
+ ```bash
75
+ flowy feature create --title "SSO Support" --description sso-spec.md
76
+ flowy feature create --title "Stability" --description "Improve error handling"
77
+ flowy feature set "SSO Support" # Set active feature
78
+ flowy feature unset # Clear active feature
79
+ flowy feature list # List features in project
80
+ flowy feature show # Show active feature
81
+ ```
82
+
83
+ ### Tasks (requires active feature)
84
+ ```bash
85
+ flowy task create --title "Implement OAuth" --description oauth.md
86
+ flowy task create --title "Write tests" --description "Unit tests for auth"
87
+ flowy task list # List tasks in feature
88
+ flowy task show <id> # Show task details
89
+ flowy task block <id1> <id2> # Mark id1 blocks id2
90
+ flowy task unblock <id1> <id2> # Remove block
91
+ ```
92
+
93
+ ### Status & Approval
94
+ ```bash
95
+ flowy status <id> in_progress
96
+ flowy status <id> done
97
+ flowy status <id> pending_review
98
+ flowy approve <id> # Must be pending_review
99
+ ```
100
+
101
+ ### Search & Explore
102
+ ```bash
103
+ flowy search "OAuth" --type task
104
+ flowy search "auth" --status draft --limit 5
105
+ flowy tree <id> --depth 3 # Show subtree
106
+ ```
107
+
108
+ ## Entity Types
109
+ - `client` — a client or company
110
+ - `project` — a codebase or product
111
+ - `feature` — a unit of work (replaces epic)
112
+ - `task` — an individual work item
113
+
114
+ ## Status Flow
115
+ `draft` → `pending_review` → `approved` → `in_progress` → `done`
116
+
117
+ Also: `blocked`, `cancelled`
118
+
119
+ ## Description Field
120
+ `--description` accepts a file path or an inline string:
121
+ - `--description spec.md` — reads file, sends raw content
122
+ - `--description "Do the thing"` — sends string as-is
123
+
124
+ ## Workflow Example
125
+
126
+ ```bash
127
+ # One-time setup
128
+ flowy setup --mode saas --email you@example.com
129
+ flowy client set name "Acme Corp"
130
+
131
+ # Create and activate project
132
+ flowy project create "Auth System"
133
+ flowy project set "Auth System"
134
+
135
+ # Plan a feature
136
+ flowy feature create --title "SSO Support" --description sso-spec.md
137
+ flowy feature set "SSO Support"
138
+
139
+ # Break into tasks
140
+ flowy task create --title "Implement OAuth" --description oauth.md
141
+ flowy task create --title "Write auth tests" --description tests.md
142
+
143
+ # Track progress
144
+ flowy status <task-id> in_progress
145
+ flowy status <task-id> done
146
+
147
+ # Move to next feature
148
+ flowy feature create --title "API Rate Limiting" --description rate-limit.md
149
+ flowy feature set "API Rate Limiting"
150
+ ```
151
+
152
+ ## Output Format
153
+ All commands output JSON. Parse with `jq` or directly in your agent code.
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { resolve } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
5
+
6
+ const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
7
+
8
+ describe('client command', () => {
9
+ let originalConfig: string | null = null
10
+
11
+ beforeEach(() => {
12
+ originalConfig = existsSync(CONFIG_PATH)
13
+ ? readFileSync(CONFIG_PATH, 'utf-8')
14
+ : null
15
+ })
16
+
17
+ afterEach(() => {
18
+ if (originalConfig !== null) {
19
+ writeFileSync(CONFIG_PATH, originalConfig)
20
+ } else if (existsSync(CONFIG_PATH)) {
21
+ rmSync(CONFIG_PATH)
22
+ }
23
+ })
24
+
25
+ test('exports a command group named client', async () => {
26
+ const { clientCommand } = await import('./client.ts')
27
+ expect(clientCommand.name()).toBe('client')
28
+ })
29
+
30
+ test('set name updates config', async () => {
31
+ const { clientCommand } = await import('./client.ts')
32
+ await clientCommand.parseAsync(['set', 'name', 'Acme Corp'], {
33
+ from: 'user',
34
+ })
35
+
36
+ const { loadConfig } = await import('../util/config.ts')
37
+ const config = loadConfig()
38
+ expect(config.client.name).toBe('Acme Corp')
39
+ })
40
+ })
@@ -0,0 +1,34 @@
1
+ import { Command } from 'commander'
2
+ import { loadConfig, saveConfig } from '../util/config.ts'
3
+ import { output, outputError } from '../util/format.ts'
4
+
5
+ const SUPPORTED_PROPERTIES = ['name'] as const
6
+
7
+ export const clientCommand = new Command('client').description(
8
+ 'Manage client settings',
9
+ )
10
+
11
+ clientCommand
12
+ .command('set')
13
+ .description('Set a client property')
14
+ .argument('<property>', 'Property to set')
15
+ .argument('<value>', 'Value to set')
16
+ .action((property: string, value: string) => {
17
+ try {
18
+ if (
19
+ !SUPPORTED_PROPERTIES.includes(
20
+ property as (typeof SUPPORTED_PROPERTIES)[number],
21
+ )
22
+ ) {
23
+ throw new Error(
24
+ `Unknown property "${property}". Supported: ${SUPPORTED_PROPERTIES.join(', ')}`,
25
+ )
26
+ }
27
+ const config = loadConfig()
28
+ config.client.name = value
29
+ saveConfig(config)
30
+ output({ property, value })
31
+ } catch (error) {
32
+ outputError(error)
33
+ }
34
+ })