genshin-mods 4.4.0 → 4.5.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 (37) hide show
  1. package/README.md +43 -18
  2. package/package.json +2 -2
  3. package/src/models/CommandRefusal.ts +3 -0
  4. package/src/services/band/getCommissionSummary.ts +2 -1
  5. package/src/services/commission/foldTaskCall.ts +2 -1
  6. package/src/services/delegation/checkIsLookupCommand.ts +2 -8
  7. package/src/services/delegation/stripLeadingSegments.ts +8 -0
  8. package/src/services/diskScan/getDenyReason.ts +7 -0
  9. package/src/services/diskScan/getDiskScanRefusal.ts +2 -78
  10. package/src/services/diskScan/getScanCommandRefusal.ts +8 -0
  11. package/src/services/diskScan/getScanRefusal.ts +21 -0
  12. package/src/services/diskScan/isRecursiveFlag.ts +4 -0
  13. package/src/services/diskScan/isRecursiveLongFlag.ts +3 -0
  14. package/src/services/diskScan/isRoot.ts +8 -0
  15. package/src/services/diskScan/normalizePath.ts +7 -0
  16. package/src/services/diskScan/readGrepArguments.ts +25 -0
  17. package/src/services/diskScan/registerDiskScan.ts +2 -1
  18. package/src/services/registerLifecycle.ts +4 -2
  19. package/src/services/shell/getDollarSubstitutionBodies.ts +25 -0
  20. package/src/services/shell/getShellRefusal.ts +4 -38
  21. package/src/services/shell/tokenize.ts +8 -0
  22. package/src/services/ward/getAddRefusal.ts +14 -0
  23. package/src/services/ward/getCheckoutPeer.ts +1 -3
  24. package/src/services/ward/getCheckoutRefusal.ts +6 -0
  25. package/src/services/ward/getCleanRefusal.ts +8 -0
  26. package/src/services/ward/getComparablePath.ts +2 -0
  27. package/src/services/ward/getGitRefusal.ts +32 -0
  28. package/src/services/ward/getResetRefusal.ts +19 -0
  29. package/src/services/ward/getRestoreRefusal.ts +10 -0
  30. package/src/services/ward/getStashRefusal.ts +2 -0
  31. package/src/services/ward/getSubcommand.ts +16 -0
  32. package/src/services/ward/getWholeIndexDenyReason.ts +9 -0
  33. package/src/services/ward/getWholeIndexRefusal.ts +1 -121
  34. package/src/services/ward/isFlag.ts +1 -0
  35. package/src/services/ward/isWholeCheckout.ts +5 -0
  36. package/src/services/ward/registerWholeIndex.ts +2 -1
  37. package/src/services/ward/splitArguments.ts +14 -0
package/README.md CHANGED
@@ -5,7 +5,19 @@
5
5
  [![NPM downloads][badge-npm-downloads]][url-npm]
6
6
  [![NPM Unpacked Size (with version)][badge-npm-unpacked-size]][url-npm]
7
7
 
8
- A Claude Code plugin of mods: five drawn as one band above the prompt, in the session character's colour (the next steps one press away, what the session is spending with a one-press handoff, recording mode, a goal's task list and clock, and a question before editing a file another session just changed), a delegation guard that refuses any subagent not on haiku and nudges a run of lookups toward a haiku agent, a disk-scan guard that refuses a Bash scan from a root or a home folder, and a whole-index guard that refuses a git command over the whole index while another session shares the checkout.
8
+ A Claude Code plugin of mods. Five mods draw one band above the prompt, in the session character's colour:
9
+
10
+ - waypoints: the next steps, one press away
11
+ - what the session is spending, with a one-press handoff
12
+ - recording mode
13
+ - a goal's task list and clock
14
+ - a question before an edit to a file that another session just changed
15
+
16
+ Three guards also run:
17
+
18
+ - a delegation guard that refuses any subagent not on haiku, and nudges a run of lookups toward a haiku agent
19
+ - a disk-scan guard that refuses a Bash scan from a root or a home folder
20
+ - a whole-index guard that refuses a git command over the whole index while another session shares the checkout
9
21
 
10
22
  Design notes live in [the specs](https://github.com/Esposter/Esposter/tree/main/openspec/specs/infra/claude-interface/genshin-mods).
11
23
 
@@ -19,22 +31,29 @@ Design notes live in [the specs](https://github.com/Esposter/Esposter/tree/main/
19
31
 
20
32
  ## <a name="getting-started">🚀 Getting Started</a>
21
33
 
22
- This repository is a Claude Code plugin marketplace named `esposter`:
34
+ This repository is a Claude Code plugin marketplace named `esposter`. Add it, then install the plugin:
23
35
 
24
36
  ```bash
25
37
  claude plugin marketplace add Esposter/Esposter
26
38
  claude plugin install genshin-mods@esposter
27
39
  ```
28
40
 
29
- `/sins` scores with [TypeSafe](https://typesafe.ai), so it needs a key: the plugin's `typesafe_key` option, the same option [`genshin-persona`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-persona) has, or the `TYPESAFE_API_KEY` variable when the option is empty:
41
+ `/sins` scores with [TypeSafe](https://typesafe.ai). It needs a key. Set the key with one of these:
42
+
43
+ - the plugin's `typesafe_key` option, the same option as [`genshin-persona`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-persona)
44
+ - the `TYPESAFE_API_KEY` variable, when the option is empty
45
+
46
+ Set the option at install:
30
47
 
31
48
  ```bash
32
49
  claude plugin install genshin-mods@esposter --config typesafe_key=<key>
33
50
  ```
34
51
 
35
- A clone of this repository needs neither command: `.agents/settings.json` enables the plugin at project scope once you trust the repository. With [`genshin-persona`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-persona) installed beside it, the band takes the session character's colour; without it, the game's interface gold.
52
+ A clone of this repository needs neither command. Once you trust the repository, `.agents/settings.json` enables the plugin at project scope.
53
+
54
+ With [`genshin-persona`](https://github.com/Esposter/Esposter/tree/main/packages/genshin-persona) installed too, the band takes the session character's colour. Without it, the band uses the game's interface gold.
36
55
 
37
- Developing the plugin from a checkout loads the directory itself, with the installed copy disabled so only one of them answers:
56
+ To develop the plugin from a checkout, run these commands in order. They disable the installed copy, so only one copy answers:
38
57
 
39
58
  ```bash
40
59
  claude plugin disable genshin-mods@esposter
@@ -43,19 +62,25 @@ claude --plugin-dir packages/genshin-mods
43
62
 
44
63
  ## <a name="commands">⌨️ Commands</a>
45
64
 
46
- Each mod is switched by its own slash command; bare, it flips, and the choice is kept across sessions:
47
-
48
- | Command | What it does |
49
- | :------------------------ | :------------------------------------------------------------------------------------------------------------------------------ |
50
- | `/commission [on \| off]` | Shows the goal meter while a turn works through a task list |
51
- | `/pulse [on \| off]` | Keeps the machine watcher running, or stops |
52
- | `/resin [on \| off]` | Shows the cache, context, limits and cost row and its warning toast |
53
- | `/sins [on \| off]` | Scores each prompt's seven sins with TypeSafe, drawn as bars in a pane; needs the `typesafe_key` option or `TYPESAFE_API_KEY` |
54
- | `/veil [on \| off]` | Turns recording mode on or off; off by default |
55
- | `/ward [on \| off]` | Asks before an edit to a file another session just changed, and refuses a whole-index git command while one shares the checkout |
56
- | `/waypoints [on \| off]` | Suggests the next steps after each answered turn, or stops |
57
-
58
- With the band focused (a click or `ctrl+x tab`), `w`, `c` and `h` warm, compact and hand off, `1` to `3` send a waypoint and `g` opens or closes the whole task list.
65
+ Each mod has its own slash command. With no argument, the command flips the mod. The choice is kept across sessions:
66
+
67
+ | Command | What it does |
68
+ | :------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
69
+ | `/commission [on \| off]` | Shows the goal meter while a turn works through a task list |
70
+ | `/pulse [on \| off]` | Keeps the machine watcher running, or stops |
71
+ | `/resin [on \| off]` | Shows the cache, context, limits and cost row and its warning toast |
72
+ | `/sins [on \| off]` | Scores each prompt's seven sins with TypeSafe, drawn as bars in a pane. Needs the `typesafe_key` option or `TYPESAFE_API_KEY` |
73
+ | `/veil [on \| off]` | Turns recording mode on or off; off by default |
74
+ | `/ward [on \| off]` | Asks before an edit to a file another session just changed, and refuses a whole-index git command while another session shares the checkout |
75
+ | `/waypoints [on \| off]` | Suggests the next steps after each answered turn, or stops |
76
+
77
+ Focus the band with a click or `ctrl+x tab`. Then press:
78
+
79
+ - `w`: warm
80
+ - `c`: compact
81
+ - `h`: hand off
82
+ - `1` to `3`: send a waypoint
83
+ - `g`: open or close the whole task list
59
84
 
60
85
  ## <a name="license">⚖️ License</a>
61
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genshin-mods",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "A Claude Code plugin of mods: five under one band above the prompt (the next steps, the cache and usage with a one-click handoff, recording mode, a goal's task list and a question before editing a file another session just changed) a delegation guard that nudges a run of lookups toward a haiku agent, a disk-scan guard that refuses a Bash scan from a root or a home folder, and a whole-index guard that refuses a git command over the whole index while another session shares the checkout.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -36,5 +36,5 @@
36
36
  "test": "vitest",
37
37
  "validate": "claude plugin validate ."
38
38
  },
39
- "gitHead": "e1a416c4b91da69cc2aa259b50a4d656f204d272"
39
+ "gitHead": "e30c001f3b4b15693e75fb53092d62a97ade4d2b"
40
40
  }
@@ -0,0 +1,3 @@
1
+ // The refusal for a command's name, the arguments after it and the folder it runs in from the session's ("" for the
2
+ // Session's own), undefined when the command is not one the guard reads
3
+ export type CommandRefusal<T = string> = (name: string, args: string[], directory: string) => T | undefined;
@@ -1,12 +1,13 @@
1
1
  import type { Commission } from "../../../types";
2
2
 
3
+ import { TaskStatus } from "../../models/TaskStatus";
3
4
  import { MINUTE_FORMATTER } from "../constants";
4
5
 
5
6
  const PERCENT_FORMATTER = new Intl.NumberFormat("en", { style: "percent" });
6
7
 
7
8
  // `goal · 3 of 4 · 75% · 12m`, the clock counting whole minutes from the commission's opening
8
9
  export const getCommissionSummary = ({ goal, openedAt, tasks }: Commission, now: number): string => {
9
- const done = tasks.filter(({ status }) => status === "completed").length;
10
+ const done = tasks.filter(({ status }) => status === TaskStatus.Completed).length;
10
11
  const elapsedMinutes = Math.floor(
11
12
  Temporal.Duration.from({ milliseconds: Math.max(0, now - openedAt) }).total("minutes"),
12
13
  );
@@ -1,6 +1,7 @@
1
1
  import type { CommissionTask } from "../../../types";
2
2
  import type { TaskCall } from "../../models/TaskCall";
3
3
 
4
+ import { TaskStatus } from "../../models/TaskStatus";
4
5
  import { TaskTool } from "../../models/TaskTool";
5
6
 
6
7
  const DELETED_STATUS = "deleted";
@@ -8,7 +9,7 @@ const DELETED_STATUS = "deleted";
8
9
  export const foldTaskCall = (tasks: CommissionTask[], call: TaskCall): CommissionTask[] => {
9
10
  switch (call.tool) {
10
11
  case TaskTool.TaskCreate:
11
- return [...tasks, { id: call.id, status: "pending", subject: call.subject }];
12
+ return [...tasks, { id: call.id, status: TaskStatus.Pending, subject: call.subject }];
12
13
  case TaskTool.TaskUpdate: {
13
14
  const { id, status, subject } = call;
14
15
  if (status === DELETED_STATUS) return tasks.filter((task) => task.id !== id);
@@ -1,11 +1,5 @@
1
- import { LEADING_SEGMENT_REGEX, LOOKUP_COMMAND_REGEX, WRITING_LOOKUP_REGEX } from "../constants";
2
-
3
- // A session chains `export` and `cd` ahead of its real command, so each leading segment is stripped before the
4
- // Command's first word is read
5
- const stripLeadingSegments = (command: string): string => {
6
- const rest = command.replace(LEADING_SEGMENT_REGEX, "");
7
- return rest === command ? command : stripLeadingSegments(rest);
8
- };
1
+ import { LOOKUP_COMMAND_REGEX, WRITING_LOOKUP_REGEX } from "../constants";
2
+ import { stripLeadingSegments } from "./stripLeadingSegments";
9
3
 
10
4
  export const checkIsLookupCommand = (command: string): boolean => {
11
5
  const rest = stripLeadingSegments(command);
@@ -0,0 +1,8 @@
1
+ import { LEADING_SEGMENT_REGEX } from "../constants";
2
+
3
+ // A session chains `export` and `cd` ahead of its real command, so each leading segment is stripped before the
4
+ // Command's first word is read
5
+ export const stripLeadingSegments = (command: string): string => {
6
+ const rest = command.replace(LEADING_SEGMENT_REGEX, "");
7
+ return rest === command ? command : stripLeadingSegments(rest);
8
+ };
@@ -0,0 +1,7 @@
1
+ import { KNOWN_HOME_LINES } from "./getDiskScanRefusal";
2
+
3
+ export const getDenyReason = (scanName: string): string =>
4
+ [
5
+ `Refused: ${scanName} scans from a root or a home folder, which reads the whole disk for a file whose home is known, and costs minutes of CPU and disk for every agent running. Look where the file lives instead:`,
6
+ ...KNOWN_HOME_LINES,
7
+ ].join("\n");
@@ -1,75 +1,5 @@
1
- import type { CommandRefusal } from "../shell/getShellRefusal";
2
-
3
1
  import { getShellRefusal } from "../shell/getShellRefusal";
4
-
5
- // A root in any spelling is a folder whose scan reads the whole disk: the drive's folders, the users' folders and the
6
- // Home folder by name, once Windows drive letters and backslashes are read as Git Bash's `/c/...` form
7
- const ROOT_REGEX =
8
- /^(?:\/[a-z]?|\/(?:c\/)?Users(?:\/[^/]+)?|\/home(?:\/[^/]+)?|~|\$HOME|\$\{HOME\}|\$USERPROFILE|%USERPROFILE%)$/u;
9
-
10
- const SCAN_COMMANDS: ReadonlySet<string> = new Set(["du", "find", "grep", "ls"]);
11
-
12
- // Backslashes become slashes, a drive letter becomes its `/c` folder, and a trailing glob and slash go, so "C:\", "/c/",
13
- // "C:/" and "/c/*" all name "/c": a folder's glob expands to its entries, whose scan reads the whole folder
14
- const normalizePath = (token: string) => {
15
- const slashed = token.replaceAll("\\", "/");
16
- const path = /^[A-Za-z]:/u.test(slashed) ? `/${slashed.charAt(0).toLowerCase()}${slashed.slice(2)}` : slashed;
17
- return path.replace(/(?<=\/)\*$/u, "").replace(/(?<=.)\/+$/u, "");
18
- };
19
-
20
- const isRoot = (token: string) => ROOT_REGEX.test(normalizePath(token));
21
-
22
- // The long form `--recursive`, which getopt also takes as any prefix of it from `--rec` on, since `--re` names two options
23
- const isRecursiveLongFlag = (token: string) => token.startsWith("--rec") && "recursive".startsWith(token.slice(2));
24
-
25
- // A flag group holding the scan's recursive letter, or the long form: grep takes r or R (`-R`, `-rn`, `-lR`), and ls takes
26
- // The capital R alone, since its lowercase r sorts in reverse
27
- const GREP_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*[rR]/u;
28
- const LS_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*R/u;
29
- const isRecursiveFlag = (token: string, shortFlagRegex: RegExp) =>
30
- shortFlagRegex.test(token) || isRecursiveLongFlag(token);
31
-
32
- // A flag that gives grep its pattern as the next argument, or as the rest of the same one
33
- const SEPARATE_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef]$|^--(?:regexp|file)$/u;
34
- const ATTACHED_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef].|^--(?:regexp|file)=/u;
35
-
36
- // Grep's flags and search paths: a pattern flag's operand is its pattern and never a flag or a path, every argument
37
- // After a `--` is a positional one, and with no pattern flag the first positional argument is the pattern
38
- const readGrepArguments = (args: string[]): { flags: string[]; paths: string[] } => {
39
- const flags: string[] = [];
40
- const positionals: string[] = [];
41
- let hasPatternFlag = false;
42
- let isPatternOperand = false;
43
- let isOptionEnd = false;
44
- for (const arg of args)
45
- if (isPatternOperand) isPatternOperand = false;
46
- else if (isOptionEnd || !arg.startsWith("-")) positionals.push(arg);
47
- else if (arg === "--") isOptionEnd = true;
48
- else {
49
- flags.push(arg);
50
- if (SEPARATE_PATTERN_FLAG_REGEX.test(arg)) {
51
- hasPatternFlag = true;
52
- isPatternOperand = true;
53
- } else if (ATTACHED_PATTERN_FLAG_REGEX.test(arg)) hasPatternFlag = true;
54
- }
55
- return { flags, paths: hasPatternFlag ? positionals : positionals.slice(1) };
56
- };
57
-
58
- // The scan's name when its arguments start it from a root, undefined when they do not
59
- const getScanRefusal = (name: string, args: string[]): string | undefined => {
60
- if (name === "find" || name === "du") return args.some((arg) => isRoot(arg)) ? name : undefined;
61
- if (name === "ls")
62
- return args.some((arg) => isRecursiveFlag(arg, LS_RECURSIVE_FLAG_REGEX)) && args.some((arg) => isRoot(arg))
63
- ? name
64
- : undefined;
65
- const { flags, paths } = readGrepArguments(args);
66
- return flags.some((flag) => isRecursiveFlag(flag, GREP_RECURSIVE_FLAG_REGEX)) && paths.some((path) => isRoot(path))
67
- ? name
68
- : undefined;
69
- };
70
-
71
- const getScanCommandRefusal: CommandRefusal = (name, args) =>
72
- SCAN_COMMANDS.has(name) ? getScanRefusal(name, args) : undefined;
2
+ import { getScanCommandRefusal } from "./getScanCommandRefusal";
73
3
 
74
4
  // The scan a Bash command runs from a root or a home folder, named by its command, undefined for every other command
75
5
  export const getDiskScanRefusal = (command: string): string | undefined =>
@@ -77,7 +7,7 @@ export const getDiskScanRefusal = (command: string): string | undefined =>
77
7
 
78
8
  // The known homes the refusal names. The mod ships without @esposter/shared, so the product's name is spelled here
79
9
  // As the folder it is on the person's disk
80
- const KNOWN_HOME_LINES: string[] = [
10
+ export const KNOWN_HOME_LINES: string[] = [
81
11
  "- the repo, searched with `rg` or `git grep` (both skip node_modules);",
82
12
  // oxlint-disable-next-line naming/no-site-name-literal -- A path on the person's disk, which a package shipped without @esposter/shared cannot derive from SITE_NAME
83
13
  "- `~/Esposter/genshin-parity`, for game data and captures;",
@@ -85,9 +15,3 @@ const KNOWN_HOME_LINES: string[] = [
85
15
  "- a pinned tool (FFmpeg, yt-dlp) through `resolvePinnedTool`;",
86
16
  "- a package through `require.resolve` from the package that depends on it.",
87
17
  ];
88
-
89
- export const getDenyReason = (scanName: string): string =>
90
- [
91
- `Refused: ${scanName} scans from a root or a home folder, which reads the whole disk for a file whose home is known, and costs minutes of CPU and disk for every agent running. Look where the file lives instead:`,
92
- ...KNOWN_HOME_LINES,
93
- ].join("\n");
@@ -0,0 +1,8 @@
1
+ import type { CommandRefusal } from "../../models/CommandRefusal";
2
+
3
+ import { getScanRefusal } from "./getScanRefusal";
4
+
5
+ const SCAN_COMMANDS: ReadonlySet<string> = new Set(["du", "find", "grep", "ls"]);
6
+
7
+ export const getScanCommandRefusal: CommandRefusal = (name, args) =>
8
+ SCAN_COMMANDS.has(name) ? getScanRefusal(name, args) : undefined;
@@ -0,0 +1,21 @@
1
+ import { isRecursiveFlag } from "./isRecursiveFlag";
2
+ import { isRoot } from "./isRoot";
3
+ import { readGrepArguments } from "./readGrepArguments";
4
+
5
+ // A flag group holding the scan's recursive letter, or the long form: grep takes r or R (`-R`, `-rn`, `-lR`), and ls takes
6
+ // The capital R alone, since its lowercase r sorts in reverse
7
+ const GREP_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*[rR]/u;
8
+ const LS_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*R/u;
9
+
10
+ // The scan's name when its arguments start it from a root, undefined when they do not
11
+ export const getScanRefusal = (name: string, args: string[]): string | undefined => {
12
+ if (name === "find" || name === "du") return args.some((arg) => isRoot(arg)) ? name : undefined;
13
+ if (name === "ls")
14
+ return args.some((arg) => isRecursiveFlag(arg, LS_RECURSIVE_FLAG_REGEX)) && args.some((arg) => isRoot(arg))
15
+ ? name
16
+ : undefined;
17
+ const { flags, paths } = readGrepArguments(args);
18
+ return flags.some((flag) => isRecursiveFlag(flag, GREP_RECURSIVE_FLAG_REGEX)) && paths.some((path) => isRoot(path))
19
+ ? name
20
+ : undefined;
21
+ };
@@ -0,0 +1,4 @@
1
+ import { isRecursiveLongFlag } from "./isRecursiveLongFlag";
2
+
3
+ export const isRecursiveFlag = (token: string, shortFlagRegex: RegExp) =>
4
+ shortFlagRegex.test(token) || isRecursiveLongFlag(token);
@@ -0,0 +1,3 @@
1
+ // The long form `--recursive`, which getopt also takes as any prefix of it from `--rec` on, since `--re` names two options
2
+ export const isRecursiveLongFlag = (token: string) =>
3
+ token.startsWith("--rec") && "recursive".startsWith(token.slice(2));
@@ -0,0 +1,8 @@
1
+ import { normalizePath } from "./normalizePath";
2
+
3
+ // A root in any spelling is a folder whose scan reads the whole disk: the drive's folders, the users' folders and the
4
+ // Home folder by name, once Windows drive letters and backslashes are read as Git Bash's `/c/...` form
5
+ const ROOT_REGEX =
6
+ /^(?:\/[a-z]?|\/(?:c\/)?Users(?:\/[^/]+)?|\/home(?:\/[^/]+)?|~|\$HOME|\$\{HOME\}|\$USERPROFILE|%USERPROFILE%)$/u;
7
+
8
+ export const isRoot = (token: string) => ROOT_REGEX.test(normalizePath(token));
@@ -0,0 +1,7 @@
1
+ // Backslashes become slashes, a drive letter becomes its `/c` folder, and a trailing glob and slash go, so "C:\", "/c/",
2
+ // "C:/" and "/c/*" all name "/c": a folder's glob expands to its entries, whose scan reads the whole folder
3
+ export const normalizePath = (token: string) => {
4
+ const slashed = token.replaceAll("\\", "/");
5
+ const path = /^[A-Za-z]:/u.test(slashed) ? `/${slashed.charAt(0).toLowerCase()}${slashed.slice(2)}` : slashed;
6
+ return path.replace(/(?<=\/)\*$/u, "").replace(/(?<=.)\/+$/u, "");
7
+ };
@@ -0,0 +1,25 @@
1
+ // A flag that gives grep its pattern as the next argument, or as the rest of the same one
2
+ const SEPARATE_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef]$|^--(?:regexp|file)$/u;
3
+ const ATTACHED_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef].|^--(?:regexp|file)=/u;
4
+
5
+ // Grep's flags and search paths: a pattern flag's operand is its pattern and never a flag or a path, every argument
6
+ // After a `--` is a positional one, and with no pattern flag the first positional argument is the pattern
7
+ export const readGrepArguments = (args: string[]): { flags: string[]; paths: string[] } => {
8
+ const flags: string[] = [];
9
+ const positionals: string[] = [];
10
+ let hasPatternFlag = false;
11
+ let isPatternOperand = false;
12
+ let isOptionEnd = false;
13
+ for (const arg of args)
14
+ if (isPatternOperand) isPatternOperand = false;
15
+ else if (isOptionEnd || !arg.startsWith("-")) positionals.push(arg);
16
+ else if (arg === "--") isOptionEnd = true;
17
+ else {
18
+ flags.push(arg);
19
+ if (SEPARATE_PATTERN_FLAG_REGEX.test(arg)) {
20
+ hasPatternFlag = true;
21
+ isPatternOperand = true;
22
+ } else if (ATTACHED_PATTERN_FLAG_REGEX.test(arg)) hasPatternFlag = true;
23
+ }
24
+ return { flags, paths: hasPatternFlag ? positionals : positionals.slice(1) };
25
+ };
@@ -1,6 +1,7 @@
1
1
  import type { EngineInterface, On } from "claude-code";
2
2
 
3
- import { getDenyReason, getDiskScanRefusal } from "./getDiskScanRefusal";
3
+ import { getDenyReason } from "./getDenyReason";
4
+ import { getDiskScanRefusal } from "./getDiskScanRefusal";
4
5
 
5
6
  // Any failure lets the command through, as the ward's and the delegation guard's do: a guard that blocked on its own
6
7
  // Failure would block every command after it
@@ -4,7 +4,9 @@ import { atom, read, update } from "claude-code";
4
4
 
5
5
  import type { EnabledMods } from "../../types";
6
6
 
7
+ import { ModName } from "../models/ModName";
7
8
  import { SinStatusKind } from "../models/SinStatusKind";
9
+ import { TaskStatus } from "../models/TaskStatus";
8
10
  import {
9
11
  CACHE_WARNING_MS,
10
12
  MACHINE_WATCHER_RETRY_MILLISECONDS,
@@ -175,7 +177,7 @@ export const registerLifecycle = (on: On, typeSafeKey: string): void => {
175
177
  const current = await read($, enabledModsAtom);
176
178
  const isNext = argument ? argument === "on" : !current[name];
177
179
  // Sins scores only with a TypeSafe key, so turning it on without one is refused and its value is left as it was
178
- if (name === "sins" && isNext) {
180
+ if (name === ModName.Sins && isNext) {
179
181
  const key = await readTypeSafeKey($, typeSafeKey);
180
182
  if (!key)
181
183
  return { text: "Sins needs a TypeSafe key: set the typesafe_key option, or TYPESAFE_API_KEY, first." };
@@ -221,7 +223,7 @@ export const registerLifecycle = (on: On, typeSafeKey: string): void => {
221
223
  await update($, lookupStreakAtom, () => InitialState.lookupStreak);
222
224
  await update($, lastPromptAtom, () => e.text);
223
225
  const { tasks } = await read($, commissionAtom);
224
- if (tasks.length > 0 && tasks.every(({ status }) => status === "completed")) await closeCommission($);
226
+ if (tasks.length > 0 && tasks.every(({ status }) => status === TaskStatus.Completed)) await closeCommission($);
225
227
  $.clock.after(0, () => {
226
228
  runDetached(() => scoreSins($, e.text, typeSafeKey));
227
229
  });
@@ -0,0 +1,25 @@
1
+ // The bodies of a command's `$(…)` substitutions, which run even inside double quotes: each runs to the parenthesis
2
+ // Closing it, past any a quote or a backslash holds, so a body such as `find / -name '(x)'` is read whole. A
3
+ // Substitution inside a body is the body's own, read when the body is
4
+ export const getDollarSubstitutionBodies = (command: string): string[] => {
5
+ const bodies: string[] = [];
6
+ let start = command.indexOf("$(");
7
+ while (start !== -1) {
8
+ let depth = 1;
9
+ let quote = "";
10
+ let index = start + 2;
11
+ for (; index < command.length && depth > 0; index++) {
12
+ const character = command[index];
13
+ if (character === "\\" && quote !== "'") index++;
14
+ else if (quote !== "") {
15
+ if (character === quote) quote = "";
16
+ } else if (character === '"' || character === "'") quote = character;
17
+ else if (character === "(") depth++;
18
+ else if (character === ")") depth--;
19
+ }
20
+ if (depth > 0) break;
21
+ bodies.push(command.slice(start + 2, index - 1));
22
+ start = command.indexOf("$(", index);
23
+ }
24
+ return bodies;
25
+ };
@@ -1,4 +1,8 @@
1
+ import type { CommandRefusal } from "../../models/CommandRefusal";
2
+
3
+ import { getDollarSubstitutionBodies } from "./getDollarSubstitutionBodies";
1
4
  import { joinDirectory } from "./joinDirectory";
5
+ import { tokenize } from "./tokenize";
2
6
 
3
7
  // A shell command passed as one argument (`bash -c "…"`) is read again on its own, to this depth at most
4
8
  const MAX_NESTING_DEPTH = 3;
@@ -8,46 +12,8 @@ const SEGMENT_REGEX = /(?:"[^"]*"|'[^']*'|[^\n"&'();`|])+/gu;
8
12
  // A backtick substitution runs even inside double quotes, so its body is read again on its own
9
13
  const BACKTICK_SUBSTITUTION_REGEX = /`(?<body>[^`]*)`/gu;
10
14
  const SHELL_FLAG_REGEX = /^-[a-zA-Z]*c$|^eval$/u;
11
- const TOKEN_REGEX = /"(?<double>[^"]*)"|'(?<single>[^']*)'|(?<bare>\S+)/gu;
12
15
  const PATH_SEPARATOR_REGEX = /[/\\]/u;
13
16
 
14
- // The refusal for a command's name, the arguments after it and the folder it runs in from the session's ("" for the
15
- // Session's own), undefined when the command is not one the guard reads
16
- export type CommandRefusal<T = string> = (name: string, args: string[], directory: string) => T | undefined;
17
-
18
- // A quoted argument keeps its inner spaces, so a shell command passed as one argument is read again on its own
19
- const tokenize = (segment: string) =>
20
- Array.from(
21
- segment.matchAll(TOKEN_REGEX),
22
- (match) => match.groups?.double ?? match.groups?.single ?? match.groups?.bare ?? "",
23
- );
24
-
25
- // The bodies of a command's `$(…)` substitutions, which run even inside double quotes: each runs to the parenthesis
26
- // Closing it, past any a quote or a backslash holds, so a body such as `find / -name '(x)'` is read whole. A
27
- // Substitution inside a body is the body's own, read when the body is
28
- const getDollarSubstitutionBodies = (command: string): string[] => {
29
- const bodies: string[] = [];
30
- let start = command.indexOf("$(");
31
- while (start !== -1) {
32
- let depth = 1;
33
- let quote = "";
34
- let index = start + 2;
35
- for (; index < command.length && depth > 0; index++) {
36
- const character = command[index];
37
- if (character === "\\" && quote !== "'") index++;
38
- else if (quote !== "") {
39
- if (character === quote) quote = "";
40
- } else if (character === '"' || character === "'") quote = character;
41
- else if (character === "(") depth++;
42
- else if (character === ")") depth--;
43
- }
44
- if (depth > 0) break;
45
- bodies.push(command.slice(start + 2, index - 1));
46
- start = command.indexOf("$(", index);
47
- }
48
- return bodies;
49
- };
50
-
51
17
  // The refusal of the first command in a Bash command that the given reader refuses, undefined when none does. A `cd`
52
18
  // Moves every command after it in the line; one to a home folder or back to the last is not followed
53
19
  export const getShellRefusal = <T = string>(
@@ -0,0 +1,8 @@
1
+ const TOKEN_REGEX = /"(?<double>[^"]*)"|'(?<single>[^']*)'|(?<bare>\S+)/gu;
2
+
3
+ // A quoted argument keeps its inner spaces, so a shell command passed as one argument is read again on its own
4
+ export const tokenize = (segment: string) =>
5
+ Array.from(
6
+ segment.matchAll(TOKEN_REGEX),
7
+ (match) => match.groups?.double ?? match.groups?.single ?? match.groups?.bare ?? "",
8
+ );
@@ -0,0 +1,14 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ const ADD_ALL_FLAGS: ReadonlySet<string> = new Set(["--all", "--update", "-A", "-u"]);
5
+
6
+ // `add .`, `add -A` and `add -N` over every path reach a peer's work, while a named path does not
7
+ export const getAddRefusal = (args: string[]): string | undefined => {
8
+ const { flags, operands } = splitArguments(args);
9
+ if (isWholeCheckout(operands)) return "git add .";
10
+ const allFlag = flags.find((flag) => ADD_ALL_FLAGS.has(flag));
11
+ if (allFlag !== undefined && operands.length === 0) return `git add ${allFlag}`;
12
+ const isIntentToAdd = flags.some((flag) => flag === "-N" || flag === "--intent-to-add");
13
+ return isIntentToAdd && operands.length === 0 ? "git add -N" : undefined;
14
+ };
@@ -1,9 +1,7 @@
1
1
  import type { WardRecord } from "../../../types";
2
2
 
3
3
  import { getCollidingRecord } from "./getCollidingRecord";
4
-
5
- // Windows paths compare as one spelling: forward slashes, lowercase, since the file system ignores case
6
- const getComparablePath = (path: string) => path.replaceAll("\\", "/").toLowerCase();
4
+ import { getComparablePath } from "./getComparablePath";
7
5
 
8
6
  // The edited path of another session's record inside this checkout, undefined when no other session in the window
9
7
  // Shares it, so a checkout alone with its session is never refused
@@ -0,0 +1,6 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ // `checkout .` and `checkout -- .` overwrite every path's working tree copy, a peer's edits included
5
+ export const getCheckoutRefusal = (args: string[]): string | undefined =>
6
+ isWholeCheckout(splitArguments(args).operands) ? "git checkout ." : undefined;
@@ -0,0 +1,8 @@
1
+ import { splitArguments } from "./splitArguments";
2
+
3
+ // A clean deletes every untracked file, a peer's new ones included; a dry run lists them and is allowed
4
+ export const getCleanRefusal = (args: string[]): string | undefined => {
5
+ const { flags } = splitArguments(args);
6
+ const isDryRun = flags.some((flag) => flag === "--dry-run" || (!flag.startsWith("--") && flag.includes("n")));
7
+ return isDryRun ? undefined : "git clean";
8
+ };
@@ -0,0 +1,2 @@
1
+ // Windows paths compare as one spelling: forward slashes, lowercase, since the file system ignores case
2
+ export const getComparablePath = (path: string) => path.replaceAll("\\", "/").toLowerCase();
@@ -0,0 +1,32 @@
1
+ import type { CommandRefusal } from "../../models/CommandRefusal";
2
+
3
+ import { joinDirectory } from "../shell/joinDirectory";
4
+ import { getAddRefusal } from "./getAddRefusal";
5
+ import { getCheckoutRefusal } from "./getCheckoutRefusal";
6
+ import { getCleanRefusal } from "./getCleanRefusal";
7
+ import { getResetRefusal } from "./getResetRefusal";
8
+ import { getRestoreRefusal } from "./getRestoreRefusal";
9
+ import { getStashRefusal } from "./getStashRefusal";
10
+ import { getSubcommand } from "./getSubcommand";
11
+
12
+ const GIT_NAMES: ReadonlySet<string> = new Set(["git", "git.exe"]);
13
+
14
+ const WHOLE_INDEX_REFUSALS: ReadonlyMap<string, (args: string[]) => string | undefined> = new Map([
15
+ ["add", getAddRefusal],
16
+ ["checkout", getCheckoutRefusal],
17
+ ["clean", getCleanRefusal],
18
+ ["reset", getResetRefusal],
19
+ ["restore", getRestoreRefusal],
20
+ ["stash", getStashRefusal],
21
+ ]);
22
+
23
+ // The form refused and the folder git runs in, from the session's ("" for the session's own): the folder a `cd` before
24
+ // It moved to, then each of its `-C` folders
25
+ export const getGitRefusal: CommandRefusal<{ directory: string; form: string }> = (name, args, directory) => {
26
+ if (!GIT_NAMES.has(name)) return undefined;
27
+ const subcommand = getSubcommand(args);
28
+ const form = WHOLE_INDEX_REFUSALS.get(subcommand.name)?.(subcommand.arguments);
29
+ return form === undefined
30
+ ? undefined
31
+ : { directory: subcommand.directories.reduce((base, next) => joinDirectory(base, next), directory), form };
32
+ };
@@ -0,0 +1,19 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ // The reset modes that touch the working tree or the index beyond HEAD, so a reset with one of them is not a commit move
5
+ const RESET_INDEX_MODES: ReadonlySet<string> = new Set(["--hard", "--keep", "--merge", "--mixed"]);
6
+
7
+ // A reset that names a path is path-scoped: after `--`, or as a second operand with none, since git reads the first
8
+ // As a commit or a path and every one after it as a path. The checkout's own pathspec names no commit, so it is the
9
+ // Whole checkout in either place. A lone operand may be a commit, so it is refused, and a reset that moves HEAD alone
10
+ // With `--soft` leaves the index as it is; every other reset rewrites the index to HEAD or the working tree, a peer's
11
+ // Staged entries included
12
+ export const getResetRefusal = (args: string[]): string | undefined => {
13
+ const { after, flags, hasSeparator, operands } = splitArguments(args);
14
+ if (isWholeCheckout(hasSeparator ? after : operands)) return "git reset -- .";
15
+ const paths = hasSeparator ? after : operands.slice(1);
16
+ if (paths.length > 0) return undefined;
17
+ const isSoftOnly = flags.includes("--soft") && !flags.some((flag) => RESET_INDEX_MODES.has(flag));
18
+ return isSoftOnly ? undefined : "git reset";
19
+ };
@@ -0,0 +1,10 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ // `restore .` rewrites every path, staged or in the working tree, so the staged form is refused as the plain one is
5
+ export const getRestoreRefusal = (args: string[]): string | undefined => {
6
+ const { flags, operands } = splitArguments(args);
7
+ if (!isWholeCheckout(operands)) return undefined;
8
+ const isStaged = flags.some((flag) => flag === "--staged" || flag === "-S");
9
+ return `git restore ${isStaged ? "--staged " : ""}.`;
10
+ };
@@ -0,0 +1,2 @@
1
+ // A stash in any form sets a peer's work aside, which is why the git skill never runs one
2
+ export const getStashRefusal = (): string => "git stash";
@@ -0,0 +1,16 @@
1
+ import { isFlag } from "./isFlag";
2
+
3
+ // The options git takes before its subcommand, each with its value as the next word
4
+ const GIT_VALUE_OPTIONS: ReadonlySet<string> = new Set(["--git-dir", "--namespace", "--work-tree", "-C", "-c"]);
5
+
6
+ // The subcommand past the options git takes before it, the arguments after the subcommand, and the folders its `-C`
7
+ // Options name, each from the one before
8
+ export const getSubcommand = (args: string[]) => {
9
+ const directories: string[] = [];
10
+ let index = 0;
11
+ while (index < args.length && isFlag(args[index] ?? "")) {
12
+ if (args[index] === "-C") directories.push(args[index + 1] ?? "");
13
+ index += GIT_VALUE_OPTIONS.has(args[index] ?? "") ? 2 : 1;
14
+ }
15
+ return { arguments: args.slice(index + 1), directories, name: args[index] ?? "" };
16
+ };
@@ -0,0 +1,9 @@
1
+ // The path-scoped forms that do the same job for the paths the session touched, named for the person's shared checkout
2
+ export const getWholeIndexDenyReason = (form: string, peerPath: string): string =>
3
+ [
4
+ `Refused: \`${form}\` reaches the staged and working changes of another session in this checkout, which is editing ${peerPath} now. Name the paths you touched instead:`,
5
+ "- `git add <path>` to stage them;",
6
+ "- `git reset -- <path>` to unstage them;",
7
+ '- `git commit -m "…" -- <path>` to commit only them.',
8
+ "The git skill's rule (`.agents/skills/git/SKILL.md`) owns the whole-index list.",
9
+ ].join("\n");
@@ -1,127 +1,7 @@
1
- import type { CommandRefusal } from "../shell/getShellRefusal";
2
-
3
1
  import { getShellRefusal } from "../shell/getShellRefusal";
4
- import { joinDirectory } from "../shell/joinDirectory";
5
-
6
- const GIT_NAMES: ReadonlySet<string> = new Set(["git", "git.exe"]);
7
-
8
- // The options git takes before its subcommand, each with its value as the next word
9
- const GIT_VALUE_OPTIONS: ReadonlySet<string> = new Set(["-C", "-c"]);
10
-
11
- // The reset modes that touch the working tree or the index beyond HEAD, so a reset with one of them is not a commit move
12
- const RESET_INDEX_MODES: ReadonlySet<string> = new Set(["--hard", "--keep", "--merge", "--mixed"]);
13
-
14
- const ADD_ALL_FLAGS: ReadonlySet<string> = new Set(["--all", "--update", "-A", "-u"]);
15
-
16
- // The pathspecs git reads as every path of the checkout, from its root or the folder the command runs in
17
- const WHOLE_CHECKOUT_PATHSPECS: ReadonlySet<string> = new Set([".", "./", ":/", ":/."]);
18
-
19
- const isFlag = (arg: string) => arg.startsWith("-");
20
-
21
- const isWholeCheckout = (operands: string[]) => operands.some((operand) => WHOLE_CHECKOUT_PATHSPECS.has(operand));
22
-
23
- // The flags, the words before a `--` and the words after it, so a path is read whether or not git was given a separator
24
- const splitArguments = (args: string[]) => {
25
- const separator = args.indexOf("--");
26
- const before = separator === -1 ? args : args.slice(0, separator);
27
- const after = separator === -1 ? [] : args.slice(separator + 1);
28
- return {
29
- after,
30
- flags: before.filter((arg) => isFlag(arg)),
31
- hasSeparator: separator !== -1,
32
- operands: [...before.filter((arg) => !isFlag(arg)), ...after],
33
- };
34
- };
35
-
36
- // The subcommand past the options git takes before it, the arguments after the subcommand, and the folders its `-C`
37
- // Options name, each from the one before
38
- const getSubcommand = (args: string[]) => {
39
- const directories: string[] = [];
40
- let index = 0;
41
- while (index < args.length && isFlag(args[index] ?? "")) {
42
- if (args[index] === "-C") directories.push(args[index + 1] ?? "");
43
- index += GIT_VALUE_OPTIONS.has(args[index] ?? "") ? 2 : 1;
44
- }
45
- return { arguments: args.slice(index + 1), directories, name: args[index] ?? "" };
46
- };
47
-
48
- // `add .`, `add -A` and `add -N` over every path reach a peer's work, while a named path does not
49
- const getAddRefusal = (args: string[]): string | undefined => {
50
- const { flags, operands } = splitArguments(args);
51
- if (isWholeCheckout(operands)) return "git add .";
52
- const allFlag = flags.find((flag) => ADD_ALL_FLAGS.has(flag));
53
- if (allFlag !== undefined && operands.length === 0) return `git add ${allFlag}`;
54
- const isIntentToAdd = flags.some((flag) => flag === "-N" || flag === "--intent-to-add");
55
- return isIntentToAdd && operands.length === 0 ? "git add -N" : undefined;
56
- };
57
-
58
- // A reset that names a path is path-scoped: after `--`, or as a second operand with none, since git reads the first
59
- // As a commit or a path and every one after it as a path. The checkout's own pathspec names no commit, so it is the
60
- // Whole checkout in either place. A lone operand may be a commit, so it is refused, and a reset that moves HEAD alone
61
- // With `--soft` leaves the index as it is; every other reset rewrites the index to HEAD or the working tree, a peer's
62
- // Staged entries included
63
- const getResetRefusal = (args: string[]): string | undefined => {
64
- const { after, flags, hasSeparator, operands } = splitArguments(args);
65
- if (isWholeCheckout(hasSeparator ? after : operands)) return "git reset -- .";
66
- const paths = hasSeparator ? after : operands.slice(1);
67
- if (paths.length > 0) return undefined;
68
- const isSoftOnly = flags.includes("--soft") && !flags.some((flag) => RESET_INDEX_MODES.has(flag));
69
- return isSoftOnly ? undefined : "git reset";
70
- };
71
-
72
- // `restore .` rewrites every path, staged or in the working tree, so the staged form is refused as the plain one is
73
- const getRestoreRefusal = (args: string[]): string | undefined => {
74
- const { flags, operands } = splitArguments(args);
75
- if (!isWholeCheckout(operands)) return undefined;
76
- const isStaged = flags.some((flag) => flag === "--staged" || flag === "-S");
77
- return `git restore ${isStaged ? "--staged " : ""}.`;
78
- };
79
-
80
- // A stash in any form sets a peer's work aside, which is why the git skill never runs one
81
- const getStashRefusal = (): string => "git stash";
82
-
83
- // `checkout .` and `checkout -- .` overwrite every path's working tree copy, a peer's edits included
84
- const getCheckoutRefusal = (args: string[]): string | undefined =>
85
- isWholeCheckout(splitArguments(args).operands) ? "git checkout ." : undefined;
86
-
87
- // A clean deletes every untracked file, a peer's new ones included; a dry run lists them and is allowed
88
- const getCleanRefusal = (args: string[]): string | undefined => {
89
- const { flags } = splitArguments(args);
90
- const isDryRun = flags.some((flag) => flag === "--dry-run" || (!flag.startsWith("--") && flag.includes("n")));
91
- return isDryRun ? undefined : "git clean";
92
- };
93
-
94
- const WHOLE_INDEX_REFUSALS: ReadonlyMap<string, (args: string[]) => string | undefined> = new Map([
95
- ["add", getAddRefusal],
96
- ["checkout", getCheckoutRefusal],
97
- ["clean", getCleanRefusal],
98
- ["reset", getResetRefusal],
99
- ["restore", getRestoreRefusal],
100
- ["stash", getStashRefusal],
101
- ]);
102
-
103
- // The form refused and the folder git runs in, from the session's ("" for the session's own): the folder a `cd` before
104
- // It moved to, then each of its `-C` folders
105
- const getGitRefusal: CommandRefusal<{ directory: string; form: string }> = (name, args, directory) => {
106
- if (!GIT_NAMES.has(name)) return undefined;
107
- const subcommand = getSubcommand(args);
108
- const form = WHOLE_INDEX_REFUSALS.get(subcommand.name)?.(subcommand.arguments);
109
- return form === undefined
110
- ? undefined
111
- : { directory: subcommand.directories.reduce((base, next) => joinDirectory(base, next), directory), form };
112
- };
2
+ import { getGitRefusal } from "./getGitRefusal";
113
3
 
114
4
  // The whole-index git form a Bash command runs, named as the form it is, with the folder it runs in, undefined for a
115
5
  // Path-scoped or read-only command
116
6
  export const getWholeIndexRefusal = (command: string): undefined | { directory: string; form: string } =>
117
7
  getShellRefusal(command, getGitRefusal);
118
-
119
- // The path-scoped forms that do the same job for the paths the session touched, named for the person's shared checkout
120
- export const getWholeIndexDenyReason = (form: string, peerPath: string): string =>
121
- [
122
- `Refused: \`${form}\` reaches the staged and working changes of another session in this checkout, which is editing ${peerPath} now. Name the paths you touched instead:`,
123
- "- `git add <path>` to stage them;",
124
- "- `git reset -- <path>` to unstage them;",
125
- '- `git commit -m "…" -- <path>` to commit only them.',
126
- "The git skill's rule (`.agents/skills/git/SKILL.md`) owns the whole-index list.",
127
- ].join("\n");
@@ -0,0 +1 @@
1
+ export const isFlag = (arg: string) => arg.startsWith("-");
@@ -0,0 +1,5 @@
1
+ // The pathspecs git reads as every path of the checkout, from its root or the folder the command runs in
2
+ const WHOLE_CHECKOUT_PATHSPECS: ReadonlySet<string> = new Set([".", "./", ":/", ":/."]);
3
+
4
+ export const isWholeCheckout = (operands: string[]) =>
5
+ operands.some((operand) => WHOLE_CHECKOUT_PATHSPECS.has(operand));
@@ -6,7 +6,8 @@ import { InitialState } from "../InitialState";
6
6
  import { joinDirectory } from "../shell/joinDirectory";
7
7
  import { getCheckoutPeer } from "./getCheckoutPeer";
8
8
  import { getParentFolder } from "./getParentFolder";
9
- import { getWholeIndexDenyReason, getWholeIndexRefusal } from "./getWholeIndexRefusal";
9
+ import { getWholeIndexDenyReason } from "./getWholeIndexDenyReason";
10
+ import { getWholeIndexRefusal } from "./getWholeIndexRefusal";
10
11
  import { readRecords } from "./readRecords";
11
12
  import { resolveRecordsPath } from "./resolveRecordsPath";
12
13
 
@@ -0,0 +1,14 @@
1
+ import { isFlag } from "./isFlag";
2
+
3
+ // The flags, the words before a `--` and the words after it, so a path is read whether or not git was given a separator
4
+ export const splitArguments = (args: string[]) => {
5
+ const separator = args.indexOf("--");
6
+ const before = separator === -1 ? args : args.slice(0, separator);
7
+ const after = separator === -1 ? [] : args.slice(separator + 1);
8
+ return {
9
+ after,
10
+ flags: before.filter((arg) => isFlag(arg)),
11
+ hasSeparator: separator !== -1,
12
+ operands: [...before.filter((arg) => !isFlag(arg)), ...after],
13
+ };
14
+ };