@4ge/cli 0.1.0 β†’ 0.2.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.
@@ -0,0 +1,10 @@
1
+ import { Help } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ export default class FlowIndex extends BaseCommand {
4
+ static description = 'Manage feature flows in your project';
5
+ async run() {
6
+ await this.renderHeader('ready', 'compact');
7
+ const help = new Help(this.config);
8
+ await help.showHelp(['flow']);
9
+ }
10
+ }
@@ -0,0 +1,85 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base.js';
4
+ import { listFlows } from '../../generated/api/sdk.gen.js';
5
+ import { zListFlowsResponse } from '../../generated/api/zod.gen.js';
6
+ import { validateResponse } from '../../core/api/index.js';
7
+ import { DataTable } from '../../ui/index.js';
8
+ export default class FlowList extends BaseCommand {
9
+ static description = 'List flows for a feature in the current project';
10
+ static examples = [
11
+ '<%= config.bin %> flow:list --feature-id <uuid>',
12
+ '<%= config.bin %> flow:list --feature-id <uuid> --roadmap now --json',
13
+ '<%= config.bin %> flow:list --feature-id <uuid> --status IN_DEVELOPMENT',
14
+ ];
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ 'feature-id': Flags.string({
18
+ description: 'Feature ID whose flows to list (a feature owns one implied flow)',
19
+ required: true,
20
+ }),
21
+ roadmap: Flags.string({
22
+ description: 'Filter by roadmap status (now|next|later)',
23
+ options: ['now', 'next', 'later'],
24
+ }),
25
+ status: Flags.string({
26
+ description: 'Filter by status (e.g. BACKLOG, IN_DEVELOPMENT, COMPLETE)',
27
+ }),
28
+ };
29
+ async run() {
30
+ const { flags } = await this.parse(FlowList);
31
+ await this.renderHeader('ready', 'compact');
32
+ const client = await this.getClient();
33
+ const projectId = await this.getProjectIdOrExit();
34
+ try {
35
+ const response = await this.withSpinner('Fetching flows...', () => listFlows({
36
+ client,
37
+ path: {
38
+ projectId,
39
+ featureId: flags['feature-id'],
40
+ },
41
+ query: {
42
+ ...(flags.roadmap ? { roadmap: flags.roadmap } : {}),
43
+ ...(flags.status ? { status: flags.status } : {}),
44
+ },
45
+ }));
46
+ const { data, error } = response;
47
+ const validated = validateResponse(data, error, zListFlowsResponse);
48
+ const items = validated.data;
49
+ const metadata = validated.metadata;
50
+ if (this.isJsonMode) {
51
+ this.outputJson(items, { source: 'flow list' });
52
+ }
53
+ else {
54
+ await this.renderInk(_jsx(DataTable, { data: items, columns: [
55
+ { key: 'label', header: 'Title', minWidth: 20 },
56
+ { key: 'id', header: 'ID' },
57
+ { key: 'status', header: 'Status', color: 'auto' },
58
+ { key: 'roadmap_status', header: 'Roadmap' },
59
+ { key: 'step_count', header: 'Steps' },
60
+ { key: 'parent_feature_id', header: 'Feature ID' },
61
+ ], title: `Flows in feature ${flags['feature-id']}${flags.roadmap ? ` (${flags.roadmap})` : ''}`, emptyMessage: `No flows found${flags.roadmap ? ` for roadmap: ${flags.roadmap}` : ''}` }));
62
+ if (this.isInteractiveMode) {
63
+ if (items.length > 0) {
64
+ const options = items.map((item) => ({
65
+ label: item.label || item.id,
66
+ value: item.id,
67
+ description: item.status || '',
68
+ }));
69
+ const selectedId = await this.selectOption('Select a flow for details:', options, { headerVariant: 'compact' });
70
+ if (selectedId) {
71
+ const { default: chalk } = await import('chalk');
72
+ this.log(`\n πŸš€ Run: ${chalk.cyan(`4ge flow show ${selectedId}`)}\n`);
73
+ }
74
+ }
75
+ if (metadata?.action_hints) {
76
+ await this.handleActionHints(metadata.action_hints);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ catch (err) {
82
+ this.handleError('An unexpected error occurred', err);
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,109 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base.js';
4
+ import { getCollectionDir } from '../../core/config/index.js';
5
+ import { resolveRecursive } from '../../core/config/defaults.js';
6
+ import { getFlow, getFlowTree } from '../../generated/api/sdk.gen.js';
7
+ import { zGetFlowTreeResponse, zGetFlowResponse } from '../../generated/api/zod.gen.js';
8
+ import { validateResponse } from '../../core/api/index.js';
9
+ import { SuccessMessage } from '../../ui/index.js';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ export default class FlowPull extends BaseCommand {
13
+ static description = 'Download flow details';
14
+ static examples = [
15
+ '<%= config.bin %> flow:pull --id <uuid>',
16
+ '<%= config.bin %> flow:pull --id <uuid> --json',
17
+ '<%= config.bin %> flow:pull --id <uuid> --recursive',
18
+ ];
19
+ static flags = {
20
+ ...BaseCommand.baseFlags,
21
+ id: Flags.string({
22
+ description: 'Flow ID',
23
+ required: true,
24
+ }),
25
+ recursive: Flags.boolean({
26
+ description: 'Pull full flow tree (flow + step nodes) instead of just the flow detail',
27
+ allowNo: true,
28
+ }),
29
+ save: Flags.boolean({
30
+ description: 'Force save to disk even in JSON mode',
31
+ default: false,
32
+ }),
33
+ stdout: Flags.boolean({
34
+ description: 'Print to stdout without saving',
35
+ default: false,
36
+ }),
37
+ };
38
+ async run() {
39
+ const { flags } = await this.parse(FlowPull);
40
+ if (flags.json && flags.stdout) {
41
+ this.error('Cannot use --json and --stdout together');
42
+ }
43
+ if (flags.save && flags.stdout) {
44
+ this.error('Cannot use --stdout and --save together');
45
+ }
46
+ await this.renderHeader('ready', 'compact');
47
+ await this.migrateAssetsIfNeeded();
48
+ const client = await this.getClient();
49
+ const projectId = await this.getProjectIdOrExit();
50
+ const isRecursive = resolveRecursive(flags.recursive, this.localConfig);
51
+ const spinnerMsg = isRecursive ? `Pulling flow ${flags.id} tree...` : `Pulling flow ${flags.id}...`;
52
+ const response = await this.withSpinner(spinnerMsg, () => {
53
+ if (isRecursive) {
54
+ return getFlowTree({
55
+ client,
56
+ path: {
57
+ projectId,
58
+ flowId: flags.id,
59
+ },
60
+ });
61
+ }
62
+ else {
63
+ return getFlow({
64
+ client,
65
+ path: {
66
+ projectId,
67
+ flowId: flags.id,
68
+ },
69
+ });
70
+ }
71
+ });
72
+ const { data, error } = response;
73
+ try {
74
+ let flow;
75
+ let metadata;
76
+ if (isRecursive) {
77
+ const validated = validateResponse(data, error, zGetFlowTreeResponse);
78
+ flow = validated.data;
79
+ metadata = validated.metadata;
80
+ }
81
+ else {
82
+ const validated = validateResponse(data, error, zGetFlowResponse);
83
+ flow = validated.data;
84
+ metadata = validated.metadata;
85
+ }
86
+ const savePath = path.join(getCollectionDir('flows'), `${flags.id}.json`);
87
+ if (this.isJsonMode) {
88
+ this.outputJson(flow);
89
+ if (flags.save) {
90
+ await this.saveToFile(savePath, flow);
91
+ }
92
+ }
93
+ else if (flags.stdout) {
94
+ this.log(JSON.stringify(flow, null, 2));
95
+ }
96
+ else {
97
+ await this.saveToFile(savePath, flow);
98
+ await this.renderInk(_jsx(SuccessMessage, { message: `Flow saved to .4ge/assets/flows/${flags.id}.json` }));
99
+ }
100
+ }
101
+ catch (err) {
102
+ this.handleError(`Failed to pull flow: ${flags.id}`, err);
103
+ }
104
+ }
105
+ async saveToFile(filePath, data) {
106
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
107
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
108
+ }
109
+ }
@@ -0,0 +1,58 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ import { getFlow } from '../../generated/api/sdk.gen.js';
4
+ import { zGetFlowResponse } from '../../generated/api/zod.gen.js';
5
+ import { validateResponse } from '../../core/api/index.js';
6
+ export default class FlowShow extends BaseCommand {
7
+ static description = 'Show detailed information for a specific flow';
8
+ static examples = [
9
+ '<%= config.bin %> flow:show --id <uuid>',
10
+ '<%= config.bin %> flow:show --id <uuid> --json',
11
+ ];
12
+ static flags = {
13
+ ...BaseCommand.baseFlags,
14
+ id: Flags.string({
15
+ description: 'Flow ID',
16
+ required: true,
17
+ }),
18
+ };
19
+ async run() {
20
+ const { flags } = await this.parse(FlowShow);
21
+ const client = await this.getClient();
22
+ const projectId = await this.getProjectIdOrExit();
23
+ await this.renderHeader('ready', 'compact');
24
+ const response = await this.withSpinner(`Fetching flow ${flags.id}...`, () => getFlow({
25
+ client,
26
+ path: {
27
+ projectId,
28
+ flowId: flags.id,
29
+ },
30
+ }));
31
+ const { data, error } = response;
32
+ try {
33
+ const validated = validateResponse(data, error, zGetFlowResponse);
34
+ const payload = validated.data;
35
+ const metadata = validated.metadata;
36
+ if (this.isJsonMode) {
37
+ this.outputJson(payload, { source: 'flow show' });
38
+ }
39
+ else {
40
+ this.log(`\nFlow: ${payload.label}`);
41
+ this.log(`ID: ${payload.id}`);
42
+ this.log(`Status: ${payload.status}`);
43
+ this.log(`Roadmap: ${payload.roadmap_status ?? 'None'}`);
44
+ this.log(`Feature ID: ${payload.parent_feature_id ?? payload.parent_id ?? 'None'}`);
45
+ this.log(`Steps: ${payload.step_count}`);
46
+ if (payload.description) {
47
+ this.log(`\nDescription:\n${payload.description}\n`);
48
+ }
49
+ if (this.isInteractiveMode && metadata?.action_hints) {
50
+ await this.handleActionHints(metadata.action_hints);
51
+ }
52
+ }
53
+ }
54
+ catch (err) {
55
+ this.handleError(`Failed to fetch flow detail for ID: ${flags.id}`, err);
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,105 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base.js';
4
+ import { updateFlow } from '../../generated/api/sdk.gen.js';
5
+ import { zUpdateFlowResponse } from '../../generated/api/zod.gen.js';
6
+ import { validateResponse } from '../../core/api/index.js';
7
+ import { SuccessMessage } from '../../ui/index.js';
8
+ export default class FlowUpdate extends BaseCommand {
9
+ static description = 'Update a flow';
10
+ static examples = [
11
+ '<%= config.bin %> flow:update --id <uuid> --status IN_DEVELOPMENT',
12
+ '<%= config.bin %> flow:update --id <uuid> --status COMPLETE --title "New Title"',
13
+ '<%= config.bin %> flow:update --id <uuid> --roadmap-status next --priority MUST_HAVE',
14
+ ];
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ id: Flags.string({
18
+ description: 'Flow ID (UUID)',
19
+ required: true,
20
+ }),
21
+ status: Flags.string({
22
+ description: 'New status (BACKLOG|NOT_STARTED|IN_PLANNING|READY_FOR_DEVELOPMENT|IN_DEVELOPMENT|BLOCKED|REVIEW|COMPLETE)',
23
+ options: ['BACKLOG', 'NOT_STARTED', 'IN_PLANNING', 'READY_FOR_DEVELOPMENT', 'IN_DEVELOPMENT', 'BLOCKED', 'REVIEW', 'COMPLETE'],
24
+ }),
25
+ title: Flags.string({
26
+ description: 'New title',
27
+ }),
28
+ description: Flags.string({
29
+ description: 'New description',
30
+ }),
31
+ priority: Flags.string({
32
+ description: 'New priority (MUST_HAVE|SHOULD_HAVE|BACKLOG)',
33
+ options: ['MUST_HAVE', 'SHOULD_HAVE', 'BACKLOG'],
34
+ }),
35
+ 'roadmap-status': Flags.string({
36
+ description: 'New roadmap status (now|next|later)',
37
+ options: ['now', 'next', 'later'],
38
+ }),
39
+ force: Flags.boolean({
40
+ description: 'Skip confirmation prompt',
41
+ default: false,
42
+ }),
43
+ };
44
+ async run() {
45
+ const { flags } = await this.parse(FlowUpdate);
46
+ const projectId = await this.getProjectIdOrExit();
47
+ await this.renderHeader('ready', 'compact');
48
+ const body = {};
49
+ if (flags.status !== undefined)
50
+ body.status = flags.status;
51
+ if (flags.title !== undefined)
52
+ body.label = flags.title;
53
+ if (flags.description !== undefined)
54
+ body.description = flags.description;
55
+ if (flags.priority !== undefined)
56
+ body.priority = flags.priority;
57
+ if (flags['roadmap-status'] !== undefined)
58
+ body.roadmap_status = flags['roadmap-status'];
59
+ if (Object.keys(body).length === 0) {
60
+ const message = 'At least one field must be provided to update (--status, --title, --description, --priority, --roadmap-status)';
61
+ if (this.isJsonMode) {
62
+ this.outputErrorAndExit({ message, code: 'MISSING_INPUT' });
63
+ }
64
+ else {
65
+ this.error(message);
66
+ }
67
+ }
68
+ const confirmed = await this.confirmAction(`Update flow ${flags.id}?`, flags.force, { headerVariant: 'compact' });
69
+ if (!confirmed)
70
+ return;
71
+ const payload = {
72
+ path: {
73
+ projectId,
74
+ flowId: flags.id,
75
+ },
76
+ body,
77
+ };
78
+ if (this.isDryRun) {
79
+ await this.handleDryRun(payload);
80
+ return;
81
+ }
82
+ const client = await this.getClient();
83
+ const { data, error } = await this.withSpinner(`Updating flow ${flags.id}...`, () => updateFlow({
84
+ client,
85
+ ...payload,
86
+ }));
87
+ try {
88
+ const validated = validateResponse(data, error, zUpdateFlowResponse);
89
+ const flow = validated.data;
90
+ const metadata = validated.metadata;
91
+ if (this.isJsonMode) {
92
+ this.outputJson(flow, { source: 'flow update', ...metadata });
93
+ }
94
+ else {
95
+ await this.renderInk(_jsx(SuccessMessage, { message: `Flow ${flags.id} updated successfully!` }));
96
+ if (this.isInteractiveMode && metadata?.action_hints) {
97
+ await this.handleActionHints(metadata.action_hints);
98
+ }
99
+ }
100
+ }
101
+ catch (err) {
102
+ this.handleError(`Failed to update flow: ${flags.id}`, err);
103
+ }
104
+ }
105
+ }
@@ -5,6 +5,8 @@ import { listTemplates } from '../../generated/api/sdk.gen.js';
5
5
  import { zListTemplatesResponse } from '../../generated/api/zod.gen.js';
6
6
  import { validateResponse } from '../../core/api/index.js';
7
7
  import { DataTable } from '../../ui/index.js';
8
+ /** Render a boolean as a checkmark or empty string. Pure β€” extracted from the DataTable column def so it's unit-testable. */
9
+ export const formatCheckmark = (v) => (v ? 'βœ“' : '');
8
10
  export default class TemplateList extends BaseCommand {
9
11
  static description = 'List all available project templates';
10
12
  static flags = {
@@ -52,7 +54,7 @@ export default class TemplateList extends BaseCommand {
52
54
  { key: 'framework', header: 'Framework' },
53
55
  { key: 'author', header: 'Author' },
54
56
  { key: 'featuresCount', header: 'Features' },
55
- { key: 'isOfficial', header: 'Official', format: (v) => (v ? 'βœ“' : '') },
57
+ { key: 'isOfficial', header: 'Official', format: formatCheckmark },
56
58
  ], title: "Available Templates", emptyMessage: flags.workspace ? `No templates found for workspace: ${flags.workspace}` : 'No templates found.' }));
57
59
  if (this.isInteractiveMode) {
58
60
  if (filteredTemplates.length > 0) {
@@ -1,2 +1,2 @@
1
1
  // This file is auto-generated by @hey-api/openapi-ts
2
- export { cliAuthCallback, cliAuthEmailLogin, cliAuthLogin, cliAuthRefresh, cliAuthStatus, createIdea, createProject, createTemplate, createTemplateFeature, deleteTemplate, deleteTemplateFeature, getEpicDetail, getEpicTree, getFeatureDetail, getFeatureTree, getLatestPlanVersion, getPlanMetadata, getPlanTree, getPlanVersionDetail, getPlanVersions, getProjectDetail, getStoryDetail, getStoryTree, getTemplateDetail, graduateIdea, listEpics, listFeatures, listIdeas, listProjects, listStories, listTemplateFeatures, listTemplates, listWorkspaces, updateEpic, updateFeature, updateIdea, updateProject, updateStory, updateTemplate, updateTemplateFeature, } from "./sdk.gen.js";
2
+ export { cliAuthCallback, cliAuthEmailLogin, cliAuthLogin, cliAuthRefresh, cliAuthStatus, createIdea, createProject, createTemplate, createTemplateFeature, deleteTemplate, deleteTemplateFeature, getEpicDetail, getEpicTree, getFeatureDetail, getFeatureTree, getFlow, getFlowTree, getLatestPlanVersion, getPlanMetadata, getPlanTree, getPlanVersionDetail, getPlanVersions, getProjectDetail, getStoryDetail, getStoryTree, getTemplateDetail, graduateIdea, listEpics, listFeatures, listFlows, listIdeas, listProjects, listStories, listTemplateFeatures, listTemplates, listWorkspaces, updateEpic, updateFeature, updateFlow, updateIdea, updateProject, updateStory, updateTemplate, updateTemplateFeature, } from "./sdk.gen.js";
@@ -301,6 +301,62 @@ export const getFeatureTree = (options) => (options.client ?? client).get({
301
301
  url: "/api/cli/v1/projects/{projectId}/features/{featureId}/tree",
302
302
  ...options,
303
303
  });
304
+ /**
305
+ * list-flows
306
+ *
307
+ * List flows under a feature. A feature owns one implied flow (its ordered useraction/systemresponse/conditional steps), so this returns a 1-element array. roadmap/status filters apply at the feature level.
308
+ */
309
+ export const listFlows = (options) => (options.client ?? client).get({
310
+ security: [
311
+ { scheme: "bearer", type: "http" },
312
+ { name: "Authorization", type: "apiKey" },
313
+ ],
314
+ url: "/api/cli/v1/projects/{projectId}/features/{featureId}/flows",
315
+ ...options,
316
+ });
317
+ /**
318
+ * get-flow
319
+ *
320
+ * Get a flow summary. flowId is the owning feature id (a flow is the implied collection of steps attached to a feature via parent_feature_id).
321
+ */
322
+ export const getFlow = (options) => (options.client ?? client).get({
323
+ security: [
324
+ { scheme: "bearer", type: "http" },
325
+ { name: "Authorization", type: "apiKey" },
326
+ ],
327
+ url: "/api/cli/v1/projects/{projectId}/flows/{flowId}",
328
+ ...options,
329
+ });
330
+ /**
331
+ * update-flow
332
+ *
333
+ * Update a flow. flowId is the owning feature id; flow-level metadata IS the feature metadata (status/label/description/roadmap/priority). Invalid enums return 422.
334
+ */
335
+ export const updateFlow = (options) => (options.client ?? client).patch({
336
+ security: [
337
+ { scheme: "bearer", type: "http" },
338
+ { name: "Authorization", type: "apiKey" },
339
+ ],
340
+ url: "/api/cli/v1/projects/{projectId}/flows/{flowId}",
341
+ ...options,
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ ...options.headers,
345
+ },
346
+ });
347
+ /**
348
+ * get-flow-tree
349
+ *
350
+ * Get a flow with its ordered step tree. Steps are topologically sorted over project_plan_edges; conditional branches nest via parent_id.
351
+ */
352
+ export const getFlowTree = (options) => (options.client ?? client).get({
353
+ security: [
354
+ { scheme: "bearer", type: "http" },
355
+ { name: "Authorization", type: "apiKey" },
356
+ ],
357
+ url: "/api/cli/v1/projects/{projectId}/flows/{flowId}/tree",
358
+ ...options,
359
+ });
304
360
  /**
305
361
  * list-stories
306
362
  *
@@ -748,6 +748,173 @@ export const zGetFeatureTreeResponse = z.object({
748
748
  action_hints: z.array(z.unknown()).optional(),
749
749
  }),
750
750
  });
751
+ export const zListFlowsPath = z.object({
752
+ projectId: z.uuid(),
753
+ featureId: z.uuid(),
754
+ });
755
+ export const zListFlowsQuery = z.object({
756
+ roadmap: z.enum(["now", "next", "later"]).optional(),
757
+ status: z.string().optional(),
758
+ });
759
+ /**
760
+ * Successful response
761
+ */
762
+ export const zListFlowsResponse = z.object({
763
+ data: z.array(z.object({
764
+ id: z.uuid(),
765
+ label: z.string(),
766
+ description: z.string().nullable(),
767
+ node_type: z.enum(["flow"]),
768
+ status: z.enum([
769
+ "BACKLOG",
770
+ "NOT_STARTED",
771
+ "IN_PLANNING",
772
+ "READY_FOR_DEVELOPMENT",
773
+ "IN_DEVELOPMENT",
774
+ "BLOCKED",
775
+ "REVIEW",
776
+ "COMPLETE",
777
+ ]),
778
+ priority: z.string().nullable(),
779
+ roadmap_status: z
780
+ .enum(["now", "next", "later", "someday", "released"])
781
+ .optional(),
782
+ parent_id: z.uuid().nullable(),
783
+ parent_feature_id: z.uuid().nullish(),
784
+ project_id: z.uuid(),
785
+ workspace_id: z.uuid(),
786
+ position_x: z.number().optional(),
787
+ position_y: z.number().optional(),
788
+ step_count: z.int().gte(0),
789
+ })),
790
+ error: z.unknown(),
791
+ metadata: z.object({
792
+ timestamp: z.string(),
793
+ request_id: z.string(),
794
+ action_hints: z.array(z.unknown()).optional(),
795
+ }),
796
+ });
797
+ export const zGetFlowPath = z.object({
798
+ projectId: z.uuid(),
799
+ flowId: z.uuid(),
800
+ });
801
+ /**
802
+ * Successful response
803
+ */
804
+ export const zGetFlowResponse = z.object({
805
+ data: z.object({
806
+ id: z.uuid(),
807
+ label: z.string(),
808
+ description: z.string().nullable(),
809
+ node_type: z.enum(["flow"]),
810
+ status: z.enum([
811
+ "BACKLOG",
812
+ "NOT_STARTED",
813
+ "IN_PLANNING",
814
+ "READY_FOR_DEVELOPMENT",
815
+ "IN_DEVELOPMENT",
816
+ "BLOCKED",
817
+ "REVIEW",
818
+ "COMPLETE",
819
+ ]),
820
+ priority: z.string().nullable(),
821
+ roadmap_status: z
822
+ .enum(["now", "next", "later", "someday", "released"])
823
+ .optional(),
824
+ parent_id: z.uuid().nullable(),
825
+ parent_feature_id: z.uuid().nullish(),
826
+ project_id: z.uuid(),
827
+ workspace_id: z.uuid(),
828
+ position_x: z.number().optional(),
829
+ position_y: z.number().optional(),
830
+ step_count: z.int().gte(0),
831
+ }),
832
+ error: z.unknown(),
833
+ metadata: z.object({
834
+ timestamp: z.string(),
835
+ request_id: z.string(),
836
+ action_hints: z.array(z.unknown()).optional(),
837
+ }),
838
+ });
839
+ export const zUpdateFlowBody = z.object({
840
+ label: z.string().min(1).optional(),
841
+ description: z.string().optional(),
842
+ status: z
843
+ .enum([
844
+ "BACKLOG",
845
+ "NOT_STARTED",
846
+ "IN_PLANNING",
847
+ "READY_FOR_DEVELOPMENT",
848
+ "IN_DEVELOPMENT",
849
+ "BLOCKED",
850
+ "REVIEW",
851
+ "COMPLETE",
852
+ ])
853
+ .optional(),
854
+ priority: z.enum(["MUST_HAVE", "SHOULD_HAVE", "BACKLOG"]).optional(),
855
+ roadmap_status: z
856
+ .enum(["now", "next", "later", "someday", "released"])
857
+ .optional(),
858
+ parent_id: z.uuid().optional(),
859
+ });
860
+ export const zUpdateFlowPath = z.object({
861
+ projectId: z.uuid(),
862
+ flowId: z.uuid(),
863
+ });
864
+ /**
865
+ * Successful response
866
+ */
867
+ export const zUpdateFlowResponse = z.object({
868
+ data: z.object({
869
+ id: z.uuid(),
870
+ label: z.string(),
871
+ description: z.string().nullable(),
872
+ node_type: z.enum(["flow"]),
873
+ status: z.enum([
874
+ "BACKLOG",
875
+ "NOT_STARTED",
876
+ "IN_PLANNING",
877
+ "READY_FOR_DEVELOPMENT",
878
+ "IN_DEVELOPMENT",
879
+ "BLOCKED",
880
+ "REVIEW",
881
+ "COMPLETE",
882
+ ]),
883
+ priority: z.string().nullable(),
884
+ roadmap_status: z
885
+ .enum(["now", "next", "later", "someday", "released"])
886
+ .optional(),
887
+ parent_id: z.uuid().nullable(),
888
+ parent_feature_id: z.uuid().nullish(),
889
+ project_id: z.uuid(),
890
+ workspace_id: z.uuid(),
891
+ position_x: z.number().optional(),
892
+ position_y: z.number().optional(),
893
+ step_count: z.int().gte(0),
894
+ }),
895
+ error: z.unknown(),
896
+ metadata: z.object({
897
+ timestamp: z.string(),
898
+ request_id: z.string(),
899
+ action_hints: z.array(z.unknown()).optional(),
900
+ }),
901
+ });
902
+ export const zGetFlowTreePath = z.object({
903
+ projectId: z.uuid(),
904
+ flowId: z.uuid(),
905
+ });
906
+ /**
907
+ * Successful response
908
+ */
909
+ export const zGetFlowTreeResponse = z.object({
910
+ data: z.unknown().optional(),
911
+ error: z.unknown(),
912
+ metadata: z.object({
913
+ timestamp: z.string(),
914
+ request_id: z.string(),
915
+ action_hints: z.array(z.unknown()).optional(),
916
+ }),
917
+ });
751
918
  export const zListStoriesPath = z.object({
752
919
  projectId: z.uuid(),
753
920
  });
@@ -23,8 +23,9 @@ const COMPACT_ART = [
23
23
  ];
24
24
  const START_COLOR = { r: 0, g: 229, b: 255 }; // #00e5ff
25
25
  const END_COLOR = { r: 224, g: 64, b: 251 }; // #e040fb
26
- const lerp = (start, end, t) => Math.round(start + (end - start) * t);
27
- const applyGradient = (text, width) => {
26
+ export const lerp = (start, end, t) => Math.round(start + (end - start) * t);
27
+ /** Apply a linear RGB gradient over a single-line string. Pure. */
28
+ export const applyGradient = (text, width) => {
28
29
  return text
29
30
  .split('')
30
31
  .map((char, i) => {
@@ -37,14 +38,16 @@ const applyGradient = (text, width) => {
37
38
  .join('');
38
39
  };
39
40
  const SPINNER_FRAMES = ['β ‹', 'β ™', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ‡', '⠏'];
40
- const STATUS_CONFIG = {
41
+ export const STATUS_CONFIG = {
41
42
  'checking-connectivity': { label: 'Checking Connectivity', icon: SPINNER_FRAMES, color: 'yellow' },
42
43
  'refreshing-token': { label: 'Refreshing Token', icon: SPINNER_FRAMES, color: 'yellow' },
43
44
  ready: { label: 'Ready', icon: 'βœ“', color: 'green' },
44
45
  'no-connection': { label: 'No Connection', icon: 'βœ—', color: 'red' },
45
46
  'update-required': { label: 'Update Required', icon: '⚠', color: 'magenta' },
46
47
  };
47
- const getArt = (variant) => {
48
+ /** Resolve the status config (label/icon/color) for a CliStatus. Pure. */
49
+ export const getStatusConfig = (status) => STATUS_CONFIG[status];
50
+ export const getArt = (variant) => {
48
51
  const art = variant === 'compact' ? MIDDY_ART : ART;
49
52
  const width = Math.max(...art.map((line) => line.length));
50
53
  return { art, width };
@@ -59,7 +62,7 @@ export function renderHeaderString(version, status = 'ready', headerVariant = 'f
59
62
  return `${chalk.dim('β•‘ ')}${applyGradient(line + padding, width)}${chalk.dim(' β•‘')}`;
60
63
  })
61
64
  .join('\n');
62
- const currentStatus = STATUS_CONFIG[status];
65
+ const currentStatus = getStatusConfig(status);
63
66
  const icon = Array.isArray(currentStatus.icon) ? currentStatus.icon[0] : currentStatus.icon;
64
67
  const statusLabel = chalk[currentStatus.color](`${currentStatus.label} ${icon}`);
65
68
  const statusBar = chalk.dim(`[ 4ge CLI | v${version} | Status: ${statusLabel} ]`);
@@ -82,7 +85,7 @@ export default function CliHeader({ version, status = 'ready', headerVariant = '
82
85
  const { art, width } = getArt(headerVariant);
83
86
  const borderTop = `β•”${'═'.repeat(width + 2)}β•—`;
84
87
  const borderBottom = `β•š${'═'.repeat(width + 2)}╝`;
85
- const currentStatus = STATUS_CONFIG[status];
88
+ const currentStatus = getStatusConfig(status);
86
89
  const icon = Array.isArray(currentStatus.icon) ? currentStatus.icon[frame] : currentStatus.icon;
87
90
  return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: borderTop }), art.map((line, i) => {
88
91
  const padding = ' '.repeat(width - line.length);
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- const getStatusColor = (value) => {
3
+ /**
4
+ * Map a status value to a chalk/ink color token.
5
+ * Pure: given a value, returns 'green' | 'yellow' | 'red' | undefined.
6
+ * Exported for unit testing β€” the mapping is logic, the JSX is wiring.
7
+ */
8
+ export const getStatusColor = (value) => {
4
9
  if (typeof value !== 'string')
5
10
  return undefined;
6
11
  const upper = value.toUpperCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4ge/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "4ge Service Architecture Bridge & CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "dev": "node --env-file=.env.local ./bin/dev.js",
39
39
  "test": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs run",
40
40
  "test:watch": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs",
41
+ "test:coverage": "node --env-file=.env.test ./node_modules/vitest/vitest.mjs run --coverage",
41
42
  "lint": "eslint src/ --ext .ts",
42
43
  "format": "prettier --write \"src/**/*.ts\"",
43
44
  "sync-api": "openapi-ts && prettier --write \"src/generated/api/**/*.ts\"",
package/skills/4ge-cli.md CHANGED
@@ -80,6 +80,7 @@ Use it for network-flaky operations.
80
80
  |-----------|----------|-----------|
81
81
  | **project** | `create`, `info`, `list`, `pull`, `update` | `--workspace` (create), `--name`, `--description`, `--template`, `--force`, `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
82
82
  | **epic** | `list`, `show`, `pull`, `update` | `--epic-id` / `--id` (pull), `--status`, `--title`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
83
+ | **flow** | `list`, `show`, `pull`, `update` | `--id`, `--feature-id` (list), `--recursive` (pull β€” full flow step tree), `--status`, `--title` (update), `--description` (update), `--priority` (update), `--roadmap-status` (update), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
83
84
  | **story** | `list`, `show`, `pull`, `update` | `--story-id` / `--id` (pull), `--status`, `--title`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
84
85
  | **feature** | `list`, `show`, `pull`, `update` | `--feature-id` / `--id` (pull), `--status`, `--roadmap`, `--recursive` (pull), `--roadmap` (pull, with `--recursive`), `--save`/`--stdout` (pull), `--dry-run`, `--retry` |
85
86
  | **idea** | `create`, `graduate`, `list`, `show`, `update` | `<ID>` (positional for show), `--title`, `--workspace`, `--dry-run`, `--retry` |
@@ -102,6 +103,76 @@ No `--project-id` flag is needed for those namespaces.
102
103
 
103
104
  Flags always override config; bare `4ge <ns>:pull --id <id>` honours the config defaults.
104
105
 
106
+ ### Plan data model β€” parent-link semantics
107
+
108
+ **Read this before reconstructing a hierarchy from `list`/`pull --json` output.
109
+ A wrong field will falsely make everything look orphaned.**
110
+
111
+ Each project plan node (epic / story / feature / flow step) carries TWO
112
+ parent-link fields. They coexist by design β€” neither is legacy β€” and they
113
+ serve different relationships:
114
+
115
+ | Field | Meaning | Populated on |
116
+ |---|---|---|
117
+ | `parent_id` | The **hierarchy tree** edge. epic β†’ story β†’ feature, plus step β†’ step containment inside flows. | epics, stories, features, flow steps |
118
+ | `parent_feature_id` | A **flow-ownership** edge. Used ONLY by flow-step nodes (`node_type`: `useraction` / `systemresponse` / `conditional`) to attach to their owning feature. These steps deliberately have `parent_id` null (or pointing at another step). | flow steps only |
119
+
120
+ **Canonical hierarchy is `epic β†’ story β†’ feature`** (parent points UP
121
+ toward the epic), and the tree read bottom-up is:
122
+
123
+ ```
124
+ feature.parent_id β†’ a story id β†’ (story.parent_id) β†’ an epic
125
+ ```
126
+
127
+ Not feature β†’ epic directly β€” the story is the mandatory intermediate node.
128
+
129
+ #### Rules agents must follow
130
+
131
+ 1. **For features and stories, `parent_feature_id` is null by design.**
132
+ Features and stories are never flow steps, so they use `parent_id`
133
+ exclusively. A null `parent_feature_id` on a feature/story is NOT a
134
+ broken link and NOT an orphan. Aggregating `parent_feature_id` to
135
+ reconstruct the tree will falsely report "orphans everywhere" β€” do
136
+ not do this.
137
+
138
+ 2. **To reconstruct the hierarchy from list output, use `parent_id`,
139
+ never `parent_feature_id`:**
140
+ - `feature.parent_id` β†’ a story id (the story that owns the feature).
141
+ - `story.parent_id` β†’ an epic id.
142
+ - Group features by epic: resolve `feature.parent_id` β†’ story, then
143
+ `story.parent_id` β†’ epic.
144
+
145
+ 3. **`children: []` under a filtered query on a flow-bearing feature is
146
+ a known, narrow bug β€” not proof of corruption.** Under an active
147
+ roadmap/status/priority filter, flow-step nodes linked ONLY via
148
+ `parent_feature_id` (no usable `parent_id`) can be pruned in the tree
149
+ builder and surface as `children: []` on their owning feature. This
150
+ affects **flow steps only** — never the `parent_id` epic→story→feature
151
+ backbone. If you see empty `children` only under a filtered query on a
152
+ feature that bears flows, cite this caveat before claiming the
153
+ hierarchy is broken.
154
+
155
+ *Contract:* the CLI surfaces the server's tree as-is. There is no CLI
156
+ flag to work around the flow-step pruning case; if you hit it, query
157
+ without the filter, or pull the flow's own step tree instead:
158
+ `flow:list --feature-id <featureId> --json` to resolve the flow id, then
159
+ `flow:pull --id <flowId> --recursive --json` (the flow step tree is not
160
+ subject to the same pruning).
161
+
162
+ #### Quick check: is the hierarchy intact?
163
+
164
+ ```bash
165
+ # Every feature's parent_id should resolve to a story id in story:list.
166
+ 4ge feature:list --json # collect feature[*].parent_id
167
+ 4ge story:list --json # every parent_id from above must appear here
168
+ # Every story's parent_id should resolve to an epic id in epic:list.
169
+ 4ge story:list --json # collect story[*].parent_id
170
+ 4ge epic:list --json # every parent_id from above must appear here
171
+ ```
172
+
173
+ If those joins are complete, the hierarchy is intact regardless of how
174
+ many `parent_feature_id` fields are null.
175
+
105
176
  ## πŸ”„ Common Workflow Patterns
106
177
 
107
178
  1. **Explore projects**: `4ge project:list --json`