gflows 1.0.4 → 1.1.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.
package/AGENTS.md CHANGED
@@ -9,7 +9,7 @@ gflows is a **local Git branching workflow CLI** (main + dev + typed short-lived
9
9
  3. **Do not finish empty branches** — if there are no commits beyond the merge target, finish exits `2`. Commit first.
10
10
  4. **Conflicts** — resolve files, then `gflows continue`. Or `gflows abort` / `gflows undo`.
11
11
  5. **Discover the API** — run `gflows schema` (JSON) or read this file + README.
12
- 6. **MCP** — `gflows mcp` (stdio JSON-RPC) exposes status/doctor/list/start/sync/finish/schema tools.
12
+ 6. **MCP** — `gflows mcp` (stdio JSON-RPC) exposes status/doctor/info/list/start/sync/finish/schema tools.
13
13
  7. **Hub / viz** — bare `gflows` (TTY) opens an Ink fullscreen hub (`/` commands). Prompts use Clack. `gflows viz` prints the scrollback status panel.
14
14
 
15
15
  ## Exit codes
@@ -51,6 +51,9 @@ gflows doctor --json
51
51
  gflows continue
52
52
  gflows abort
53
53
  gflows undo
54
+
55
+ # Repo shape / stacks
56
+ gflows info --json
54
57
  ```
55
58
 
56
59
  ## Branch merge targets
package/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-07-25
11
+
12
+ ### Added
13
+
14
+ - `gflows info` — describe repo layout (monolith vs monorepo), package versions, and detected frontend / fullstack / backend stacks (`--json` supported)
15
+ - Hub `/info` read-only screen; MCP tool `gflows_info`
16
+
10
17
  ## [1.0.4] - 2026-07-25
11
18
 
12
19
  ### Fixed
package/README.md CHANGED
@@ -231,6 +231,7 @@ Hub: `g` → select the action. Typed short form assumes `alias g=gflows`.
231
231
  | `gflows abort` | — |
232
232
  | `gflows undo` | — |
233
233
  | `gflows doctor` | — |
234
+ | `gflows info` | — |
234
235
 
235
236
 
236
237
 
@@ -274,6 +275,7 @@ Bare `gflows` / `g` in a **TTY** opens the Ink hub (see [Way 1](#way-1--hub-reco
274
275
  | `status` `-t` | Current branch flow info |
275
276
  | `viz` | Scrollback visual map |
276
277
  | `doctor` | Health checks |
278
+ | `info` | Layout, versions, frontend/backend stacks |
277
279
  | `config` | `get` / `set` `.gflows.json` |
278
280
  | `bump` `-U` | Version bump/rollback in package files |
279
281
  | `continue` / `abort` / `undo` | Recovery |
@@ -395,6 +397,7 @@ Rules of thumb:
395
397
  | `gflows finish feature -y -P` | `g -F -f -y -P` |
396
398
  | `gflows status --json` | `g -t --json` |
397
399
  | `gflows doctor --json` | — |
400
+ | `gflows info --json` | — |
398
401
 
399
402
  **Exit codes:** `0` ok · `1` usage/validation · `2` git/system (conflict, dirty tree, empty finish, …)
400
403
 
package/llms.txt CHANGED
@@ -14,7 +14,7 @@
14
14
  - `gflows viz`: scrollback status panel — lifecycle, branches, recommended next step
15
15
  - `gflows init` / `start` / `finish` / `sync` / `pr`
16
16
  - `gflows continue` / `undo` / `abort` — recovery after conflicts
17
- - `gflows doctor` / `config` / `status --json` / `mcp`
17
+ - `gflows doctor` / `info` / `config` / `status --json` / `mcp`
18
18
 
19
19
  ## Package
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "author": "Ali AlNaghmoush (https://github.com/alialnaghmoush)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5,47 +5,16 @@
5
5
  * @module commands/bump
6
6
  */
7
7
 
8
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { join, relative } from "node:path";
10
10
  import { EXIT_OK, EXIT_USER } from "../constants.js";
11
11
  import { InvalidVersionError } from "../errors.js";
12
12
  import { hint, success } from "../out.js";
13
+ import { findPackageRoots, PACKAGE_JSON } from "../packages.js";
13
14
  import type { BumpDirection, BumpType, ParsedArgs } from "../types.js";
14
15
 
15
- const PACKAGE_JSON = "package.json";
16
16
  const JSR_JSON = "jsr.json";
17
17
 
18
- /** Directory names to skip when discovering package roots (monorepo). */
19
- const SKIP_DIRS = new Set(["node_modules", ".git"]);
20
-
21
- /**
22
- * Recursively finds all directories under `root` that contain a package.json.
23
- * Skips node_modules and .git.
24
- */
25
- function findPackageRoots(root: string): string[] {
26
- const acc: string[] = [];
27
- if (!existsSync(root) || !statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
28
- return acc;
29
- }
30
- if (existsSync(join(root, PACKAGE_JSON))) {
31
- acc.push(root);
32
- }
33
- let entries: Array<{ isDirectory(): boolean; name: string }>;
34
- try {
35
- entries = readdirSync(root, { withFileTypes: true }) as Array<{
36
- isDirectory(): boolean;
37
- name: string;
38
- }>;
39
- } catch {
40
- return acc;
41
- }
42
- for (const e of entries) {
43
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
44
- acc.push(...findPackageRoots(join(root, e.name)));
45
- }
46
- return acc;
47
- }
48
-
49
18
  /** Semver triplet. */
50
19
  interface Semver {
51
20
  major: number;
@@ -20,6 +20,7 @@ const COMMANDS = [
20
20
  "list",
21
21
  "bump",
22
22
  "doctor",
23
+ "info",
23
24
  "config",
24
25
  "schema",
25
26
  "continue",
@@ -260,6 +261,8 @@ complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}"
260
261
  -a "list" -d "List branches by type"
261
262
  complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
262
263
  -a "bump" -d "Bump or rollback version"
264
+ complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
265
+ -a "info" -d "Repo layout, versions, and stacks"
263
266
  complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
264
267
  -a "completion" -d "Print shell completion script"
265
268
  complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
@@ -27,6 +27,7 @@ Commands:
27
27
  status, -t Show current branch flow info
28
28
  viz Visual branch map + flow (also shown in interactive hub)
29
29
  doctor Repo health checks
30
+ info Repo layout, versions, and stacks
30
31
  config get/set .gflows.json
31
32
  bump, -U Bump or rollback package version
32
33
  continue Resume after merge conflict
@@ -53,7 +54,7 @@ Common flags:
53
54
  -v, --verbose Verbose output
54
55
  -q, --quiet Minimal output
55
56
  -C, --path <dir> Run as if in <dir>
56
- --json Machine-readable output (status/list/doctor/config)
57
+ --json Machine-readable output (status/list/doctor/info/config)
57
58
 
58
59
  Init: --script-alias <name> Add package.json script (e.g. g → "gflows")
59
60
  --no-script-alias Never add a script alias
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Info: describe repo layout, package versions, and detected stacks.
3
+ * @module commands/info
4
+ */
5
+
6
+ import { resolveRepoRoot } from "../git.js";
7
+ import { collectInfoReport, formatInfoReport } from "../repo-inspect.js";
8
+ import type { ParsedArgs } from "../types.js";
9
+
10
+ /**
11
+ * Collects repo info without printing (CLI + hub).
12
+ */
13
+ export async function collectInfo(cwd: string) {
14
+ const repoRoot = await resolveRepoRoot(cwd);
15
+ return collectInfoReport(repoRoot);
16
+ }
17
+
18
+ /**
19
+ * Runs the info command (human or JSON).
20
+ */
21
+ export async function run(args: ParsedArgs): Promise<void> {
22
+ const report = await collectInfo(args.cwd);
23
+
24
+ if (args.json) {
25
+ console.log(JSON.stringify(report, null, 2));
26
+ return;
27
+ }
28
+
29
+ const lines = formatInfoReport(report);
30
+ for (const line of lines) {
31
+ console.log(line);
32
+ }
33
+ }
@@ -31,6 +31,14 @@ const TOOLS = [
31
31
  properties: { path: { type: "string" } },
32
32
  },
33
33
  },
34
+ {
35
+ name: "gflows_info",
36
+ description: "Describe repo layout (monolith/monorepo), package versions, and stacks.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: { path: { type: "string" } },
40
+ },
41
+ },
34
42
  {
35
43
  name: "gflows_list",
36
44
  description: "List workflow branches.",
@@ -123,6 +131,9 @@ async function runTool(
123
131
  case "gflows_doctor":
124
132
  argv.push("doctor", "--json");
125
133
  break;
134
+ case "gflows_info":
135
+ argv.push("info", "--json");
136
+ break;
126
137
  case "gflows_list":
127
138
  argv.push("list", "-q");
128
139
  if (typeof args.type === "string") argv.push(args.type);
@@ -174,7 +185,7 @@ async function runTool(
174
185
  */
175
186
  export async function run(_args: ParsedArgs): Promise<void> {
176
187
  console.error(
177
- "gflows mcp: listening on stdio (JSON-RPC). Tools: status, doctor, list, start, sync, finish, schema.",
188
+ "gflows mcp: listening on stdio (JSON-RPC). Tools: status, doctor, info, list, start, sync, finish, schema.",
178
189
  );
179
190
 
180
191
  const decoder = new TextDecoder();
@@ -60,6 +60,8 @@ export async function run(_args: ParsedArgs): Promise<void> {
60
60
  sync: ["--rebase", "--force"],
61
61
  list: ["-r/--include-remote", "--json"],
62
62
  status: ["--json"],
63
+ info: ["--json"],
64
+ doctor: ["--json"],
63
65
  },
64
66
  agentNotes: [
65
67
  "Always pass explicit flags in non-TTY (never assume interactive hub).",
@@ -84,6 +86,7 @@ function commandMeta(command: string): { summary: string; interactiveOk: boolean
84
86
  pr: { summary: "Open PR/MR via gh/glab", interactiveOk: true },
85
87
  viz: { summary: "Visual branch map and flow diagram", interactiveOk: true },
86
88
  doctor: { summary: "Repo health checks", interactiveOk: false },
89
+ info: { summary: "Repo layout, versions, and stacks", interactiveOk: false },
87
90
  config: { summary: "Get/set .gflows.json", interactiveOk: true },
88
91
  schema: { summary: "Machine-readable command catalog", interactiveOk: false },
89
92
  continue: { summary: "Resume suspended operation", interactiveOk: false },
@@ -86,6 +86,7 @@ function buildLegacyOptions(
86
86
  { value: "list", label: "List branches" },
87
87
  { value: "viz", label: "Refresh map" },
88
88
  { value: "doctor", label: "Doctor (check setup)" },
89
+ { value: "info", label: "Info (layout & stacks)" },
89
90
  { value: "config", label: "Config" },
90
91
  { value: "bump", label: "Bump version" },
91
92
  { value: "continue", label: "Continue suspended operation" },
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared package.json discovery for monorepo-aware commands (bump, info).
3
+ * @module packages
4
+ */
5
+
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ /** Filename for Node/Bun package manifests. */
10
+ export const PACKAGE_JSON = "package.json";
11
+
12
+ /** Directory names to skip when discovering package roots. */
13
+ const SKIP_DIRS = new Set([
14
+ "node_modules",
15
+ ".git",
16
+ ".next",
17
+ ".nuxt",
18
+ ".turbo",
19
+ "dist",
20
+ "build",
21
+ "coverage",
22
+ ".cache",
23
+ ]);
24
+
25
+ /**
26
+ * Recursively finds all directories under `root` that contain a package.json.
27
+ * Skips node_modules, .git, and common build/cache dirs.
28
+ */
29
+ export function findPackageRoots(root: string): string[] {
30
+ const acc: string[] = [];
31
+ if (!existsSync(root) || !statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
32
+ return acc;
33
+ }
34
+ if (existsSync(join(root, PACKAGE_JSON))) {
35
+ acc.push(root);
36
+ }
37
+ let entries: Array<{ isDirectory(): boolean; name: string }>;
38
+ try {
39
+ entries = readdirSync(root, { withFileTypes: true }) as Array<{
40
+ isDirectory(): boolean;
41
+ name: string;
42
+ }>;
43
+ } catch {
44
+ return acc;
45
+ }
46
+ for (const e of entries) {
47
+ if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
48
+ if (e.name.startsWith(".") && e.name !== ".") continue;
49
+ acc.push(...findPackageRoots(join(root, e.name)));
50
+ }
51
+ return acc;
52
+ }
53
+
54
+ /** Minimal package.json fields used by inspectors. */
55
+ export interface PackageJsonFields {
56
+ name?: string;
57
+ version?: string;
58
+ description?: string;
59
+ private?: boolean;
60
+ workspaces?: string[] | { packages?: string[] };
61
+ dependencies?: Record<string, string>;
62
+ devDependencies?: Record<string, string>;
63
+ peerDependencies?: Record<string, string>;
64
+ }
65
+
66
+ /**
67
+ * Reads and parses package.json from a directory. Returns null if missing or invalid.
68
+ */
69
+ export function readPackageJson(dir: string): PackageJsonFields | null {
70
+ const path = join(dir, PACKAGE_JSON);
71
+ if (!existsSync(path)) return null;
72
+ try {
73
+ const raw = readFileSync(path, "utf-8");
74
+ return JSON.parse(raw) as PackageJsonFields;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Repo inspection for `gflows info`: layout, versions, and stack detection.
3
+ * @module repo-inspect
4
+ */
5
+
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { join, relative } from "node:path";
8
+ import { resolveConfig } from "./config.js";
9
+ import { getCurrentBranch, runGit } from "./git.js";
10
+ import { findPackageRoots, type PackageJsonFields, readPackageJson } from "./packages.js";
11
+
12
+ /** Layout classification. */
13
+ export type RepoLayout = "monolith" | "monorepo";
14
+
15
+ /** One detected stack entry. */
16
+ export interface StackHit {
17
+ id: string;
18
+ name: string;
19
+ packages: string[];
20
+ }
21
+
22
+ /** One package.json summary row. */
23
+ export interface PackageVersionRow {
24
+ path: string;
25
+ name: string | null;
26
+ version: string | null;
27
+ private: boolean;
28
+ }
29
+
30
+ /** Structured report for CLI, hub, and tests. */
31
+ export interface InfoReport {
32
+ root: string;
33
+ layout: RepoLayout;
34
+ layoutReasons: string[];
35
+ name: string | null;
36
+ version: string | null;
37
+ description: string | null;
38
+ packages: PackageVersionRow[];
39
+ stacks: {
40
+ frontend: StackHit[];
41
+ fullstack: StackHit[];
42
+ backend: StackHit[];
43
+ };
44
+ packageManager: string | null;
45
+ remoteUrl: string | null;
46
+ branch: string | null;
47
+ gflows: { main: string; dev: string; remote: string };
48
+ runtimes: string[];
49
+ }
50
+
51
+ type StackKind = "frontend" | "fullstack" | "backend";
52
+
53
+ interface StackRule {
54
+ id: string;
55
+ name: string;
56
+ kind: StackKind;
57
+ keys: string[];
58
+ /** When set, all keys must be present (AND). Otherwise any key matches (OR). */
59
+ requireAll?: boolean;
60
+ }
61
+
62
+ const STACK_RULES: StackRule[] = [
63
+ // Frontend
64
+ { id: "react", name: "React", kind: "frontend", keys: ["react"] },
65
+ { id: "vue", name: "Vue", kind: "frontend", keys: ["vue"] },
66
+ { id: "angular", name: "Angular", kind: "frontend", keys: ["@angular/core"] },
67
+ { id: "svelte", name: "Svelte", kind: "frontend", keys: ["svelte"] },
68
+ { id: "solid", name: "Solid", kind: "frontend", keys: ["solid-js"] },
69
+ { id: "qwik", name: "Qwik", kind: "frontend", keys: ["@builder.io/qwik"] },
70
+ { id: "preact", name: "Preact", kind: "frontend", keys: ["preact"] },
71
+ { id: "alpine", name: "Alpine.js", kind: "frontend", keys: ["alpinejs"] },
72
+ { id: "lit", name: "Lit", kind: "frontend", keys: ["lit", "lit-element"] },
73
+ { id: "expo", name: "Expo", kind: "frontend", keys: ["expo"] },
74
+ { id: "react-native", name: "React Native", kind: "frontend", keys: ["react-native"] },
75
+ // Fullstack
76
+ { id: "next", name: "Next.js", kind: "fullstack", keys: ["next"] },
77
+ { id: "nuxt", name: "Nuxt", kind: "fullstack", keys: ["nuxt", "nuxt3"] },
78
+ { id: "sveltekit", name: "SvelteKit", kind: "fullstack", keys: ["@sveltejs/kit"] },
79
+ { id: "astro", name: "Astro", kind: "fullstack", keys: ["astro"] },
80
+ {
81
+ id: "remix",
82
+ name: "Remix",
83
+ kind: "fullstack",
84
+ keys: ["remix", "@remix-run/node", "@remix-run/react", "@remix-run/serve"],
85
+ },
86
+ {
87
+ id: "react-router",
88
+ name: "React Router (framework)",
89
+ kind: "fullstack",
90
+ keys: ["@react-router/node", "@react-router/dev", "@react-router/serve"],
91
+ },
92
+ {
93
+ id: "tanstack-start",
94
+ name: "TanStack Start",
95
+ kind: "fullstack",
96
+ keys: ["@tanstack/react-start", "@tanstack/solid-start"],
97
+ },
98
+ { id: "solidstart", name: "SolidStart", kind: "fullstack", keys: ["@solidjs/start"] },
99
+ {
100
+ id: "analog",
101
+ name: "Analog",
102
+ kind: "fullstack",
103
+ keys: ["@analogjs/platform", "@analogjs/vite-plugin-angular"],
104
+ },
105
+ {
106
+ id: "redwood",
107
+ name: "RedwoodJS",
108
+ kind: "fullstack",
109
+ keys: ["@redwoodjs/core", "@redwoodjs/api"],
110
+ },
111
+ { id: "blitz", name: "Blitz", kind: "fullstack", keys: ["blitz"] },
112
+ { id: "quasar", name: "Quasar", kind: "fullstack", keys: ["quasar"] },
113
+ { id: "gatsby", name: "Gatsby", kind: "fullstack", keys: ["gatsby"] },
114
+ // Backend
115
+ { id: "express", name: "Express", kind: "backend", keys: ["express"] },
116
+ { id: "nestjs", name: "NestJS", kind: "backend", keys: ["@nestjs/core"] },
117
+ { id: "fastify", name: "Fastify", kind: "backend", keys: ["fastify"] },
118
+ { id: "hono", name: "Hono", kind: "backend", keys: ["hono"] },
119
+ { id: "elysia", name: "Elysia", kind: "backend", keys: ["elysia"] },
120
+ { id: "koa", name: "Koa", kind: "backend", keys: ["koa"] },
121
+ { id: "oak", name: "Oak", kind: "backend", keys: ["@oak/oak", "oak"] },
122
+ { id: "hapi", name: "Hapi", kind: "backend", keys: ["@hapi/hapi"] },
123
+ { id: "restify", name: "Restify", kind: "backend", keys: ["restify"] },
124
+ { id: "trpc", name: "tRPC", kind: "backend", keys: ["@trpc/server"] },
125
+ { id: "nitro", name: "Nitro", kind: "backend", keys: ["nitropack", "nitro"] },
126
+ { id: "encore", name: "Encore.ts", kind: "backend", keys: ["encore.dev"] },
127
+ { id: "adonis", name: "AdonisJS", kind: "backend", keys: ["@adonisjs/core"] },
128
+ { id: "feathers", name: "Feathers", kind: "backend", keys: ["@feathersjs/feathers"] },
129
+ { id: "socketio", name: "Socket.IO", kind: "backend", keys: ["socket.io"] },
130
+ ];
131
+
132
+ const WORKSPACE_MARKER_FILES = [
133
+ "pnpm-workspace.yaml",
134
+ "lerna.json",
135
+ "nx.json",
136
+ "turbo.json",
137
+ "rush.json",
138
+ "go.work",
139
+ ] as const;
140
+
141
+ /**
142
+ * Collects direct dependency names from a package.json.
143
+ */
144
+ function directDepNames(pkg: PackageJsonFields): Set<string> {
145
+ const names = new Set<string>();
146
+ for (const block of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) {
147
+ if (!block) continue;
148
+ for (const key of Object.keys(block)) names.add(key);
149
+ }
150
+ return names;
151
+ }
152
+
153
+ /**
154
+ * Detects package manager from lockfiles at repo root.
155
+ */
156
+ function detectPackageManager(root: string): string | null {
157
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun";
158
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
159
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
160
+ if (existsSync(join(root, "package-lock.json"))) return "npm";
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Detects monorepo vs monolith and returns reasons.
166
+ */
167
+ function detectLayout(
168
+ root: string,
169
+ rootPkg: PackageJsonFields | null,
170
+ packageRoots: string[],
171
+ ): { layout: RepoLayout; reasons: string[] } {
172
+ const reasons: string[] = [];
173
+
174
+ if (rootPkg?.workspaces !== undefined) {
175
+ reasons.push("package.json workspaces");
176
+ }
177
+ for (const file of WORKSPACE_MARKER_FILES) {
178
+ if (existsSync(join(root, file))) {
179
+ reasons.push(file);
180
+ }
181
+ }
182
+ if (existsSync(join(root, "Cargo.toml"))) {
183
+ try {
184
+ const cargo = readFileSync(join(root, "Cargo.toml"), "utf-8");
185
+ if (/\[workspace\]/.test(cargo)) reasons.push("Cargo.toml [workspace]");
186
+ } catch {
187
+ /* ignore */
188
+ }
189
+ }
190
+ if (packageRoots.length > 1) {
191
+ reasons.push(`${packageRoots.length} package.json files`);
192
+ }
193
+
194
+ if (reasons.length > 0) {
195
+ return { layout: "monorepo", reasons };
196
+ }
197
+ return { layout: "monolith", reasons: ["single package root"] };
198
+ }
199
+
200
+ /**
201
+ * Matches stack rules against direct deps of one package.
202
+ */
203
+ function matchStacks(
204
+ depNames: Set<string>,
205
+ packageLabel: string,
206
+ acc: Map<string, StackHit>,
207
+ ): void {
208
+ for (const rule of STACK_RULES) {
209
+ const hit = rule.requireAll
210
+ ? rule.keys.every((k) => depNames.has(k))
211
+ : rule.keys.some((k) => depNames.has(k));
212
+ if (!hit) continue;
213
+ const existing = acc.get(rule.id);
214
+ if (existing) {
215
+ if (!existing.packages.includes(packageLabel)) {
216
+ existing.packages.push(packageLabel);
217
+ }
218
+ } else {
219
+ acc.set(rule.id, {
220
+ id: rule.id,
221
+ name: rule.name,
222
+ packages: [packageLabel],
223
+ });
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * When NestJS is present, drop Express/Fastify if they only appear as Nest platforms.
230
+ */
231
+ function suppressNestPlatformNoise(hits: Map<string, StackHit>): void {
232
+ if (!hits.has("nestjs")) return;
233
+ for (const id of ["express", "fastify"] as const) {
234
+ const nest = hits.get("nestjs");
235
+ const platform = hits.get(id);
236
+ if (!nest || !platform) continue;
237
+ const onlyNestPkgs = platform.packages.every((p) => nest.packages.includes(p));
238
+ if (onlyNestPkgs) hits.delete(id);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Detects non-JS runtimes / frameworks from root manifests.
244
+ */
245
+ function detectNonJsRuntimes(root: string): string[] {
246
+ const out: string[] = [];
247
+ if (existsSync(join(root, "go.mod"))) out.push("Go");
248
+ if (existsSync(join(root, "Cargo.toml"))) out.push("Rust");
249
+ if (
250
+ existsSync(join(root, "pyproject.toml")) ||
251
+ existsSync(join(root, "requirements.txt")) ||
252
+ existsSync(join(root, "Pipfile"))
253
+ ) {
254
+ out.push("Python");
255
+ for (const file of ["pyproject.toml", "requirements.txt", "Pipfile"] as const) {
256
+ const path = join(root, file);
257
+ if (!existsSync(path)) continue;
258
+ try {
259
+ const text = readFileSync(path, "utf-8").toLowerCase();
260
+ if (text.includes("django")) out.push("Django");
261
+ if (text.includes("flask")) out.push("Flask");
262
+ if (text.includes("fastapi")) out.push("FastAPI");
263
+ } catch {
264
+ /* ignore */
265
+ }
266
+ }
267
+ }
268
+ if (existsSync(join(root, "Gemfile"))) {
269
+ out.push("Ruby");
270
+ try {
271
+ const gem = readFileSync(join(root, "Gemfile"), "utf-8").toLowerCase();
272
+ if (/\brails\b/.test(gem)) out.push("Rails");
273
+ } catch {
274
+ /* ignore */
275
+ }
276
+ }
277
+ if (existsSync(join(root, "composer.json"))) {
278
+ out.push("PHP");
279
+ try {
280
+ const composer = readFileSync(join(root, "composer.json"), "utf-8");
281
+ if (composer.includes("laravel/framework")) out.push("Laravel");
282
+ } catch {
283
+ /* ignore */
284
+ }
285
+ }
286
+ if (hasFileWithExt(root, ".csproj") || hasFileWithExt(root, ".sln")) {
287
+ out.push(".NET");
288
+ }
289
+ if (
290
+ existsSync(join(root, "pom.xml")) ||
291
+ existsSync(join(root, "build.gradle")) ||
292
+ existsSync(join(root, "build.gradle.kts"))
293
+ ) {
294
+ out.push("JVM");
295
+ }
296
+ return [...new Set(out)];
297
+ }
298
+
299
+ /**
300
+ * Returns true if any file in `dir` (non-recursive) ends with `ext`.
301
+ */
302
+ function hasFileWithExt(dir: string, ext: string): boolean {
303
+ try {
304
+ const entries = readdirSync(dir);
305
+ return entries.some((name) => name.endsWith(ext));
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Formats package path relative to root (`.` for root).
313
+ */
314
+ function packageLabel(root: string, dir: string): string {
315
+ const rel = relative(root, dir);
316
+ return rel === "" ? "." : rel;
317
+ }
318
+
319
+ /**
320
+ * Collects a full info report for a git/repo root directory.
321
+ */
322
+ export async function collectInfoReport(repoRoot: string): Promise<InfoReport> {
323
+ const rootPkg = readPackageJson(repoRoot);
324
+ let packageRoots = findPackageRoots(repoRoot);
325
+ packageRoots = [...packageRoots].sort((a, b) => {
326
+ if (a === repoRoot && b !== repoRoot) return -1;
327
+ if (a !== repoRoot && b === repoRoot) return 1;
328
+ return a.localeCompare(b);
329
+ });
330
+
331
+ const { layout, reasons } = detectLayout(repoRoot, rootPkg, packageRoots);
332
+
333
+ const packages: PackageVersionRow[] = [];
334
+ const stackAcc = new Map<string, StackHit>();
335
+ const kindById = new Map(STACK_RULES.map((r) => [r.id, r.kind]));
336
+
337
+ for (const dir of packageRoots) {
338
+ const pkg = readPackageJson(dir);
339
+ const label = packageLabel(repoRoot, dir);
340
+ packages.push({
341
+ path: label,
342
+ name: typeof pkg?.name === "string" ? pkg.name : null,
343
+ version: typeof pkg?.version === "string" ? pkg.version : null,
344
+ private: pkg?.private === true,
345
+ });
346
+ if (pkg) {
347
+ matchStacks(directDepNames(pkg), label, stackAcc);
348
+ }
349
+ }
350
+
351
+ suppressNestPlatformNoise(stackAcc);
352
+
353
+ const frontend: StackHit[] = [];
354
+ const fullstack: StackHit[] = [];
355
+ const backend: StackHit[] = [];
356
+ for (const hit of stackAcc.values()) {
357
+ const kind = kindById.get(hit.id);
358
+ if (kind === "frontend") frontend.push(hit);
359
+ else if (kind === "fullstack") fullstack.push(hit);
360
+ else if (kind === "backend") backend.push(hit);
361
+ }
362
+ const byName = (a: StackHit, b: StackHit) => a.name.localeCompare(b.name);
363
+ frontend.sort(byName);
364
+ fullstack.sort(byName);
365
+ backend.sort(byName);
366
+
367
+ const gflows = resolveConfig(repoRoot, {}, {});
368
+ let remoteUrl: string | null = null;
369
+ try {
370
+ const remoteResult = await runGit(["remote", "get-url", gflows.remote], {
371
+ cwd: repoRoot,
372
+ verbose: false,
373
+ });
374
+ const url = remoteResult.stdout.trim();
375
+ if (url) remoteUrl = url;
376
+ } catch {
377
+ remoteUrl = null;
378
+ }
379
+
380
+ let branch: string | null = null;
381
+ try {
382
+ branch = await getCurrentBranch(repoRoot, { verbose: false });
383
+ } catch {
384
+ branch = null;
385
+ }
386
+
387
+ return {
388
+ root: repoRoot,
389
+ layout,
390
+ layoutReasons: reasons,
391
+ name: typeof rootPkg?.name === "string" ? rootPkg.name : null,
392
+ version: typeof rootPkg?.version === "string" ? rootPkg.version : null,
393
+ description: typeof rootPkg?.description === "string" ? rootPkg.description : null,
394
+ packages,
395
+ stacks: { frontend, fullstack, backend },
396
+ packageManager: detectPackageManager(repoRoot),
397
+ remoteUrl,
398
+ branch,
399
+ gflows: { main: gflows.main, dev: gflows.dev, remote: gflows.remote },
400
+ runtimes: detectNonJsRuntimes(repoRoot),
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Formats an InfoReport as human-readable lines.
406
+ */
407
+ export function formatInfoReport(report: InfoReport): string[] {
408
+ const lines: string[] = [];
409
+ const title = report.name ?? (relative(process.cwd(), report.root) || report.root);
410
+ lines.push(`Repo: ${title} (${report.layout})`);
411
+ if (report.version) {
412
+ lines.push(`Version: ${report.version} (root)`);
413
+ } else if (report.packages.length === 0) {
414
+ lines.push("Version: (no package.json)");
415
+ } else {
416
+ lines.push("Version: (none at root)");
417
+ }
418
+ if (report.description) {
419
+ lines.push(`Description: ${report.description}`);
420
+ }
421
+
422
+ const nested = report.packages.filter((p) => p.path !== ".");
423
+ if (nested.length > 0) {
424
+ lines.push("Packages:");
425
+ for (const p of nested) {
426
+ const ver = p.version ?? "—";
427
+ const nm = p.name ? ` ${p.name}` : "";
428
+ lines.push(` ${p.path.padEnd(24)} ${ver}${nm}`);
429
+ }
430
+ }
431
+
432
+ const fmt = (hits: StackHit[]) => hits.map((h) => h.name).join(", ");
433
+ if (report.stacks.frontend.length > 0) {
434
+ lines.push(`Frontend: ${fmt(report.stacks.frontend)}`);
435
+ }
436
+ if (report.stacks.fullstack.length > 0) {
437
+ lines.push(`Fullstack: ${fmt(report.stacks.fullstack)}`);
438
+ }
439
+ if (report.stacks.backend.length > 0) {
440
+ lines.push(`Backend: ${fmt(report.stacks.backend)}`);
441
+ }
442
+ if (
443
+ report.stacks.frontend.length === 0 &&
444
+ report.stacks.fullstack.length === 0 &&
445
+ report.stacks.backend.length === 0 &&
446
+ report.runtimes.length === 0
447
+ ) {
448
+ lines.push("Stacks: (none detected)");
449
+ }
450
+ if (report.runtimes.length > 0) {
451
+ lines.push(`Runtimes: ${report.runtimes.join(", ")}`);
452
+ }
453
+
454
+ const meta: string[] = [];
455
+ if (report.packageManager) meta.push(report.packageManager);
456
+ meta.push(`${report.gflows.main}/${report.gflows.dev}`);
457
+ if (report.branch) meta.push(`branch ${report.branch}`);
458
+ if (meta.length > 0) {
459
+ lines.push(`PM: ${meta.join(" · ")}`);
460
+ }
461
+ if (report.remoteUrl) {
462
+ lines.push(`Remote: ${report.remoteUrl}`);
463
+ }
464
+ return lines;
465
+ }
@@ -289,7 +289,7 @@ export function HubHome({
289
289
  ) : (
290
290
  <Text color={MUTED}>
291
291
  {showHelp
292
- ? "/init /start /sync /pr /finish /doctor /help · esc clear · ctrl+c quit"
292
+ ? "/init /start /sync /pr /finish /doctor /info /help · esc clear · ctrl+c quit"
293
293
  : "? for shortcuts · / for command menu"}
294
294
  </Text>
295
295
  )}
@@ -376,6 +376,7 @@ function buildActions(snap: VizSnapshot | null, recommended: RecommendAction | n
376
376
  push("switch", "Switch branch", "/switch");
377
377
  push("list", "List branches", "/list");
378
378
  push("doctor", "Doctor", "/doctor");
379
+ push("info", "Info", "/info");
379
380
  push("help", "Help", "/help");
380
381
  push("quit", "Quit", "q");
381
382
 
@@ -18,7 +18,7 @@ import {
18
18
  import { HubHome } from "./HubHome.js";
19
19
  import { WizardFrame } from "./prompts.js";
20
20
  import { SLASH_COMMANDS } from "./slash.js";
21
- import { ConfigView, DoctorView, HelpView, StatusView, VersionView } from "./views.js";
21
+ import { ConfigView, DoctorView, HelpView, InfoView, StatusView, VersionView } from "./views.js";
22
22
 
23
23
  const ACCENT = "#E88C4A";
24
24
  const MUTED = "#8A8A8A";
@@ -37,6 +37,7 @@ type Screen =
37
37
  | { id: "init" }
38
38
  | { id: "bump" }
39
39
  | { id: "doctor" }
40
+ | { id: "info" }
40
41
  | { id: "help" }
41
42
  | { id: "status" }
42
43
  | { id: "config" }
@@ -104,6 +105,9 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
104
105
  case "doctor":
105
106
  setScreen({ id: "doctor" });
106
107
  return true;
108
+ case "info":
109
+ setScreen({ id: "info" });
110
+ return true;
107
111
  case "help":
108
112
  setScreen({ id: "help" });
109
113
  return true;
@@ -231,6 +235,9 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
231
235
  if (screen.id === "doctor") {
232
236
  return <DoctorView cwd={cwd} onDone={cancelWizard} />;
233
237
  }
238
+ if (screen.id === "info") {
239
+ return <InfoView cwd={cwd} onDone={cancelWizard} />;
240
+ }
234
241
  if (screen.id === "help") {
235
242
  return <HelpView onDone={cancelWizard} />;
236
243
  }
package/src/tui/slash.ts CHANGED
@@ -24,6 +24,7 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
24
24
  { name: "switch", hint: "Switch branch" },
25
25
  { name: "list", hint: "List branches" },
26
26
  { name: "doctor", hint: "Doctor checks" },
27
+ { name: "info", hint: "Repo layout & stacks" },
27
28
  { name: "help", hint: "Show help" },
28
29
  { name: "status", hint: "Repo status" },
29
30
  { name: "config", hint: "Show config" },
package/src/tui/views.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Read-only Ink screens that stay inside the hub (doctor / help / status / config).
2
+ * Read-only Ink screens that stay inside the hub (doctor / info / help / status / config).
3
3
  * @module tui/views
4
4
  */
5
5
 
@@ -7,9 +7,11 @@ import type React from "react";
7
7
  import { useEffect, useState } from "react";
8
8
  import { collectDoctorReport } from "../commands/doctor.js";
9
9
  import { getHelpText } from "../commands/help.js";
10
+ import { collectInfo } from "../commands/info.js";
10
11
  import { collectStatusLines } from "../commands/status-lines.js";
11
12
  import { resolveConfig } from "../config.js";
12
13
  import { resolveRepoRoot } from "../git.js";
14
+ import { formatInfoReport } from "../repo-inspect.js";
13
15
  import { getVersion } from "../version.js";
14
16
  import { type HubPanelLine, HubScrollPanel } from "./panels.js";
15
17
 
@@ -60,6 +62,45 @@ export function DoctorView({
60
62
  return <HubScrollPanel title="Doctor" lines={lines} error={error} onDone={onDone} />;
61
63
  }
62
64
 
65
+ /**
66
+ * Repo layout / versions / stacks inside the hub.
67
+ */
68
+ export function InfoView({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
69
+ const [lines, setLines] = useState<HubPanelLine[] | null>(null);
70
+ const [error, setError] = useState<string | null>(null);
71
+
72
+ useEffect(() => {
73
+ let cancelled = false;
74
+ void (async () => {
75
+ try {
76
+ const report = await collectInfo(cwd);
77
+ if (cancelled) return;
78
+ const rows: HubPanelLine[] = formatInfoReport(report).map((text, i) => ({
79
+ id: `i${i}`,
80
+ text: text.length === 0 ? " " : text,
81
+ tone:
82
+ text.startsWith("Repo:") ||
83
+ text.startsWith("Frontend:") ||
84
+ text.startsWith("Fullstack:")
85
+ ? "accent"
86
+ : "default",
87
+ }));
88
+ setLines(rows);
89
+ } catch (err) {
90
+ if (!cancelled) {
91
+ setError(err instanceof Error ? err.message : String(err));
92
+ setLines([]);
93
+ }
94
+ }
95
+ })();
96
+ return () => {
97
+ cancelled = true;
98
+ };
99
+ }, [cwd]);
100
+
101
+ return <HubScrollPanel title="Info" lines={lines} error={error} onDone={onDone} />;
102
+ }
103
+
63
104
  /**
64
105
  * Scrollable help inside the hub.
65
106
  */
package/src/types.ts CHANGED
@@ -38,6 +38,7 @@ export type Command =
38
38
  | "sync"
39
39
  | "pr"
40
40
  | "doctor"
41
+ | "info"
41
42
  | "config"
42
43
  | "schema"
43
44
  | "continue"
@@ -59,6 +60,7 @@ export const ALL_COMMANDS: Command[] = [
59
60
  "pr",
60
61
  "viz",
61
62
  "doctor",
63
+ "info",
62
64
  "config",
63
65
  "schema",
64
66
  "continue",