ai-commons 0.2.7
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/AGENTS.md +73 -0
- package/README.md +113 -0
- package/bin/ai-commons.js +38 -0
- package/hooks/ensure-rules-pointer.js +45 -0
- package/hooks/save-dialog.js +201 -0
- package/package.json +22 -0
- package/scripts/installer.js +145 -0
- package/scripts/managed-block.js +44 -0
- package/scripts/postinstall.js +28 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# ai-commons — общие правила для всех проектов
|
|
2
|
+
|
|
3
|
+
Этот файл подгружается в каждую Claude Code сессию проекта-потребителя через
|
|
4
|
+
импорт `@node_modules/ai-commons/AGENTS.md` в корневом `CLAUDE.md`. В корневом
|
|
5
|
+
`CLAUDE.md` потребителя есть управляемая секция между метками
|
|
6
|
+
`<!-- ai-commons:begin -->` и `<!-- ai-commons:end -->` — её содержимое
|
|
7
|
+
перезаписывается хуком `ai-commons` на каждом старте сессии. **Не редактируй
|
|
8
|
+
блок между метками** — изменения будут потеряны. Всё, что в `CLAUDE.md`
|
|
9
|
+
снаружи блока, остаётся нетронутым: пиши там свои проектные правила.
|
|
10
|
+
|
|
11
|
+
Чтобы поменять общие правила, правь этот файл в репозитории `ai-commons`.
|
|
12
|
+
|
|
13
|
+
Правила сгруппированы по трём уровням приоритета. Игнорировать MUST нельзя
|
|
14
|
+
ни при каких обстоятельствах. Желательно — стандартное поведение по
|
|
15
|
+
умолчанию, отступление возможно только с явной причиной. Остальное —
|
|
16
|
+
стиль и привычки.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## MUST — обязательно, игнорировать нельзя
|
|
21
|
+
|
|
22
|
+
- **Никогда не делай `git push --force` в `main`/`master`.** Если нужно
|
|
23
|
+
переписать историю, спроси разрешения и работай через feature-ветку.
|
|
24
|
+
- **Никогда не коммить секреты** (`.env`, ключи, токены, credentials.json
|
|
25
|
+
и т.п.). Если такой файл попал в `git add`, удали его из стейджа и
|
|
26
|
+
предупреди пользователя.
|
|
27
|
+
- **Не запускай деструктивные операции без подтверждения**: `rm -rf`,
|
|
28
|
+
`git reset --hard`, `DROP TABLE`, `git clean -fd`, отключение pre-commit
|
|
29
|
+
хуков (`--no-verify`), force push куда угодно.
|
|
30
|
+
- **Не правь блок между метками `ai-commons:begin / end`** в корневом
|
|
31
|
+
`CLAUDE.md` потребителя — он управляется `ai-commons` и перезаписывается
|
|
32
|
+
на старте сессии. Свои правила пиши снаружи этого блока.
|
|
33
|
+
- **Не отключай и не модифицируй хуки `ai-commons`** в `.claude/settings.json`
|
|
34
|
+
без явного запроса пользователя.
|
|
35
|
+
|
|
36
|
+
## Желательно — стандарт по умолчанию, отступать только с причиной
|
|
37
|
+
|
|
38
|
+
- Отвечай кратко: один-два абзаца, без избыточных вводных и итогов.
|
|
39
|
+
- Перед нетривиальной задачей дай короткий план (3–5 пунктов), потом
|
|
40
|
+
делай. Не пиши длинные эссе про подход.
|
|
41
|
+
- Не добавляй фич, рефакторингов и абстракций сверх задачи. Bug fix не
|
|
42
|
+
тянет за собой cleanup.
|
|
43
|
+
- Не пиши комментарии, описывающие *что* делает код. Пиши только если
|
|
44
|
+
нужно объяснить *почему* — и только если это неочевидно.
|
|
45
|
+
- Не валидируй то, что не может случиться. Доверяй внутренним
|
|
46
|
+
гарантиям типов и фреймворков; валидируй только на границах системы
|
|
47
|
+
(вход пользователя, внешние API).
|
|
48
|
+
- Перед удалением «непонятного» файла/ветки/конфигурации — выясни, не
|
|
49
|
+
работа ли это пользователя в процессе. Спроси, если есть сомнения.
|
|
50
|
+
|
|
51
|
+
## Остальное — стиль и привычки
|
|
52
|
+
|
|
53
|
+
- `curl`-примеры — одной строкой, без переносов `\`.
|
|
54
|
+
- При ссылках на код используй формат `path/to/file.ts:42`.
|
|
55
|
+
- Для UI-задач — запусти dev-сервер и проверь в браузере перед тем, как
|
|
56
|
+
говорить «готово». Тесты и типы проверяют корректность кода, не
|
|
57
|
+
фичи.
|
|
58
|
+
- Если задача неясная или формулировка двусмысленная — задай один-два
|
|
59
|
+
уточняющих вопроса вместо угадывания.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Как это устроено технически
|
|
64
|
+
|
|
65
|
+
- `ai-commons` ставится как обычная npm-зависимость:
|
|
66
|
+
`npm i -D ai-commons` (или `file:~/okneigres-repos/ai-commons` для локальной разработки).
|
|
67
|
+
- `postinstall` вставляет/обновляет блок между метками `ai-commons:begin/end`
|
|
68
|
+
в корневом `CLAUDE.md` потребителя. Если файла нет — создаёт его.
|
|
69
|
+
- `SessionStart`-хук `ai-commons` обновляет этот блок на каждом старте
|
|
70
|
+
сессии — это гарантирует, что правила всегда актуальны. Остальное
|
|
71
|
+
содержимое `CLAUDE.md` не трогается.
|
|
72
|
+
- `Stop`-хук сохраняет каждый диалог в
|
|
73
|
+
`.ai-dialogs/YYMMDD-HHII-<topic>.md` (см. README модуля).
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ai-commons
|
|
2
|
+
|
|
3
|
+
Общие правила для Claude Code + автосохранение диалогов. Поставляется как
|
|
4
|
+
npm-зависимость. После `npm install` в проекте появляется:
|
|
5
|
+
|
|
6
|
+
- В корневом `CLAUDE.md` — управляемый блок между метками:
|
|
7
|
+
```
|
|
8
|
+
<!-- ai-commons:begin -->
|
|
9
|
+
... @node_modules/ai-commons/AGENTS.md ...
|
|
10
|
+
<!-- ai-commons:end -->
|
|
11
|
+
```
|
|
12
|
+
Этот блок **обновляется хуком** при каждом старте сессии. Всё, что в файле
|
|
13
|
+
снаружи блока, — твоё, не трогается. Если `CLAUDE.md` не было — создастся
|
|
14
|
+
с одним блоком.
|
|
15
|
+
- Хуки в `.claude/settings.json`:
|
|
16
|
+
- `SessionStart` — обновляет блок в `CLAUDE.md` на каждом старте сессии.
|
|
17
|
+
- `Stop` и `SessionEnd` — сохраняют диалог в `.ai-dialogs/YYMMDD-HHII-<topic>.md`.
|
|
18
|
+
- Запись `.ai-dialogs/` в `.gitignore`.
|
|
19
|
+
|
|
20
|
+
## Установка в новый проект
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd my-new-project
|
|
24
|
+
npm i -D /Users/os/okneigres-repos/ai-commons
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Или, если опубликовано в реестре:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm i -D ai-commons
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`postinstall` всё настраивает автоматически. Запустить вручную:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx ai-commons install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Откат
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx ai-commons uninstall
|
|
43
|
+
# затем удалить зависимость:
|
|
44
|
+
npm uninstall ai-commons
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`uninstall` уберёт блок `ai-commons` из `CLAUDE.md` (сам файл остаётся, если в
|
|
48
|
+
нём есть другое содержимое — удаляется только если оказался пустым) и
|
|
49
|
+
вычистит хуки `ai-commons` из `.claude/settings.json`, не трогая чужие хуки.
|
|
50
|
+
|
|
51
|
+
## Уровни правил
|
|
52
|
+
|
|
53
|
+
`AGENTS.md` в этом репо разбит на три секции:
|
|
54
|
+
|
|
55
|
+
- **MUST** — обязательно, игнорировать нельзя ни при каких обстоятельствах
|
|
56
|
+
(например: не пушить force в main, не коммитить секреты).
|
|
57
|
+
- **Желательно** — стандарт по умолчанию, отступать можно только с явной
|
|
58
|
+
причиной (например: краткие ответы, без избыточных абстракций).
|
|
59
|
+
- **Остальное** — стиль и привычки (например: `curl` одной строкой,
|
|
60
|
+
ссылки на код в формате `path:line`).
|
|
61
|
+
|
|
62
|
+
## Как обновить правила во всех проектах
|
|
63
|
+
|
|
64
|
+
1. Правишь `AGENTS.md` здесь.
|
|
65
|
+
2. `git commit && git push` (или просто коммит, если репо локальный).
|
|
66
|
+
3. В каждом потребителе: `npm update ai-commons` (или `npm install`, если
|
|
67
|
+
зависимость через `file:`).
|
|
68
|
+
|
|
69
|
+
Содержимое подгружается Claude Code через `@`-импорт каждый сеанс, поэтому
|
|
70
|
+
после обновления `node_modules/ai-commons/AGENTS.md` следующая сессия
|
|
71
|
+
увидит новые правила автоматически.
|
|
72
|
+
|
|
73
|
+
## Сохранение диалогов
|
|
74
|
+
|
|
75
|
+
После каждого Claude Stop (и при завершении сессии) хук пишет
|
|
76
|
+
`.ai-dialogs/YYMMDD-HHII-<topic>.md`:
|
|
77
|
+
|
|
78
|
+
- Timestamp — момент первой реплики пользователя в сессии.
|
|
79
|
+
- `<topic>` — `ai-title` из транскрипта Claude Code, fallback на первую
|
|
80
|
+
пользовательскую реплику.
|
|
81
|
+
- Файл переписывается на каждом Stop в пределах одной сессии (то есть
|
|
82
|
+
всегда содержит полный диалог).
|
|
83
|
+
- Thinking-блоки модели не сохраняются — только пользовательский текст,
|
|
84
|
+
ответы ассистента, tool calls и их результаты.
|
|
85
|
+
|
|
86
|
+
`.ai-dialogs/` гитигнорится автоматически.
|
|
87
|
+
|
|
88
|
+
## Структура репо
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
ai-commons/
|
|
92
|
+
├── AGENTS.md — правила, импортируются потребителями
|
|
93
|
+
├── package.json — bin + postinstall
|
|
94
|
+
├── bin/ai-commons.js — CLI: install / uninstall
|
|
95
|
+
├── scripts/
|
|
96
|
+
│ ├── postinstall.js — npm-хук, запускает installer.install
|
|
97
|
+
│ ├── installer.js — pointer-блок + merge hooks + .gitignore
|
|
98
|
+
│ └── managed-block.js — хелперы для блока в CLAUDE.md
|
|
99
|
+
└── hooks/
|
|
100
|
+
├── ensure-rules-pointer.js — SessionStart-хук
|
|
101
|
+
└── save-dialog.js — Stop / SessionEnd-хук
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Переменные окружения
|
|
105
|
+
|
|
106
|
+
- `AI_COMMONS_SKIP_POSTINSTALL=1` — отключить автоустановку (CI и т.п.).
|
|
107
|
+
|
|
108
|
+
## Что не делается
|
|
109
|
+
|
|
110
|
+
- `ai-commons` **не** распространяет общие сабагенты/скиллы/слэш-команды.
|
|
111
|
+
Этот скоуп умышленно убран — если понадобится, добавим отдельной командой
|
|
112
|
+
`ai-commons sync-agents`.
|
|
113
|
+
- `permissions` и `env` из `settings.json` потребителя не трогаются.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { install, uninstall } = require('../scripts/installer');
|
|
3
|
+
|
|
4
|
+
const USAGE = `Usage: ai-commons <command>
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
install Write CLAUDE.md pointer, register hooks, update .gitignore
|
|
8
|
+
(idempotent — same as the postinstall script)
|
|
9
|
+
uninstall Remove pointer file (if unchanged) and ai-commons hooks
|
|
10
|
+
help Show this help
|
|
11
|
+
|
|
12
|
+
Run from the consumer project root (where package.json lives).
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
function main() {
|
|
16
|
+
const cmd = process.argv[2];
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
|
|
19
|
+
switch (cmd) {
|
|
20
|
+
case 'install':
|
|
21
|
+
install(cwd);
|
|
22
|
+
break;
|
|
23
|
+
case 'uninstall':
|
|
24
|
+
uninstall(cwd);
|
|
25
|
+
break;
|
|
26
|
+
case 'help':
|
|
27
|
+
case '--help':
|
|
28
|
+
case '-h':
|
|
29
|
+
case undefined:
|
|
30
|
+
process.stdout.write(USAGE);
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
process.stderr.write(`Unknown command: ${cmd}\n\n${USAGE}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart hook: ensures the consumer's root CLAUDE.md contains an
|
|
3
|
+
// up-to-date ai-commons block. The block lives between
|
|
4
|
+
// `<!-- ai-commons:begin -->` and `<!-- ai-commons:end -->` markers; any
|
|
5
|
+
// content OUTSIDE the markers is preserved.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { applyBlock } = require('../scripts/managed-block');
|
|
10
|
+
|
|
11
|
+
const POINTER_FILE = 'CLAUDE.md';
|
|
12
|
+
|
|
13
|
+
function readStdinJson() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
16
|
+
return raw ? JSON.parse(raw) : {};
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function main() {
|
|
23
|
+
const input = readStdinJson();
|
|
24
|
+
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
25
|
+
if (!cwd || !fs.existsSync(cwd)) {
|
|
26
|
+
process.stderr.write('[ai-commons] no cwd, skipping pointer refresh\n');
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pointerPath = path.join(cwd, POINTER_FILE);
|
|
31
|
+
const existing = fs.existsSync(pointerPath)
|
|
32
|
+
? fs.readFileSync(pointerPath, 'utf8')
|
|
33
|
+
: null;
|
|
34
|
+
const next = applyBlock(existing);
|
|
35
|
+
|
|
36
|
+
if (existing != null && next === existing) {
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fs.writeFileSync(pointerPath, next);
|
|
41
|
+
process.stderr.write(`[ai-commons] refreshed block in ${POINTER_FILE}\n`);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main();
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Stop / SessionEnd hook: dumps the current Claude Code transcript to
|
|
3
|
+
// <cwd>/.ai-dialogs/YYMMDD-HHII-<topic>.md. Filename is deterministic per
|
|
4
|
+
// session, so repeated Stop calls overwrite the same file.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const MAX_SLUG_LEN = 60;
|
|
10
|
+
const DIALOGS_DIR = '.ai-dialogs';
|
|
11
|
+
|
|
12
|
+
function readStdinJson() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
15
|
+
return raw ? JSON.parse(raw) : {};
|
|
16
|
+
} catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseJsonl(filePath) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
24
|
+
for (const line of content.split('\n')) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed) continue;
|
|
27
|
+
try {
|
|
28
|
+
out.push(JSON.parse(trimmed));
|
|
29
|
+
} catch {
|
|
30
|
+
// ignore malformed line
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pad2(n) {
|
|
37
|
+
return String(n).padStart(2, '0');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatStamp(date) {
|
|
41
|
+
return (
|
|
42
|
+
pad2(date.getFullYear() % 100) +
|
|
43
|
+
pad2(date.getMonth() + 1) +
|
|
44
|
+
pad2(date.getDate()) +
|
|
45
|
+
'-' +
|
|
46
|
+
pad2(date.getHours()) +
|
|
47
|
+
pad2(date.getMinutes())
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function slugify(text) {
|
|
52
|
+
if (!text) return 'untitled';
|
|
53
|
+
// Lowercase, replace forbidden filename chars and whitespace, collapse dashes.
|
|
54
|
+
// Preserve Unicode letters/digits (so Cyrillic stays readable).
|
|
55
|
+
let s = text.toLowerCase();
|
|
56
|
+
s = s.replace(/[\/\\:*?"<>|]/g, ' ');
|
|
57
|
+
s = s.replace(/[\s_]+/g, '-');
|
|
58
|
+
s = s.replace(/-+/g, '-');
|
|
59
|
+
s = s.replace(/^-+|-+$/g, '');
|
|
60
|
+
s = s.slice(0, MAX_SLUG_LEN);
|
|
61
|
+
s = s.replace(/-+$/g, '');
|
|
62
|
+
return s || 'untitled';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractText(content) {
|
|
66
|
+
if (typeof content === 'string') return content;
|
|
67
|
+
if (!Array.isArray(content)) return '';
|
|
68
|
+
const parts = [];
|
|
69
|
+
for (const block of content) {
|
|
70
|
+
if (!block || typeof block !== 'object') continue;
|
|
71
|
+
switch (block.type) {
|
|
72
|
+
case 'text':
|
|
73
|
+
if (block.text) parts.push(block.text);
|
|
74
|
+
break;
|
|
75
|
+
case 'tool_use':
|
|
76
|
+
parts.push(
|
|
77
|
+
`*[tool_use: ${block.name}]*\n\`\`\`json\n${JSON.stringify(
|
|
78
|
+
block.input,
|
|
79
|
+
null,
|
|
80
|
+
2
|
|
81
|
+
)}\n\`\`\``
|
|
82
|
+
);
|
|
83
|
+
break;
|
|
84
|
+
case 'tool_result': {
|
|
85
|
+
const inner =
|
|
86
|
+
typeof block.content === 'string'
|
|
87
|
+
? block.content
|
|
88
|
+
: Array.isArray(block.content)
|
|
89
|
+
? block.content
|
|
90
|
+
.map((b) => (b && b.type === 'text' ? b.text : ''))
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n')
|
|
93
|
+
: '';
|
|
94
|
+
parts.push(`*[tool_result]*\n\`\`\`\n${truncate(inner, 4000)}\n\`\`\``);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'thinking':
|
|
98
|
+
// Skip thinking blocks — keep dialogs concise.
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
// ignore unknown block types
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return parts.join('\n\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function truncate(s, limit) {
|
|
109
|
+
if (s.length <= limit) return s;
|
|
110
|
+
return s.slice(0, limit) + `\n... [truncated ${s.length - limit} chars]`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pickTopic(messages) {
|
|
114
|
+
// Prefer Claude Code's own generated title if present.
|
|
115
|
+
for (const m of messages) {
|
|
116
|
+
if (m.type === 'ai-title' && m.aiTitle) return m.aiTitle;
|
|
117
|
+
}
|
|
118
|
+
// Fallback: first user message text.
|
|
119
|
+
for (const m of messages) {
|
|
120
|
+
if (m.type === 'user' && m.message) {
|
|
121
|
+
const text = extractText(m.message.content);
|
|
122
|
+
if (text) return text.split('\n')[0].slice(0, 80);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return 'untitled';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pickStartTime(messages) {
|
|
129
|
+
for (const m of messages) {
|
|
130
|
+
if ((m.type === 'user' || m.type === 'assistant') && m.timestamp) {
|
|
131
|
+
return new Date(m.timestamp);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Fallback: file mtime or now
|
|
135
|
+
return new Date();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function render(messages, topic) {
|
|
139
|
+
const out = [];
|
|
140
|
+
out.push(`# ${topic}`);
|
|
141
|
+
out.push('');
|
|
142
|
+
|
|
143
|
+
for (const m of messages) {
|
|
144
|
+
if (m.type !== 'user' && m.type !== 'assistant') continue;
|
|
145
|
+
if (!m.message) continue;
|
|
146
|
+
|
|
147
|
+
const role = m.message.role || m.type;
|
|
148
|
+
const text = extractText(m.message.content);
|
|
149
|
+
if (!text.trim()) continue;
|
|
150
|
+
|
|
151
|
+
const ts = m.timestamp ? ` \n*${m.timestamp}*` : '';
|
|
152
|
+
out.push(`## ${role}${ts}`);
|
|
153
|
+
out.push('');
|
|
154
|
+
out.push(text);
|
|
155
|
+
out.push('');
|
|
156
|
+
}
|
|
157
|
+
return out.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function main() {
|
|
161
|
+
const input = readStdinJson();
|
|
162
|
+
const transcriptPath = input.transcript_path || input.transcriptPath;
|
|
163
|
+
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
164
|
+
|
|
165
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
166
|
+
process.stderr.write('[ai-commons] no transcript path, skipping dialog save\n');
|
|
167
|
+
process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
if (!cwd || !fs.existsSync(cwd)) {
|
|
170
|
+
process.stderr.write('[ai-commons] no cwd, skipping dialog save\n');
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const messages = parseJsonl(transcriptPath);
|
|
175
|
+
if (messages.length === 0) {
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const topic = pickTopic(messages);
|
|
180
|
+
const slug = slugify(topic);
|
|
181
|
+
const start = pickStartTime(messages);
|
|
182
|
+
const stamp = formatStamp(start);
|
|
183
|
+
|
|
184
|
+
const dialogsDir = path.join(cwd, DIALOGS_DIR);
|
|
185
|
+
fs.mkdirSync(dialogsDir, { recursive: true });
|
|
186
|
+
|
|
187
|
+
const filename = `${stamp}-${slug}.md`;
|
|
188
|
+
const outPath = path.join(dialogsDir, filename);
|
|
189
|
+
|
|
190
|
+
fs.writeFileSync(outPath, render(messages, topic));
|
|
191
|
+
process.stderr.write(`[ai-commons] saved dialog ${DIALOGS_DIR}/${filename}\n`);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
main();
|
|
197
|
+
} catch (err) {
|
|
198
|
+
process.stderr.write(`[ai-commons] save-dialog error: ${err.message}\n`);
|
|
199
|
+
// Don't fail the hook chain — exit 0.
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-commons",
|
|
3
|
+
"version": "0.2.7",
|
|
4
|
+
"description": "Shared Claude Code rules + dialog-saver hook for projects",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ai-commons": "bin/ai-commons.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"postinstall": "node scripts/postinstall.js",
|
|
10
|
+
"prepublishOnly": "npm version patch --force"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"scripts",
|
|
15
|
+
"hooks",
|
|
16
|
+
"AGENTS.md",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=16"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Installs ai-commons into a consumer project:
|
|
2
|
+
// - writes/refreshes the root CLAUDE.md pointer
|
|
3
|
+
// - merges our hooks into .claude/settings.json (preserving any existing hooks)
|
|
4
|
+
// - ensures .ai-dialogs/ is gitignored
|
|
5
|
+
//
|
|
6
|
+
// Designed to be idempotent: running install repeatedly is safe and cleans up
|
|
7
|
+
// duplicate ai-commons entries.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { applyBlock, removeBlock } = require('./managed-block');
|
|
12
|
+
|
|
13
|
+
const PACKAGE_NAME = require('../package.json').name;
|
|
14
|
+
const POINTER_FILE = 'CLAUDE.md';
|
|
15
|
+
const SETTINGS_FILE = path.join('.claude', 'settings.json');
|
|
16
|
+
const GITIGNORE_FILE = '.gitignore';
|
|
17
|
+
|
|
18
|
+
const HOOK_MARKER = `node_modules/${PACKAGE_NAME}/hooks/`;
|
|
19
|
+
|
|
20
|
+
const MANAGED_HOOKS = {
|
|
21
|
+
SessionStart: `node "$CLAUDE_PROJECT_DIR/node_modules/${PACKAGE_NAME}/hooks/ensure-rules-pointer.js"`,
|
|
22
|
+
Stop: `node "$CLAUDE_PROJECT_DIR/node_modules/${PACKAGE_NAME}/hooks/save-dialog.js"`,
|
|
23
|
+
SessionEnd: `node "$CLAUDE_PROJECT_DIR/node_modules/${PACKAGE_NAME}/hooks/save-dialog.js"`,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const GITIGNORE_BLOCK = `\n# ai-commons\n.ai-dialogs/\n`;
|
|
27
|
+
|
|
28
|
+
function log(msg) {
|
|
29
|
+
process.stderr.write(`[ai-commons] ${msg}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readJsonOrEmpty(p) {
|
|
33
|
+
if (!fs.existsSync(p)) return {};
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
log(`warning: ${p} is not valid JSON (${err.message}) — leaving as-is`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writePointer(consumerRoot) {
|
|
43
|
+
const pointerPath = path.join(consumerRoot, POINTER_FILE);
|
|
44
|
+
const existing = fs.existsSync(pointerPath)
|
|
45
|
+
? fs.readFileSync(pointerPath, 'utf8')
|
|
46
|
+
: null;
|
|
47
|
+
const next = applyBlock(existing);
|
|
48
|
+
if (existing != null && next === existing) return false;
|
|
49
|
+
fs.writeFileSync(pointerPath, next);
|
|
50
|
+
log(
|
|
51
|
+
existing == null
|
|
52
|
+
? `created ${POINTER_FILE} with ai-commons block`
|
|
53
|
+
: `refreshed ai-commons block in ${POINTER_FILE}`
|
|
54
|
+
);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stripManagedHooksFromEvent(eventEntries) {
|
|
59
|
+
if (!Array.isArray(eventEntries)) return [];
|
|
60
|
+
return eventEntries
|
|
61
|
+
.map((group) => {
|
|
62
|
+
if (!group || !Array.isArray(group.hooks)) return group;
|
|
63
|
+
const filteredHooks = group.hooks.filter(
|
|
64
|
+
(h) => !(h && typeof h.command === 'string' && h.command.includes(HOOK_MARKER))
|
|
65
|
+
);
|
|
66
|
+
return { ...group, hooks: filteredHooks };
|
|
67
|
+
})
|
|
68
|
+
.filter((group) => group && Array.isArray(group.hooks) && group.hooks.length > 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mergeHooks(consumerRoot) {
|
|
72
|
+
const settingsPath = path.join(consumerRoot, SETTINGS_FILE);
|
|
73
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
74
|
+
|
|
75
|
+
const settings = readJsonOrEmpty(settingsPath);
|
|
76
|
+
if (settings === null) return;
|
|
77
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
78
|
+
|
|
79
|
+
for (const [event, command] of Object.entries(MANAGED_HOOKS)) {
|
|
80
|
+
const stripped = stripManagedHooksFromEvent(settings.hooks[event]);
|
|
81
|
+
stripped.push({
|
|
82
|
+
hooks: [{ type: 'command', command }],
|
|
83
|
+
});
|
|
84
|
+
settings.hooks[event] = stripped;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
88
|
+
log(`merged hooks into ${SETTINGS_FILE}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ensureGitignore(consumerRoot) {
|
|
92
|
+
const giPath = path.join(consumerRoot, GITIGNORE_FILE);
|
|
93
|
+
const existing = fs.existsSync(giPath) ? fs.readFileSync(giPath, 'utf8') : '';
|
|
94
|
+
if (existing.split('\n').some((l) => l.trim() === '.ai-dialogs/')) return;
|
|
95
|
+
const next = existing + (existing.endsWith('\n') || existing === '' ? '' : '\n') + GITIGNORE_BLOCK;
|
|
96
|
+
fs.writeFileSync(giPath, next);
|
|
97
|
+
log(`added .ai-dialogs/ to ${GITIGNORE_FILE}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function install(consumerRoot) {
|
|
101
|
+
if (!consumerRoot || !fs.existsSync(consumerRoot)) {
|
|
102
|
+
throw new Error(`Invalid consumer root: ${consumerRoot}`);
|
|
103
|
+
}
|
|
104
|
+
writePointer(consumerRoot);
|
|
105
|
+
mergeHooks(consumerRoot);
|
|
106
|
+
ensureGitignore(consumerRoot);
|
|
107
|
+
log('install complete');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function uninstall(consumerRoot) {
|
|
111
|
+
// Strip our managed block from the pointer file. Leave any user-written
|
|
112
|
+
// content intact; remove the file only if it ends up empty.
|
|
113
|
+
const pointerPath = path.join(consumerRoot, POINTER_FILE);
|
|
114
|
+
if (fs.existsSync(pointerPath)) {
|
|
115
|
+
const existing = fs.readFileSync(pointerPath, 'utf8');
|
|
116
|
+
const next = removeBlock(existing);
|
|
117
|
+
if (next.trim() === '') {
|
|
118
|
+
fs.rmSync(pointerPath);
|
|
119
|
+
log(`removed empty ${POINTER_FILE}`);
|
|
120
|
+
} else if (next !== existing) {
|
|
121
|
+
fs.writeFileSync(pointerPath, next);
|
|
122
|
+
log(`removed ai-commons block from ${POINTER_FILE}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Strip our hooks from settings.
|
|
127
|
+
const settingsPath = path.join(consumerRoot, SETTINGS_FILE);
|
|
128
|
+
if (fs.existsSync(settingsPath)) {
|
|
129
|
+
const settings = readJsonOrEmpty(settingsPath);
|
|
130
|
+
if (settings && settings.hooks) {
|
|
131
|
+
for (const event of Object.keys(MANAGED_HOOKS)) {
|
|
132
|
+
if (settings.hooks[event]) {
|
|
133
|
+
const stripped = stripManagedHooksFromEvent(settings.hooks[event]);
|
|
134
|
+
if (stripped.length === 0) delete settings.hooks[event];
|
|
135
|
+
else settings.hooks[event] = stripped;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
139
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
140
|
+
log(`removed hooks from ${SETTINGS_FILE}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { install, uninstall, PACKAGE_NAME };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Shared helpers for inserting/updating the ai-commons block inside a
|
|
2
|
+
// host markdown file (typically the consumer's CLAUDE.md).
|
|
3
|
+
|
|
4
|
+
const PACKAGE_NAME = 'ai-commons';
|
|
5
|
+
const BEGIN = '<!-- ai-commons:begin -->';
|
|
6
|
+
const END = '<!-- ai-commons:end -->';
|
|
7
|
+
|
|
8
|
+
const BLOCK_BODY = [
|
|
9
|
+
BEGIN,
|
|
10
|
+
'<!-- Managed by ai-commons. Anything between these markers is overwritten -->',
|
|
11
|
+
'<!-- on every Claude Code session start. To change rules, edit AGENTS.md -->',
|
|
12
|
+
'<!-- in the ai-commons repo. Everything OUTSIDE these markers is yours. -->',
|
|
13
|
+
`@node_modules/${PACKAGE_NAME}/AGENTS.md`,
|
|
14
|
+
END,
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
function escapeRegex(s) {
|
|
18
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BLOCK_RE = new RegExp(`${escapeRegex(BEGIN)}[\\s\\S]*?${escapeRegex(END)}`);
|
|
22
|
+
|
|
23
|
+
// Returns the host content with our managed block inserted/refreshed.
|
|
24
|
+
// - If the host already contains markers, replace content between them.
|
|
25
|
+
// - Otherwise, prepend the block at the top, separated by a blank line.
|
|
26
|
+
// - If host is null/undefined, return a file containing just the block.
|
|
27
|
+
function applyBlock(hostContent) {
|
|
28
|
+
if (hostContent == null) return BLOCK_BODY + '\n';
|
|
29
|
+
if (BLOCK_RE.test(hostContent)) {
|
|
30
|
+
return hostContent.replace(BLOCK_RE, BLOCK_BODY);
|
|
31
|
+
}
|
|
32
|
+
const sep = hostContent.length === 0 || hostContent.startsWith('\n') ? '\n' : '\n\n';
|
|
33
|
+
return BLOCK_BODY + sep + hostContent;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Returns the host content with the managed block (and surrounding blank
|
|
37
|
+
// lines) removed. Safe to call when no block is present.
|
|
38
|
+
function removeBlock(hostContent) {
|
|
39
|
+
if (!hostContent) return '';
|
|
40
|
+
const re = new RegExp(`${escapeRegex(BEGIN)}[\\s\\S]*?${escapeRegex(END)}\\n*`, 'g');
|
|
41
|
+
return hostContent.replace(re, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { applyBlock, removeBlock, BEGIN, END, BLOCK_BODY };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { install } = require('./installer');
|
|
3
|
+
|
|
4
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
5
|
+
const consumerRoot = process.env.INIT_CWD;
|
|
6
|
+
|
|
7
|
+
if (!consumerRoot) {
|
|
8
|
+
console.log('[ai-commons] INIT_CWD not set, skipping install');
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (path.resolve(consumerRoot) === path.resolve(PACKAGE_ROOT)) {
|
|
13
|
+
console.log('[ai-commons] installing own deps, skipping install');
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (process.env.AI_COMMONS_SKIP_POSTINSTALL === '1') {
|
|
18
|
+
console.log('[ai-commons] AI_COMMONS_SKIP_POSTINSTALL=1, skipping install');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
install(consumerRoot);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error('[ai-commons] install failed:', err.message);
|
|
26
|
+
// Don't fail the npm install — Claude config is non-critical.
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|