@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,127 @@
1
+ import { Command } from 'commander'
2
+ import { graphql } from '../util/client.ts'
3
+ import { requireFeature } from '../util/config.ts'
4
+ import { resolveDescription } from '../util/description.ts'
5
+ import { output, outputError } from '../util/format.ts'
6
+
7
+ export const taskCommand = new Command('task').description(
8
+ 'Manage tasks in the active feature',
9
+ )
10
+
11
+ taskCommand
12
+ .command('create')
13
+ .description('Create a task in the active feature')
14
+ .requiredOption('--title <title>', 'Task title')
15
+ .requiredOption(
16
+ '--description <desc>',
17
+ 'Task description (text or file path)',
18
+ )
19
+ .action(async (opts) => {
20
+ try {
21
+ const featureId = requireFeature()
22
+ const description = await resolveDescription(opts.description)
23
+ const data = await graphql<{ createNode: { id: string } }>(
24
+ `mutation CreateTask($type: String!, $title: String!, $description: String) {
25
+ createNode(type: $type, title: $title, description: $description) {
26
+ id type title description status createdAt
27
+ }
28
+ }`,
29
+ { type: 'task', title: opts.title, description },
30
+ )
31
+ const taskId = data.createNode.id
32
+ await graphql(
33
+ `mutation LinkTask($sourceId: String!, $targetId: String!, $relation: String!) {
34
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
35
+ sourceId targetId relation
36
+ }
37
+ }`,
38
+ { sourceId: taskId, targetId: featureId, relation: 'part_of' },
39
+ )
40
+ output(data.createNode)
41
+ } catch (error) {
42
+ outputError(error)
43
+ }
44
+ })
45
+
46
+ taskCommand
47
+ .command('list')
48
+ .description('List tasks in the active feature')
49
+ .action(async () => {
50
+ try {
51
+ const featureId = requireFeature()
52
+ const data = await graphql<{ descendants: unknown[] }>(
53
+ `query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
54
+ descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
55
+ id type title status createdAt
56
+ }
57
+ }`,
58
+ { nodeId: featureId, relation: 'part_of', maxDepth: 1 },
59
+ )
60
+ const tasks = data.descendants.filter(
61
+ (n: unknown) => (n as { type: string }).type === 'task',
62
+ )
63
+ output(tasks)
64
+ } catch (error) {
65
+ outputError(error)
66
+ }
67
+ })
68
+
69
+ taskCommand
70
+ .command('show')
71
+ .description('Show task details')
72
+ .argument('<id>', 'Task ID')
73
+ .action(async (id: string) => {
74
+ try {
75
+ const data = await graphql<{ node: unknown }>(
76
+ `query ShowTask($id: String!) {
77
+ node(id: $id) {
78
+ id type title description status metadata createdAt updatedAt
79
+ }
80
+ }`,
81
+ { id },
82
+ )
83
+ output(data.node)
84
+ } catch (error) {
85
+ outputError(error)
86
+ }
87
+ })
88
+
89
+ taskCommand
90
+ .command('block')
91
+ .description('Mark a task as blocking another')
92
+ .argument('<id1>', 'Blocking task ID')
93
+ .argument('<id2>', 'Blocked task ID')
94
+ .action(async (id1: string, id2: string) => {
95
+ try {
96
+ const data = await graphql<{ createEdge: unknown }>(
97
+ `mutation BlockTask($sourceId: String!, $targetId: String!, $relation: String!) {
98
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
99
+ sourceId targetId relation createdAt
100
+ }
101
+ }`,
102
+ { sourceId: id1, targetId: id2, relation: 'blocks' },
103
+ )
104
+ output(data.createEdge)
105
+ } catch (error) {
106
+ outputError(error)
107
+ }
108
+ })
109
+
110
+ taskCommand
111
+ .command('unblock')
112
+ .description('Remove a blocking relationship')
113
+ .argument('<id1>', 'Blocking task ID')
114
+ .argument('<id2>', 'Blocked task ID')
115
+ .action(async (id1: string, id2: string) => {
116
+ try {
117
+ const data = await graphql<{ removeEdge: boolean }>(
118
+ `mutation UnblockTask($sourceId: String!, $targetId: String!, $relation: String!) {
119
+ removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
120
+ }`,
121
+ { sourceId: id1, targetId: id2, relation: 'blocks' },
122
+ )
123
+ output({ removed: data.removeEdge })
124
+ } catch (error) {
125
+ outputError(error)
126
+ }
127
+ })
@@ -0,0 +1,9 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ describe('tree command', () => {
4
+ test('exports a flat command with id argument and depth option', async () => {
5
+ const { treeCommand } = await import('./tree.ts')
6
+ expect(treeCommand.name()).toBe('tree')
7
+ expect(treeCommand.commands).toHaveLength(0)
8
+ })
9
+ })
@@ -2,13 +2,8 @@ import { Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
3
  import { output, outputError } from '../util/format.ts'
4
4
 
5
- export const treeCommand = new Command('tree').description(
6
- 'Graph traversal commands',
7
- )
8
-
9
- treeCommand
10
- .command('subtree')
11
- .description('Show node subtree (node + all descendants)')
5
+ export const treeCommand = new Command('tree')
6
+ .description('Show subtree from any entity')
12
7
  .argument('<id>', 'Root node ID')
13
8
  .option('--depth <n>', 'Max depth', '10')
14
9
  .action(async (id: string, opts) => {
@@ -26,55 +21,3 @@ treeCommand
26
21
  outputError(error)
27
22
  }
28
23
  })
29
-
30
- treeCommand
31
- .command('ancestors')
32
- .description('Show ancestors of a node')
33
- .argument('<id>', 'Node ID')
34
- .option('--depth <n>', 'Max depth', '10')
35
- .option('--relation <type>', 'Edge relation filter', 'part_of')
36
- .action(async (id: string, opts) => {
37
- try {
38
- const data = await graphql<{ ancestors: unknown[] }>(
39
- `query Ancestors($nodeId: String!, $relation: String, $maxDepth: Int) {
40
- ancestors(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
41
- id type title status
42
- }
43
- }`,
44
- {
45
- nodeId: id,
46
- relation: opts.relation,
47
- maxDepth: Number.parseInt(opts.depth, 10),
48
- },
49
- )
50
- output(data.ancestors)
51
- } catch (error) {
52
- outputError(error)
53
- }
54
- })
55
-
56
- treeCommand
57
- .command('descendants')
58
- .description('Show descendants of a node')
59
- .argument('<id>', 'Node ID')
60
- .option('--depth <n>', 'Max depth', '10')
61
- .option('--relation <type>', 'Edge relation filter', 'part_of')
62
- .action(async (id: string, opts) => {
63
- try {
64
- const data = await graphql<{ descendants: unknown[] }>(
65
- `query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
66
- descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
67
- id type title status
68
- }
69
- }`,
70
- {
71
- nodeId: id,
72
- relation: opts.relation,
73
- maxDepth: Number.parseInt(opts.depth, 10),
74
- },
75
- )
76
- output(data.descendants)
77
- } catch (error) {
78
- outputError(error)
79
- }
80
- })
package/src/index.ts CHANGED
@@ -1,25 +1,29 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from 'commander'
3
3
  import { approveCommand } from './commands/approve.ts'
4
- import { edgeCommand } from './commands/edge.ts'
5
- import { nodeCommand } from './commands/node.ts'
6
- import { registerCommand } from './commands/register.ts'
4
+ import { clientCommand } from './commands/client.ts'
5
+ import { featureCommand } from './commands/feature.ts'
6
+ import { projectCommand } from './commands/project.ts'
7
7
  import { searchCommand } from './commands/search.ts'
8
+ import { setupCommand } from './commands/setup.ts'
8
9
  import { statusCommand } from './commands/status.ts'
10
+ import { taskCommand } from './commands/task.ts'
9
11
  import { treeCommand } from './commands/tree.ts'
10
12
  import { whoamiCommand } from './commands/whoami.ts'
11
13
 
12
14
  const program = new Command()
13
15
  .name('flowy')
14
16
  .description('Project management for AI coding agents')
15
- .version('0.1.0')
17
+ .version('0.2.0')
16
18
 
19
+ program.addCommand(setupCommand)
20
+ program.addCommand(clientCommand)
21
+ program.addCommand(projectCommand)
22
+ program.addCommand(featureCommand)
23
+ program.addCommand(taskCommand)
24
+ program.addCommand(statusCommand)
17
25
  program.addCommand(approveCommand)
18
- program.addCommand(registerCommand)
19
- program.addCommand(nodeCommand)
20
- program.addCommand(edgeCommand)
21
26
  program.addCommand(searchCommand)
22
- program.addCommand(statusCommand)
23
27
  program.addCommand(treeCommand)
24
28
  program.addCommand(whoamiCommand)
25
29
 
@@ -0,0 +1,151 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { resolve } from 'node:path'
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ test,
12
+ vi,
13
+ } from 'vitest'
14
+
15
+ const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
16
+
17
+ describe('config', () => {
18
+ let originalConfig: string | null = null
19
+
20
+ beforeAll(() => {
21
+ originalConfig = existsSync(CONFIG_PATH)
22
+ ? readFileSync(CONFIG_PATH, 'utf-8')
23
+ : null
24
+ })
25
+
26
+ afterAll(() => {
27
+ if (originalConfig !== null) {
28
+ writeFileSync(CONFIG_PATH, originalConfig)
29
+ } else if (existsSync(CONFIG_PATH)) {
30
+ rmSync(CONFIG_PATH)
31
+ }
32
+ })
33
+
34
+ beforeEach(() => {
35
+ if (existsSync(CONFIG_PATH)) rmSync(CONFIG_PATH)
36
+ })
37
+
38
+ afterEach(() => {
39
+ delete process.env.FLOWY_PROJECT
40
+ delete process.env.FLOWY_FEATURE
41
+ vi.resetModules()
42
+ })
43
+ test('loadConfig returns defaults when no config file exists', async () => {
44
+ const { loadConfig } = await import('./config.ts')
45
+ const config = loadConfig()
46
+ expect(config.mode).toBe('saas')
47
+ expect(config.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
48
+ expect(config.apiKey).toBe('')
49
+ expect(config.client.name).toBe('')
50
+ expect(config.projects).toEqual({})
51
+ })
52
+
53
+ test('getConfig uses env var overrides', async () => {
54
+ process.env.FLOWY_API_URL = 'http://localhost:4000/graphql'
55
+ process.env.FLOWY_API_KEY = 'test-key'
56
+ const { getConfig } = await import('./config.ts')
57
+ const { apiUrl, apiKey } = getConfig()
58
+ expect(apiUrl).toBe('http://localhost:4000/graphql')
59
+ expect(apiKey).toBe('test-key')
60
+ delete process.env.FLOWY_API_URL
61
+ delete process.env.FLOWY_API_KEY
62
+ })
63
+
64
+ test('saveConfig writes and loadConfig reads from disk', async () => {
65
+ const { saveConfig, loadConfig } = await import('./config.ts')
66
+ const config = loadConfig()
67
+ config.client.name = 'Test Client'
68
+ saveConfig(config)
69
+ const reloaded = loadConfig()
70
+ expect(reloaded.client.name).toBe('Test Client')
71
+ })
72
+
73
+ test('resolveProject returns null when no project configured', async () => {
74
+ const { resolveProject } = await import('./config.ts')
75
+ expect(resolveProject()).toBeNull()
76
+ })
77
+
78
+ test('resolveProject matches cwd against project paths', async () => {
79
+ const { saveConfig, loadConfig, resolveProject } = await import(
80
+ './config.ts'
81
+ )
82
+ const config = loadConfig()
83
+ const cwd = process.cwd()
84
+ config.projects[cwd] = { id: 'proj_1', name: 'Test Project' }
85
+ saveConfig(config)
86
+ const project = resolveProject()
87
+ expect(project).not.toBeNull()
88
+ expect(project?.id).toBe('proj_1')
89
+ expect(project?.name).toBe('Test Project')
90
+ })
91
+
92
+ test('resolveProject uses FLOWY_PROJECT env var', async () => {
93
+ const { saveConfig, loadConfig, resolveProject } = await import(
94
+ './config.ts'
95
+ )
96
+ const config = loadConfig()
97
+ config.projects['/other/path'] = { id: 'proj_2', name: 'Env Project' }
98
+ saveConfig(config)
99
+ process.env.FLOWY_PROJECT = 'Env Project'
100
+ const project = resolveProject()
101
+ expect(project?.name).toBe('Env Project')
102
+ })
103
+
104
+ test('requireProject throws when no project configured', async () => {
105
+ const { requireProject } = await import('./config.ts')
106
+ expect(() => requireProject()).toThrow('No active project')
107
+ })
108
+
109
+ test('resolveFeature returns FLOWY_FEATURE env var', async () => {
110
+ process.env.FLOWY_FEATURE = 'feat_123'
111
+ const { resolveFeature } = await import('./config.ts')
112
+ expect(resolveFeature()).toBe('feat_123')
113
+ })
114
+
115
+ test('resolveFeature falls back to activeFeature from project config', async () => {
116
+ const { saveConfig, loadConfig, resolveFeature } = await import(
117
+ './config.ts'
118
+ )
119
+ const config = loadConfig()
120
+ const cwd = process.cwd()
121
+ config.projects[cwd] = {
122
+ id: 'proj_1',
123
+ name: 'Test',
124
+ activeFeature: 'feat_abc',
125
+ }
126
+ saveConfig(config)
127
+ expect(resolveFeature()).toBe('feat_abc')
128
+ })
129
+
130
+ test('requireFeature throws when no feature set', async () => {
131
+ const { requireFeature } = await import('./config.ts')
132
+ expect(() => requireFeature()).toThrow('No active feature')
133
+ })
134
+
135
+ test('updateProjectConfig modifies project entry for cwd', async () => {
136
+ const { saveConfig, loadConfig, updateProjectConfig } = await import(
137
+ './config.ts'
138
+ )
139
+ const config = loadConfig()
140
+ const cwd = process.cwd()
141
+ config.projects[cwd] = { id: 'proj_1', name: 'Test' }
142
+ saveConfig(config)
143
+ updateProjectConfig((p) => {
144
+ p.activeFeature = 'feat_999'
145
+ })
146
+ const updated = loadConfig()
147
+ expect(
148
+ (updated.projects[cwd] as { activeFeature?: string }).activeFeature,
149
+ ).toBe('feat_999')
150
+ })
151
+ })
@@ -1,5 +1,110 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { resolve } from 'node:path'
4
+
5
+ const CONFIG_DIR = resolve(homedir(), '.config', 'flowy')
6
+ const CONFIG_PATH = resolve(CONFIG_DIR, 'config.json')
7
+
8
+ export function loadConfig() {
9
+ if (!existsSync(CONFIG_PATH)) {
10
+ return {
11
+ mode: 'saas' as const,
12
+ apiUrl: 'https://flowy-ai.fly.dev/graphql',
13
+ apiKey: '',
14
+ client: { name: '' },
15
+ projects: {} as Record<string, unknown>,
16
+ }
17
+ }
18
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
19
+ }
20
+
21
+ export function saveConfig(config: ReturnType<typeof loadConfig>): void {
22
+ mkdirSync(CONFIG_DIR, { recursive: true })
23
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
24
+ }
25
+
26
+ export interface ProjectConfig {
27
+ id: string
28
+ name: string
29
+ activeFeature?: string
30
+ }
31
+
32
+ export function resolveProject(): ProjectConfig | null {
33
+ const envProject = process.env.FLOWY_PROJECT
34
+ const config = loadConfig()
35
+
36
+ if (envProject) {
37
+ return (
38
+ (Object.values(config.projects).find(
39
+ (p) => (p as ProjectConfig).name === envProject,
40
+ ) as ProjectConfig) ?? null
41
+ )
42
+ }
43
+
44
+ const cwd = process.cwd()
45
+ let bestMatch: ProjectConfig | null = null
46
+ let bestLength = 0
47
+
48
+ for (const [path, project] of Object.entries(config.projects)) {
49
+ if (
50
+ (cwd === path || cwd.startsWith(`${path}/`)) &&
51
+ path.length > bestLength
52
+ ) {
53
+ bestMatch = project as ProjectConfig
54
+ bestLength = path.length
55
+ }
56
+ }
57
+
58
+ return bestMatch
59
+ }
60
+
61
+ export function resolveFeature(): string | null {
62
+ const envFeature = process.env.FLOWY_FEATURE
63
+ if (envFeature) return envFeature
64
+ const project = resolveProject()
65
+ return project?.activeFeature ?? null
66
+ }
67
+
68
+ export function requireFeature(): string {
69
+ const feature = resolveFeature()
70
+ if (!feature) {
71
+ throw new Error(
72
+ 'No active feature. Run "flowy feature set <name-or-id>" or set FLOWY_FEATURE.',
73
+ )
74
+ }
75
+ return feature
76
+ }
77
+
78
+ export function requireProject(): ProjectConfig {
79
+ const project = resolveProject()
80
+ if (!project) {
81
+ throw new Error(
82
+ 'No active project. Run "flowy project set <name>" or set FLOWY_PROJECT.',
83
+ )
84
+ }
85
+ return project
86
+ }
87
+
88
+ export function updateProjectConfig(
89
+ updater: (project: ProjectConfig) => void,
90
+ ): void {
91
+ const config = loadConfig()
92
+ const cwd = process.cwd()
93
+
94
+ for (const [path, project] of Object.entries(config.projects)) {
95
+ if (cwd === path || cwd.startsWith(`${path}/`)) {
96
+ updater(project as ProjectConfig)
97
+ saveConfig(config)
98
+ return
99
+ }
100
+ }
101
+
102
+ throw new Error('No directory mapping. Run "flowy project set <name>" first.')
103
+ }
104
+
1
105
  export function getConfig() {
2
- const apiUrl = process.env.FLOWY_API_URL ?? 'https://flowy-ai.fly.dev/graphql'
3
- const apiKey = process.env.FLOWY_API_KEY ?? ''
106
+ const config = loadConfig()
107
+ const apiUrl = process.env.FLOWY_API_URL ?? config.apiUrl
108
+ const apiKey = process.env.FLOWY_API_KEY ?? config.apiKey
4
109
  return { apiUrl, apiKey }
5
110
  }
@@ -0,0 +1,29 @@
1
+ import { rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { afterEach, describe, expect, test } from 'vitest'
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+
8
+ describe('resolveDescription', () => {
9
+ const TEST_FILE = resolve(__dirname, '../../.test-desc.md')
10
+
11
+ afterEach(() => {
12
+ try {
13
+ rmSync(TEST_FILE)
14
+ } catch {}
15
+ })
16
+
17
+ test('returns file content when path exists', async () => {
18
+ writeFileSync(TEST_FILE, '# Test\nSome content')
19
+ const { resolveDescription } = await import('./description.ts')
20
+ const result = await resolveDescription(TEST_FILE)
21
+ expect(result).toBe('# Test\nSome content')
22
+ })
23
+
24
+ test('returns value as-is when path does not exist', async () => {
25
+ const { resolveDescription } = await import('./description.ts')
26
+ const result = await resolveDescription('Just a plain description')
27
+ expect(result).toBe('Just a plain description')
28
+ })
29
+ })
@@ -0,0 +1,8 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+
3
+ export async function resolveDescription(value: string): Promise<string> {
4
+ if (existsSync(value)) {
5
+ return readFileSync(value, 'utf-8')
6
+ }
7
+ return value
8
+ }
@@ -1,84 +0,0 @@
1
- import { Command } from 'commander'
2
- import { graphql } from '../util/client.ts'
3
- import { output, outputError } from '../util/format.ts'
4
-
5
- export const edgeCommand = new Command('edge').description(
6
- 'Manage edges between nodes',
7
- )
8
-
9
- edgeCommand
10
- .command('create')
11
- .description('Create an edge between two nodes')
12
- .requiredOption('--source <id>', 'Source node ID')
13
- .requiredOption('--target <id>', 'Target node ID')
14
- .requiredOption(
15
- '--relation <rel>',
16
- 'Relation type (part_of, depends_on, blocks, informs)',
17
- )
18
- .action(async (opts) => {
19
- try {
20
- const data = await graphql<{ createEdge: unknown }>(
21
- `mutation CreateEdge($sourceId: String!, $targetId: String!, $relation: String!) {
22
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
23
- sourceId targetId relation createdAt
24
- }
25
- }`,
26
- {
27
- sourceId: opts.source,
28
- targetId: opts.target,
29
- relation: opts.relation,
30
- },
31
- )
32
- output(data.createEdge)
33
- } catch (error) {
34
- outputError(error)
35
- }
36
- })
37
-
38
- edgeCommand
39
- .command('remove')
40
- .description('Remove an edge')
41
- .requiredOption('--source <id>', 'Source node ID')
42
- .requiredOption('--target <id>', 'Target node ID')
43
- .requiredOption('--relation <rel>', 'Relation type')
44
- .action(async (opts) => {
45
- try {
46
- const data = await graphql<{ removeEdge: boolean }>(
47
- `mutation RemoveEdge($sourceId: String!, $targetId: String!, $relation: String!) {
48
- removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
49
- }`,
50
- {
51
- sourceId: opts.source,
52
- targetId: opts.target,
53
- relation: opts.relation,
54
- },
55
- )
56
- output({ removed: data.removeEdge })
57
- } catch (error) {
58
- outputError(error)
59
- }
60
- })
61
-
62
- edgeCommand
63
- .command('list')
64
- .description('List edges')
65
- .option('--node <id>', 'Filter by node ID')
66
- .option('--relation <rel>', 'Filter by relation type')
67
- .action(async (opts) => {
68
- try {
69
- const data = await graphql<{ edges: unknown[] }>(
70
- `query ListEdges($nodeId: String, $relation: String) {
71
- edges(nodeId: $nodeId, relation: $relation) {
72
- sourceId targetId relation createdAt
73
- }
74
- }`,
75
- {
76
- nodeId: opts.node,
77
- relation: opts.relation,
78
- },
79
- )
80
- output(data.edges)
81
- } catch (error) {
82
- outputError(error)
83
- }
84
- })