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.
- package/README.md +43 -18
- package/package.json +2 -2
- package/src/models/CommandRefusal.ts +3 -0
- package/src/services/band/getCommissionSummary.ts +2 -1
- package/src/services/commission/foldTaskCall.ts +2 -1
- package/src/services/delegation/checkIsLookupCommand.ts +2 -8
- package/src/services/delegation/stripLeadingSegments.ts +8 -0
- package/src/services/diskScan/getDenyReason.ts +7 -0
- package/src/services/diskScan/getDiskScanRefusal.ts +2 -78
- package/src/services/diskScan/getScanCommandRefusal.ts +8 -0
- package/src/services/diskScan/getScanRefusal.ts +21 -0
- package/src/services/diskScan/isRecursiveFlag.ts +4 -0
- package/src/services/diskScan/isRecursiveLongFlag.ts +3 -0
- package/src/services/diskScan/isRoot.ts +8 -0
- package/src/services/diskScan/normalizePath.ts +7 -0
- package/src/services/diskScan/readGrepArguments.ts +25 -0
- package/src/services/diskScan/registerDiskScan.ts +2 -1
- package/src/services/registerLifecycle.ts +4 -2
- package/src/services/shell/getDollarSubstitutionBodies.ts +25 -0
- package/src/services/shell/getShellRefusal.ts +4 -38
- package/src/services/shell/tokenize.ts +8 -0
- package/src/services/ward/getAddRefusal.ts +14 -0
- package/src/services/ward/getCheckoutPeer.ts +1 -3
- package/src/services/ward/getCheckoutRefusal.ts +6 -0
- package/src/services/ward/getCleanRefusal.ts +8 -0
- package/src/services/ward/getComparablePath.ts +2 -0
- package/src/services/ward/getGitRefusal.ts +32 -0
- package/src/services/ward/getResetRefusal.ts +19 -0
- package/src/services/ward/getRestoreRefusal.ts +10 -0
- package/src/services/ward/getStashRefusal.ts +2 -0
- package/src/services/ward/getSubcommand.ts +16 -0
- package/src/services/ward/getWholeIndexDenyReason.ts +9 -0
- package/src/services/ward/getWholeIndexRefusal.ts +1 -121
- package/src/services/ward/isFlag.ts +1 -0
- package/src/services/ward/isWholeCheckout.ts +5 -0
- package/src/services/ward/registerWholeIndex.ts +2 -1
- 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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
56
|
-
| `/waypoints [on \| off]` | Suggests the next steps after each answered turn, or stops
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
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": "
|
|
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 ===
|
|
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:
|
|
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 {
|
|
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,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
|
|
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 ===
|
|
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 ===
|
|
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,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,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 {
|
|
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
|
|
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
|
+
};
|