sonamu 0.10.4 → 0.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/bin/cli.js +206 -187
  2. package/dist/cone/cone-generator.js +3 -9
  3. package/dist/database/knex.d.ts.map +1 -1
  4. package/dist/database/knex.js +2 -2
  5. package/dist/database/puri.d.ts +17 -0
  6. package/dist/database/puri.d.ts.map +1 -1
  7. package/dist/database/puri.js +102 -3
  8. package/dist/migration/code-generation.js +6 -6
  9. package/dist/migration/migrator.d.ts.map +1 -1
  10. package/dist/migration/migrator.js +51 -6
  11. package/dist/ui-web/assets/{index-CDd6xT-F.js → index-D0MHYbxl.js} +2 -2
  12. package/dist/ui-web/assets/index-Dx_JX4aQ.css +1 -0
  13. package/dist/ui-web/index.html +2 -2
  14. package/package.json +2 -2
  15. package/src/bin/cli.ts +283 -282
  16. package/src/cone/cone-generator.ts +2 -18
  17. package/src/database/__tests__/puri.test.ts +183 -0
  18. package/src/database/knex.ts +4 -1
  19. package/src/database/puri.ts +229 -2
  20. package/src/database/puri.types.test-d.ts +56 -0
  21. package/src/migration/code-generation.ts +5 -5
  22. package/src/migration/migrator.ts +81 -7
  23. package/src/skills/AGENTS.md +76 -50
  24. package/src/skills/sonamu/SKILL.md +46 -227
  25. package/src/skills/{sonamu/ai-agents.md → sonamu-ai-agents/SKILL.md} +3 -3
  26. package/src/skills/{sonamu/api.md → sonamu-api/SKILL.md} +3 -2
  27. package/src/skills/{sonamu/auth.md → sonamu-auth/SKILL.md} +9 -4
  28. package/src/skills/{sonamu/auth-plugins.md → sonamu-auth/references/plugins.md} +2 -7
  29. package/src/skills/sonamu-auth/references/user-id-migration-followups.md +192 -0
  30. package/src/skills/{sonamu/auth-migration.md → sonamu-auth/references/user-id-migration.md} +1 -195
  31. package/src/skills/sonamu-config/SKILL.md +203 -0
  32. package/src/skills/{sonamu → sonamu-config/references}/database.md +3 -8
  33. package/src/skills/sonamu-config/references/environments.md +178 -0
  34. package/src/skills/sonamu-config/references/server-options.md +400 -0
  35. package/src/skills/sonamu-entity/SKILL.md +180 -0
  36. package/src/skills/{sonamu/entity-validation-checklist.md → sonamu-entity/references/creation-workflow.md} +117 -8
  37. package/src/skills/sonamu-entity/references/design-guides.md +243 -0
  38. package/src/skills/sonamu-entity/references/field-types.md +170 -0
  39. package/src/skills/sonamu-entity/references/relations-detail.md +245 -0
  40. package/src/skills/{sonamu/entity-relations.md → sonamu-entity/references/relations.md} +1 -258
  41. package/src/skills/{sonamu → sonamu-entity/references}/subset.md +1 -11
  42. package/src/skills/sonamu-fixture/SKILL.md +180 -0
  43. package/src/skills/{sonamu/fixture-cli.md → sonamu-fixture/references/cli-usage.md} +5 -176
  44. package/src/skills/{sonamu → sonamu-fixture/references}/cone.md +4 -14
  45. package/src/skills/sonamu-frontend/SKILL.md +142 -0
  46. package/src/skills/sonamu-frontend/references/components.md +323 -0
  47. package/src/skills/sonamu-frontend/references/examples.md +64 -0
  48. package/src/skills/sonamu-frontend/references/hooks.md +273 -0
  49. package/src/skills/sonamu-frontend/references/runtime.md +165 -0
  50. package/src/skills/{sonamu → sonamu-frontend/references}/scaffolding.md +3 -8
  51. package/src/skills/{sonamu/i18n.md → sonamu-i18n/SKILL.md} +2 -2
  52. package/src/skills/{sonamu/migration.md → sonamu-migration/SKILL.md} +1 -1
  53. package/src/skills/{sonamu/naite.md → sonamu-naite/SKILL.md} +5 -5
  54. package/src/skills/sonamu-query/SKILL.md +48 -0
  55. package/src/skills/sonamu-query/references/model-patterns.md +390 -0
  56. package/src/skills/{sonamu → sonamu-query/references}/model.md +1 -401
  57. package/src/skills/{sonamu → sonamu-query/references}/puri.md +31 -243
  58. package/src/skills/sonamu-query/references/search.md +238 -0
  59. package/src/skills/{sonamu → sonamu-query/references}/upsert.md +1 -6
  60. package/src/skills/{sonamu/tasks.md → sonamu-tasks/SKILL.md} +1 -1
  61. package/src/skills/sonamu-testing/SKILL.md +251 -0
  62. package/src/skills/{sonamu/testing-devrunner.md → sonamu-testing/references/devrunner.md} +4 -9
  63. package/src/skills/sonamu-testing/references/helpers.md +185 -0
  64. package/src/skills/sonamu-testing/references/patterns.md +263 -0
  65. package/src/skills/sonamu-testing/references/pitfalls.md +588 -0
  66. package/src/skills/sonamu-testing/references/quick-start.md +285 -0
  67. package/src/skills/sonamu-testing/references/type-safety.md +172 -0
  68. package/src/skills/sonamu-testing/references/writing-plan.md +375 -0
  69. package/src/skills/{sonamu/vector.md → sonamu-vector/SKILL.md} +1 -1
  70. package/dist/ui-web/assets/index-Dx4ap5i4.css +0 -1
  71. package/src/skills/commands/sonamu-skills.md +0 -20
  72. package/src/skills/project/README.md +0 -19
  73. package/src/skills/project/architecture.md +0 -373
  74. package/src/skills/sonamu/cdd.md +0 -129
  75. package/src/skills/sonamu/config.md +0 -772
  76. package/src/skills/sonamu/create-sonamu.md +0 -208
  77. package/src/skills/sonamu/entity-basic.md +0 -678
  78. package/src/skills/sonamu/framework-change.md +0 -96
  79. package/src/skills/sonamu/frontend.md +0 -915
  80. package/src/skills/sonamu/project-init.md +0 -477
  81. package/src/skills/sonamu/skill-contribution.md +0 -247
  82. package/src/skills/sonamu/testing.md +0 -2163
package/src/bin/cli.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import assert from "assert";
2
2
  import { execSync, spawn } from "child_process";
3
- import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "fs/promises";
3
+ import {
4
+ cp,
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ readlink,
9
+ realpath,
10
+ rm,
11
+ symlink,
12
+ writeFile,
13
+ } from "fs/promises";
4
14
  import { createRequire } from "module";
5
15
  import os from "os";
6
16
  import path from "path";
@@ -177,7 +187,7 @@ async function bootstrap() {
177
187
  ["dev", "web"],
178
188
  ["start"],
179
189
  ["skills", "sync"],
180
- ["skills", "create", "#name"],
190
+ ["skills", "index"],
181
191
  ["test"],
182
192
  ["auth", "generate"],
183
193
  ["auth", "add-companions"],
@@ -197,8 +207,8 @@ async function bootstrap() {
197
207
  stub_entity,
198
208
  scaffold_model,
199
209
  scaffold_model_test,
200
- // scaffold_view_list,
201
- // scaffold_view_form,
210
+ scaffold_view_list,
211
+ scaffold_view_form,
202
212
  cone_gen,
203
213
  sync,
204
214
  build_all,
@@ -209,7 +219,7 @@ async function bootstrap() {
209
219
  dev_web,
210
220
  start,
211
221
  skills_sync,
212
- skills_create,
222
+ skills_index,
213
223
  test: testCommand,
214
224
  auth_generate,
215
225
  "auth_add-companions": auth_add_companions,
@@ -983,6 +993,19 @@ async function scaffold_model_test(entityId: string) {
983
993
  });
984
994
  }
985
995
 
996
+ async function scaffold_view_list(entityId: string) {
997
+ await Sonamu.syncer.generateTemplate("view_list", {
998
+ entityId,
999
+ extra: undefined,
1000
+ });
1001
+ }
1002
+
1003
+ async function scaffold_view_form(entityId: string) {
1004
+ await Sonamu.syncer.generateTemplate("view_form", {
1005
+ entityId,
1006
+ });
1007
+ }
1008
+
986
1009
  /**
987
1010
  * pnpm sonamu skills sync 하면 실행되는 함수입니다.
988
1011
  * 공식 Skills를 로컬 프로젝트 또는 글로벌 ~/.claude/로 동기화합니다.
@@ -997,117 +1020,236 @@ async function skills_sync() {
997
1020
  // 빌드 후 - cli.js: node_modules/sonamu/dist/bin/cli.js (실제 실행)
998
1021
  // skills 위치: node_modules/sonamu/src/skills (npm 배포 시)
999
1022
  const sourceBase = path.resolve(import.meta.dirname, "..", "..", "src", "skills");
1000
- const sourceSkillsDir = path.join(sourceBase, "sonamu");
1001
- const sourceClaudeMd = path.join(sourceBase, "CLAUDE.md");
1002
-
1003
- if (!(await exists(sourceSkillsDir))) {
1023
+ if (!(await exists(sourceBase))) {
1004
1024
  console.log(chalk.yellow("Skills source not found in sonamu package."));
1005
1025
  return;
1006
1026
  }
1007
1027
 
1008
1028
  if (isGlobal) {
1009
- const homeClaudeDir = path.join(os.homedir(), ".claude");
1010
- await skills_sync_to(homeClaudeDir, sourceSkillsDir, sourceClaudeMd, {
1029
+ await skills_sync_to(os.homedir(), {
1011
1030
  useSymlink: false,
1012
- copyProjectTemplates: false,
1013
- isGlobal: true,
1031
+ sourceBase,
1014
1032
  });
1015
1033
 
1016
- // ~/.claude/commands/sonamu-skills.md 설치
1017
- const sourceCommandsDir = path.join(sourceBase, "commands");
1018
- const sourceCommand = path.join(sourceCommandsDir, "sonamu-skills.md");
1019
- if (await exists(sourceCommand)) {
1020
- const targetCommandsDir = path.join(homeClaudeDir, "commands");
1021
- await mkdir(targetCommandsDir, { recursive: true });
1022
- await cp(sourceCommand, path.join(targetCommandsDir, "sonamu-skills.md"));
1023
- console.log(chalk.green(`✓ /sonamu-skills command installed → ~/.claude/commands/`));
1024
- }
1034
+ // 구버전이 설치한 /sonamu-skills 커맨드 제거.
1035
+ // 수동 스킬 메뉴는 자동 discovery와 AGENTS.md 스킬 인덱스로 대체됐습니다.
1036
+ await rm(path.join(os.homedir(), ".claude", "commands", "sonamu-skills.md"), { force: true });
1025
1037
 
1026
- console.log(chalk.cyan(`\n Global sync complete → ~/.claude/skills/sonamu/`));
1027
- console.log(chalk.dim(` These skills are available in all Claude Code sessions.`));
1038
+ console.log(chalk.cyan(`\n Global sync complete → ~/.agents/skills/`));
1039
+ console.log(chalk.dim(` These skills are available in every agent session.`));
1028
1040
  console.log(
1029
1041
  chalk.dim(
1030
1042
  ` Once a project is created, run 'pnpm sonamu skills sync' for project-local sync.`,
1031
1043
  ),
1032
1044
  );
1033
1045
  } else {
1034
- const workspaceRoot = await findWorkspaceRoot();
1035
- const claudeDir = path.join(workspaceRoot, ".claude");
1036
- await skills_sync_to(claudeDir, sourceSkillsDir, sourceClaudeMd, {
1046
+ await skills_sync_to(findProjectRoot(), {
1037
1047
  useSymlink: true,
1038
- copyProjectTemplates: true,
1048
+ createSettings: true,
1039
1049
  sourceBase,
1040
1050
  });
1041
1051
  }
1042
1052
  }
1043
1053
 
1044
1054
  /**
1045
- * claudeDir로 skills를 동기화하는 공통 로직입니다.
1055
+ * 두 경로가 심볼릭 링크를 따라 같은 실체를 가리키는지 확인합니다.
1056
+ */
1057
+ async function resolvesToSameDir(a: string, b: string): Promise<boolean> {
1058
+ try {
1059
+ return (await realpath(a)) === (await realpath(b));
1060
+ } catch {
1061
+ return false;
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * `.claude/<entry>`가 `../.agents/<entry>`를 가리키도록 보장합니다.
1067
+ *
1068
+ * 원본은 에이전트 중립인 `.agents/`에 두고, Claude Code용 `.claude/`는 심볼릭
1069
+ * 링크로만 연결합니다. 이 저장소의 `CLAUDE.md -> AGENTS.md` 규약과 같은 방식입니다.
1070
+ * 이미 심볼릭 링크가 아닌 실체가 있으면 사용자 파일일 수 있으므로 건드리지 않습니다.
1071
+ */
1072
+ async function linkClaudeEntry(root: string, relative: string): Promise<boolean> {
1073
+ // `.claude` 자체가 `.agents`를 가리키는 심볼릭 링크인 저장소에서는 두 경로가
1074
+ // 같은 실체를 가리킵니다. 이때 링크를 걸면 원본을 자기 자신을 가리키는
1075
+ // 링크로 덮어쓰게 되므로 아무것도 하지 않습니다.
1076
+ if (await resolvesToSameDir(path.join(root, ".claude"), path.join(root, ".agents"))) {
1077
+ return false;
1078
+ }
1079
+
1080
+ const target = path.join(root, ".claude", relative);
1081
+ const source = path.join(root, ".agents", relative);
1082
+
1083
+ let existing: string | undefined;
1084
+ try {
1085
+ existing = await readlink(target);
1086
+ } catch {
1087
+ // 심볼릭 링크가 아니거나 존재하지 않음
1088
+ }
1089
+
1090
+ if (existing === undefined && (await exists(target))) {
1091
+ return false;
1092
+ }
1093
+
1094
+ await mkdir(path.dirname(target), { recursive: true });
1095
+ await rm(target, { recursive: true, force: true });
1096
+ await symlink(path.relative(path.dirname(target), source), target, "dir");
1097
+ return true;
1098
+ }
1099
+
1100
+ /**
1101
+ * 스킬을 배치할 프로젝트 루트를 찾습니다.
1102
+ *
1103
+ * Sonamu 프로젝트는 두 가지 레이아웃이 공존합니다.
1104
+ *
1105
+ * - `<root>/api` (예: examples/miomock)
1106
+ * - `<root>/packages/api` (create-sonamu가 생성하는 레이아웃)
1107
+ *
1108
+ * `findAppRootPath()`는 api의 부모를 그대로 돌려주므로 후자에서 `<root>/packages`가
1109
+ * 나오고, `findWorkspaceRoot()`는 pnpm-workspace.yaml을 찾아 올라가므로 모노레포에
1110
+ * 든 프로젝트에서 바깥 저장소가 잡힙니다. api의 부모가 `packages`면 한 단계 더
1111
+ * 올라가는 방식으로 두 경우를 모두 맞춥니다.
1112
+ */
1113
+ function findProjectRoot(): string {
1114
+ const apiParent = findAppRootPath();
1115
+ return path.basename(apiParent) === "packages" ? path.dirname(apiParent) : apiParent;
1116
+ }
1117
+
1118
+ /**
1119
+ * 구버전이 남긴 `skills/sonamu` 평면 디렉토리를 제거합니다.
1120
+ *
1121
+ * 예전에는 모든 스킬 문서를 `skills/sonamu/*.md` 한 디렉토리에 복사했습니다.
1122
+ * 지금은 `sonamu`가 라우팅 테이블을 담은 루트 스킬 이름이라 경로가 겹치는데,
1123
+ * 남아 있는 구버전 디렉토리에는 삭제된 문서를 가리키는 옛 SKILL.md가 들어 있어
1124
+ * 그대로 두면 낡은 라우팅 테이블이 스킬로 계속 로드됩니다.
1125
+ *
1126
+ * 심볼릭 링크는 정상 배치 경로에서 처리하므로 건드리지 않습니다. 최상위에
1127
+ * SKILL.md 외의 `.md`가 있으면 구버전 산출물로 판정합니다 — 새 루트 스킬은
1128
+ * SKILL.md 하나만 가집니다.
1129
+ */
1130
+ async function removeLegacyFlatSkillDir(root: string): Promise<void> {
1131
+ for (const base of [".agents", ".claude"]) {
1132
+ const target = path.join(root, base, "skills", "sonamu");
1133
+
1134
+ try {
1135
+ await readlink(target);
1136
+ continue; // 심볼릭 링크는 정상 경로에서 갱신됩니다
1137
+ } catch {
1138
+ // 심볼릭 링크가 아님
1139
+ }
1140
+
1141
+ if (!(await exists(target))) {
1142
+ continue;
1143
+ }
1144
+
1145
+ let entries: string[];
1146
+ try {
1147
+ entries = await readdir(target);
1148
+ } catch {
1149
+ continue;
1150
+ }
1151
+
1152
+ const hasLegacyDocs = entries.some((e) => e.endsWith(".md") && e !== "SKILL.md");
1153
+ if (!hasLegacyDocs) {
1154
+ continue;
1155
+ }
1156
+
1157
+ await rm(target, { recursive: true, force: true });
1158
+ console.log(chalk.yellow(`✓ Removed legacy ${base}/skills/sonamu (flat layout)`));
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * 배치 대상 스킬 디렉토리를 나열합니다.
1164
+ *
1165
+ * 에이전트는 `skills/<name>/SKILL.md` 단위로 스킬을 인식하므로,
1166
+ * SKILL.md를 가진 디렉토리만 대상으로 삼습니다.
1167
+ */
1168
+ async function listSkillDirs(sourceBase?: string): Promise<{ source: string; name: string }[]> {
1169
+ if (!sourceBase || !(await exists(sourceBase))) {
1170
+ return [];
1171
+ }
1172
+
1173
+ const entries = await readdir(sourceBase, { withFileTypes: true });
1174
+ const result: { source: string; name: string }[] = [];
1175
+
1176
+ for (const entry of entries) {
1177
+ if (!entry.isDirectory() || !entry.name.startsWith("sonamu")) {
1178
+ continue;
1179
+ }
1180
+ const source = path.join(sourceBase, entry.name);
1181
+ if (!(await exists(path.join(source, "SKILL.md")))) {
1182
+ continue;
1183
+ }
1184
+ result.push({ source, name: entry.name });
1185
+ }
1186
+
1187
+ return result.sort((a, b) => a.name.localeCompare(b.name));
1188
+ }
1189
+
1190
+ /**
1191
+ * 프로젝트(또는 홈) 루트에 skills를 동기화하는 공통 로직입니다.
1192
+ *
1193
+ * 원본은 `.agents/`에 두고 `.claude/`에는 심볼릭 링크만 겁니다.
1046
1194
  */
1047
1195
  async function skills_sync_to(
1048
- claudeDir: string,
1049
- sourceSkillsDir: string,
1050
- sourceClaudeMd: string,
1196
+ root: string,
1051
1197
  options: {
1052
1198
  useSymlink: boolean;
1053
- copyProjectTemplates: boolean;
1199
+ createSettings?: boolean;
1054
1200
  sourceBase?: string;
1055
- isGlobal?: boolean;
1056
1201
  },
1057
1202
  ) {
1058
- const targetSkillsDir = path.join(claudeDir, "skills", "sonamu");
1203
+ const agentsDir = path.join(root, ".agents");
1204
+ const skillsRoot = path.join(agentsDir, "skills");
1205
+ await mkdir(skillsRoot, { recursive: true });
1059
1206
 
1060
- // 기존 디렉토리/symlink 삭제 후 재생성
1061
- // exists()는 broken symlink를 감지하지 못하므로 rm을 무조건 시도합니다
1062
- try {
1063
- await rm(targetSkillsDir, { recursive: true, force: true });
1064
- } catch {
1065
- // 파일이 없으면 무시
1066
- }
1207
+ await removeLegacyFlatSkillDir(root);
1208
+
1209
+ // 에이전트는 skills/<name>/SKILL.md 단위로 스킬을 인식하므로 각각 별도 배치해야 합니다.
1210
+ const targets = await listSkillDirs(options.sourceBase);
1067
1211
 
1068
- await mkdir(path.dirname(targetSkillsDir), { recursive: true });
1212
+ for (const { source, name } of targets) {
1213
+ const targetDir = path.join(skillsRoot, name);
1069
1214
 
1070
- if (options.useSymlink) {
1215
+ // exists()는 broken symlink를 감지하지 못하므로 rm을 무조건 시도합니다
1071
1216
  try {
1072
- await symlink(sourceSkillsDir, targetSkillsDir, "dir");
1073
- console.log(chalk.green(`✓ Skills linked (symlink)`));
1074
- } catch (error) {
1075
- console.log(
1076
- chalk.yellow(`⚠ Symlink failed: ${error instanceof Error ? error.message : String(error)}`),
1077
- );
1078
- console.log(chalk.yellow(` Falling back to copy...`));
1079
- await skillsCopy(sourceSkillsDir, targetSkillsDir);
1217
+ await rm(targetDir, { recursive: true, force: true });
1218
+ } catch {
1219
+ // 파일이 없으면 무시
1080
1220
  }
1081
- } else {
1082
- await skillsCopy(sourceSkillsDir, targetSkillsDir);
1083
- }
1084
1221
 
1085
- // project 디렉토리 초기화 (없으면 생성, 있으면 유지)
1086
- if (options.copyProjectTemplates && options.sourceBase) {
1087
- const sourceProjectDir = path.join(options.sourceBase, "project");
1088
- const targetProjectDir = path.join(claudeDir, "skills", "project");
1089
-
1090
- if (await exists(sourceProjectDir)) {
1091
- if (!(await exists(targetProjectDir))) {
1092
- try {
1093
- await cp(sourceProjectDir, targetProjectDir, { recursive: true });
1094
- console.log(chalk.green(`✓ Project templates initialized`));
1095
- } catch (error) {
1096
- console.error(
1097
- chalk.red(
1098
- `✗ Failed to initialize project templates: ${error instanceof Error ? error.message : String(error)}`,
1099
- ),
1100
- );
1101
- }
1102
- } else {
1103
- console.log(chalk.dim(`⏭ Project templates already exist (preserved)`));
1222
+ if (options.useSymlink) {
1223
+ try {
1224
+ await symlink(source, targetDir, "dir");
1225
+ } catch (error) {
1226
+ console.log(
1227
+ chalk.yellow(
1228
+ `⚠ Symlink failed (${name}): ${error instanceof Error ? error.message : String(error)}`,
1229
+ ),
1230
+ );
1231
+ console.log(chalk.yellow(` Falling back to copy...`));
1232
+ await skillsCopy(source, targetDir);
1104
1233
  }
1234
+ } else {
1235
+ await skillsCopy(source, targetDir);
1105
1236
  }
1237
+
1238
+ await linkClaudeEntry(root, path.join("skills", name));
1106
1239
  }
1107
1240
 
1108
- // settings.local.json — project-local 모드에서만, 없을 때만 생성
1109
- if (options.copyProjectTemplates) {
1110
- const settingsLocalPath = path.join(claudeDir, "settings.local.json");
1241
+ console.log(
1242
+ chalk.green(
1243
+ `✓ Skills ${options.useSymlink ? "linked" : "copied"} → .agents/skills/ (${targets.length}): ${targets.map((t) => t.name).join(", ")}`,
1244
+ ),
1245
+ );
1246
+ console.log(chalk.dim(` .claude/skills/* symlinked for Claude Code`));
1247
+
1248
+ // settings.local.json — Claude Code 전용 파일이므로 .claude/에 직접 둡니다.
1249
+ // project-local 모드에서만, 없을 때만 생성합니다.
1250
+ if (options.createSettings) {
1251
+ await mkdir(path.join(root, ".claude"), { recursive: true });
1252
+ const settingsLocalPath = path.join(root, ".claude", "settings.local.json");
1111
1253
  if (!(await exists(settingsLocalPath))) {
1112
1254
  try {
1113
1255
  const settingsContent = {
@@ -1139,191 +1281,91 @@ async function skills_sync_to(
1139
1281
  }
1140
1282
  }
1141
1283
 
1142
- // CLAUDE.md 복사/업데이트
1143
- if (await exists(sourceClaudeMd)) {
1144
- try {
1145
- const targetClaudeMd = path.join(claudeDir, "CLAUDE.md");
1146
- const rawContent = await readFile(sourceClaudeMd, "utf-8");
1147
- // 글로벌 모드에서는 상대 경로를 절대 경로로 변환합니다
1148
- const sourceContent = options.isGlobal
1149
- ? rawContent.replaceAll(".claude/skills/sonamu/", "~/.claude/skills/sonamu/")
1150
- : rawContent;
1151
-
1152
- if (await exists(targetClaudeMd)) {
1153
- const targetContent = await readFile(targetClaudeMd, "utf-8");
1154
- const startMarker = "<!-- SONAMU:START -->";
1155
- const endMarker = "<!-- SONAMU:END -->";
1156
- if (targetContent.includes(startMarker) && targetContent.includes(endMarker)) {
1157
- const startIdx = targetContent.indexOf(startMarker);
1158
- const endIdx = targetContent.indexOf(endMarker);
1159
-
1160
- if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
1161
- const before = targetContent.substring(0, startIdx);
1162
- const after = targetContent.substring(endIdx + endMarker.length);
1163
- const newContent = `${before}${startMarker}\n${sourceContent}\n${endMarker}${after}`;
1164
- await writeFile(targetClaudeMd, newContent);
1165
- console.log(chalk.green(`✓ CLAUDE.md updated (marker region)`));
1166
- } else {
1167
- console.log(chalk.yellow(`⏭ CLAUDE.md marker positions invalid, skipped`));
1168
- }
1169
- } else {
1170
- // 마커가 없는 기존 CLAUDE.md에 Sonamu 섹션을 추가합니다
1171
- const appended = `${targetContent.trimEnd()}\n\n<!-- SONAMU:START -->\n${sourceContent}\n<!-- SONAMU:END -->\n`;
1172
- await writeFile(targetClaudeMd, appended);
1173
- console.log(chalk.green(`✓ CLAUDE.md updated (appended Sonamu section)`));
1174
- }
1175
- } else {
1176
- const withMarkers = `<!-- SONAMU:START -->\n${sourceContent}\n<!-- SONAMU:END -->\n`;
1177
- await writeFile(targetClaudeMd, withMarkers);
1178
- console.log(chalk.green(`✓ CLAUDE.md created`));
1179
- }
1180
- } catch (error) {
1181
- console.error(
1182
- chalk.red(
1183
- `✗ Failed to update CLAUDE.md: ${error instanceof Error ? error.message : String(error)}`,
1184
- ),
1185
- );
1186
- }
1187
- }
1188
- }
1189
-
1190
- async function skillsCopy(src: string, dest: string) {
1191
- try {
1192
- await cp(src, dest, { recursive: true });
1193
- console.log(chalk.green(`✓ Skills copied`));
1194
- } catch (copyError) {
1195
- console.error(
1196
- chalk.red(
1197
- `✗ Failed to copy skills: ${copyError instanceof Error ? copyError.message : String(copyError)}`,
1198
- ),
1199
- );
1200
- throw copyError;
1201
- }
1284
+ console.log(
1285
+ chalk.dim(
1286
+ `\n To also write the skill index into this project's AGENTS.md, run 'sonamu skills index'.`,
1287
+ ),
1288
+ );
1202
1289
  }
1203
1290
 
1204
1291
  /**
1205
- * pnpm sonamu skills create <name> 하면 실행되는 함수입니다.
1206
- * 로컬 skill 초안을 생성합니다.
1292
+ * 루트 스킬(`sonamu`)의 라우팅 테이블과 공통 규약을 프로젝트 AGENTS.md에 씁니다.
1293
+ *
1294
+ * 개별 스킬의 description은 각자의 발동 순간에만 걸리므로, 어느 스킬에도 맞지
1295
+ * 않는 작업에서는 아무것도 안 걸립니다. 상시 로드되는 AGENTS.md에 인덱스를 두면
1296
+ * 그 공백을 메울 수 있습니다.
1297
+ *
1298
+ * 다만 사용자의 에이전트 프롬프트 파일을 고치는 동작이므로 `skills sync`에
1299
+ * 포함하지 않고, 사용자가 명시적으로 실행할 때만 수행합니다.
1300
+ * `<!-- SONAMU:START -->` ~ `<!-- SONAMU:END -->` 구간만 갱신하며 나머지는 보존합니다.
1207
1301
  */
1208
- async function skills_create(name: string) {
1209
- const workspaceRoot = await findWorkspaceRoot();
1210
- const localDir = path.join(workspaceRoot, ".claude", "skills", "local");
1302
+ async function skills_index() {
1303
+ const appRoot = findProjectRoot();
1304
+ const sourceBase = path.resolve(import.meta.dirname, "..", "..", "src", "skills");
1305
+ const rootSkill = path.join(sourceBase, "sonamu", "SKILL.md");
1211
1306
 
1212
- // === 파일명 검증 및 Sanitize ===
1213
- if (!name || name.trim() === "") {
1214
- console.error(chalk.red("✗ Skill name is required"));
1307
+ if (!(await exists(rootSkill))) {
1308
+ console.log(chalk.yellow("Root skill not found in sonamu package."));
1215
1309
  return;
1216
1310
  }
1217
1311
 
1218
- let sanitized = name
1219
- // 공백을 하이픈으로
1220
- .replace(/\s+/g, "-")
1221
- // 경로 구분자 제거
1222
- .replace(/[/\\]/g, "-")
1223
- // Path traversal 방지
1224
- .replace(/\.\./g, "")
1225
- // Windows 금지 문자 제거
1226
- .replace(/[<>:"|?*]/g, "")
1227
- // 시작/끝 점, 하이픈, 언더스코어 제거
1228
- .replace(/^[.\-_]+|[.\-_]+$/g, "")
1229
- // 연속된 하이픈을 하나로
1230
- .replace(/-+/g, "-")
1231
- // 알파벳, 숫자, 하이픈, 언더스코어, 한글만 허용
1232
- .replace(/[^a-zA-Z0-9-_가-힣]/g, "");
1233
-
1234
- // 길이 제한
1235
- const MAX_LENGTH = 100;
1236
- if (sanitized.length > MAX_LENGTH) {
1237
- sanitized = sanitized.substring(0, MAX_LENGTH);
1238
- console.log(chalk.yellow(`⚠ Name truncated to ${MAX_LENGTH} characters`));
1239
- }
1312
+ // frontmatter는 스킬 메타데이터이므로 주입 대상에서 제외합니다
1313
+ const body = (await readFile(rootSkill, "utf-8")).replace(/^---\n[\s\S]*?\n---\n+/, "");
1240
1314
 
1241
- // Windows 예약어 확인
1242
- const RESERVED_NAMES = [
1243
- "CON",
1244
- "PRN",
1245
- "AUX",
1246
- "NUL",
1247
- "COM1",
1248
- "COM2",
1249
- "COM3",
1250
- "COM4",
1251
- "COM5",
1252
- "COM6",
1253
- "COM7",
1254
- "COM8",
1255
- "COM9",
1256
- "LPT1",
1257
- "LPT2",
1258
- "LPT3",
1259
- "LPT4",
1260
- "LPT5",
1261
- "LPT6",
1262
- "LPT7",
1263
- "LPT8",
1264
- "LPT9",
1265
- ];
1266
- if (RESERVED_NAMES.includes(sanitized.toUpperCase())) {
1267
- sanitized = `skill-${sanitized}`;
1268
- console.log(chalk.yellow(`⚠ Reserved name detected, prefixed with "skill-"`));
1269
- }
1315
+ const startMarker = "<!-- SONAMU:START -->";
1316
+ const endMarker = "<!-- SONAMU:END -->";
1317
+ const section = `${startMarker}\n${body.trim()}\n${endMarker}`;
1270
1318
 
1271
- // 빈 문자열 체크
1272
- if (sanitized === "") {
1273
- console.error(chalk.red("✗ Invalid skill name after sanitization"));
1274
- console.log(chalk.dim(` Original: "${name}"`));
1275
- return;
1276
- }
1319
+ const targetPath = path.join(appRoot, "AGENTS.md");
1320
+ const existing = (await exists(targetPath)) ? await readFile(targetPath, "utf-8") : undefined;
1277
1321
 
1278
- // 변경 알림
1279
- if (sanitized !== name) {
1280
- console.log(chalk.yellow(`⚠ Name sanitized: "${name}" → "${sanitized}"`));
1322
+ let next: string;
1323
+ let action: string;
1324
+ if (existing === undefined) {
1325
+ next = `${section}\n`;
1326
+ action = "created";
1327
+ } else {
1328
+ const startIdx = existing.indexOf(startMarker);
1329
+ const endIdx = existing.indexOf(endMarker);
1330
+ if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
1331
+ next = existing.slice(0, startIdx) + section + existing.slice(endIdx + endMarker.length);
1332
+ action = "updated";
1333
+ } else {
1334
+ next = `${existing.trimEnd()}\n\n${section}\n`;
1335
+ action = "appended";
1336
+ }
1281
1337
  }
1282
1338
 
1283
- const filePath = path.join(localDir, `${sanitized}.md`);
1284
-
1285
- if (await exists(filePath)) {
1286
- console.log(chalk.yellow(`Skill "${sanitized}" already exists.`));
1339
+ if (next === existing) {
1340
+ console.log(chalk.dim(`⏭ AGENTS.md already up to date`));
1287
1341
  return;
1288
1342
  }
1289
1343
 
1290
- await mkdir(localDir, { recursive: true });
1291
-
1292
- const template = `---
1293
- name: ${sanitized}
1294
- category: other
1295
- created_at: ${new Date().toISOString().split("T")[0]}
1296
- status: draft
1297
- ---
1298
-
1299
- # ${sanitized}
1300
-
1301
- ## 상황
1302
-
1303
- [어떤 문제였는지]
1304
-
1305
- ## 해결 방법
1344
+ await writeFile(targetPath, next);
1345
+ console.log(chalk.green(`✓ AGENTS.md ${action} (Sonamu marker region)`));
1306
1346
 
1307
- [어떻게 해결했는지]
1308
-
1309
- ## 코드 예시
1347
+ if (!(await exists(path.join(appRoot, "CLAUDE.md")))) {
1348
+ await symlink("AGENTS.md", path.join(appRoot, "CLAUDE.md"));
1349
+ console.log(chalk.dim(` CLAUDE.md → AGENTS.md`));
1350
+ }
1310
1351
 
1311
- \`\`\`typescript
1312
- // 예시 코드
1313
- \`\`\`
1314
- `;
1352
+ console.log(chalk.dim(` Remove the marker region to undo.`));
1353
+ }
1315
1354
 
1316
- await writeFile(filePath, template);
1317
- console.log(chalk.green(`✓ Created .claude/skills/local/${sanitized}.md`));
1355
+ async function skillsCopy(src: string, dest: string) {
1356
+ try {
1357
+ await cp(src, dest, { recursive: true });
1358
+ console.log(chalk.green(`✓ Skills copied`));
1359
+ } catch (copyError) {
1360
+ console.error(
1361
+ chalk.red(
1362
+ `✗ Failed to copy skills: ${copyError instanceof Error ? copyError.message : String(copyError)}`,
1363
+ ),
1364
+ );
1365
+ throw copyError;
1366
+ }
1318
1367
  }
1319
1368
 
1320
- /**
1321
- * pnpm sonamu auth generate 하면 실행되는 함수입니다.
1322
- * better-auth 엔티티들(User, Session, Account, Verification)을 생성합니다.
1323
- *
1324
- * 옵션:
1325
- * --plugins phone-number,2fa 플러그인 엔티티도 함께 생성
1326
- */
1327
1369
  async function auth_generate() {
1328
1370
  // --plugins 옵션 파싱
1329
1371
  const pluginsArg = process.argv.find((arg) => arg.startsWith("--plugins"));
@@ -1368,44 +1410,3 @@ async function auth_add_companions() {
1368
1410
  await addCompanionsToEntities();
1369
1411
  console.log(chalk.bold("\n✅ Done!"));
1370
1412
  }
1371
-
1372
- /**
1373
- * 워크스페이스 루트를 찾습니다.
1374
- * 우선순위: pnpm-workspace.yaml > package.json(workspaces) > .agents/
1375
- *
1376
- * CLAUDE.md는 서브패키지에도 존재할 수 있으므로 사용하지 않습니다.
1377
- * .agents/는 agents init이 생성하는 디렉토리로, 워크스페이스 루트에만 존재합니다.
1378
- */
1379
- async function findWorkspaceRoot() {
1380
- let dir = process.cwd();
1381
-
1382
- while (dir !== path.dirname(dir)) {
1383
- // 1. pnpm-workspace.yaml: 확실한 monorepo 루트.
1384
- if (await exists(path.join(dir, "pnpm-workspace.yaml"))) {
1385
- return dir;
1386
- }
1387
-
1388
- // 2. package.json에 workspaces 필드가 있으면 monorepo 루트.
1389
- const packagePath = path.join(dir, "package.json");
1390
- if (await exists(packagePath)) {
1391
- try {
1392
- const packageJson = JSON.parse(await readFile(packagePath, "utf-8"));
1393
- if (packageJson.workspaces) {
1394
- return dir;
1395
- }
1396
- } catch {
1397
- // 파싱 실패시 무시
1398
- }
1399
- }
1400
-
1401
- // 3. .agents/: agents init이 생성한 디렉토리. 서브패키지에는 존재하지 않음.
1402
- if (await exists(path.join(dir, ".agents"))) {
1403
- return dir;
1404
- }
1405
-
1406
- dir = path.dirname(dir);
1407
- }
1408
-
1409
- // 찾지 못하면 api 폴더의 부모 사용
1410
- return findAppRootPath();
1411
- }