@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 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 path4 from "node:path";
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 fs4 from "fs-extra";
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 fs4.pathExists(sourceDir)) {
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 fs4.readdir(sourceDir, { withFileTypes: true });
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 fs4.remove(tempDir);
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 fs4.remove(tempDir);
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 = path4.join(agentsBase, subdir);
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 fs4.remove(tempDir);
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" ? path4.parse(item).name : item;
886
- if (await fs4.pathExists(path4.join(targetBase, targetName))) {
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 fs4.remove(tempDir);
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
- let installedCount = 0;
916
- await fs4.ensureDir(targetBase);
1030
+ const installed = [];
1031
+ await fs5.ensureDir(targetBase);
917
1032
  for (const item of selectedItems) {
918
- const targetName = normalizedType === "skill" ? path4.parse(item).name : item;
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 ? path4.join(tempDir, item) : tempDir : path4.join(TYPE_DIRS[normalizedType], item);
923
- if (!await fs4.pathExists(sourcePath)) {
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 = path4.join(targetBase, targetName);
927
- await fs4.copy(sourcePath, targetPath, { overwrite: true });
928
- installedCount++;
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 fs4.remove(tempDir);
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: ${installedCount}, Errors: ${errors.length}`
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 ${installedCount} ${normalizedType}s.`
1066
+ `Successfully installed ${installed.length} ${normalizedType}s.`
952
1067
  )
953
1068
  );
954
1069
  }
955
- if (installedCount > 0) {
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 fs4.remove(tempDir);
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 path5 from "node:path";
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 fs5 from "fs-extra";
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 : path5.join(targetBaseDir, itemName);
1240
+ const targetPath = shouldFlatten ? targetBaseDir : path6.join(targetBaseDir, itemName);
1100
1241
  if (shouldFlatten) {
1101
- const srcFiles = await fs5.readdir(tempDir);
1242
+ const srcFiles = await fs6.readdir(tempDir);
1102
1243
  const conflicts = [];
1103
1244
  for (const file of srcFiles) {
1104
- if (await fs5.pathExists(path5.join(targetBaseDir, file))) {
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 fs5.remove(tempDir);
1259
+ await fs6.remove(tempDir);
1119
1260
  return;
1120
1261
  }
1121
1262
  }
1122
- } else if (await fs5.pathExists(targetPath)) {
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 fs5.remove(tempDir);
1269
+ await fs6.remove(tempDir);
1129
1270
  return;
1130
1271
  }
1131
1272
  }
1132
1273
  s.start(`Importing ${itemName} to ${targetBaseDir}...`);
1133
- await fs5.ensureDir(targetBaseDir);
1274
+ await fs6.ensureDir(targetBaseDir);
1134
1275
  if (isFile) {
1135
- await fs5.copy(path5.join(tempDir, itemName), targetPath, {
1276
+ await fs6.copy(path6.join(tempDir, itemName), targetPath, {
1136
1277
  overwrite: true
1137
1278
  });
1138
1279
  } else {
1139
1280
  if (shouldFlatten) {
1140
- await fs5.copy(tempDir, targetBaseDir, { overwrite: true });
1281
+ await fs6.copy(tempDir, targetBaseDir, { overwrite: true });
1141
1282
  } else {
1142
- await fs5.copy(tempDir, targetPath, { overwrite: true });
1283
+ await fs6.copy(tempDir, targetPath, { overwrite: true });
1143
1284
  }
1144
1285
  }
1145
1286
  s.stop(pc3.green(`Successfully imported ${itemName}!`));
1146
- await fs5.remove(tempDir);
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 fs5.remove(tempDir);
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 os5 from "node:os";
1160
- import path6 from "node:path";
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 fs6 from "fs-extra";
1303
+ import fs7 from "fs-extra";
1163
1304
  import pc4 from "picocolors";
1164
1305
  async function collectEntries(subdirPath) {
1165
- const entries = await fs6.readdir(subdirPath, { withFileTypes: true });
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(os5.homedir(), "~");
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 = path6.join(section.base, subdir);
1180
- if (!await fs6.pathExists(subdirPath)) continue;
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 fs6.pathExists(dirPath)) {
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 fs6.readdir(dirPath, { withFileTypes: true });
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 path7 from "node:path";
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 fs7 from "fs-extra";
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 = path7.join(base, subdir);
1359
- if (!await fs7.pathExists(subdirPath)) continue;
1360
- const entries = await fs7.readdir(subdirPath, { withFileTypes: true });
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 = path7.join(dir, entry);
1541
+ const link = path8.join(dir, entry);
1401
1542
  try {
1402
- const stat = await fs7.lstat(link);
1543
+ const stat = await fs8.lstat(link);
1403
1544
  if (!stat.isSymbolicLink()) continue;
1404
- const resolved = await fs7.realpath(link).catch(() => "");
1545
+ const resolved = await fs8.realpath(link).catch(() => "");
1405
1546
  if (resolved === removedAgentsPath || resolved === "") {
1406
- await fs7.remove(link);
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 = path7.join(scoped.base, subdir, item.name);
1507
- if (await fs7.pathExists(agentsPath)) {
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 fs7.remove(agentsPath);
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.2.1";
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 — STOP
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 — Emit a detailed technical summary
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 — Propose a commit message
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 — 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.
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 — Ask the user verbatim
72
+ ### Step 4: Ask the user verbatim
73
73
 
74
74
  Print exactly: **"Commit as-is, edit the message, or skip?"**
75
75
 
76
- ### Step 5 — Wait for explicit approval
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurokeita/add-skill",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "CLI to install AI agent skills to various platforms",
5
5
  "type": "module",
6
6
  "bin": {