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.
- package/package.json +1 -1
- package/src/__tests__/alias-parser.test.ts +317 -0
- package/src/__tests__/alias-shell-writer.test.ts +661 -0
- package/src/__tests__/alias-store.test.ts +86 -0
- package/src/__tests__/gitignore-fixer.test.ts +64 -1
- package/src/__tests__/gitignore-prerun.test.ts +2 -2
- package/src/__tests__/gitignore-service.test.ts +42 -0
- package/src/__tests__/marketplaces.test.ts +40 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
- package/src/__tests__/useGitignoreModal.test.ts +2 -2
- package/src/data/alias-flags.js +196 -0
- package/src/data/alias-flags.ts +291 -0
- package/src/data/gitignore-reasons.js +97 -0
- package/src/data/gitignore-reasons.ts +103 -0
- package/src/data/marketplaces.js +19 -1
- package/src/data/marketplaces.ts +17 -1
- package/src/services/alias-settings.js +51 -0
- package/src/services/alias-settings.ts +63 -0
- package/src/services/alias-shell-writer.js +764 -0
- package/src/services/alias-shell-writer.ts +873 -0
- package/src/services/alias-store.js +77 -0
- package/src/services/alias-store.ts +112 -0
- package/src/services/gitignore-fixer.js +70 -10
- package/src/services/gitignore-fixer.ts +76 -9
- package/src/services/gitignore-prerun.js +3 -3
- package/src/services/gitignore-prerun.ts +3 -3
- package/src/services/gitignore-service.js +20 -2
- package/src/services/gitignore-service.ts +23 -1
- package/src/services/marketplace-fetcher.js +96 -0
- package/src/services/marketplace-fetcher.ts +137 -0
- package/src/services/plugin-manager.js +6 -59
- package/src/services/plugin-manager.ts +16 -91
- package/src/services/skillsmp-client.js +29 -9
- package/src/services/skillsmp-client.ts +38 -8
- package/src/types/gitignore.ts +1 -1
- package/src/types/index.ts +1 -0
- package/src/ui/App.js +10 -4
- package/src/ui/App.tsx +9 -3
- package/src/ui/components/TabBar.js +2 -1
- package/src/ui/components/TabBar.tsx +2 -1
- package/src/ui/components/layout/FooterHints.js +29 -0
- package/src/ui/components/layout/FooterHints.tsx +52 -0
- package/src/ui/components/layout/ScreenLayout.js +2 -1
- package/src/ui/components/layout/ScreenLayout.tsx +12 -3
- package/src/ui/components/layout/index.js +1 -0
- package/src/ui/components/layout/index.ts +5 -0
- package/src/ui/components/modals/SelectModal.js +8 -1
- package/src/ui/components/modals/SelectModal.tsx +12 -1
- package/src/ui/hooks/useGitignoreModal.js +7 -8
- package/src/ui/hooks/useGitignoreModal.ts +8 -9
- package/src/ui/renderers/gitignoreRenderers.js +36 -23
- package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
- package/src/ui/screens/AliasScreen.js +1008 -0
- package/src/ui/screens/AliasScreen.tsx +1402 -0
- package/src/ui/screens/CliToolsScreen.js +6 -1
- package/src/ui/screens/CliToolsScreen.tsx +6 -1
- package/src/ui/screens/EnvVarsScreen.js +6 -1
- package/src/ui/screens/EnvVarsScreen.tsx +6 -1
- package/src/ui/screens/GitignoreScreen.js +189 -88
- package/src/ui/screens/GitignoreScreen.tsx +312 -132
- package/src/ui/screens/McpRegistryScreen.js +13 -2
- package/src/ui/screens/McpRegistryScreen.tsx +13 -2
- package/src/ui/screens/McpScreen.js +6 -1
- package/src/ui/screens/McpScreen.tsx +6 -1
- package/src/ui/screens/ModelSelectorScreen.js +8 -2
- package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
- package/src/ui/screens/PluginsScreen.js +13 -2
- package/src/ui/screens/PluginsScreen.tsx +13 -2
- package/src/ui/screens/ProfilesScreen.js +8 -1
- package/src/ui/screens/ProfilesScreen.tsx +8 -1
- package/src/ui/screens/SkillsScreen.js +21 -4
- package/src/ui/screens/SkillsScreen.tsx +39 -5
- package/src/ui/screens/StatusLineScreen.js +7 -1
- package/src/ui/screens/StatusLineScreen.tsx +7 -1
- package/src/ui/screens/index.js +1 -0
- package/src/ui/screens/index.ts +1 -0
- 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
|
|
10
|
-
* gitignore
|
|
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,
|
|
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,
|
|
92
|
-
return { applied: true, message: `Added ${
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
10
|
-
* gitignore
|
|
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,
|
|
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,
|
|
121
|
-
return { applied: true, message: `Added ${
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
*
|
|
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}
|
|
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
|
|
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
|
-
*
|
|
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}
|
|
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
|
|
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 {
|