pasika 0.1.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/AGENTS.md +15 -0
- package/CLAUDE.md +1 -0
- package/README.md +112 -0
- package/claude/.claude/hooks/notification.sh +38 -0
- package/claude/.claude/hooks/protect-files.sh +21 -0
- package/claude/.claude/hooks/status-line/index.js +57 -0
- package/claude/.claude/settings.base.json +38 -0
- package/claude/scripts/render-settings.ts +207 -0
- package/dist/claude/scripts/render-settings.js +132 -0
- package/dist/eslint.config.js +7 -0
- package/dist/scripts/pasika.js +59 -0
- package/docs/claude/hooks.md +30 -0
- package/docs/common/merge-behavior.md +17 -0
- package/docs/common/overview.md +20 -0
- package/package.json +34 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# pasika
|
|
2
|
+
pasika scope and usage.
|
|
3
|
+
Full documentation: docs/common/overview.md
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Merge Behavior
|
|
8
|
+
How pasika merges into existing Claude settings.
|
|
9
|
+
Full documentation: docs/common/merge-behavior.md
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Claude Hooks
|
|
14
|
+
Shared Claude hooks shipped by pasika.
|
|
15
|
+
Full documentation: docs/claude/hooks.md
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# pasika
|
|
2
|
+
|
|
3
|
+
Reusable base assets for Claude Code setups.
|
|
4
|
+
|
|
5
|
+
`pasika` stores portable Claude base config assets that individual repos can copy from or adapt.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
This repo intentionally starts narrow:
|
|
10
|
+
|
|
11
|
+
- Claude only for v1
|
|
12
|
+
- reusable base config only
|
|
13
|
+
- shared docs at the repo root
|
|
14
|
+
- no project-specific rules, workflows, or business logic
|
|
15
|
+
|
|
16
|
+
## Layout
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
claude/
|
|
20
|
+
scripts/
|
|
21
|
+
render-settings.ts
|
|
22
|
+
.claude/
|
|
23
|
+
settings.base.json
|
|
24
|
+
hooks/
|
|
25
|
+
status-line/
|
|
26
|
+
index.js
|
|
27
|
+
notification.sh
|
|
28
|
+
protect-files.sh
|
|
29
|
+
docs/
|
|
30
|
+
common/
|
|
31
|
+
overview.md
|
|
32
|
+
merge-behavior.md
|
|
33
|
+
claude/
|
|
34
|
+
hooks.md
|
|
35
|
+
scripts/
|
|
36
|
+
pasika.ts
|
|
37
|
+
dist/
|
|
38
|
+
...
|
|
39
|
+
vulyk.json
|
|
40
|
+
AGENTS.md
|
|
41
|
+
CLAUDE.md
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What belongs here
|
|
45
|
+
|
|
46
|
+
- portable hook scripts
|
|
47
|
+
- base settings templates
|
|
48
|
+
- shared docs and generated `CLAUDE.md`
|
|
49
|
+
- shared naming and layout conventions
|
|
50
|
+
|
|
51
|
+
## What stays in project repos
|
|
52
|
+
|
|
53
|
+
- final `.claude` folders
|
|
54
|
+
- project-specific skills, rules, agents, and prompts
|
|
55
|
+
- repository-specific plugin choices
|
|
56
|
+
- scripts that depend on a specific app, CI setup, or codebase
|
|
57
|
+
|
|
58
|
+
## CLI
|
|
59
|
+
|
|
60
|
+
The main entry point is:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx pasika claude
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Optional flags:
|
|
67
|
+
|
|
68
|
+
- `--target-dir <path>` writes into another repo
|
|
69
|
+
- `--force` replaces an existing generated file instead of merging
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
The CLI source lives in TypeScript.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm run lint
|
|
77
|
+
npm run typecheck
|
|
78
|
+
npm run build
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`npm run build` uses `vulyk docs` to emit root `AGENTS.md` and `CLAUDE.md`.
|
|
82
|
+
|
|
83
|
+
## Recommended Integration
|
|
84
|
+
|
|
85
|
+
Hooks and helper executables should come from `node_modules`.
|
|
86
|
+
|
|
87
|
+
That gives us:
|
|
88
|
+
|
|
89
|
+
- versioned reusable scripts
|
|
90
|
+
- easy upgrades through the package manager
|
|
91
|
+
- no manual copying of hook files into every repo
|
|
92
|
+
|
|
93
|
+
`settings.json` is different. It still needs to exist in each project repo, because Claude Code does not give us a clean inheritance model for it.
|
|
94
|
+
|
|
95
|
+
So the recommended pattern is:
|
|
96
|
+
|
|
97
|
+
1. install `pasika` as a dev dependency
|
|
98
|
+
2. run `npx pasika claude`
|
|
99
|
+
3. generate or merge into `.claude/settings.json` in the project
|
|
100
|
+
4. point hook commands at `./node_modules/pasika/claude/...`
|
|
101
|
+
5. keep project-specific plugin, skill, and rule decisions in the project repo
|
|
102
|
+
|
|
103
|
+
## Merge Behavior
|
|
104
|
+
|
|
105
|
+
By default, `pasika` merges its Claude base into an existing `.claude/settings.json`.
|
|
106
|
+
|
|
107
|
+
That means:
|
|
108
|
+
|
|
109
|
+
- it updates the portable base pieces shipped by `pasika`
|
|
110
|
+
- it preserves unrelated project-specific settings such as extra hooks, plugin config, and custom permissions
|
|
111
|
+
|
|
112
|
+
Use `--force` only when you want to replace the existing file with the `pasika` base output.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# notification.sh - Cross-platform notification
|
|
3
|
+
|
|
4
|
+
OS="$(uname -s)"
|
|
5
|
+
|
|
6
|
+
case "$OS" in
|
|
7
|
+
Darwin*)
|
|
8
|
+
if command -v terminal-notifier &> /dev/null; then
|
|
9
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
10
|
+
TEMP_SCRIPT=$(mktemp)
|
|
11
|
+
|
|
12
|
+
cat > "$TEMP_SCRIPT" <<SCRIPTEND
|
|
13
|
+
#!/bin/bash
|
|
14
|
+
/usr/local/bin/code -r "$PROJECT_DIR"
|
|
15
|
+
sleep 0.5
|
|
16
|
+
osascript -e 'tell application "Visual Studio Code" to activate'
|
|
17
|
+
SCRIPTEND
|
|
18
|
+
|
|
19
|
+
chmod +x "$TEMP_SCRIPT"
|
|
20
|
+
|
|
21
|
+
terminal-notifier \
|
|
22
|
+
-message "Claude Code needs your attention - Click to focus terminal" \
|
|
23
|
+
-title "Claude Code" \
|
|
24
|
+
-sound Ping \
|
|
25
|
+
-execute "$TEMP_SCRIPT"
|
|
26
|
+
|
|
27
|
+
(sleep 60; rm -f "$TEMP_SCRIPT") &
|
|
28
|
+
else
|
|
29
|
+
osascript -e 'display notification "Claude Code needs your attention" with title "Claude Code"'
|
|
30
|
+
fi
|
|
31
|
+
;;
|
|
32
|
+
Linux*)
|
|
33
|
+
notify-send 'Claude Code' 'Claude Code needs your attention'
|
|
34
|
+
;;
|
|
35
|
+
MINGW*|MSYS*|CYGWIN*)
|
|
36
|
+
powershell.exe -Command "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude Code needs your attention', 'Claude Code')"
|
|
37
|
+
;;
|
|
38
|
+
esac
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# protect-files.sh - Block edits to sensitive files
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat)
|
|
5
|
+
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
|
6
|
+
|
|
7
|
+
PROTECTED_PATTERNS=(
|
|
8
|
+
"/\.env$|^\.env$"
|
|
9
|
+
"package-lock\.json$"
|
|
10
|
+
"/\.git/|^\.git/"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
for pattern in "${PROTECTED_PATTERNS[@]}"; do
|
|
14
|
+
if echo "$FILE_PATH" | grep -qE "$pattern"; then
|
|
15
|
+
echo "Blocked: Cannot edit protected file '$FILE_PATH'" >&2
|
|
16
|
+
echo "Reason: This is a sensitive/generated file that should not be manually edited" >&2
|
|
17
|
+
exit 2
|
|
18
|
+
fi
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
exit 0
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
let input = "";
|
|
7
|
+
|
|
8
|
+
process.stdin.on("data", (chunk) => {
|
|
9
|
+
input += chunk;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
process.stdin.on("end", () => {
|
|
13
|
+
try {
|
|
14
|
+
const data = JSON.parse(input || "{}");
|
|
15
|
+
const model = data.model?.display_name || data.model?.name || "Claude";
|
|
16
|
+
const dir = path.basename(data.workspace?.current_dir || process.cwd());
|
|
17
|
+
const cost = Number(data.cost?.total_cost_usd || 0);
|
|
18
|
+
const pct = Math.floor(Number(data.context_window?.used_percentage || 0));
|
|
19
|
+
const durationMs = Number(data.cost?.total_duration_ms || 0);
|
|
20
|
+
|
|
21
|
+
const CYAN = "\x1b[36m";
|
|
22
|
+
const GREEN = "\x1b[32m";
|
|
23
|
+
const YELLOW = "\x1b[33m";
|
|
24
|
+
const RED = "\x1b[31m";
|
|
25
|
+
const RESET = "\x1b[0m";
|
|
26
|
+
|
|
27
|
+
let barColor = GREEN;
|
|
28
|
+
if (pct >= 90) {
|
|
29
|
+
barColor = RED;
|
|
30
|
+
} else if (pct >= 70) {
|
|
31
|
+
barColor = YELLOW;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const filled = Math.max(0, Math.min(10, Math.floor(pct / 10)));
|
|
35
|
+
const bar = "█".repeat(filled) + "░".repeat(10 - filled);
|
|
36
|
+
const mins = Math.floor(durationMs / 60000);
|
|
37
|
+
const secs = Math.floor((durationMs % 60000) / 1000);
|
|
38
|
+
|
|
39
|
+
let branch = "";
|
|
40
|
+
try {
|
|
41
|
+
branch = execSync("git branch --show-current", {
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
44
|
+
}).trim();
|
|
45
|
+
branch = branch ? ` | 🌿 ${branch}` : "";
|
|
46
|
+
} catch {
|
|
47
|
+
branch = "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
process.stdout.write(`${CYAN}[${model}]${RESET} 📁 ${dir}${branch}\n`);
|
|
51
|
+
process.stdout.write(
|
|
52
|
+
`${barColor}${bar}${RESET} ${pct}% | ${YELLOW}$${cost.toFixed(2)}${RESET} | ⏱️ ${mins}m ${secs}s\n`,
|
|
53
|
+
);
|
|
54
|
+
} catch {
|
|
55
|
+
process.stdout.write("Claude\n");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
|
3
|
+
"attribution": {
|
|
4
|
+
"commit": "",
|
|
5
|
+
"pr": ""
|
|
6
|
+
},
|
|
7
|
+
"permissions": {
|
|
8
|
+
"ask": ["Bash(git commit:*)", "Bash(git push:*)"]
|
|
9
|
+
},
|
|
10
|
+
"hooks": {
|
|
11
|
+
"Notification": [
|
|
12
|
+
{
|
|
13
|
+
"matcher": "",
|
|
14
|
+
"hooks": [
|
|
15
|
+
{
|
|
16
|
+
"type": "command",
|
|
17
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notification.sh"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"PreToolUse": [
|
|
23
|
+
{
|
|
24
|
+
"matcher": "Edit|Write",
|
|
25
|
+
"hooks": [
|
|
26
|
+
{
|
|
27
|
+
"type": "command",
|
|
28
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"statusLine": {
|
|
35
|
+
"type": "command",
|
|
36
|
+
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/status-line/index.js"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
interface ClaudeSettings {
|
|
10
|
+
$schema?: string;
|
|
11
|
+
attribution?: Record<string, unknown>;
|
|
12
|
+
permissions?: {
|
|
13
|
+
ask?: string[];
|
|
14
|
+
deny?: string[];
|
|
15
|
+
};
|
|
16
|
+
hooks: {
|
|
17
|
+
Notification?: HookMatcherEntry[];
|
|
18
|
+
PreToolUse?: HookMatcherEntry[];
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
};
|
|
21
|
+
statusLine: {
|
|
22
|
+
type?: string;
|
|
23
|
+
command: string;
|
|
24
|
+
};
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CommandHook {
|
|
29
|
+
type?: string;
|
|
30
|
+
command: string;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface HookMatcherEntry {
|
|
35
|
+
matcher?: string;
|
|
36
|
+
hooks: CommandHook[];
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
|
41
|
+
|
|
42
|
+
const fileExists = async (filePath: string): Promise<boolean> => {
|
|
43
|
+
try {
|
|
44
|
+
await access(filePath, constants.F_OK);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isClaudeSettings = (value: unknown): value is ClaudeSettings => {
|
|
52
|
+
if (!isRecord(value)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hooks = value.hooks;
|
|
57
|
+
const statusLine = value.statusLine;
|
|
58
|
+
|
|
59
|
+
if (!isRecord(hooks) || !isRecord(statusLine) || typeof statusLine.command !== "string") {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const notification = hooks.Notification;
|
|
64
|
+
if (notification !== undefined && !Array.isArray(notification)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const preToolUse = hooks.PreToolUse;
|
|
69
|
+
return preToolUse === undefined || Array.isArray(preToolUse);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const readClaudeSettings = async (templatePath: string): Promise<ClaudeSettings> => {
|
|
73
|
+
const templateRaw = await readFile(templatePath, "utf8");
|
|
74
|
+
const parsed: unknown = JSON.parse(templateRaw);
|
|
75
|
+
|
|
76
|
+
if (!isClaudeSettings(parsed)) {
|
|
77
|
+
throw new Error("Claude settings template has an invalid shape.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return parsed;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const mergeStringArrays = (left: string[] = [], right: string[] = []): string[] =>
|
|
84
|
+
Array.from(new Set([...left, ...right]));
|
|
85
|
+
|
|
86
|
+
const upsertHookMatcherEntry = (
|
|
87
|
+
entries: HookMatcherEntry[] | undefined,
|
|
88
|
+
matcher: string,
|
|
89
|
+
command: string,
|
|
90
|
+
): HookMatcherEntry[] => {
|
|
91
|
+
const nextEntries = [...(entries ?? [])];
|
|
92
|
+
const existingIndex = nextEntries.findIndex((entry) => (entry.matcher ?? "") === matcher);
|
|
93
|
+
const hook: CommandHook = { type: "command", command };
|
|
94
|
+
|
|
95
|
+
if (existingIndex === -1) {
|
|
96
|
+
nextEntries.push({ matcher, hooks: [hook] });
|
|
97
|
+
return nextEntries;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const existingEntry = nextEntries[existingIndex];
|
|
101
|
+
nextEntries[existingIndex] = { ...existingEntry, hooks: [hook] };
|
|
102
|
+
return nextEntries;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const mergeClaudeSettings = (
|
|
106
|
+
existingSettings: ClaudeSettings,
|
|
107
|
+
baseSettings: ClaudeSettings,
|
|
108
|
+
packagePath: string,
|
|
109
|
+
): ClaudeSettings => {
|
|
110
|
+
const notificationCommand = `${packagePath}/.claude/hooks/notification.sh`;
|
|
111
|
+
const protectFilesCommand = `${packagePath}/.claude/hooks/protect-files.sh`;
|
|
112
|
+
const statusLineCommand = `node ${packagePath}/.claude/hooks/status-line/index.js`;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
...existingSettings,
|
|
116
|
+
$schema: baseSettings.$schema ?? existingSettings.$schema,
|
|
117
|
+
attribution: { ...(baseSettings.attribution ?? {}), ...(existingSettings.attribution ?? {}) },
|
|
118
|
+
permissions: {
|
|
119
|
+
...(existingSettings.permissions ?? {}),
|
|
120
|
+
...(baseSettings.permissions ?? {}),
|
|
121
|
+
ask: mergeStringArrays(baseSettings.permissions?.ask, existingSettings.permissions?.ask),
|
|
122
|
+
deny: mergeStringArrays(baseSettings.permissions?.deny, existingSettings.permissions?.deny),
|
|
123
|
+
},
|
|
124
|
+
hooks: {
|
|
125
|
+
...existingSettings.hooks,
|
|
126
|
+
Notification: upsertHookMatcherEntry(existingSettings.hooks.Notification, "", notificationCommand),
|
|
127
|
+
PreToolUse: upsertHookMatcherEntry(existingSettings.hooks.PreToolUse, "Edit|Write", protectFilesCommand),
|
|
128
|
+
},
|
|
129
|
+
statusLine: {
|
|
130
|
+
...existingSettings.statusLine,
|
|
131
|
+
...baseSettings.statusLine,
|
|
132
|
+
type: "command",
|
|
133
|
+
command: statusLineCommand,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const renderClaude = async (targetDir: string, force: boolean, packageRoot: string): Promise<string[]> => {
|
|
139
|
+
const templatePath = path.join(packageRoot, "claude/.claude/settings.base.json");
|
|
140
|
+
const baseSettings = await readClaudeSettings(templatePath);
|
|
141
|
+
const packagePath = "./node_modules/pasika/claude";
|
|
142
|
+
const notificationEntries = upsertHookMatcherEntry(
|
|
143
|
+
baseSettings.hooks.Notification,
|
|
144
|
+
"",
|
|
145
|
+
`${packagePath}/.claude/hooks/notification.sh`,
|
|
146
|
+
);
|
|
147
|
+
const preToolUseEntries = upsertHookMatcherEntry(
|
|
148
|
+
baseSettings.hooks.PreToolUse,
|
|
149
|
+
"Edit|Write",
|
|
150
|
+
`${packagePath}/.claude/hooks/protect-files.sh`,
|
|
151
|
+
);
|
|
152
|
+
const settings: ClaudeSettings = {
|
|
153
|
+
...baseSettings,
|
|
154
|
+
hooks: {
|
|
155
|
+
...baseSettings.hooks,
|
|
156
|
+
Notification: notificationEntries,
|
|
157
|
+
PreToolUse: preToolUseEntries,
|
|
158
|
+
},
|
|
159
|
+
statusLine: {
|
|
160
|
+
...baseSettings.statusLine,
|
|
161
|
+
type: "command",
|
|
162
|
+
command: `node ${packagePath}/.claude/hooks/status-line/index.js`,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const outputDir = path.join(targetDir, ".claude");
|
|
167
|
+
const outputPath = path.join(outputDir, "settings.json");
|
|
168
|
+
|
|
169
|
+
await mkdir(outputDir, { recursive: true });
|
|
170
|
+
|
|
171
|
+
const finalSettings =
|
|
172
|
+
!force && (await fileExists(outputPath))
|
|
173
|
+
? mergeClaudeSettings(await readClaudeSettings(outputPath), settings, packagePath)
|
|
174
|
+
: settings;
|
|
175
|
+
|
|
176
|
+
await writeFile(outputPath, `${JSON.stringify(finalSettings, null, 2)}\n`, "utf8");
|
|
177
|
+
|
|
178
|
+
return [outputPath];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const isDirectRun = process.argv[1] ? path.resolve(process.argv[1]) === __filename : false;
|
|
182
|
+
|
|
183
|
+
if (isDirectRun) {
|
|
184
|
+
const args = process.argv.slice(2);
|
|
185
|
+
const getArgValue = (flag: string): string | undefined => {
|
|
186
|
+
const index = args.indexOf(flag);
|
|
187
|
+
if (index === -1) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return args[index + 1];
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const targetDir = path.resolve(process.cwd(), getArgValue("--target-dir") ?? ".");
|
|
195
|
+
const force = args.includes("--force");
|
|
196
|
+
const packageRoot = path.resolve(__dirname, "../..");
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const outputs = await renderClaude(targetDir, force, packageRoot);
|
|
200
|
+
for (const output of outputs) {
|
|
201
|
+
process.stdout.write(`Wrote ${output}\n`);
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
8
|
+
const fileExists = async (filePath) => {
|
|
9
|
+
try {
|
|
10
|
+
await access(filePath, constants.F_OK);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const isClaudeSettings = (value) => {
|
|
18
|
+
if (!isRecord(value)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const hooks = value.hooks;
|
|
22
|
+
const statusLine = value.statusLine;
|
|
23
|
+
if (!isRecord(hooks) || !isRecord(statusLine) || typeof statusLine.command !== "string") {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const notification = hooks.Notification;
|
|
27
|
+
if (notification !== undefined && !Array.isArray(notification)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const preToolUse = hooks.PreToolUse;
|
|
31
|
+
return preToolUse === undefined || Array.isArray(preToolUse);
|
|
32
|
+
};
|
|
33
|
+
const readClaudeSettings = async (templatePath) => {
|
|
34
|
+
const templateRaw = await readFile(templatePath, "utf8");
|
|
35
|
+
const parsed = JSON.parse(templateRaw);
|
|
36
|
+
if (!isClaudeSettings(parsed)) {
|
|
37
|
+
throw new Error("Claude settings template has an invalid shape.");
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
};
|
|
41
|
+
const mergeStringArrays = (left = [], right = []) => Array.from(new Set([...left, ...right]));
|
|
42
|
+
const upsertHookMatcherEntry = (entries, matcher, command) => {
|
|
43
|
+
const nextEntries = [...(entries ?? [])];
|
|
44
|
+
const existingIndex = nextEntries.findIndex((entry) => (entry.matcher ?? "") === matcher);
|
|
45
|
+
const hook = { type: "command", command };
|
|
46
|
+
if (existingIndex === -1) {
|
|
47
|
+
nextEntries.push({ matcher, hooks: [hook] });
|
|
48
|
+
return nextEntries;
|
|
49
|
+
}
|
|
50
|
+
const existingEntry = nextEntries[existingIndex];
|
|
51
|
+
nextEntries[existingIndex] = { ...existingEntry, hooks: [hook] };
|
|
52
|
+
return nextEntries;
|
|
53
|
+
};
|
|
54
|
+
const mergeClaudeSettings = (existingSettings, baseSettings, packagePath) => {
|
|
55
|
+
const notificationCommand = `${packagePath}/.claude/hooks/notification.sh`;
|
|
56
|
+
const protectFilesCommand = `${packagePath}/.claude/hooks/protect-files.sh`;
|
|
57
|
+
const statusLineCommand = `node ${packagePath}/.claude/hooks/status-line/index.js`;
|
|
58
|
+
return {
|
|
59
|
+
...existingSettings,
|
|
60
|
+
$schema: baseSettings.$schema ?? existingSettings.$schema,
|
|
61
|
+
attribution: { ...(baseSettings.attribution ?? {}), ...(existingSettings.attribution ?? {}) },
|
|
62
|
+
permissions: {
|
|
63
|
+
...(existingSettings.permissions ?? {}),
|
|
64
|
+
...(baseSettings.permissions ?? {}),
|
|
65
|
+
ask: mergeStringArrays(baseSettings.permissions?.ask, existingSettings.permissions?.ask),
|
|
66
|
+
deny: mergeStringArrays(baseSettings.permissions?.deny, existingSettings.permissions?.deny),
|
|
67
|
+
},
|
|
68
|
+
hooks: {
|
|
69
|
+
...existingSettings.hooks,
|
|
70
|
+
Notification: upsertHookMatcherEntry(existingSettings.hooks.Notification, "", notificationCommand),
|
|
71
|
+
PreToolUse: upsertHookMatcherEntry(existingSettings.hooks.PreToolUse, "Edit|Write", protectFilesCommand),
|
|
72
|
+
},
|
|
73
|
+
statusLine: {
|
|
74
|
+
...existingSettings.statusLine,
|
|
75
|
+
...baseSettings.statusLine,
|
|
76
|
+
type: "command",
|
|
77
|
+
command: statusLineCommand,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
export const renderClaude = async (targetDir, force, packageRoot) => {
|
|
82
|
+
const templatePath = path.join(packageRoot, "claude/.claude/settings.base.json");
|
|
83
|
+
const baseSettings = await readClaudeSettings(templatePath);
|
|
84
|
+
const packagePath = "./node_modules/pasika/claude";
|
|
85
|
+
const notificationEntries = upsertHookMatcherEntry(baseSettings.hooks.Notification, "", `${packagePath}/.claude/hooks/notification.sh`);
|
|
86
|
+
const preToolUseEntries = upsertHookMatcherEntry(baseSettings.hooks.PreToolUse, "Edit|Write", `${packagePath}/.claude/hooks/protect-files.sh`);
|
|
87
|
+
const settings = {
|
|
88
|
+
...baseSettings,
|
|
89
|
+
hooks: {
|
|
90
|
+
...baseSettings.hooks,
|
|
91
|
+
Notification: notificationEntries,
|
|
92
|
+
PreToolUse: preToolUseEntries,
|
|
93
|
+
},
|
|
94
|
+
statusLine: {
|
|
95
|
+
...baseSettings.statusLine,
|
|
96
|
+
type: "command",
|
|
97
|
+
command: `node ${packagePath}/.claude/hooks/status-line/index.js`,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const outputDir = path.join(targetDir, ".claude");
|
|
101
|
+
const outputPath = path.join(outputDir, "settings.json");
|
|
102
|
+
await mkdir(outputDir, { recursive: true });
|
|
103
|
+
const finalSettings = !force && (await fileExists(outputPath))
|
|
104
|
+
? mergeClaudeSettings(await readClaudeSettings(outputPath), settings, packagePath)
|
|
105
|
+
: settings;
|
|
106
|
+
await writeFile(outputPath, `${JSON.stringify(finalSettings, null, 2)}\n`, "utf8");
|
|
107
|
+
return [outputPath];
|
|
108
|
+
};
|
|
109
|
+
const isDirectRun = process.argv[1] ? path.resolve(process.argv[1]) === __filename : false;
|
|
110
|
+
if (isDirectRun) {
|
|
111
|
+
const args = process.argv.slice(2);
|
|
112
|
+
const getArgValue = (flag) => {
|
|
113
|
+
const index = args.indexOf(flag);
|
|
114
|
+
if (index === -1) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return args[index + 1];
|
|
118
|
+
};
|
|
119
|
+
const targetDir = path.resolve(process.cwd(), getArgValue("--target-dir") ?? ".");
|
|
120
|
+
const force = args.includes("--force");
|
|
121
|
+
const packageRoot = path.resolve(__dirname, "../..");
|
|
122
|
+
try {
|
|
123
|
+
const outputs = await renderClaude(targetDir, force, packageRoot);
|
|
124
|
+
for (const output of outputs) {
|
|
125
|
+
process.stdout.write(`Wrote ${output}\n`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { renderClaude } from "../claude/scripts/render-settings.js";
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const packageRoot = path.basename(path.dirname(__dirname)) === "dist" ? path.resolve(__dirname, "../..") : path.resolve(__dirname, "..");
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const helpText = `pasika
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
pasika claude [--target-dir <path>] [--force]
|
|
13
|
+
|
|
14
|
+
Platforms:
|
|
15
|
+
claude
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
npx pasika claude
|
|
19
|
+
npx pasika claude --force
|
|
20
|
+
|
|
21
|
+
Behavior:
|
|
22
|
+
merges into existing .claude/settings.json by default
|
|
23
|
+
replaces it only when --force is passed
|
|
24
|
+
`;
|
|
25
|
+
const getFlagValue = (flag) => {
|
|
26
|
+
const index = args.indexOf(flag);
|
|
27
|
+
if (index === -1) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return args[index + 1];
|
|
31
|
+
};
|
|
32
|
+
const hasFlag = (flag) => args.includes(flag);
|
|
33
|
+
const platform = args[0];
|
|
34
|
+
if (!platform || hasFlag("--help") || hasFlag("-h")) {
|
|
35
|
+
process.stdout.write(helpText);
|
|
36
|
+
process.exit(platform ? 0 : 1);
|
|
37
|
+
}
|
|
38
|
+
const targetDir = path.resolve(process.cwd(), getFlagValue("--target-dir") ?? ".");
|
|
39
|
+
const force = hasFlag("--force");
|
|
40
|
+
const run = async () => {
|
|
41
|
+
switch (platform) {
|
|
42
|
+
case "claude":
|
|
43
|
+
return renderClaude(targetDir, force, packageRoot);
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Unknown platform: ${platform}`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
const outputs = await run();
|
|
50
|
+
process.stdout.write(`Initialized ${platform} in ${targetDir}\n`);
|
|
51
|
+
for (const output of outputs) {
|
|
52
|
+
process.stdout.write(`Wrote ${output}\n`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
57
|
+
process.stderr.write(helpText);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Claude Hooks
|
|
2
|
+
|
|
3
|
+
`pasika` ships the reusable shared Claude hooks.
|
|
4
|
+
|
|
5
|
+
## `status-line/index.js`
|
|
6
|
+
|
|
7
|
+
Displays a two-line status view with:
|
|
8
|
+
|
|
9
|
+
- model name
|
|
10
|
+
- current folder
|
|
11
|
+
- git branch when available
|
|
12
|
+
- used context percentage as a 10-segment bar
|
|
13
|
+
- session cost
|
|
14
|
+
- session duration
|
|
15
|
+
|
|
16
|
+
## `notification.sh`
|
|
17
|
+
|
|
18
|
+
Sends a desktop notification when Claude needs attention.
|
|
19
|
+
|
|
20
|
+
- macOS: `terminal-notifier` with click-to-focus VS Code behavior, falls back to `osascript`
|
|
21
|
+
- Linux: `notify-send`
|
|
22
|
+
- Windows: PowerShell message box
|
|
23
|
+
|
|
24
|
+
## `protect-files.sh`
|
|
25
|
+
|
|
26
|
+
Blocks edits to:
|
|
27
|
+
|
|
28
|
+
- `.env` files
|
|
29
|
+
- `package-lock.json`
|
|
30
|
+
- files inside `.git/`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Merge Behavior
|
|
2
|
+
|
|
3
|
+
`pasika` merges into an existing `.claude/settings.json` by default.
|
|
4
|
+
|
|
5
|
+
It updates the Claude base pieces owned by `pasika`:
|
|
6
|
+
|
|
7
|
+
- `hooks.Notification`
|
|
8
|
+
- `hooks.PreToolUse`
|
|
9
|
+
- `statusLine`
|
|
10
|
+
|
|
11
|
+
It preserves unrelated project-specific settings such as:
|
|
12
|
+
|
|
13
|
+
- custom permissions
|
|
14
|
+
- extra hooks like `PostToolUse`
|
|
15
|
+
- plugin and marketplace config
|
|
16
|
+
|
|
17
|
+
Use `--force` only when you want to replace the file instead of merging.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# pasika
|
|
2
|
+
|
|
3
|
+
`pasika` is a reusable Claude Code base package.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
- Claude only for v1
|
|
8
|
+
- shared hooks and base settings
|
|
9
|
+
- merge-friendly project integration
|
|
10
|
+
- no project-specific skills, rules, or plugin choices
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
Install `pasika` as a dev dependency, then run:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx pasika claude
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That generates or updates `.claude/settings.json` to point shared hooks at `./node_modules/pasika/claude/...`.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pasika",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable Claude Code base config package",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "rm -rf dist AGENTS.md CLAUDE.md && tsc -p tsconfig.json && npx vulyk docs",
|
|
9
|
+
"check": "npm run lint && npm run typecheck",
|
|
10
|
+
"fix": "eslint . --fix",
|
|
11
|
+
"lint": "eslint .",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
13
|
+
"prepack": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"pasika": "dist/scripts/pasika.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"claude",
|
|
20
|
+
"docs",
|
|
21
|
+
"dist",
|
|
22
|
+
"AGENTS.md",
|
|
23
|
+
"CLAUDE.md",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^24.12.2",
|
|
28
|
+
"eslint": "^9.39.4",
|
|
29
|
+
"prettier": "^3.8.1",
|
|
30
|
+
"typescript": "^5.9.2",
|
|
31
|
+
"vulyk": "^0.6.1",
|
|
32
|
+
"zirka": "^0.0.28"
|
|
33
|
+
}
|
|
34
|
+
}
|