@kurokeita/add-skill 2.2.1 → 2.3.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 +2 -0
- package/dist/bin/cli.js +201 -60
- package/dist/skills/git-commit/SKILL.md +8 -6
- package/dist/skills/git-commit/examples/commit-messages.txt +7 -0
- package/dist/skills/git-commit/hooks/commit-gate.py +275 -0
- package/dist/skills/git-commit/hooks/hooks.json +39 -0
- package/dist/skills/statusline-setup/SKILL.md +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,6 +103,8 @@ pnpm dev import https://github.com/owner/repo/tree/main/skills/skill-name
|
|
|
103
103
|
|
|
104
104
|
Skills go under `skills`, agents under `agents`, and workflows under `commands`. After copying, `add` can optionally wire each platform you select: it installs a session-start hook (under `.agents/hooks/`) and registers it in that platform's config. On each new session the hook symlinks the canonical `.agents/` entries back into the platform's own directories, so every tool sees the same set of items from one source of truth.
|
|
105
105
|
|
|
106
|
+
A skill can also ship Claude Code hooks in a `hooks/` dir. `hooks/hooks.json` holds the `hooks` object of `.claude/settings.json`, and `${HOOKS_DIR}` in its commands resolves to `.claude/hooks`. When `add` installs a skill that has this manifest, it offers to copy the other files in `hooks/` to `.claude/hooks/` and merge the entries into `.claude/settings.json` (global or project, matching the install scope). `git-commit` uses this to install its `commit-gate.py` hook.
|
|
107
|
+
|
|
106
108
|
`list` and `remove` operate on the canonical `.agents/` dir. Removing an entry also prunes any platform symlink left pointing at it.
|
|
107
109
|
|
|
108
110
|
## Supported Platforms
|
package/dist/bin/cli.js
CHANGED
|
@@ -6,20 +6,21 @@ import { Command } from "commander";
|
|
|
6
6
|
import pc6 from "picocolors";
|
|
7
7
|
|
|
8
8
|
// src/commands/add.ts
|
|
9
|
-
import
|
|
9
|
+
import path5 from "node:path";
|
|
10
10
|
import {
|
|
11
11
|
autocompleteMultiselect,
|
|
12
12
|
cancel,
|
|
13
13
|
confirm,
|
|
14
14
|
intro,
|
|
15
15
|
isCancel as isCancel2,
|
|
16
|
+
log,
|
|
16
17
|
multiselect,
|
|
17
18
|
note as note2,
|
|
18
19
|
outro,
|
|
19
20
|
select as select2,
|
|
20
21
|
spinner
|
|
21
22
|
} from "@clack/prompts";
|
|
22
|
-
import
|
|
23
|
+
import fs5 from "fs-extra";
|
|
23
24
|
import pc2 from "picocolors";
|
|
24
25
|
|
|
25
26
|
// src/utils/agent-setup.ts
|
|
@@ -773,6 +774,120 @@ async function chooseListRemoveScope(options) {
|
|
|
773
774
|
};
|
|
774
775
|
}
|
|
775
776
|
|
|
777
|
+
// src/utils/skill-hooks.ts
|
|
778
|
+
import os5 from "node:os";
|
|
779
|
+
import path4 from "node:path";
|
|
780
|
+
import fs4 from "fs-extra";
|
|
781
|
+
var MANIFEST = "hooks.json";
|
|
782
|
+
async function hasSkillHooks(skillDir) {
|
|
783
|
+
return fs4.pathExists(path4.join(skillDir, "hooks", MANIFEST));
|
|
784
|
+
}
|
|
785
|
+
var UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
786
|
+
function isUnsafe(char) {
|
|
787
|
+
return UNSAFE.test(char);
|
|
788
|
+
}
|
|
789
|
+
function escapeChar(char) {
|
|
790
|
+
const code = char.codePointAt(0) ?? 0;
|
|
791
|
+
return code <= 255 ? `\\x${code.toString(16).padStart(2, "0")}` : `\\u{${code.toString(16)}}`;
|
|
792
|
+
}
|
|
793
|
+
function printable(text2) {
|
|
794
|
+
return [...text2].map((char) => isUnsafe(char) ? escapeChar(char) : char).join("");
|
|
795
|
+
}
|
|
796
|
+
function isText(value) {
|
|
797
|
+
return typeof value === "string" && ![...value].some(isUnsafe);
|
|
798
|
+
}
|
|
799
|
+
function isRecord(value) {
|
|
800
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
801
|
+
}
|
|
802
|
+
function hasOnlyKeys(value, keys) {
|
|
803
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
804
|
+
}
|
|
805
|
+
function isHook(value) {
|
|
806
|
+
return isRecord(value) && hasOnlyKeys(value, ["type", "command", "timeout"]) && value.type === "command" && isText(value.command) && (value.timeout === void 0 || typeof value.timeout === "number");
|
|
807
|
+
}
|
|
808
|
+
function isHookEntry(value) {
|
|
809
|
+
return isRecord(value) && hasOnlyKeys(value, ["matcher", "hooks"]) && (value.matcher === void 0 || isText(value.matcher)) && Array.isArray(value.hooks) && value.hooks.every(isHook);
|
|
810
|
+
}
|
|
811
|
+
function hookTarget(scope, root) {
|
|
812
|
+
const base = scope === "project" ? root : os5.homedir();
|
|
813
|
+
const hooksDir = path4.join(base, ".claude", "hooks");
|
|
814
|
+
const token = scope === "project" ? '"$CLAUDE_PROJECT_DIR"/.claude/hooks' : hooksDir;
|
|
815
|
+
return { base, hooksDir, token };
|
|
816
|
+
}
|
|
817
|
+
async function hookFiles(srcDir) {
|
|
818
|
+
const files = (await fs4.readdir(srcDir)).filter((file) => file !== MANIFEST);
|
|
819
|
+
for (const file of files) {
|
|
820
|
+
if (!isText(file) || !(await fs4.lstat(path4.join(srcDir, file))).isFile()) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
`hooks/${printable(file)} must be a regular file with a printable name`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return files;
|
|
827
|
+
}
|
|
828
|
+
async function readManifest(srcDir, token) {
|
|
829
|
+
const raw = await fs4.readFile(path4.join(srcDir, MANIFEST), "utf-8");
|
|
830
|
+
const manifest = JSON.parse(
|
|
831
|
+
raw.replaceAll(`\${HOOKS_DIR}`, JSON.stringify(token).slice(1, -1))
|
|
832
|
+
);
|
|
833
|
+
const hooks = isRecord(manifest) ? manifest.hooks ?? {} : void 0;
|
|
834
|
+
if (!isRecord(hooks)) {
|
|
835
|
+
throw new Error(`hooks/${MANIFEST} must be an object with a "hooks" object`);
|
|
836
|
+
}
|
|
837
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
838
|
+
if (!isText(event) || !Array.isArray(entries) || !entries.every(isHookEntry)) {
|
|
839
|
+
throw new Error(
|
|
840
|
+
`hooks/${MANIFEST}: "${printable(event)}" must list { matcher?, hooks: [{ type: "command", command, timeout? }] }`
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return hooks;
|
|
845
|
+
}
|
|
846
|
+
async function planSkillHooks(skillDir, scope, root) {
|
|
847
|
+
const { hooksDir, token } = hookTarget(scope, root);
|
|
848
|
+
const srcDir = path4.join(skillDir, "hooks");
|
|
849
|
+
const replaces = [];
|
|
850
|
+
for (const file of await hookFiles(srcDir)) {
|
|
851
|
+
const dest = path4.join(hooksDir, file);
|
|
852
|
+
if (!await fs4.pathExists(dest)) continue;
|
|
853
|
+
const [src, existing] = await Promise.all([
|
|
854
|
+
fs4.readFile(path4.join(srcDir, file)),
|
|
855
|
+
fs4.readFile(dest)
|
|
856
|
+
]);
|
|
857
|
+
if (!src.equals(existing)) replaces.push(dest);
|
|
858
|
+
}
|
|
859
|
+
const commands = [];
|
|
860
|
+
for (const [event, entries] of Object.entries(
|
|
861
|
+
await readManifest(srcDir, token)
|
|
862
|
+
)) {
|
|
863
|
+
for (const entry of entries) {
|
|
864
|
+
const matcher = entry.matcher ? ` [${entry.matcher}]` : "";
|
|
865
|
+
for (const hook of entry.hooks) {
|
|
866
|
+
commands.push(`${event}${matcher}: ${hook.command}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return { commands, replaces };
|
|
871
|
+
}
|
|
872
|
+
async function installSkillHooks(skillDir, scope, root) {
|
|
873
|
+
const { base, hooksDir, token } = hookTarget(scope, root);
|
|
874
|
+
const srcDir = path4.join(skillDir, "hooks");
|
|
875
|
+
const files = await hookFiles(srcDir);
|
|
876
|
+
const manifest = await readManifest(srcDir, token);
|
|
877
|
+
for (const file of files) {
|
|
878
|
+
await fs4.copy(path4.join(srcDir, file), path4.join(hooksDir, file), {
|
|
879
|
+
overwrite: true
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
const settings = path4.join(base, ".claude", "settings.json");
|
|
883
|
+
for (const [event, entries] of Object.entries(manifest)) {
|
|
884
|
+
for (const entry of entries) {
|
|
885
|
+
await mergeJsonHookFile(settings, event, entry);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return files.map((file) => path4.join(hooksDir, file));
|
|
889
|
+
}
|
|
890
|
+
|
|
776
891
|
// src/commands/add.ts
|
|
777
892
|
enableAutocompleteMultiSelectShiftAToggle();
|
|
778
893
|
async function add(type, url, options) {
|
|
@@ -822,12 +937,12 @@ async function add(type, url, options) {
|
|
|
822
937
|
}
|
|
823
938
|
} else {
|
|
824
939
|
const sourceDir = TYPE_DIRS[normalizedType];
|
|
825
|
-
if (!await
|
|
940
|
+
if (!await fs5.pathExists(sourceDir)) {
|
|
826
941
|
cancel(`${normalizedType} directory not found!`);
|
|
827
942
|
process.exit(1);
|
|
828
943
|
return;
|
|
829
944
|
}
|
|
830
|
-
const entries = await
|
|
945
|
+
const entries = await fs5.readdir(sourceDir, { withFileTypes: true });
|
|
831
946
|
const availableItems = entries.filter(
|
|
832
947
|
(entry) => entry.isDirectory() || (normalizedType === "workflow" || normalizedType === "agent") && entry.isFile() && entry.name.endsWith(".md")
|
|
833
948
|
).map((entry) => ({ label: entry.name, value: entry.name })).sort((a, b) => a.label.localeCompare(b.label));
|
|
@@ -850,7 +965,7 @@ async function add(type, url, options) {
|
|
|
850
965
|
}
|
|
851
966
|
const scopeChoice = await chooseInstallScope({ flag: options?.scope });
|
|
852
967
|
if ("rejected" in scopeChoice) {
|
|
853
|
-
if (tempDir) await
|
|
968
|
+
if (tempDir) await fs5.remove(tempDir);
|
|
854
969
|
cancel(
|
|
855
970
|
`Scope --scope=${options?.scope} unavailable: ${scopeChoice.reason}`
|
|
856
971
|
);
|
|
@@ -858,7 +973,7 @@ async function add(type, url, options) {
|
|
|
858
973
|
return;
|
|
859
974
|
}
|
|
860
975
|
if (scopeChoice.cancelled) {
|
|
861
|
-
if (tempDir) await
|
|
976
|
+
if (tempDir) await fs5.remove(tempDir);
|
|
862
977
|
if (isSingleShot) break;
|
|
863
978
|
currentType = void 0;
|
|
864
979
|
continue;
|
|
@@ -866,14 +981,14 @@ async function add(type, url, options) {
|
|
|
866
981
|
const { scope: chosenScope, root: scopeRoot } = scopeChoice;
|
|
867
982
|
const agentsBase = getAgentsBase(chosenScope, scopeRoot);
|
|
868
983
|
const subdir = TYPE_SUBDIRS[normalizedType] ?? "skills";
|
|
869
|
-
const targetBase =
|
|
984
|
+
const targetBase = path5.join(agentsBase, subdir);
|
|
870
985
|
if (chosenScope === "project") {
|
|
871
986
|
const proceed = await confirm({
|
|
872
987
|
message: `Install to project scope at ${pc2.cyan(scopeRoot)}?`,
|
|
873
988
|
initialValue: true
|
|
874
989
|
});
|
|
875
990
|
if (isCancel2(proceed) || !proceed) {
|
|
876
|
-
if (tempDir) await
|
|
991
|
+
if (tempDir) await fs5.remove(tempDir);
|
|
877
992
|
if (isSingleShot) break;
|
|
878
993
|
currentType = void 0;
|
|
879
994
|
continue;
|
|
@@ -882,8 +997,8 @@ async function add(type, url, options) {
|
|
|
882
997
|
const subdirName = subdir;
|
|
883
998
|
const preExisting = [];
|
|
884
999
|
for (const item of selectedItems) {
|
|
885
|
-
const targetName = normalizedType === "skill" ?
|
|
886
|
-
if (await
|
|
1000
|
+
const targetName = normalizedType === "skill" ? path5.parse(item).name : item;
|
|
1001
|
+
if (await fs5.pathExists(path5.join(targetBase, targetName))) {
|
|
887
1002
|
preExisting.push({ entry: targetName, subdir: subdirName });
|
|
888
1003
|
}
|
|
889
1004
|
}
|
|
@@ -895,7 +1010,7 @@ async function add(type, url, options) {
|
|
|
895
1010
|
required: false
|
|
896
1011
|
});
|
|
897
1012
|
if (isCancel2(toOverwrite)) {
|
|
898
|
-
if (tempDir) await
|
|
1013
|
+
if (tempDir) await fs5.remove(tempDir);
|
|
899
1014
|
if (isSingleShot) break;
|
|
900
1015
|
currentType = void 0;
|
|
901
1016
|
continue;
|
|
@@ -912,33 +1027,33 @@ async function add(type, url, options) {
|
|
|
912
1027
|
const s = spinner();
|
|
913
1028
|
s.start("Installing...");
|
|
914
1029
|
const errors = [];
|
|
915
|
-
|
|
916
|
-
await
|
|
1030
|
+
const installed = [];
|
|
1031
|
+
await fs5.ensureDir(targetBase);
|
|
917
1032
|
for (const item of selectedItems) {
|
|
918
|
-
const targetName = normalizedType === "skill" ?
|
|
1033
|
+
const targetName = normalizedType === "skill" ? path5.parse(item).name : item;
|
|
919
1034
|
if (skipEntries.has(targetName)) continue;
|
|
920
1035
|
s.message(`Installing ${pc2.bold(item)}...`);
|
|
921
1036
|
try {
|
|
922
|
-
const sourcePath = url && tempDir ? isGitHubFile ?
|
|
923
|
-
if (!await
|
|
1037
|
+
const sourcePath = url && tempDir ? isGitHubFile ? path5.join(tempDir, item) : tempDir : path5.join(TYPE_DIRS[normalizedType], item);
|
|
1038
|
+
if (!await fs5.pathExists(sourcePath)) {
|
|
924
1039
|
throw new Error(`Item '${item}' not found at ${sourcePath}`);
|
|
925
1040
|
}
|
|
926
|
-
const targetPath =
|
|
927
|
-
await
|
|
928
|
-
|
|
1041
|
+
const targetPath = path5.join(targetBase, targetName);
|
|
1042
|
+
await fs5.copy(sourcePath, targetPath, { overwrite: true });
|
|
1043
|
+
installed.push(targetPath);
|
|
929
1044
|
} catch (err) {
|
|
930
1045
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
931
1046
|
errors.push(`${item}: ${errorMessage}`);
|
|
932
1047
|
}
|
|
933
1048
|
}
|
|
934
1049
|
if (tempDir) {
|
|
935
|
-
await
|
|
1050
|
+
await fs5.remove(tempDir);
|
|
936
1051
|
tempDir = null;
|
|
937
1052
|
}
|
|
938
1053
|
if (errors.length > 0) {
|
|
939
1054
|
s.stop(
|
|
940
1055
|
pc2.yellow(
|
|
941
|
-
`Completed with errors. Installed: ${
|
|
1056
|
+
`Completed with errors. Installed: ${installed.length}, Errors: ${errors.length}`
|
|
942
1057
|
)
|
|
943
1058
|
);
|
|
944
1059
|
console.error(pc2.red("\nErrors encountered:"));
|
|
@@ -948,15 +1063,18 @@ async function add(type, url, options) {
|
|
|
948
1063
|
} else {
|
|
949
1064
|
s.stop(
|
|
950
1065
|
pc2.green(
|
|
951
|
-
`Successfully installed ${
|
|
1066
|
+
`Successfully installed ${installed.length} ${normalizedType}s.`
|
|
952
1067
|
)
|
|
953
1068
|
);
|
|
954
1069
|
}
|
|
955
|
-
if (
|
|
1070
|
+
if (installed.length > 0) {
|
|
956
1071
|
await maybeWireAgentSetup(agentsBase, chosenScope, scopeRoot);
|
|
1072
|
+
if (normalizedType === "skill") {
|
|
1073
|
+
await maybeInstallSkillHooks(installed, chosenScope, scopeRoot, !!url);
|
|
1074
|
+
}
|
|
957
1075
|
}
|
|
958
1076
|
} catch (error) {
|
|
959
|
-
if (tempDir) await
|
|
1077
|
+
if (tempDir) await fs5.remove(tempDir);
|
|
960
1078
|
cancel(`An error occurred: ${error}`);
|
|
961
1079
|
process.exit(1);
|
|
962
1080
|
return;
|
|
@@ -1006,6 +1124,29 @@ async function maybeWireAgentSetup(agentsBase, scope, root) {
|
|
|
1006
1124
|
s.error(`Failed to wire agent-setup hook: ${errorMessage}`);
|
|
1007
1125
|
}
|
|
1008
1126
|
}
|
|
1127
|
+
async function maybeInstallSkillHooks(skillDirs, scope, root, fromUrl) {
|
|
1128
|
+
for (const dir of skillDirs) {
|
|
1129
|
+
if (!await hasSkillHooks(dir)) continue;
|
|
1130
|
+
const name = printable(path5.basename(dir));
|
|
1131
|
+
try {
|
|
1132
|
+
const { commands, replaces } = await planSkillHooks(dir, scope, root);
|
|
1133
|
+
note2(
|
|
1134
|
+
[...commands, ...replaces.map((file) => `Replaces ${file}`)].map(printable).join("\n"),
|
|
1135
|
+
`Claude Code hooks in ${name}`
|
|
1136
|
+
);
|
|
1137
|
+
const install = await confirm({
|
|
1138
|
+
message: `Install the Claude Code hooks bundled with ${name}?`,
|
|
1139
|
+
initialValue: !fromUrl && replaces.length === 0
|
|
1140
|
+
});
|
|
1141
|
+
if (isCancel2(install) || !install) continue;
|
|
1142
|
+
const files = await installSkillHooks(dir, scope, root);
|
|
1143
|
+
log.success(`Installed ${name} hooks: ${printable(files.join(", "))}.`);
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1146
|
+
log.error(`Failed to install ${name} hooks: ${printable(errorMessage)}`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1009
1150
|
async function resolveConflicts(agentsBase, platforms, scope, root) {
|
|
1010
1151
|
const conflicts = await detectConflicts(agentsBase, platforms, scope, root);
|
|
1011
1152
|
if (conflicts.length === 0) return "ok";
|
|
@@ -1027,7 +1168,7 @@ async function resolveConflicts(agentsBase, platforms, scope, root) {
|
|
|
1027
1168
|
}
|
|
1028
1169
|
|
|
1029
1170
|
// src/commands/import.ts
|
|
1030
|
-
import
|
|
1171
|
+
import path6 from "node:path";
|
|
1031
1172
|
import {
|
|
1032
1173
|
cancel as cancel2,
|
|
1033
1174
|
confirm as confirm2,
|
|
@@ -1038,7 +1179,7 @@ import {
|
|
|
1038
1179
|
spinner as spinner2,
|
|
1039
1180
|
text
|
|
1040
1181
|
} from "@clack/prompts";
|
|
1041
|
-
import
|
|
1182
|
+
import fs6 from "fs-extra";
|
|
1042
1183
|
import pc3 from "picocolors";
|
|
1043
1184
|
async function importItem(type, url, options) {
|
|
1044
1185
|
if (!options?.skipIntro) {
|
|
@@ -1096,12 +1237,12 @@ async function importItem(type, url, options) {
|
|
|
1096
1237
|
throw e;
|
|
1097
1238
|
}
|
|
1098
1239
|
const shouldFlatten = (normalizedType === "agent" || normalizedType === "workflow") && !isFile;
|
|
1099
|
-
const targetPath = shouldFlatten ? targetBaseDir :
|
|
1240
|
+
const targetPath = shouldFlatten ? targetBaseDir : path6.join(targetBaseDir, itemName);
|
|
1100
1241
|
if (shouldFlatten) {
|
|
1101
|
-
const srcFiles = await
|
|
1242
|
+
const srcFiles = await fs6.readdir(tempDir);
|
|
1102
1243
|
const conflicts = [];
|
|
1103
1244
|
for (const file of srcFiles) {
|
|
1104
|
-
if (await
|
|
1245
|
+
if (await fs6.pathExists(path6.join(targetBaseDir, file))) {
|
|
1105
1246
|
conflicts.push(file);
|
|
1106
1247
|
}
|
|
1107
1248
|
}
|
|
@@ -1115,40 +1256,40 @@ async function importItem(type, url, options) {
|
|
|
1115
1256
|
initialValue: false
|
|
1116
1257
|
});
|
|
1117
1258
|
if (isCancel3(shouldOverwrite) || !shouldOverwrite) {
|
|
1118
|
-
await
|
|
1259
|
+
await fs6.remove(tempDir);
|
|
1119
1260
|
return;
|
|
1120
1261
|
}
|
|
1121
1262
|
}
|
|
1122
|
-
} else if (await
|
|
1263
|
+
} else if (await fs6.pathExists(targetPath)) {
|
|
1123
1264
|
const shouldOverwrite = await confirm2({
|
|
1124
1265
|
message: `${normalizedType} '${itemName}' already exists in the repo. Overwrite?`,
|
|
1125
1266
|
initialValue: false
|
|
1126
1267
|
});
|
|
1127
1268
|
if (isCancel3(shouldOverwrite) || !shouldOverwrite) {
|
|
1128
|
-
await
|
|
1269
|
+
await fs6.remove(tempDir);
|
|
1129
1270
|
return;
|
|
1130
1271
|
}
|
|
1131
1272
|
}
|
|
1132
1273
|
s.start(`Importing ${itemName} to ${targetBaseDir}...`);
|
|
1133
|
-
await
|
|
1274
|
+
await fs6.ensureDir(targetBaseDir);
|
|
1134
1275
|
if (isFile) {
|
|
1135
|
-
await
|
|
1276
|
+
await fs6.copy(path6.join(tempDir, itemName), targetPath, {
|
|
1136
1277
|
overwrite: true
|
|
1137
1278
|
});
|
|
1138
1279
|
} else {
|
|
1139
1280
|
if (shouldFlatten) {
|
|
1140
|
-
await
|
|
1281
|
+
await fs6.copy(tempDir, targetBaseDir, { overwrite: true });
|
|
1141
1282
|
} else {
|
|
1142
|
-
await
|
|
1283
|
+
await fs6.copy(tempDir, targetPath, { overwrite: true });
|
|
1143
1284
|
}
|
|
1144
1285
|
}
|
|
1145
1286
|
s.stop(pc3.green(`Successfully imported ${itemName}!`));
|
|
1146
|
-
await
|
|
1287
|
+
await fs6.remove(tempDir);
|
|
1147
1288
|
if (!options?.skipIntro) {
|
|
1148
1289
|
outro2(`${normalizedType} available at: ${targetPath}`);
|
|
1149
1290
|
}
|
|
1150
1291
|
} catch (error) {
|
|
1151
|
-
if (tempDir) await
|
|
1292
|
+
if (tempDir) await fs6.remove(tempDir);
|
|
1152
1293
|
cancel2(`An error occurred: ${error}`);
|
|
1153
1294
|
process.exit(1);
|
|
1154
1295
|
return;
|
|
@@ -1156,19 +1297,19 @@ async function importItem(type, url, options) {
|
|
|
1156
1297
|
}
|
|
1157
1298
|
|
|
1158
1299
|
// src/commands/list.ts
|
|
1159
|
-
import
|
|
1160
|
-
import
|
|
1300
|
+
import os6 from "node:os";
|
|
1301
|
+
import path7 from "node:path";
|
|
1161
1302
|
import { cancel as cancel3, intro as intro3, isCancel as isCancel4, outro as outro3, select as select4 } from "@clack/prompts";
|
|
1162
|
-
import
|
|
1303
|
+
import fs7 from "fs-extra";
|
|
1163
1304
|
import pc4 from "picocolors";
|
|
1164
1305
|
async function collectEntries(subdirPath) {
|
|
1165
|
-
const entries = await
|
|
1306
|
+
const entries = await fs7.readdir(subdirPath, { withFileTypes: true });
|
|
1166
1307
|
return entries.filter(
|
|
1167
1308
|
(entry) => entry.isDirectory() || entry.isFile() && entry.name.endsWith(".md")
|
|
1168
1309
|
).map((entry) => entry.name).sort();
|
|
1169
1310
|
}
|
|
1170
1311
|
function displayBase(base, scope) {
|
|
1171
|
-
if (scope === "global") return base.replace(
|
|
1312
|
+
if (scope === "global") return base.replace(os6.homedir(), "~");
|
|
1172
1313
|
return base;
|
|
1173
1314
|
}
|
|
1174
1315
|
async function listLocalForScopes(normalizedType, sections) {
|
|
@@ -1176,8 +1317,8 @@ async function listLocalForScopes(normalizedType, sections) {
|
|
|
1176
1317
|
console.log(pc4.bold(pc4.blue(`Installed ${normalizedType}s (Local):`)));
|
|
1177
1318
|
let foundAny = false;
|
|
1178
1319
|
for (const section of sections) {
|
|
1179
|
-
const subdirPath =
|
|
1180
|
-
if (!await
|
|
1320
|
+
const subdirPath = path7.join(section.base, subdir);
|
|
1321
|
+
if (!await fs7.pathExists(subdirPath)) continue;
|
|
1181
1322
|
const items = await collectEntries(subdirPath);
|
|
1182
1323
|
if (items.length === 0) continue;
|
|
1183
1324
|
foundAny = true;
|
|
@@ -1221,13 +1362,13 @@ async function performListing(type) {
|
|
|
1221
1362
|
return;
|
|
1222
1363
|
}
|
|
1223
1364
|
const dirPath = TYPE_DIRS[normalizedType];
|
|
1224
|
-
if (!await
|
|
1365
|
+
if (!await fs7.pathExists(dirPath)) {
|
|
1225
1366
|
console.log(
|
|
1226
1367
|
pc4.yellow(`No ${normalizedType} directory found at: ${dirPath}`)
|
|
1227
1368
|
);
|
|
1228
1369
|
return;
|
|
1229
1370
|
}
|
|
1230
|
-
const entries = await
|
|
1371
|
+
const entries = await fs7.readdir(dirPath, { withFileTypes: true });
|
|
1231
1372
|
const items = entries.filter(
|
|
1232
1373
|
(entry) => entry.isDirectory() || normalizedType !== "skill" && entry.isFile() && entry.name.endsWith(".md")
|
|
1233
1374
|
).map((entry) => entry.name).sort();
|
|
@@ -1317,7 +1458,7 @@ async function list(type, options) {
|
|
|
1317
1458
|
}
|
|
1318
1459
|
|
|
1319
1460
|
// src/commands/remove.ts
|
|
1320
|
-
import
|
|
1461
|
+
import path8 from "node:path";
|
|
1321
1462
|
import {
|
|
1322
1463
|
autocompleteMultiselect as autocompleteMultiselect2,
|
|
1323
1464
|
cancel as cancel4,
|
|
@@ -1329,7 +1470,7 @@ import {
|
|
|
1329
1470
|
select as select5,
|
|
1330
1471
|
spinner as spinner3
|
|
1331
1472
|
} from "@clack/prompts";
|
|
1332
|
-
import
|
|
1473
|
+
import fs8 from "fs-extra";
|
|
1333
1474
|
import pc5 from "picocolors";
|
|
1334
1475
|
enableAutocompleteMultiSelectShiftAToggle();
|
|
1335
1476
|
function buildScopedBases(scope, homedir, projectRoot) {
|
|
@@ -1355,9 +1496,9 @@ async function scanInstalledItems(scopedBases, subdir, normalizedType) {
|
|
|
1355
1496
|
const items = [];
|
|
1356
1497
|
const showScopePrefix = scopedBases.length > 1;
|
|
1357
1498
|
for (const { scope, base } of scopedBases) {
|
|
1358
|
-
const subdirPath =
|
|
1359
|
-
if (!await
|
|
1360
|
-
const entries = await
|
|
1499
|
+
const subdirPath = path8.join(base, subdir);
|
|
1500
|
+
if (!await fs8.pathExists(subdirPath)) continue;
|
|
1501
|
+
const entries = await fs8.readdir(subdirPath, { withFileTypes: true });
|
|
1361
1502
|
for (const entry of entries) {
|
|
1362
1503
|
const qualifies = entry.isDirectory() || normalizedType !== "skill" && entry.isFile() && entry.name.endsWith(".md");
|
|
1363
1504
|
if (!qualifies) continue;
|
|
@@ -1397,13 +1538,13 @@ function getPlatformDirs(scope, root, normalizedType) {
|
|
|
1397
1538
|
async function pruneDanglingLinks(platformDirs, entry, removedAgentsPath) {
|
|
1398
1539
|
for (const dir of Object.values(platformDirs)) {
|
|
1399
1540
|
if (!dir) continue;
|
|
1400
|
-
const link =
|
|
1541
|
+
const link = path8.join(dir, entry);
|
|
1401
1542
|
try {
|
|
1402
|
-
const stat = await
|
|
1543
|
+
const stat = await fs8.lstat(link);
|
|
1403
1544
|
if (!stat.isSymbolicLink()) continue;
|
|
1404
|
-
const resolved = await
|
|
1545
|
+
const resolved = await fs8.realpath(link).catch(() => "");
|
|
1405
1546
|
if (resolved === removedAgentsPath || resolved === "") {
|
|
1406
|
-
await
|
|
1547
|
+
await fs8.remove(link);
|
|
1407
1548
|
}
|
|
1408
1549
|
} catch {
|
|
1409
1550
|
}
|
|
@@ -1503,11 +1644,11 @@ async function remove(type, options) {
|
|
|
1503
1644
|
for (const item of selectedScannedItems) {
|
|
1504
1645
|
const scoped = basesByScope.get(item.scope);
|
|
1505
1646
|
if (!scoped) continue;
|
|
1506
|
-
const agentsPath =
|
|
1507
|
-
if (await
|
|
1647
|
+
const agentsPath = path8.join(scoped.base, subdir, item.name);
|
|
1648
|
+
if (await fs8.pathExists(agentsPath)) {
|
|
1508
1649
|
sDel.message(`Removing ${pc5.bold(item.label)} from .agents...`);
|
|
1509
1650
|
try {
|
|
1510
|
-
await
|
|
1651
|
+
await fs8.remove(agentsPath);
|
|
1511
1652
|
removedCount++;
|
|
1512
1653
|
const platformDirs = getPlatformDirs(
|
|
1513
1654
|
item.scope,
|
|
@@ -1549,7 +1690,7 @@ async function remove(type, options) {
|
|
|
1549
1690
|
}
|
|
1550
1691
|
|
|
1551
1692
|
// package.json
|
|
1552
|
-
var version = "2.
|
|
1693
|
+
var version = "2.3.0";
|
|
1553
1694
|
|
|
1554
1695
|
// bin/cli.ts
|
|
1555
1696
|
var program = new Command();
|
|
@@ -29,11 +29,11 @@ The `permissions.ask` rule in `~/.claude/settings.json` will surface a permissio
|
|
|
29
29
|
|
|
30
30
|
Every gated command, every time. No exceptions for "tiny" or "obvious" changes. Each commit gets its own gate even within the same session. Plan approval, prior "go ahead" signals, and approvals of earlier commits do NOT carry over.
|
|
31
31
|
|
|
32
|
-
### Step 1
|
|
32
|
+
### Step 1: STOP
|
|
33
33
|
|
|
34
34
|
Do not retry the command yet. The first attempt is your trigger, not your green light.
|
|
35
35
|
|
|
36
|
-
### Step 2
|
|
36
|
+
### Step 2: Emit a detailed technical summary
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
### Technical summary
|
|
@@ -46,11 +46,11 @@ Do not retry the command yet. The first attempt is your trigger, not your green
|
|
|
46
46
|
|
|
47
47
|
Fill every bullet. "None" is a valid answer; an omitted bullet is not.
|
|
48
48
|
|
|
49
|
-
### Step 3
|
|
49
|
+
### Step 3: Propose a commit message
|
|
50
50
|
|
|
51
51
|
Format per the Conventional Commits section below. No `Co-Authored-By: Claude` trailer. Imperative present tense, no trailing period in the subject. When a body is included, it explains the why.
|
|
52
52
|
|
|
53
|
-
**Match the message size to the change.** For small commits
|
|
53
|
+
**Match the message size to the change.** For small commits (single file, trivial scope, narrow type change, doc tweak, small revert, one-line config flip, renamed prop), propose a title-only message. No body, no bullets. The subject alone conveys intent and a body adds friction without value.
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
56
|
### Proposed commit message
|
|
@@ -69,11 +69,13 @@ Reserve the full proposal format (body + bullets) for commits that touch multipl
|
|
|
69
69
|
- bullet of notable change
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
### Step 4
|
|
72
|
+
### Step 4: Ask the user verbatim
|
|
73
73
|
|
|
74
74
|
Print exactly: **"Commit as-is, edit the message, or skip?"**
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
Steps 2 and 3 are your final chat message, using the exact headings and bullet labels above. Put the commit message in a code fence directly under its heading. The fence is the message's only boundary, so any note after the fence stays out of the approved message. End the turn there, with no tool call after the proposal. The `~/.claude/hooks/commit-gate.py` Stop hook checks the message and either lists what is missing or records the proposal and sends you back. Then ask through AskUserQuestion with that text as the question and "Commit as-is" as the first option. The preview can hold just the commit message. The same hook denies a commit question with no recorded proposal, and denies `git commit` until the user picks "Commit as-is". Each approval covers one commit. Commit with the approved message verbatim in a quoted heredoc (`-m "$(cat <<'EOF' ... EOF)"`), with no attribution trailers: the hook denies a changed message, `-F`, the editor, `--amend` without `-m`, and any `Co-Authored-By` or "Generated with Claude Code" line.
|
|
77
|
+
|
|
78
|
+
### Step 5: Wait for explicit approval
|
|
77
79
|
|
|
78
80
|
Only after the user replies with an affirmative answer may you re-attempt the command. The OS permission prompt will then surface for final confirmation.
|
|
79
81
|
|
|
@@ -1,34 +1,41 @@
|
|
|
1
1
|
# Example Commit Messages
|
|
2
2
|
|
|
3
3
|
## Features
|
|
4
|
+
|
|
4
5
|
- `feat(ui): add progress bar component`
|
|
5
6
|
- `feat(auth): implement oauth2 login with google`
|
|
6
7
|
- `feat!: change default api version to v2`
|
|
7
8
|
|
|
8
9
|
## Bug Fixes
|
|
10
|
+
|
|
9
11
|
- `fix(parser): handle empty input string gracefully`
|
|
10
12
|
- `fix: resolve race condition in database connection pool`
|
|
11
13
|
- `fix(styles): fix padding issues on mobile devices`
|
|
12
14
|
|
|
13
15
|
## Documentation
|
|
16
|
+
|
|
14
17
|
- `docs: update readme with installation instructions`
|
|
15
18
|
- `docs(api): document the new /user/profile endpoint`
|
|
16
19
|
- `docs: add contributing guide`
|
|
17
20
|
|
|
18
21
|
## Refactoring
|
|
22
|
+
|
|
19
23
|
- `refactor(core): simplify the request handling logic`
|
|
20
24
|
- `refactor: move utility functions to a separate module`
|
|
21
25
|
- `refactor(tests): reduce duplication in integration tests`
|
|
22
26
|
|
|
23
27
|
## Performance
|
|
28
|
+
|
|
24
29
|
- `perf(image): optimize image loading speed using lazy-loading`
|
|
25
30
|
- `perf: add caching layer for frequently accessed data`
|
|
26
31
|
|
|
27
32
|
## Tests
|
|
33
|
+
|
|
28
34
|
- `test(auth): add unit tests for jwt validation`
|
|
29
35
|
- `test: increase coverage for the math module`
|
|
30
36
|
|
|
31
37
|
## Chore
|
|
38
|
+
|
|
32
39
|
- `chore: update dependencies`
|
|
33
40
|
- `chore(deps): bump vite from 4.0 to 5.0`
|
|
34
41
|
- `chore: remove unused log statements`
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Protocol guard, not a sandbox. It reads Bash command text to keep the model from
|
|
3
|
+
# committing without an approved proposal. Shell expansions ($'...', $VAR, backticks),
|
|
4
|
+
# git options it does not know, xargs, or a script file can still run a commit it
|
|
5
|
+
# never sees; only a hook inside git itself could close that.
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
ASK = "Commit as-is, edit the message, or skip?"
|
|
13
|
+
BULLETS = ["Scope", "Behavior change", "Architecture/contract impact", "Tests", "Risk notes"]
|
|
14
|
+
SUBJECT = re.compile(r"^[a-z]+(\([^)]+\))?!?: \S")
|
|
15
|
+
STATE_DIR = os.path.expanduser("~/.cache/commit-gate")
|
|
16
|
+
SHELLS = {"sh", "bash", "zsh", "dash"}
|
|
17
|
+
OPERATORS = set(";&|()")
|
|
18
|
+
GIT_VALUE_OPTIONS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env"}
|
|
19
|
+
HOW = (
|
|
20
|
+
"Write the proposal (### Technical summary with the five bullets, then ### Proposed commit message with the message in a ``` code fence) "
|
|
21
|
+
"as your final chat message and end the turn. The Stop hook records it and sends you back to ask "
|
|
22
|
+
f'with AskUserQuestion: question "{ASK}", first option "Commit as-is".'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def proposal_problems(text):
|
|
27
|
+
missing = []
|
|
28
|
+
|
|
29
|
+
if "### Technical summary" not in text:
|
|
30
|
+
missing.append('"### Technical summary" heading')
|
|
31
|
+
|
|
32
|
+
for bullet in BULLETS:
|
|
33
|
+
if not re.search(rf"^- {re.escape(bullet)}:[ \t]*(\S|\n[ \t]+\S)", text, re.M):
|
|
34
|
+
missing.append(f'filled "- {bullet}:" bullet')
|
|
35
|
+
|
|
36
|
+
message = proposed_message(text).splitlines()
|
|
37
|
+
|
|
38
|
+
if not message or not SUBJECT.match(message[0]):
|
|
39
|
+
missing.append('"### Proposed commit message" followed by a ``` code fence whose first line is "<type>(<scope>): <subject>"')
|
|
40
|
+
|
|
41
|
+
return missing
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def proposed_message(text):
|
|
45
|
+
after = text.partition("### Proposed commit message")[2].lstrip("\n")
|
|
46
|
+
fenced = re.match(r"```[^\n]*\n(.*?)\n```", after, re.S)
|
|
47
|
+
|
|
48
|
+
return fenced.group(1).strip() if fenced else ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize(text):
|
|
52
|
+
return " ".join(text.split())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def commit_command_problems(command, message):
|
|
56
|
+
if re.search(r"co-authored-by|generated with claude code", command, re.I):
|
|
57
|
+
return "Remove the Co-Authored-By and 'Generated with Claude Code' lines; the user's rules forbid attribution."
|
|
58
|
+
|
|
59
|
+
if re.search(r"(?:^|\s)(?:-[a-zA-Z]*F|--file)(?:[\s=]|$)", command) or not re.search(r"(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:[\s=]|$)", command):
|
|
60
|
+
return "Pass the message with -m, using a quoted heredoc: -m \"$(cat <<'EOF' ... EOF)\". The hook cannot check -F, the editor, or --amend without -m."
|
|
61
|
+
|
|
62
|
+
if normalize(message) not in normalize(command):
|
|
63
|
+
return "The commit message differs from the approved proposal. Use the approved message verbatim, or propose the new one and ask again."
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_commit_question(question):
|
|
69
|
+
labels = [o.get("label", "") for o in question.get("options", [])]
|
|
70
|
+
|
|
71
|
+
return any(re.match(r"\s*commit as-is\b", label, re.I) for label in labels) or "commit" in question.get("header", "").lower()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def git_commit_count(command):
|
|
75
|
+
command = command.replace("\\\n", "")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|()")
|
|
79
|
+
lexer.whitespace_split = True
|
|
80
|
+
tokens = list(lexer)
|
|
81
|
+
except ValueError:
|
|
82
|
+
unquoted = re.sub(r"[\"'\\\\]", "", command)
|
|
83
|
+
|
|
84
|
+
return len(re.findall(r"(?<![\w-])git(?![\w-])\s.*?\bcommit\b", unquoted, re.S))
|
|
85
|
+
|
|
86
|
+
count = 0
|
|
87
|
+
|
|
88
|
+
for i, token in enumerate(tokens):
|
|
89
|
+
name = os.path.basename(token)
|
|
90
|
+
rest = tokens[i + 1:]
|
|
91
|
+
words = []
|
|
92
|
+
|
|
93
|
+
for word in rest:
|
|
94
|
+
if set(word) <= OPERATORS:
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
words.append(word)
|
|
98
|
+
|
|
99
|
+
if name == "eval" and words:
|
|
100
|
+
count += git_commit_count(" ".join(words))
|
|
101
|
+
elif name in SHELLS and len(words) > 1 and words[0] == "-c":
|
|
102
|
+
count += git_commit_count(words[1])
|
|
103
|
+
|
|
104
|
+
if name != "git":
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
while rest and rest[0].startswith("-"):
|
|
108
|
+
rest = rest[2:] if rest[0] in GIT_VALUE_OPTIONS else rest[1:]
|
|
109
|
+
|
|
110
|
+
if rest and rest[0] == "commit":
|
|
111
|
+
count += 1
|
|
112
|
+
|
|
113
|
+
return count
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load(path):
|
|
117
|
+
try:
|
|
118
|
+
return json.load(open(path))
|
|
119
|
+
except (OSError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save(path, state):
|
|
124
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
125
|
+
json.dump(state, open(path, "w"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def drop(path):
|
|
129
|
+
if os.path.exists(path):
|
|
130
|
+
os.remove(path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def decide(payload, path):
|
|
134
|
+
event = payload.get("hook_event_name")
|
|
135
|
+
state = load(path)
|
|
136
|
+
|
|
137
|
+
if event == "Stop":
|
|
138
|
+
text = payload.get("last_assistant_message") or ""
|
|
139
|
+
|
|
140
|
+
if not re.search(r"^### (Technical summary|Proposed commit message)\s*$", text, re.M):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
missing = proposal_problems(text)
|
|
144
|
+
|
|
145
|
+
if payload.get("stop_hook_active") and (missing or state):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
if missing:
|
|
149
|
+
return {"decision": "block", "reason": "Commit proposal incomplete. Missing: " + "; ".join(missing) + ". " + HOW}
|
|
150
|
+
|
|
151
|
+
save(path, {"approved": False, "message": proposed_message(text)})
|
|
152
|
+
|
|
153
|
+
return {"decision": "block", "reason": f'Proposal recorded. Now call AskUserQuestion with question "{ASK}" and first option "Commit as-is".'}
|
|
154
|
+
|
|
155
|
+
tool_input = payload.get("tool_input") or {}
|
|
156
|
+
questions = [q for q in tool_input.get("questions", []) if is_commit_question(q)]
|
|
157
|
+
|
|
158
|
+
if event == "PreToolUse" and payload.get("tool_name") == "AskUserQuestion" and questions:
|
|
159
|
+
if not state or state.get("approved"):
|
|
160
|
+
return deny("No recorded commit proposal. " + HOW)
|
|
161
|
+
|
|
162
|
+
if not all(ASK in q.get("question", "") for q in questions):
|
|
163
|
+
return deny(f'The commit question text must include "{ASK}".')
|
|
164
|
+
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
if event == "PostToolUse" and payload.get("tool_name") == "AskUserQuestion" and questions:
|
|
168
|
+
answers = (payload.get("tool_response") or {}).get("answers", {})
|
|
169
|
+
approved = all(re.match(r"\s*commit as-is\b", answers.get(q.get("question"), ""), re.I) for q in questions)
|
|
170
|
+
|
|
171
|
+
if approved:
|
|
172
|
+
save(path, {**state, "approved": True})
|
|
173
|
+
else:
|
|
174
|
+
drop(path)
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
commits = git_commit_count(tool_input.get("command", "")) if payload.get("tool_name") == "Bash" else 0
|
|
179
|
+
|
|
180
|
+
if event == "PreToolUse" and commits:
|
|
181
|
+
if not state or not state.get("approved"):
|
|
182
|
+
return deny("git commit needs an approved proposal. " + HOW)
|
|
183
|
+
|
|
184
|
+
if commits > 1:
|
|
185
|
+
return deny("Run exactly one git commit per command; each approval covers one commit.")
|
|
186
|
+
|
|
187
|
+
problem = commit_command_problems(tool_input["command"], state.get("message", ""))
|
|
188
|
+
|
|
189
|
+
if problem:
|
|
190
|
+
return deny(problem)
|
|
191
|
+
|
|
192
|
+
drop(path)
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def deny(reason):
|
|
198
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main():
|
|
202
|
+
payload = json.load(sys.stdin)
|
|
203
|
+
path = os.path.join(STATE_DIR, f"{payload.get('session_id', 'unknown')}.json")
|
|
204
|
+
result = decide(payload, path)
|
|
205
|
+
|
|
206
|
+
if result:
|
|
207
|
+
print(json.dumps(result))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def self_test():
|
|
211
|
+
global STATE_DIR
|
|
212
|
+
import tempfile
|
|
213
|
+
|
|
214
|
+
STATE_DIR = tempfile.mkdtemp()
|
|
215
|
+
path = os.path.join(STATE_DIR, "s.json")
|
|
216
|
+
good = (
|
|
217
|
+
"### Technical summary\n- Scope:\n - a.ts: loader gate\n- Behavior change: 403 for viewers\n"
|
|
218
|
+
"- Architecture/contract impact: none\n- Tests: 101 pass\n- Risk notes: none\n\n"
|
|
219
|
+
"### Proposed commit message\n```\nfix(api-access): gate the MCPs page loader\n\nViewers without manage rights\nnow get a 403.\n```\n\n" + ASK
|
|
220
|
+
)
|
|
221
|
+
question = {"question": ASK, "header": "Commit 1", "options": [{"label": "Commit as-is"}, {"label": "Skip"}]}
|
|
222
|
+
ask = {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "tool_input": {"questions": [question]}}
|
|
223
|
+
heredoc = "git -C /repo commit -m \"$(cat <<'EOF'\nfix(api-access): gate the MCPs page loader\n\nViewers without manage rights now get a 403.\nEOF\n)\""
|
|
224
|
+
commit = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": heredoc}}
|
|
225
|
+
|
|
226
|
+
def run(command):
|
|
227
|
+
return decide({**commit, "tool_input": {"command": command}}, path)
|
|
228
|
+
|
|
229
|
+
def answered(label):
|
|
230
|
+
return {**ask, "hook_event_name": "PostToolUse", "tool_response": {"answers": {ASK: label}}}
|
|
231
|
+
|
|
232
|
+
assert decide(ask, path), "popup without a recorded proposal is denied"
|
|
233
|
+
assert decide(commit, path), "commit without approval is denied"
|
|
234
|
+
assert "incomplete" in decide({"hook_event_name": "Stop", "last_assistant_message": good.replace("- Tests: 101 pass", "- Tests:")}, path)["reason"]
|
|
235
|
+
assert "recorded" in decide({"hook_event_name": "Stop", "last_assistant_message": good}, path)["reason"]
|
|
236
|
+
assert decide({"hook_event_name": "Stop", "last_assistant_message": good, "stop_hook_active": True}, path) is None
|
|
237
|
+
assert decide(ask, path) is None
|
|
238
|
+
assert decide(answered("Commit as-is (Recommended)"), path) is None
|
|
239
|
+
assert "differs" in run('git commit -m "fix(api-access): something else"')["hookSpecificOutput"]["permissionDecisionReason"]
|
|
240
|
+
assert "attribution" in run(heredoc.replace("\nEOF", "\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF"))["hookSpecificOutput"]["permissionDecisionReason"]
|
|
241
|
+
assert "-F" in run("git commit -F msg.txt")["hookSpecificOutput"]["permissionDecisionReason"]
|
|
242
|
+
assert run("git commit --amend --no-edit"), "bare amend is denied"
|
|
243
|
+
assert "exactly one" in run(heredoc + ' && git commit --allow-empty -m "other"')["hookSpecificOutput"]["permissionDecisionReason"]
|
|
244
|
+
assert decide(commit, path) is None, "approved commit with the proposed message runs"
|
|
245
|
+
assert decide(commit, path), "approval is single use"
|
|
246
|
+
unfenced = good.replace("```\nfix(api-access)", "fix(api-access)").replace("403.\n```", "403.")
|
|
247
|
+
assert "fence" in decide({"hook_event_name": "Stop", "last_assistant_message": unfenced}, path)["reason"]
|
|
248
|
+
decide({"hook_event_name": "Stop", "last_assistant_message": good.replace("\n\n" + ASK, "\n\nNote: trailers left out.\n\n" + ASK)}, path)
|
|
249
|
+
assert "trailers" not in load(path)["message"], "prose after the fence is not part of the message"
|
|
250
|
+
drop(path)
|
|
251
|
+
assert decide({"hook_event_name": "Stop", "last_assistant_message": "All done."}, path) is None
|
|
252
|
+
assert decide({"hook_event_name": "Stop", "last_assistant_message": "End with `### Technical summary` and `### Proposed commit message`."}, path) is None
|
|
253
|
+
assert decide({"hook_event_name": "Stop", "last_assistant_message": "### Technical summary\n- Scope:", "stop_hook_active": True}, path) is None
|
|
254
|
+
assert "recorded" in decide({"hook_event_name": "Stop", "last_assistant_message": good, "stop_hook_active": True}, path)["reason"]
|
|
255
|
+
drop(path)
|
|
256
|
+
decide({"hook_event_name": "Stop", "last_assistant_message": good}, path)
|
|
257
|
+
decide(answered("Skip"), path)
|
|
258
|
+
assert decide(commit, path), "skip cancels the proposal"
|
|
259
|
+
assert git_commit_count("git log --grep commit") == 0
|
|
260
|
+
assert git_commit_count("cd x && git commit --amend") == 1
|
|
261
|
+
assert git_commit_count('sh -c "git commit -m x"') == 1
|
|
262
|
+
assert git_commit_count("bash -c 'git -C /r commit -m x'") == 1
|
|
263
|
+
assert git_commit_count('eval "git commit -m x"; echo done') == 1
|
|
264
|
+
assert git_commit_count("echo it's the git-commit skill") == 0, "unparseable text naming git-commit is not a commit"
|
|
265
|
+
assert git_commit_count("echo it's; git -C /r commit -m x") == 1, "unparseable fallback still sees a real commit"
|
|
266
|
+
assert git_commit_count("echo it's; \"git\" commit -m x") == 1, "unparseable fallback ignores quotes"
|
|
267
|
+
assert git_commit_count("git --git-dir /r/.git commit -m x") == 1
|
|
268
|
+
assert git_commit_count("git --work-tree /r commit -m x") == 1
|
|
269
|
+
assert git_commit_count("git \\\ncommit -m x") == 1, "line continuation is joined"
|
|
270
|
+
assert decide({**ask, "tool_input": {"questions": [{"question": "Which branch?", "header": "Branch", "options": [{"label": "Commit to current branch"}]}]}}, path) is None
|
|
271
|
+
print("ok")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
self_test() if sys.argv[1:] == ["--self-test"] else main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "AskUserQuestion|Bash",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "python3 ${HOOKS_DIR}/commit-gate.py",
|
|
10
|
+
"timeout": 10
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"Stop": [
|
|
16
|
+
{
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "python3 ${HOOKS_DIR}/commit-gate.py",
|
|
21
|
+
"timeout": 10
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"PostToolUse": [
|
|
27
|
+
{
|
|
28
|
+
"matcher": "AskUserQuestion",
|
|
29
|
+
"hooks": [
|
|
30
|
+
{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "python3 ${HOOKS_DIR}/commit-gate.py",
|
|
33
|
+
"timeout": 10
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -121,6 +121,8 @@ Read **all** git state from a single `git -C "$cwd" status -b --porcelain` call,
|
|
|
121
121
|
|
|
122
122
|
The last-commit row needs one further call: `git -C "$cwd" log -1 --format='%h %s'`.
|
|
123
123
|
|
|
124
|
+
Every git call must skip optional locks: export `GIT_OPTIONAL_LOCKS=0` once at the top of the script. A plain `git status` takes `.git/index.lock` to refresh stat info, and the statusline re-runs on every turn, so it races the user's own `git add`/`commit` and makes them fail with `Unable to create '.git/index.lock': File exists`.
|
|
125
|
+
|
|
124
126
|
## Quota Colors (by REMAINING quota = 100 - used%)
|
|
125
127
|
|
|
126
128
|
| Remaining | Color | 24-bit ANSI |
|
|
@@ -195,6 +197,10 @@ JSON parsing rules:
|
|
|
195
197
|
|
|
196
198
|
set -u
|
|
197
199
|
|
|
200
|
+
# The statusline re-runs on every Claude turn. A plain `git status` grabs
|
|
201
|
+
# .git/index.lock to refresh stat info, racing the user's own git writes.
|
|
202
|
+
export GIT_OPTIONAL_LOCKS=0
|
|
203
|
+
|
|
198
204
|
if ! command -v jq >/dev/null 2>&1; then
|
|
199
205
|
printf '%s' "statusline: jq not installed"
|
|
200
206
|
exit 0
|
|
@@ -532,6 +538,9 @@ Write `statusline-command.ps1` to the targeted platform directory. Use `ConvertF
|
|
|
532
538
|
|
|
533
539
|
```powershell
|
|
534
540
|
$ErrorActionPreference = 'SilentlyContinue'
|
|
541
|
+
# The statusline re-runs on every Claude turn. A plain `git status` grabs
|
|
542
|
+
# .git/index.lock to refresh stat info, racing the user's own git writes.
|
|
543
|
+
$env:GIT_OPTIONAL_LOCKS = '0'
|
|
535
544
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
536
545
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
537
546
|
$ESC = [char]27
|