claudeup 4.16.0 → 4.18.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 (77) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/alias-parser.test.ts +317 -0
  3. package/src/__tests__/alias-shell-writer.test.ts +661 -0
  4. package/src/__tests__/alias-store.test.ts +86 -0
  5. package/src/__tests__/gitignore-fixer.test.ts +64 -1
  6. package/src/__tests__/gitignore-prerun.test.ts +2 -2
  7. package/src/__tests__/gitignore-service.test.ts +42 -0
  8. package/src/__tests__/marketplaces.test.ts +40 -0
  9. package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
  10. package/src/__tests__/useGitignoreModal.test.ts +2 -2
  11. package/src/data/alias-flags.js +196 -0
  12. package/src/data/alias-flags.ts +291 -0
  13. package/src/data/gitignore-reasons.js +97 -0
  14. package/src/data/gitignore-reasons.ts +103 -0
  15. package/src/data/marketplaces.js +19 -1
  16. package/src/data/marketplaces.ts +17 -1
  17. package/src/services/alias-settings.js +51 -0
  18. package/src/services/alias-settings.ts +63 -0
  19. package/src/services/alias-shell-writer.js +764 -0
  20. package/src/services/alias-shell-writer.ts +873 -0
  21. package/src/services/alias-store.js +77 -0
  22. package/src/services/alias-store.ts +112 -0
  23. package/src/services/gitignore-fixer.js +70 -10
  24. package/src/services/gitignore-fixer.ts +76 -9
  25. package/src/services/gitignore-prerun.js +3 -3
  26. package/src/services/gitignore-prerun.ts +3 -3
  27. package/src/services/gitignore-service.js +20 -2
  28. package/src/services/gitignore-service.ts +23 -1
  29. package/src/services/marketplace-fetcher.js +96 -0
  30. package/src/services/marketplace-fetcher.ts +137 -0
  31. package/src/services/plugin-manager.js +6 -59
  32. package/src/services/plugin-manager.ts +16 -91
  33. package/src/services/skillsmp-client.js +29 -9
  34. package/src/services/skillsmp-client.ts +38 -8
  35. package/src/types/gitignore.ts +1 -1
  36. package/src/types/index.ts +1 -0
  37. package/src/ui/App.js +10 -4
  38. package/src/ui/App.tsx +9 -3
  39. package/src/ui/components/TabBar.js +2 -1
  40. package/src/ui/components/TabBar.tsx +2 -1
  41. package/src/ui/components/layout/FooterHints.js +29 -0
  42. package/src/ui/components/layout/FooterHints.tsx +52 -0
  43. package/src/ui/components/layout/ScreenLayout.js +2 -1
  44. package/src/ui/components/layout/ScreenLayout.tsx +12 -3
  45. package/src/ui/components/layout/index.js +1 -0
  46. package/src/ui/components/layout/index.ts +5 -0
  47. package/src/ui/components/modals/SelectModal.js +8 -1
  48. package/src/ui/components/modals/SelectModal.tsx +12 -1
  49. package/src/ui/hooks/useGitignoreModal.js +7 -8
  50. package/src/ui/hooks/useGitignoreModal.ts +8 -9
  51. package/src/ui/renderers/gitignoreRenderers.js +36 -23
  52. package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
  53. package/src/ui/screens/AliasScreen.js +1008 -0
  54. package/src/ui/screens/AliasScreen.tsx +1402 -0
  55. package/src/ui/screens/CliToolsScreen.js +6 -1
  56. package/src/ui/screens/CliToolsScreen.tsx +6 -1
  57. package/src/ui/screens/EnvVarsScreen.js +6 -1
  58. package/src/ui/screens/EnvVarsScreen.tsx +6 -1
  59. package/src/ui/screens/GitignoreScreen.js +189 -88
  60. package/src/ui/screens/GitignoreScreen.tsx +312 -132
  61. package/src/ui/screens/McpRegistryScreen.js +13 -2
  62. package/src/ui/screens/McpRegistryScreen.tsx +13 -2
  63. package/src/ui/screens/McpScreen.js +6 -1
  64. package/src/ui/screens/McpScreen.tsx +6 -1
  65. package/src/ui/screens/ModelSelectorScreen.js +8 -2
  66. package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
  67. package/src/ui/screens/PluginsScreen.js +13 -2
  68. package/src/ui/screens/PluginsScreen.tsx +13 -2
  69. package/src/ui/screens/ProfilesScreen.js +8 -1
  70. package/src/ui/screens/ProfilesScreen.tsx +8 -1
  71. package/src/ui/screens/SkillsScreen.js +21 -4
  72. package/src/ui/screens/SkillsScreen.tsx +39 -5
  73. package/src/ui/screens/StatusLineScreen.js +7 -1
  74. package/src/ui/screens/StatusLineScreen.tsx +7 -1
  75. package/src/ui/screens/index.js +1 -0
  76. package/src/ui/screens/index.ts +1 -0
  77. package/src/ui/state/types.ts +4 -2
@@ -0,0 +1,77 @@
1
+ /**
2
+ * In-memory types and defaults for the managed `claude` alias.
3
+ *
4
+ * Persistence has moved:
5
+ * - Alias name lives in Claude Code's `settings.json` under `claudeup.aliasName`
6
+ * (read/written via `alias-settings.ts`).
7
+ * - Flag values are read from the user's shell rc file on screen mount
8
+ * (via `parseAliasFromRc` in `alias-shell-writer.ts`). Edits stay in
9
+ * memory until the user presses `w` to write the rc file.
10
+ *
11
+ * This module is filesystem-free so it can be imported by tests and pure
12
+ * logic without dragging in `claude-settings.ts`'s module-level `os.homedir()`
13
+ * constants.
14
+ */
15
+ import { ALIAS_FLAGS } from "../data/alias-flags.js";
16
+ /** Default alias name. POSIX/fish-safe, short, and not in conflict with `claude`. */
17
+ export const DEFAULT_ALIAS_NAME = "c";
18
+ /**
19
+ * Validate a candidate alias name. Returns null when valid, or a human
20
+ * reason when invalid.
21
+ *
22
+ * We refuse anything that isn't `[A-Za-z_][A-Za-z0-9_-]*` — that's the
23
+ * intersection of POSIX `name` rules and fish's `alias NAME …` accepting
24
+ * set, and avoids the need to quote the name on either side of the `=`.
25
+ */
26
+ export function validateAliasName(name) {
27
+ if (name.length === 0)
28
+ return "Alias name cannot be empty.";
29
+ if (name.length > 32)
30
+ return "Alias name is unreasonably long (>32 chars).";
31
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) {
32
+ return "Alias name must start with a letter or underscore and contain only letters, digits, _ or -.";
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Build the default in-memory config. Used when no managed block exists in
38
+ * the rc file (every flag disabled, default alias name).
39
+ */
40
+ export function defaultAliasConfig() {
41
+ const flags = {};
42
+ for (const flag of ALIAS_FLAGS) {
43
+ flags[flag.id] = defaultValueFor(flag);
44
+ }
45
+ return { aliasName: DEFAULT_ALIAS_NAME, flags };
46
+ }
47
+ export function defaultValueFor(flag) {
48
+ switch (flag.kind) {
49
+ case "boolean":
50
+ return { kind: "boolean", enabled: false };
51
+ case "tri-state":
52
+ return { kind: "tri-state", state: "unset" };
53
+ case "select":
54
+ return {
55
+ kind: "select",
56
+ enabled: false,
57
+ value: flag.options?.[0]?.value ?? "",
58
+ };
59
+ case "text":
60
+ return { kind: "text", enabled: false, value: "" };
61
+ case "optional-text":
62
+ return { kind: "optional-text", enabled: false, value: "" };
63
+ case "text-list":
64
+ return {
65
+ kind: "text-list",
66
+ enabled: false,
67
+ values: [...(flag.defaultValues ?? [])],
68
+ };
69
+ case "multi-with-custom":
70
+ return {
71
+ kind: "multi-with-custom",
72
+ enabled: false,
73
+ picked: [],
74
+ custom: [...(flag.defaultValues ?? [])],
75
+ };
76
+ }
77
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * In-memory types and defaults for the managed `claude` alias.
3
+ *
4
+ * Persistence has moved:
5
+ * - Alias name lives in Claude Code's `settings.json` under `claudeup.aliasName`
6
+ * (read/written via `alias-settings.ts`).
7
+ * - Flag values are read from the user's shell rc file on screen mount
8
+ * (via `parseAliasFromRc` in `alias-shell-writer.ts`). Edits stay in
9
+ * memory until the user presses `w` to write the rc file.
10
+ *
11
+ * This module is filesystem-free so it can be imported by tests and pure
12
+ * logic without dragging in `claude-settings.ts`'s module-level `os.homedir()`
13
+ * constants.
14
+ */
15
+
16
+ import { ALIAS_FLAGS, type AliasFlag } from "../data/alias-flags.js";
17
+
18
+ /**
19
+ * Per-flag value shapes. Discriminator is `kind`, mirroring `AliasFlag.kind`.
20
+ */
21
+ export type FlagValue =
22
+ | { kind: "boolean"; enabled: boolean }
23
+ | { kind: "tri-state"; state: "unset" | "on" | "off" }
24
+ | { kind: "select"; enabled: boolean; value: string }
25
+ | { kind: "text"; enabled: boolean; value: string }
26
+ | { kind: "optional-text"; enabled: boolean; value: string }
27
+ | { kind: "text-list"; enabled: boolean; values: string[] }
28
+ | {
29
+ kind: "multi-with-custom";
30
+ enabled: boolean;
31
+ picked: string[];
32
+ custom: string[];
33
+ };
34
+
35
+ /**
36
+ * In-memory alias configuration. No `version` field since this isn't
37
+ * persisted as-is anymore — the alias name is stored in `settings.json`
38
+ * and flag values come from the rc file.
39
+ */
40
+ export interface AliasConfig {
41
+ /**
42
+ * Name of the shell alias. Default `"c"` so users can keep typing the
43
+ * vanilla `claude` command alongside the wrapped one.
44
+ */
45
+ aliasName: string;
46
+ /** Per-flag state, keyed by `AliasFlag.id`. */
47
+ flags: Record<string, FlagValue>;
48
+ }
49
+
50
+ /** Default alias name. POSIX/fish-safe, short, and not in conflict with `claude`. */
51
+ export const DEFAULT_ALIAS_NAME = "c";
52
+
53
+ /**
54
+ * Validate a candidate alias name. Returns null when valid, or a human
55
+ * reason when invalid.
56
+ *
57
+ * We refuse anything that isn't `[A-Za-z_][A-Za-z0-9_-]*` — that's the
58
+ * intersection of POSIX `name` rules and fish's `alias NAME …` accepting
59
+ * set, and avoids the need to quote the name on either side of the `=`.
60
+ */
61
+ export function validateAliasName(name: string): string | null {
62
+ if (name.length === 0) return "Alias name cannot be empty.";
63
+ if (name.length > 32) return "Alias name is unreasonably long (>32 chars).";
64
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) {
65
+ return "Alias name must start with a letter or underscore and contain only letters, digits, _ or -.";
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Build the default in-memory config. Used when no managed block exists in
72
+ * the rc file (every flag disabled, default alias name).
73
+ */
74
+ export function defaultAliasConfig(): AliasConfig {
75
+ const flags: Record<string, FlagValue> = {};
76
+ for (const flag of ALIAS_FLAGS) {
77
+ flags[flag.id] = defaultValueFor(flag);
78
+ }
79
+ return { aliasName: DEFAULT_ALIAS_NAME, flags };
80
+ }
81
+
82
+ export function defaultValueFor(flag: AliasFlag): FlagValue {
83
+ switch (flag.kind) {
84
+ case "boolean":
85
+ return { kind: "boolean", enabled: false };
86
+ case "tri-state":
87
+ return { kind: "tri-state", state: "unset" };
88
+ case "select":
89
+ return {
90
+ kind: "select",
91
+ enabled: false,
92
+ value: flag.options?.[0]?.value ?? "",
93
+ };
94
+ case "text":
95
+ return { kind: "text", enabled: false, value: "" };
96
+ case "optional-text":
97
+ return { kind: "optional-text", enabled: false, value: "" };
98
+ case "text-list":
99
+ return {
100
+ kind: "text-list",
101
+ enabled: false,
102
+ values: [...(flag.defaultValues ?? [])],
103
+ };
104
+ case "multi-with-custom":
105
+ return {
106
+ kind: "multi-with-custom",
107
+ enabled: false,
108
+ picked: [],
109
+ custom: [...(flag.defaultValues ?? [])],
110
+ };
111
+ }
112
+ }
@@ -6,8 +6,9 @@
6
6
  * because patterns are already present).
7
7
  *
8
8
  * - `applyDestructiveFix`: index-mutating — runs `git rm --cached <path>`
9
- * for tracked-but-should-ignore, `git add <path>` (after stripping
10
- * gitignore lines) for ignored-but-should-track / untracked-and-should-track.
9
+ * for tracked-but-should-ignore, `git add <path>` (after making sure
10
+ * gitignore has any needed `!` exceptions) for ignored-but-should-track /
11
+ * untracked-and-should-track.
11
12
  * Each call handles ONE violation; the caller is responsible for confirming
12
13
  * each one with the user.
13
14
  *
@@ -17,6 +18,7 @@ import { readFile, writeFile, appendFile } from "node:fs/promises";
17
18
  import { existsSync } from "node:fs";
18
19
  import { join } from "node:path";
19
20
  import { spawn } from "node:child_process";
21
+ import { getGitignoreRuleReason } from "../data/gitignore-reasons.js";
20
22
  const CLAUDEUP_HEADER = "# Added by claudeup";
21
23
  export async function applySafeFixes(cwd, violations) {
22
24
  const safe = violations.filter((v) => v.severity === "safe" && v.kind === "missing-from-gitignore");
@@ -44,7 +46,7 @@ export async function applySafeFixes(cwd, violations) {
44
46
  const block = [
45
47
  "",
46
48
  CLAUDEUP_HEADER,
47
- ...appended,
49
+ ...appended.flatMap(formatGitignoreEntry),
48
50
  "",
49
51
  ].join("\n");
50
52
  if (existing.length > 0 && !existing.endsWith("\n")) {
@@ -55,19 +57,26 @@ export async function applySafeFixes(cwd, violations) {
55
57
  }
56
58
  return { appended, alreadyPresent };
57
59
  }
58
- export async function applyDestructiveFix(cwd, violation) {
60
+ export async function applyDestructiveFix(cwd, violation, rulePattern = violation.path) {
59
61
  switch (violation.kind) {
60
62
  case "tracked-but-should-ignore": {
61
63
  await runGit(["rm", "--cached", "--", violation.path], cwd);
62
64
  // Then make sure .gitignore has the entry so it stays out
63
- await appendIfMissing(cwd, violation.path);
65
+ await appendIfMissing(cwd, rulePattern);
64
66
  return {
65
67
  applied: true,
66
- message: `Removed ${violation.path} from index and added to .gitignore`,
68
+ message: `Removed ${violation.path} from index and added ${rulePattern} to .gitignore`,
67
69
  };
68
70
  }
69
71
  case "ignored-but-should-track": {
70
72
  await stripGitignoreLine(cwd, violation.path);
73
+ await appendTrackExceptionsIfNeeded(cwd, violation.path);
74
+ if (!existsSync(join(cwd, violation.path))) {
75
+ return {
76
+ applied: true,
77
+ message: `Made ${violation.path} trackable; file does not exist yet`,
78
+ };
79
+ }
71
80
  try {
72
81
  await runGit(["add", "--", violation.path], cwd);
73
82
  }
@@ -83,13 +92,14 @@ export async function applyDestructiveFix(cwd, violation) {
83
92
  };
84
93
  }
85
94
  case "untracked-and-should-track": {
95
+ await appendTrackExceptionsIfNeeded(cwd, violation.path);
86
96
  await runGit(["add", "--", violation.path], cwd);
87
97
  return { applied: true, message: `Staged ${violation.path}` };
88
98
  }
89
99
  case "missing-from-gitignore": {
90
100
  // Treat as a safe append for symmetry — caller can route through here too.
91
- await appendIfMissing(cwd, violation.path);
92
- return { applied: true, message: `Added ${violation.path} to .gitignore` };
101
+ await appendIfMissing(cwd, rulePattern);
102
+ return { applied: true, message: `Added ${rulePattern} to .gitignore` };
93
103
  }
94
104
  }
95
105
  }
@@ -127,10 +137,49 @@ async function appendIfMissing(cwd, pattern) {
127
137
  const lines = existing.split("\n").map((l) => l.trim());
128
138
  if (lines.includes(pattern))
129
139
  return;
130
- const block = (existing.length > 0 && !existing.endsWith("\n") ? "\n" : "") +
131
- `${CLAUDEUP_HEADER}\n${pattern}\n`;
140
+ // Only emit the "# Added by claudeup" header once when fixing several
141
+ // entries in one pass (F), the file already has the header from the first.
142
+ const needsHeader = !lines.includes(CLAUDEUP_HEADER);
143
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
144
+ const header = needsHeader ? `${CLAUDEUP_HEADER}\n` : "";
145
+ const block = `${prefix}${header}${formatGitignoreEntry(pattern).join("\n")}\n`;
132
146
  await appendFile(gitignorePath, block);
133
147
  }
148
+ function formatGitignoreEntry(pattern) {
149
+ return [`# ${getGitignoreRuleReason("ignore", pattern)}`, pattern];
150
+ }
151
+ async function appendTrackExceptionsIfNeeded(cwd, path) {
152
+ if (!(await pathIsIgnored(cwd, path)))
153
+ return;
154
+ const gitignorePath = join(cwd, ".gitignore");
155
+ const existing = existsSync(gitignorePath)
156
+ ? await readFile(gitignorePath, "utf8")
157
+ : "";
158
+ const lines = new Set(existing.split("\n").map((line) => line.trim()).filter(Boolean));
159
+ const additions = unignorePatternsForPath(path).filter((line) => !lines.has(line));
160
+ if (additions.length === 0)
161
+ return;
162
+ const block = [
163
+ existing.length > 0 && !existing.endsWith("\n") ? "\n" : "",
164
+ CLAUDEUP_HEADER,
165
+ `# ${getGitignoreRuleReason("track", path)}`,
166
+ ...additions,
167
+ "",
168
+ ].join("\n");
169
+ await appendFile(gitignorePath, block);
170
+ }
171
+ function unignorePatternsForPath(path) {
172
+ const normalized = path.replace(/^\/+/, "").replace(/\/+$/, "");
173
+ if (!normalized)
174
+ return [];
175
+ const parts = normalized.split("/");
176
+ const patterns = [];
177
+ for (let i = 0; i < parts.length - 1; i++) {
178
+ patterns.push(`!/${parts.slice(0, i + 1).join("/")}/`);
179
+ }
180
+ patterns.push(`!/${normalized}`);
181
+ return patterns;
182
+ }
134
183
  async function stripGitignoreLine(cwd, pattern) {
135
184
  const gitignorePath = join(cwd, ".gitignore");
136
185
  if (!existsSync(gitignorePath))
@@ -144,6 +193,17 @@ async function stripGitignoreLine(cwd, pattern) {
144
193
  await writeFile(gitignorePath, filtered);
145
194
  }
146
195
  }
196
+ async function pathIsIgnored(cwd, path) {
197
+ try {
198
+ await runGit(["check-ignore", "--quiet", "--", path], cwd);
199
+ return true;
200
+ }
201
+ catch (err) {
202
+ if (err instanceof GitError && err.code === 1)
203
+ return false;
204
+ return false;
205
+ }
206
+ }
147
207
  class GitError extends Error {
148
208
  code;
149
209
  stderr;
@@ -6,8 +6,9 @@
6
6
  * because patterns are already present).
7
7
  *
8
8
  * - `applyDestructiveFix`: index-mutating — runs `git rm --cached <path>`
9
- * for tracked-but-should-ignore, `git add <path>` (after stripping
10
- * gitignore lines) for ignored-but-should-track / untracked-and-should-track.
9
+ * for tracked-but-should-ignore, `git add <path>` (after making sure
10
+ * gitignore has any needed `!` exceptions) for ignored-but-should-track /
11
+ * untracked-and-should-track.
11
12
  * Each call handles ONE violation; the caller is responsible for confirming
12
13
  * each one with the user.
13
14
  *
@@ -18,6 +19,7 @@ import { readFile, writeFile, appendFile } from "node:fs/promises";
18
19
  import { existsSync } from "node:fs";
19
20
  import { join } from "node:path";
20
21
  import { spawn } from "node:child_process";
22
+ import { getGitignoreRuleReason } from "../data/gitignore-reasons.js";
21
23
  import type { Violation } from "../types/gitignore.js";
22
24
 
23
25
  const CLAUDEUP_HEADER = "# Added by claudeup";
@@ -62,7 +64,7 @@ export async function applySafeFixes(
62
64
  const block = [
63
65
  "",
64
66
  CLAUDEUP_HEADER,
65
- ...appended,
67
+ ...appended.flatMap(formatGitignoreEntry),
66
68
  "",
67
69
  ].join("\n");
68
70
 
@@ -83,19 +85,27 @@ export interface DestructiveFixResult {
83
85
  export async function applyDestructiveFix(
84
86
  cwd: string,
85
87
  violation: Violation,
88
+ rulePattern = violation.path,
86
89
  ): Promise<DestructiveFixResult> {
87
90
  switch (violation.kind) {
88
91
  case "tracked-but-should-ignore": {
89
92
  await runGit(["rm", "--cached", "--", violation.path], cwd);
90
93
  // Then make sure .gitignore has the entry so it stays out
91
- await appendIfMissing(cwd, violation.path);
94
+ await appendIfMissing(cwd, rulePattern);
92
95
  return {
93
96
  applied: true,
94
- message: `Removed ${violation.path} from index and added to .gitignore`,
97
+ message: `Removed ${violation.path} from index and added ${rulePattern} to .gitignore`,
95
98
  };
96
99
  }
97
100
  case "ignored-but-should-track": {
98
101
  await stripGitignoreLine(cwd, violation.path);
102
+ await appendTrackExceptionsIfNeeded(cwd, violation.path);
103
+ if (!existsSync(join(cwd, violation.path))) {
104
+ return {
105
+ applied: true,
106
+ message: `Made ${violation.path} trackable; file does not exist yet`,
107
+ };
108
+ }
99
109
  try {
100
110
  await runGit(["add", "--", violation.path], cwd);
101
111
  } catch (err) {
@@ -112,13 +122,14 @@ export async function applyDestructiveFix(
112
122
  };
113
123
  }
114
124
  case "untracked-and-should-track": {
125
+ await appendTrackExceptionsIfNeeded(cwd, violation.path);
115
126
  await runGit(["add", "--", violation.path], cwd);
116
127
  return { applied: true, message: `Staged ${violation.path}` };
117
128
  }
118
129
  case "missing-from-gitignore": {
119
130
  // Treat as a safe append for symmetry — caller can route through here too.
120
- await appendIfMissing(cwd, violation.path);
121
- return { applied: true, message: `Added ${violation.path} to .gitignore` };
131
+ await appendIfMissing(cwd, rulePattern);
132
+ return { applied: true, message: `Added ${rulePattern} to .gitignore` };
122
133
  }
123
134
  }
124
135
  }
@@ -158,11 +169,57 @@ async function appendIfMissing(cwd: string, pattern: string): Promise<void> {
158
169
  : "";
159
170
  const lines = existing.split("\n").map((l) => l.trim());
160
171
  if (lines.includes(pattern)) return;
161
- const block = (existing.length > 0 && !existing.endsWith("\n") ? "\n" : "") +
162
- `${CLAUDEUP_HEADER}\n${pattern}\n`;
172
+ // Only emit the "# Added by claudeup" header once when fixing several
173
+ // entries in one pass (F), the file already has the header from the first.
174
+ const needsHeader = !lines.includes(CLAUDEUP_HEADER);
175
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
176
+ const header = needsHeader ? `${CLAUDEUP_HEADER}\n` : "";
177
+ const block = `${prefix}${header}${formatGitignoreEntry(pattern).join("\n")}\n`;
163
178
  await appendFile(gitignorePath, block);
164
179
  }
165
180
 
181
+ function formatGitignoreEntry(pattern: string): string[] {
182
+ return [`# ${getGitignoreRuleReason("ignore", pattern)}`, pattern];
183
+ }
184
+
185
+ async function appendTrackExceptionsIfNeeded(
186
+ cwd: string,
187
+ path: string,
188
+ ): Promise<void> {
189
+ if (!(await pathIsIgnored(cwd, path))) return;
190
+
191
+ const gitignorePath = join(cwd, ".gitignore");
192
+ const existing = existsSync(gitignorePath)
193
+ ? await readFile(gitignorePath, "utf8")
194
+ : "";
195
+ const lines = new Set(
196
+ existing.split("\n").map((line) => line.trim()).filter(Boolean),
197
+ );
198
+ const additions = unignorePatternsForPath(path).filter((line) => !lines.has(line));
199
+ if (additions.length === 0) return;
200
+
201
+ const block = [
202
+ existing.length > 0 && !existing.endsWith("\n") ? "\n" : "",
203
+ CLAUDEUP_HEADER,
204
+ `# ${getGitignoreRuleReason("track", path)}`,
205
+ ...additions,
206
+ "",
207
+ ].join("\n");
208
+ await appendFile(gitignorePath, block);
209
+ }
210
+
211
+ function unignorePatternsForPath(path: string): string[] {
212
+ const normalized = path.replace(/^\/+/, "").replace(/\/+$/, "");
213
+ if (!normalized) return [];
214
+ const parts = normalized.split("/");
215
+ const patterns: string[] = [];
216
+ for (let i = 0; i < parts.length - 1; i++) {
217
+ patterns.push(`!/${parts.slice(0, i + 1).join("/")}/`);
218
+ }
219
+ patterns.push(`!/${normalized}`);
220
+ return patterns;
221
+ }
222
+
166
223
  async function stripGitignoreLine(cwd: string, pattern: string): Promise<void> {
167
224
  const gitignorePath = join(cwd, ".gitignore");
168
225
  if (!existsSync(gitignorePath)) return;
@@ -176,6 +233,16 @@ async function stripGitignoreLine(cwd: string, pattern: string): Promise<void> {
176
233
  }
177
234
  }
178
235
 
236
+ async function pathIsIgnored(cwd: string, path: string): Promise<boolean> {
237
+ try {
238
+ await runGit(["check-ignore", "--quiet", "--", path], cwd);
239
+ return true;
240
+ } catch (err) {
241
+ if (err instanceof GitError && err.code === 1) return false;
242
+ return false;
243
+ }
244
+ }
245
+
179
246
  class GitError extends Error {
180
247
  constructor(public code: number, public stderr: string) {
181
248
  super(`git exited ${code}: ${stderr}`);
@@ -4,7 +4,7 @@
4
4
  * Called from `prerunner/index.ts` (Step 0.9) on every claudeup invocation.
5
5
  * Resolves the manifest, runs detection, and prints a concise warning when
6
6
  * violations exist. Does NOT auto-apply fixes — the user must open the
7
- * Gitignore tab and confirm. This mirrors the plugin-version-mismatch flow
7
+ * Git State tab and confirm. This mirrors the plugin-version-mismatch flow
8
8
  * where prerun *warns* and the TUI provides the interactive fix dialog.
9
9
  */
10
10
  import { resolveGitignore } from "./gitignore-resolver.js";
@@ -34,10 +34,10 @@ export async function checkGitignore(cwd) {
34
34
  export function formatPrerunWarning(result) {
35
35
  if (result.violationCount === 0)
36
36
  return null;
37
- return `⚠ ${result.violationCount} gitignore issue(s) detected. Run: claudeup → Gitignore tab to review and fix`;
37
+ return `⚠ ${result.violationCount} git state issue(s) detected. Run: claudeup → Git State tab to review and fix`;
38
38
  }
39
39
  /**
40
- * Verbose multi-line summary, used by the Gitignore tab when the user wants
40
+ * Verbose multi-line summary, used by the Git State tab when the user wants
41
41
  * to know what's wrong before deciding what to do.
42
42
  */
43
43
  export async function describeViolations(cwd, violations) {
@@ -4,7 +4,7 @@
4
4
  * Called from `prerunner/index.ts` (Step 0.9) on every claudeup invocation.
5
5
  * Resolves the manifest, runs detection, and prints a concise warning when
6
6
  * violations exist. Does NOT auto-apply fixes — the user must open the
7
- * Gitignore tab and confirm. This mirrors the plugin-version-mismatch flow
7
+ * Git State tab and confirm. This mirrors the plugin-version-mismatch flow
8
8
  * where prerun *warns* and the TUI provides the interactive fix dialog.
9
9
  */
10
10
 
@@ -43,11 +43,11 @@ export async function checkGitignore(cwd: string): Promise<PrerunResult> {
43
43
  */
44
44
  export function formatPrerunWarning(result: PrerunResult): string | null {
45
45
  if (result.violationCount === 0) return null;
46
- return `⚠ ${result.violationCount} gitignore issue(s) detected. Run: claudeup → Gitignore tab to review and fix`;
46
+ return `⚠ ${result.violationCount} git state issue(s) detected. Run: claudeup → Git State tab to review and fix`;
47
47
  }
48
48
 
49
49
  /**
50
- * Verbose multi-line summary, used by the Gitignore tab when the user wants
50
+ * Verbose multi-line summary, used by the Git State tab when the user wants
51
51
  * to know what's wrong before deciding what to do.
52
52
  */
53
53
  export async function describeViolations(
@@ -22,8 +22,26 @@ export async function loadGitignoreState(cwd) {
22
22
  export async function applyAllSafeFixes(cwd, violations) {
23
23
  return coreApplySafeFixes(cwd, violations);
24
24
  }
25
- export async function applyDestructiveFix(cwd, violation) {
26
- return coreApplyDestructiveFix(cwd, violation);
25
+ export async function applyDestructiveFix(cwd, violation, rulePattern) {
26
+ return coreApplyDestructiveFix(cwd, violation, rulePattern);
27
+ }
28
+ export async function ensureProjectManifestForEdit(cwd, resolved) {
29
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
30
+ if (existsSync(projectManifestPath)) {
31
+ const existing = await readFile(projectManifestPath, "utf8");
32
+ if (existing.trim().length > 0)
33
+ return projectManifestPath;
34
+ }
35
+ const manifest = { ignore: [], track: [] };
36
+ for (const rule of resolved.rules) {
37
+ if (rule.action === "ignore")
38
+ manifest.ignore.push(rule.pattern);
39
+ else
40
+ manifest.track.push(rule.pattern);
41
+ }
42
+ await mkdir(dirname(projectManifestPath), { recursive: true });
43
+ await writeFile(projectManifestPath, JSON.stringify(manifest, null, 2) + "\n");
44
+ return projectManifestPath;
27
45
  }
28
46
  /**
29
47
  * Merge a built-in template into the project manifest at .claude/gitignore.json.
@@ -49,8 +49,30 @@ export async function applyAllSafeFixes(
49
49
  export async function applyDestructiveFix(
50
50
  cwd: string,
51
51
  violation: Violation,
52
+ rulePattern?: string,
52
53
  ): Promise<DestructiveFixResult> {
53
- return coreApplyDestructiveFix(cwd, violation);
54
+ return coreApplyDestructiveFix(cwd, violation, rulePattern);
55
+ }
56
+
57
+ export async function ensureProjectManifestForEdit(
58
+ cwd: string,
59
+ resolved: ResolvedManifest,
60
+ ): Promise<string> {
61
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
62
+ if (existsSync(projectManifestPath)) {
63
+ const existing = await readFile(projectManifestPath, "utf8");
64
+ if (existing.trim().length > 0) return projectManifestPath;
65
+ }
66
+
67
+ const manifest: GitignoreManifest = { ignore: [], track: [] };
68
+ for (const rule of resolved.rules) {
69
+ if (rule.action === "ignore") manifest.ignore.push(rule.pattern);
70
+ else manifest.track.push(rule.pattern);
71
+ }
72
+
73
+ await mkdir(dirname(projectManifestPath), { recursive: true });
74
+ await writeFile(projectManifestPath, JSON.stringify(manifest, null, 2) + "\n");
75
+ return projectManifestPath;
54
76
  }
55
77
 
56
78
  export interface ApplyTemplateResult {