claudeup 4.15.1 → 4.16.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/gitignore-detector.test.ts +156 -0
  3. package/src/__tests__/gitignore-fixer.test.ts +197 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +75 -0
  5. package/src/__tests__/gitignore-resolver.test.ts +166 -0
  6. package/src/__tests__/gitignore-service.test.ts +93 -0
  7. package/src/__tests__/useGitignoreModal.test.ts +71 -0
  8. package/src/data/gitignore-defaults.js +24 -0
  9. package/src/data/gitignore-defaults.ts +26 -0
  10. package/src/data/gitignore-templates.js +21 -0
  11. package/src/data/gitignore-templates.ts +25 -0
  12. package/src/prerunner/index.js +17 -0
  13. package/src/prerunner/index.ts +20 -0
  14. package/src/services/gitignore-detector.js +155 -0
  15. package/src/services/gitignore-detector.ts +157 -0
  16. package/src/services/gitignore-fixer.js +171 -0
  17. package/src/services/gitignore-fixer.ts +198 -0
  18. package/src/services/gitignore-prerun.js +46 -0
  19. package/src/services/gitignore-prerun.ts +59 -0
  20. package/src/services/gitignore-resolver.js +143 -0
  21. package/src/services/gitignore-resolver.ts +190 -0
  22. package/src/services/gitignore-service.js +99 -0
  23. package/src/services/gitignore-service.ts +142 -0
  24. package/src/types/gitignore.js +6 -0
  25. package/src/types/gitignore.ts +68 -0
  26. package/src/ui/App.js +47 -3
  27. package/src/ui/App.tsx +51 -2
  28. package/src/ui/components/TabBar.js +1 -0
  29. package/src/ui/components/TabBar.tsx +1 -0
  30. package/src/ui/hooks/useGitignoreModal.js +75 -0
  31. package/src/ui/hooks/useGitignoreModal.ts +97 -0
  32. package/src/ui/renderers/gitignoreRenderers.js +33 -0
  33. package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
  34. package/src/ui/screens/GitignoreScreen.js +227 -0
  35. package/src/ui/screens/GitignoreScreen.tsx +364 -0
  36. package/src/ui/screens/index.js +1 -0
  37. package/src/ui/screens/index.ts +1 -0
  38. package/src/ui/state/types.ts +4 -2
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Three-tier gitignore manifest resolver.
3
+ *
4
+ * Precedence (lowest → highest, last-wins):
5
+ * 1. Built-in defaults (shipped with claudeup)
6
+ * 2. Global manifest at ~/.claude/gitignore.json (team-level, manually shared)
7
+ * 3. Project manifest at <cwd>/.claude/gitignore.json
8
+ *
9
+ * `extends` references are resolved against BUILTIN_TEMPLATES. Within a single
10
+ * tier, the same pattern cannot appear in both `ignore` and `track` — that
11
+ * throws. Across tiers, the higher-precedence tier wins; losers are recorded
12
+ * in `conflicts` so the TUI can show "global says ignore .env, project
13
+ * overrides to track."
14
+ */
15
+
16
+ import { readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { existsSync } from "node:fs";
20
+ import type {
21
+ GitignoreManifest,
22
+ ResolvedManifest,
23
+ ResolvedRule,
24
+ ResolvedConflict,
25
+ Tier,
26
+ RuleAction,
27
+ } from "../types/gitignore.js";
28
+ import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
29
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
30
+
31
+ export interface ResolveOptions {
32
+ /** Override $HOME (test seam). */
33
+ homeDir?: string;
34
+ /** Optional logger for non-fatal warnings (malformed JSON, missing template). */
35
+ logger?: (msg: string) => void;
36
+ }
37
+
38
+ const TIER_ORDER: Tier[] = ["builtin", "global", "project"];
39
+
40
+ export async function resolveGitignore(
41
+ projectCwd: string,
42
+ opts: ResolveOptions = {},
43
+ ): Promise<ResolvedManifest> {
44
+ const home = opts.homeDir ?? homedir();
45
+ const log = opts.logger ?? (() => {});
46
+
47
+ const tierManifests = new Map<Tier, GitignoreManifest>();
48
+
49
+ // 1. Built-in (always present)
50
+ tierManifests.set("builtin", BUILTIN_DEFAULTS);
51
+
52
+ // 2. Global ~/.claude/gitignore.json
53
+ const globalPath = join(home, ".claude", "gitignore.json");
54
+ const globalManifest = await readManifest(globalPath, log);
55
+ if (globalManifest) tierManifests.set("global", globalManifest);
56
+
57
+ // 3. Project .claude/gitignore.json
58
+ const projectPath = join(projectCwd, ".claude", "gitignore.json");
59
+ const projectManifest = await readManifest(projectPath, log);
60
+ if (projectManifest) tierManifests.set("project", projectManifest);
61
+
62
+ // Apply `extends` for each tier (templates count as part of their tier)
63
+ for (const tier of TIER_ORDER) {
64
+ const m = tierManifests.get(tier);
65
+ if (!m?.extends?.length) continue;
66
+ const merged: GitignoreManifest = {
67
+ ignore: [...m.ignore],
68
+ track: [...m.track],
69
+ };
70
+ for (const tplName of m.extends) {
71
+ const tpl = BUILTIN_TEMPLATES[tplName];
72
+ if (!tpl) {
73
+ log(`[gitignore] unknown template "${tplName}" in ${tier} manifest`);
74
+ continue;
75
+ }
76
+ merged.ignore.push(...tpl.ignore);
77
+ merged.track.push(...tpl.track);
78
+ }
79
+ tierManifests.set(tier, merged);
80
+ }
81
+
82
+ // Validate intra-tier consistency
83
+ for (const [tier, m] of tierManifests.entries()) {
84
+ const ig = new Set(m.ignore);
85
+ for (const t of m.track) {
86
+ if (ig.has(t)) {
87
+ throw new Error(
88
+ `Intra-tier conflict in ${tier} manifest: "${t}" is in both ignore and track`,
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ // Merge across tiers — last-wins, track conflicts
95
+ const ruleByPattern = new Map<
96
+ string,
97
+ { action: RuleAction; source: Tier; history: Array<{ tier: Tier; action: RuleAction }> }
98
+ >();
99
+
100
+ for (const tier of TIER_ORDER) {
101
+ const m = tierManifests.get(tier);
102
+ if (!m) continue;
103
+ for (const p of dedupe(m.ignore)) {
104
+ pushRule(ruleByPattern, p, "ignore", tier);
105
+ }
106
+ for (const p of dedupe(m.track)) {
107
+ pushRule(ruleByPattern, p, "track", tier);
108
+ }
109
+ }
110
+
111
+ const rules: ResolvedRule[] = [];
112
+ const conflicts: ResolvedConflict[] = [];
113
+
114
+ for (const [pattern, entry] of ruleByPattern.entries()) {
115
+ rules.push({ pattern, action: entry.action, source: entry.source });
116
+
117
+ const losers = entry.history.filter(
118
+ (h) => h.tier !== entry.source || h.action !== entry.action,
119
+ );
120
+ if (losers.length > 0 && hasActionDisagreement(entry.history)) {
121
+ conflicts.push({
122
+ pattern,
123
+ winner: entry.source,
124
+ losers: losers.filter((l) => l.action !== entry.action),
125
+ });
126
+ }
127
+ }
128
+
129
+ return { rules, conflicts };
130
+ }
131
+
132
+ function pushRule(
133
+ map: Map<
134
+ string,
135
+ { action: RuleAction; source: Tier; history: Array<{ tier: Tier; action: RuleAction }> }
136
+ >,
137
+ pattern: string,
138
+ action: RuleAction,
139
+ tier: Tier,
140
+ ): void {
141
+ const existing = map.get(pattern);
142
+ if (existing) {
143
+ existing.history.push({ tier, action });
144
+ existing.action = action;
145
+ existing.source = tier;
146
+ } else {
147
+ map.set(pattern, { action, source: tier, history: [{ tier, action }] });
148
+ }
149
+ }
150
+
151
+ function hasActionDisagreement(
152
+ history: Array<{ tier: Tier; action: RuleAction }>,
153
+ ): boolean {
154
+ const actions = new Set(history.map((h) => h.action));
155
+ return actions.size > 1;
156
+ }
157
+
158
+ function dedupe(xs: string[]): string[] {
159
+ return Array.from(new Set(xs));
160
+ }
161
+
162
+ async function readManifest(
163
+ path: string,
164
+ log: (msg: string) => void,
165
+ ): Promise<GitignoreManifest | null> {
166
+ if (!existsSync(path)) return null;
167
+ try {
168
+ const raw = await readFile(path, "utf8");
169
+ const parsed = JSON.parse(raw);
170
+ if (!isManifest(parsed)) {
171
+ log(`[gitignore] ${path} is not a valid manifest (expected { ignore: string[], track: string[] })`);
172
+ return null;
173
+ }
174
+ return parsed;
175
+ } catch (err) {
176
+ log(`[gitignore] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
177
+ return null;
178
+ }
179
+ }
180
+
181
+ function isManifest(x: unknown): x is GitignoreManifest {
182
+ if (!x || typeof x !== "object") return false;
183
+ const m = x as Record<string, unknown>;
184
+ if (!Array.isArray(m.ignore) || !m.ignore.every((p) => typeof p === "string")) return false;
185
+ if (!Array.isArray(m.track) || !m.track.every((p) => typeof p === "string")) return false;
186
+ if (m.extends !== undefined) {
187
+ if (!Array.isArray(m.extends) || !m.extends.every((p) => typeof p === "string")) return false;
188
+ }
189
+ return true;
190
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * TUI-facing wrapper around the gitignore core services. Loads everything
3
+ * the Gitignore screen needs in one call, and exposes a small set of
4
+ * action helpers (apply safe fixes, apply one destructive fix, apply a
5
+ * built-in template).
6
+ */
7
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { resolveGitignore } from "./gitignore-resolver.js";
11
+ import { detectViolations } from "./gitignore-detector.js";
12
+ import { applySafeFixes as coreApplySafeFixes, applyDestructiveFix as coreApplyDestructiveFix, } from "./gitignore-fixer.js";
13
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
14
+ export async function loadGitignoreState(cwd) {
15
+ const warnings = [];
16
+ const resolved = await resolveGitignore(cwd, {
17
+ logger: (m) => warnings.push(m),
18
+ });
19
+ const violations = await detectViolations(cwd, resolved);
20
+ return { resolved, violations, warnings };
21
+ }
22
+ export async function applyAllSafeFixes(cwd, violations) {
23
+ return coreApplySafeFixes(cwd, violations);
24
+ }
25
+ export async function applyDestructiveFix(cwd, violation) {
26
+ return coreApplyDestructiveFix(cwd, violation);
27
+ }
28
+ /**
29
+ * Merge a built-in template into the project manifest at .claude/gitignore.json.
30
+ * Conflicts (a pattern that already exists with the opposite action) are
31
+ * skipped and reported back to the caller.
32
+ */
33
+ export async function applyTemplate(cwd, templateName) {
34
+ const tpl = BUILTIN_TEMPLATES[templateName];
35
+ if (!tpl)
36
+ throw new Error(`Unknown template: ${templateName}`);
37
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
38
+ const existing = await readManifestOrEmpty(projectManifestPath);
39
+ const existingIgnore = new Set(existing.ignore);
40
+ const existingTrack = new Set(existing.track);
41
+ const conflicts = [];
42
+ const newIgnore = [];
43
+ const newTrack = [];
44
+ for (const p of tpl.ignore) {
45
+ if (existingTrack.has(p)) {
46
+ conflicts.push(p);
47
+ continue;
48
+ }
49
+ if (!existingIgnore.has(p)) {
50
+ newIgnore.push(p);
51
+ existingIgnore.add(p);
52
+ }
53
+ }
54
+ for (const p of tpl.track) {
55
+ if (existingIgnore.has(p)) {
56
+ conflicts.push(p);
57
+ continue;
58
+ }
59
+ if (!existingTrack.has(p)) {
60
+ newTrack.push(p);
61
+ existingTrack.add(p);
62
+ }
63
+ }
64
+ const merged = {
65
+ ignore: [...existing.ignore, ...newIgnore],
66
+ track: [...existing.track, ...newTrack],
67
+ extends: existing.extends,
68
+ };
69
+ await mkdir(dirname(projectManifestPath), { recursive: true });
70
+ await writeFile(projectManifestPath, JSON.stringify(merged, null, 2) + "\n");
71
+ return {
72
+ appliedTo: projectManifestPath,
73
+ added: { ignore: newIgnore, track: newTrack },
74
+ conflicts,
75
+ };
76
+ }
77
+ async function readManifestOrEmpty(path) {
78
+ if (!existsSync(path))
79
+ return { ignore: [], track: [] };
80
+ try {
81
+ const raw = await readFile(path, "utf8");
82
+ const parsed = JSON.parse(raw);
83
+ if (!isManifest(parsed))
84
+ return { ignore: [], track: [] };
85
+ return parsed;
86
+ }
87
+ catch {
88
+ return { ignore: [], track: [] };
89
+ }
90
+ }
91
+ function isManifest(x) {
92
+ if (!x || typeof x !== "object")
93
+ return false;
94
+ const m = x;
95
+ return (Array.isArray(m.ignore) &&
96
+ m.ignore.every((p) => typeof p === "string") &&
97
+ Array.isArray(m.track) &&
98
+ m.track.every((p) => typeof p === "string"));
99
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * TUI-facing wrapper around the gitignore core services. Loads everything
3
+ * the Gitignore screen needs in one call, and exposes a small set of
4
+ * action helpers (apply safe fixes, apply one destructive fix, apply a
5
+ * built-in template).
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
9
+ import { existsSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { resolveGitignore } from "./gitignore-resolver.js";
12
+ import { detectViolations } from "./gitignore-detector.js";
13
+ import {
14
+ applySafeFixes as coreApplySafeFixes,
15
+ applyDestructiveFix as coreApplyDestructiveFix,
16
+ type SafeFixResult,
17
+ type DestructiveFixResult,
18
+ } from "./gitignore-fixer.js";
19
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
20
+ import type {
21
+ GitignoreManifest,
22
+ ResolvedManifest,
23
+ Violation,
24
+ } from "../types/gitignore.js";
25
+
26
+ export interface GitignoreState {
27
+ resolved: ResolvedManifest;
28
+ violations: Violation[];
29
+ /** Logger lines from resolution (malformed JSON, missing template, etc). */
30
+ warnings: string[];
31
+ }
32
+
33
+ export async function loadGitignoreState(cwd: string): Promise<GitignoreState> {
34
+ const warnings: string[] = [];
35
+ const resolved = await resolveGitignore(cwd, {
36
+ logger: (m) => warnings.push(m),
37
+ });
38
+ const violations = await detectViolations(cwd, resolved);
39
+ return { resolved, violations, warnings };
40
+ }
41
+
42
+ export async function applyAllSafeFixes(
43
+ cwd: string,
44
+ violations: Violation[],
45
+ ): Promise<SafeFixResult> {
46
+ return coreApplySafeFixes(cwd, violations);
47
+ }
48
+
49
+ export async function applyDestructiveFix(
50
+ cwd: string,
51
+ violation: Violation,
52
+ ): Promise<DestructiveFixResult> {
53
+ return coreApplyDestructiveFix(cwd, violation);
54
+ }
55
+
56
+ export interface ApplyTemplateResult {
57
+ appliedTo: string;
58
+ added: { ignore: string[]; track: string[] };
59
+ conflicts: string[];
60
+ }
61
+
62
+ /**
63
+ * Merge a built-in template into the project manifest at .claude/gitignore.json.
64
+ * Conflicts (a pattern that already exists with the opposite action) are
65
+ * skipped and reported back to the caller.
66
+ */
67
+ export async function applyTemplate(
68
+ cwd: string,
69
+ templateName: string,
70
+ ): Promise<ApplyTemplateResult> {
71
+ const tpl = BUILTIN_TEMPLATES[templateName];
72
+ if (!tpl) throw new Error(`Unknown template: ${templateName}`);
73
+
74
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
75
+ const existing = await readManifestOrEmpty(projectManifestPath);
76
+
77
+ const existingIgnore = new Set(existing.ignore);
78
+ const existingTrack = new Set(existing.track);
79
+
80
+ const conflicts: string[] = [];
81
+ const newIgnore: string[] = [];
82
+ const newTrack: string[] = [];
83
+
84
+ for (const p of tpl.ignore) {
85
+ if (existingTrack.has(p)) {
86
+ conflicts.push(p);
87
+ continue;
88
+ }
89
+ if (!existingIgnore.has(p)) {
90
+ newIgnore.push(p);
91
+ existingIgnore.add(p);
92
+ }
93
+ }
94
+ for (const p of tpl.track) {
95
+ if (existingIgnore.has(p)) {
96
+ conflicts.push(p);
97
+ continue;
98
+ }
99
+ if (!existingTrack.has(p)) {
100
+ newTrack.push(p);
101
+ existingTrack.add(p);
102
+ }
103
+ }
104
+
105
+ const merged: GitignoreManifest = {
106
+ ignore: [...existing.ignore, ...newIgnore],
107
+ track: [...existing.track, ...newTrack],
108
+ extends: existing.extends,
109
+ };
110
+
111
+ await mkdir(dirname(projectManifestPath), { recursive: true });
112
+ await writeFile(projectManifestPath, JSON.stringify(merged, null, 2) + "\n");
113
+
114
+ return {
115
+ appliedTo: projectManifestPath,
116
+ added: { ignore: newIgnore, track: newTrack },
117
+ conflicts,
118
+ };
119
+ }
120
+
121
+ async function readManifestOrEmpty(path: string): Promise<GitignoreManifest> {
122
+ if (!existsSync(path)) return { ignore: [], track: [] };
123
+ try {
124
+ const raw = await readFile(path, "utf8");
125
+ const parsed = JSON.parse(raw);
126
+ if (!isManifest(parsed)) return { ignore: [], track: [] };
127
+ return parsed;
128
+ } catch {
129
+ return { ignore: [], track: [] };
130
+ }
131
+ }
132
+
133
+ function isManifest(x: unknown): x is GitignoreManifest {
134
+ if (!x || typeof x !== "object") return false;
135
+ const m = x as Record<string, unknown>;
136
+ return (
137
+ Array.isArray(m.ignore) &&
138
+ m.ignore.every((p) => typeof p === "string") &&
139
+ Array.isArray(m.track) &&
140
+ m.track.every((p) => typeof p === "string")
141
+ );
142
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Types for the gitignore manager: a three-tier (built-in → global → project)
3
+ * manifest system that declares which paths must be ignored and which must
4
+ * stay tracked. Last-wins on conflict across tiers; intra-tier conflicts throw.
5
+ */
6
+ export {};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Types for the gitignore manager: a three-tier (built-in → global → project)
3
+ * manifest system that declares which paths must be ignored and which must
4
+ * stay tracked. Last-wins on conflict across tiers; intra-tier conflicts throw.
5
+ */
6
+
7
+ export type RuleAction = "ignore" | "track";
8
+
9
+ export type Tier = "builtin" | "global" | "project";
10
+
11
+ /** A user-authored manifest, deserialized from JSON. */
12
+ export interface GitignoreManifest {
13
+ /** Paths or globs that must be excluded from git. */
14
+ ignore: string[];
15
+ /** Paths that must remain tracked even if a parent is in ignore. */
16
+ track: string[];
17
+ /** Optional template names to merge in (e.g. "common-dev"). */
18
+ extends?: string[];
19
+ }
20
+
21
+ /** A single rule after tier resolution. */
22
+ export interface ResolvedRule {
23
+ pattern: string;
24
+ action: RuleAction;
25
+ source: Tier;
26
+ }
27
+
28
+ /** When two tiers disagree on a pattern, the higher-precedence tier wins. */
29
+ export interface ResolvedConflict {
30
+ pattern: string;
31
+ winner: Tier;
32
+ losers: Array<{ tier: Tier; action: RuleAction }>;
33
+ }
34
+
35
+ export interface ResolvedManifest {
36
+ rules: ResolvedRule[];
37
+ conflicts: ResolvedConflict[];
38
+ }
39
+
40
+ /**
41
+ * Categorized violation state. The detector inspects the working tree against
42
+ * the resolved manifest and emits one of these per offending path.
43
+ *
44
+ * - tracked-but-should-ignore: path is in git index, manifest says ignore
45
+ * - ignored-but-should-track: path matches .gitignore, manifest says track
46
+ * - untracked-and-should-track: path exists but neither tracked nor ignored;
47
+ * manifest says it should be tracked
48
+ * - missing-from-gitignore: path is in manifest.ignore but no .gitignore line
49
+ * covers it (and the path isn't currently tracked)
50
+ */
51
+ export type ViolationKind =
52
+ | "tracked-but-should-ignore"
53
+ | "ignored-but-should-track"
54
+ | "untracked-and-should-track"
55
+ | "missing-from-gitignore";
56
+
57
+ export interface Violation {
58
+ kind: ViolationKind;
59
+ path: string;
60
+ /** Which tier asserted the rule that's being violated. */
61
+ source: Tier;
62
+ /**
63
+ * "safe" — fix is purely additive (append a .gitignore line)
64
+ * "destructive" — fix mutates git index (`git rm --cached`, `git add`,
65
+ * removing existing .gitignore lines)
66
+ */
67
+ severity: "safe" | "destructive";
68
+ }
package/src/ui/App.js CHANGED
@@ -5,7 +5,10 @@ import fs from "node:fs";
5
5
  import { AppProvider, useApp, useNavigation, useModal, } from "./state/AppContext.js";
6
6
  import { DimensionsProvider, useDimensions, } from "./state/DimensionsContext.js";
7
7
  import { ModalContainer } from "./components/modals/index.js";
8
- import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, } from "./screens/index.js";
8
+ import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, GitignoreScreen, } from "./screens/index.js";
9
+ import { checkGitignore } from "../services/gitignore-prerun.js";
10
+ import { loadGitignoreState, applyAllSafeFixes, } from "../services/gitignore-service.js";
11
+ import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
9
12
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
10
13
  import { migrateMarketplaceRename, recoverMarketplaceSettings, } from "../services/claude-settings.js";
11
14
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
@@ -40,6 +43,8 @@ function Router() {
40
43
  return _jsx(ProfilesScreen, {});
41
44
  case "skills":
42
45
  return _jsx(SkillsScreen, {});
46
+ case "gitignore":
47
+ return _jsx(GitignoreScreen, {});
43
48
  default:
44
49
  return _jsx(PluginsScreen, {});
45
50
  }
@@ -81,7 +86,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
81
86
  // Don't handle keys when modal is open or searching
82
87
  if (state.modal || state.isSearching)
83
88
  return;
84
- // Global navigation shortcuts (1-5) - include mcp-registry as it's a sub-screen of mcp
89
+ // Global navigation shortcuts (1-7) - include mcp-registry as it's a sub-screen of mcp
85
90
  const isTopLevel = [
86
91
  "plugins",
87
92
  "mcp",
@@ -91,6 +96,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
91
96
  "model-selector",
92
97
  "profiles",
93
98
  "skills",
99
+ "gitignore",
94
100
  ].includes(state.currentRoute.screen);
95
101
  if (isTopLevel) {
96
102
  if (input === "1")
@@ -105,6 +111,8 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
105
111
  navigateToScreen("profiles");
106
112
  else if (input === "6")
107
113
  navigateToScreen("cli-tools");
114
+ else if (input === "7")
115
+ navigateToScreen("gitignore");
108
116
  // Tab navigation cycling
109
117
  if (key.tab) {
110
118
  const screens = [
@@ -114,6 +122,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
114
122
  "settings",
115
123
  "profiles",
116
124
  "cli-tools",
125
+ "gitignore",
117
126
  ];
118
127
  const currentIndex = screens.indexOf(state.currentRoute.screen);
119
128
  if (currentIndex !== -1) {
@@ -148,7 +157,7 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
148
157
  ? This help
149
158
 
150
159
  Quick Navigation
151
- 1 Plugins 4 Settings
160
+ 1 Plugins 4 Settings 7 Gitignore
152
161
  2 Skills 5 Profiles
153
162
  3 MCP Servers 6 CLI Tools
154
163
 
@@ -193,6 +202,8 @@ function AppContent({ onExit }) {
193
202
  const [recoveryReport, setRecoveryReport] = useState(null);
194
203
  const [mismatchData, setMismatchData] = useState(null);
195
204
  const mismatchModal = useMismatchModal();
205
+ const [gitignoreData, setGitignoreData] = useState(null);
206
+ const gitignoreModal = useGitignoreModal();
196
207
  // Check for updates on startup (non-blocking)
197
208
  useEffect(() => {
198
209
  checkForUpdates()
@@ -213,6 +224,23 @@ function AppContent({ onExit }) {
213
224
  mismatchModal.show(mismatchData);
214
225
  setMismatchData(null);
215
226
  }, [mismatchData, mismatchModal]);
227
+ // Show gitignore modal — wait for the modal slot to be free so we don't
228
+ // stomp on the mismatch modal that may have just been shown.
229
+ useEffect(() => {
230
+ if (!gitignoreData)
231
+ return;
232
+ if (state.modal)
233
+ return;
234
+ gitignoreModal.show({
235
+ violationCount: gitignoreData.violationCount,
236
+ safeCount: gitignoreData.safeCount,
237
+ onFixSafe: async () => {
238
+ const s = await loadGitignoreState(state.projectPath);
239
+ await applyAllSafeFixes(state.projectPath, s.violations);
240
+ },
241
+ });
242
+ setGitignoreData(null);
243
+ }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
216
244
  // Auto-refresh marketplaces on startup
217
245
  useEffect(() => {
218
246
  const noRefresh = process.argv.includes("--no-refresh");
@@ -277,6 +305,22 @@ function AppContent({ onExit }) {
277
305
  }
278
306
  })
279
307
  .catch(() => { }); // non-fatal
308
+ // Check for gitignore manifest violations and surface a popup if any.
309
+ // Loads twice (once for count, once when user picks "fix safe") because
310
+ // the second load happens after edits — keeps detection cheap on startup.
311
+ (async () => {
312
+ try {
313
+ const result = await checkGitignore(process.cwd());
314
+ if (result.violationCount > 0) {
315
+ const s = await loadGitignoreState(process.cwd());
316
+ const safeCount = s.violations.filter((v) => v.severity === "safe").length;
317
+ setGitignoreData({ violationCount: result.violationCount, safeCount });
318
+ }
319
+ }
320
+ catch {
321
+ // non-fatal
322
+ }
323
+ })();
280
324
  repairAllMarketplaces()
281
325
  .then(async () => {
282
326
  dispatch({ type: "HIDE_PROGRESS" });