@aladac/hu 0.1.0-a1 → 0.1.0-a2
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/CLAUDE.md +54 -29
- package/HOOKS.md +146 -0
- package/commands/reinstall.md +6 -3
- package/hooks/session-start.sh +85 -0
- package/hooks/stop.sh +51 -0
- package/hooks/user-prompt-submit.sh +74 -0
- package/package.json +5 -2
- package/plans/gleaming-crunching-bear.md +179 -0
- package/src/commands/data.ts +877 -0
- package/src/commands/plugin.ts +216 -0
- package/src/index.ts +5 -1
- package/src/lib/claude-paths.ts +136 -0
- package/src/lib/config.ts +244 -0
- package/src/lib/db.ts +59 -0
- package/src/lib/hook-io.ts +128 -0
- package/src/lib/jsonl.ts +95 -0
- package/src/lib/schema.ts +164 -0
- package/src/lib/sync.ts +300 -0
- package/tests/lib/claude-paths.test.ts +73 -0
- package/tests/lib/config.test.ts +163 -0
- package/tests/lib/db.test.ts +230 -0
- package/tests/lib/escaping.test.ts +257 -0
- package/tests/lib/hook-io.test.ts +151 -0
- package/tests/lib/jsonl.test.ts +166 -0
- package/HOOKS-DATA-INTEGRATION.md +0 -457
- package/SAMPLE.md +0 -378
- package/TODO.md +0 -25
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hu plugin - Plugin management commands
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineCommand } from 'citty';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { c } from '../lib/colors.ts';
|
|
9
|
+
|
|
10
|
+
// Plugin manifest template
|
|
11
|
+
function getManifest(name: string): object {
|
|
12
|
+
return {
|
|
13
|
+
name,
|
|
14
|
+
version: '0.1.0',
|
|
15
|
+
description: 'Claude Code data integration plugin - provides session context, similarity search, and usage tracking',
|
|
16
|
+
author: 'hu',
|
|
17
|
+
hooks: {
|
|
18
|
+
SessionStart: [
|
|
19
|
+
{
|
|
20
|
+
matcher: '.*',
|
|
21
|
+
hooks: [
|
|
22
|
+
{
|
|
23
|
+
type: 'command',
|
|
24
|
+
command: path.join('$PLUGIN_DIR', 'hooks', 'session-start.sh'),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
Stop: [
|
|
30
|
+
{
|
|
31
|
+
matcher: '.*',
|
|
32
|
+
hooks: [
|
|
33
|
+
{
|
|
34
|
+
type: 'command',
|
|
35
|
+
command: path.join('$PLUGIN_DIR', 'hooks', 'stop.sh'),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Session start hook script
|
|
45
|
+
const sessionStartHook = `#!/bin/bash
|
|
46
|
+
# Session Start Hook - Load context from previous sessions
|
|
47
|
+
set -e
|
|
48
|
+
|
|
49
|
+
INPUT_FILE=$(mktemp /tmp/hu-session-start-XXXXXX.json)
|
|
50
|
+
cat > "$INPUT_FILE"
|
|
51
|
+
|
|
52
|
+
PROJECT=$(jq -r '.cwd // empty' "$INPUT_FILE" 2>/dev/null || echo "")
|
|
53
|
+
rm -f "$INPUT_FILE"
|
|
54
|
+
|
|
55
|
+
if ! command -v hu &> /dev/null; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
hu data sync --quiet 2>/dev/null || true
|
|
60
|
+
|
|
61
|
+
SESSIONS_FILE=$(mktemp /tmp/hu-sessions-XXXXXX.json)
|
|
62
|
+
if [ -n "$PROJECT" ]; then
|
|
63
|
+
hu data session list --project "$PROJECT" --limit 5 --json > "$SESSIONS_FILE" 2>/dev/null || echo "[]" > "$SESSIONS_FILE"
|
|
64
|
+
else
|
|
65
|
+
hu data session list --limit 5 --json > "$SESSIONS_FILE" 2>/dev/null || echo "[]" > "$SESSIONS_FILE"
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
TODOS_FILE=$(mktemp /tmp/hu-todos-XXXXXX.json)
|
|
69
|
+
if [ -n "$PROJECT" ]; then
|
|
70
|
+
hu data todos pending --project "$PROJECT" --json > "$TODOS_FILE" 2>/dev/null || echo "[]" > "$TODOS_FILE"
|
|
71
|
+
else
|
|
72
|
+
hu data todos pending --json > "$TODOS_FILE" 2>/dev/null || echo "[]" > "$TODOS_FILE"
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
SESSION_COUNT=$(jq 'length' "$SESSIONS_FILE" 2>/dev/null || echo "0")
|
|
76
|
+
TODO_COUNT=$(jq 'length' "$TODOS_FILE" 2>/dev/null || echo "0")
|
|
77
|
+
|
|
78
|
+
CONTEXT=""
|
|
79
|
+
|
|
80
|
+
if [ "$SESSION_COUNT" -gt 0 ]; then
|
|
81
|
+
CONTEXT="## Previous Sessions\\n\\n"
|
|
82
|
+
CONTEXT+=$(jq -r '.[] | "- Session \\(.id[0:8]): \\(.display // "No description") (\\(.message_count) messages)"' "$SESSIONS_FILE" 2>/dev/null || echo "")
|
|
83
|
+
CONTEXT+="\\n\\n"
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
if [ "$TODO_COUNT" -gt 0 ]; then
|
|
87
|
+
CONTEXT+="## Pending Tasks\\n\\n"
|
|
88
|
+
CONTEXT+=$(jq -r '.[] | "- [\\(.status)] \\(.content)"' "$TODOS_FILE" 2>/dev/null || echo "")
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
rm -f "$SESSIONS_FILE" "$TODOS_FILE"
|
|
92
|
+
|
|
93
|
+
if [ -n "$CONTEXT" ]; then
|
|
94
|
+
CONTEXT_ESCAPED=$(echo -e "$CONTEXT" | jq -Rs .)
|
|
95
|
+
echo "{\\"additionalContext\\": $CONTEXT_ESCAPED}"
|
|
96
|
+
else
|
|
97
|
+
echo "{}"
|
|
98
|
+
fi
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
// Stop hook script
|
|
102
|
+
const stopHook = `#!/bin/bash
|
|
103
|
+
# Stop Hook - Sync session data on stop
|
|
104
|
+
set -e
|
|
105
|
+
|
|
106
|
+
INPUT_FILE=$(mktemp /tmp/hu-stop-XXXXXX.json)
|
|
107
|
+
cat > "$INPUT_FILE"
|
|
108
|
+
|
|
109
|
+
STOP_HOOK_ACTIVE=$(jq -r '.stop_hook_active // false' "$INPUT_FILE" 2>/dev/null || echo "false")
|
|
110
|
+
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
|
111
|
+
rm -f "$INPUT_FILE"
|
|
112
|
+
echo "{}"
|
|
113
|
+
exit 0
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
rm -f "$INPUT_FILE"
|
|
117
|
+
|
|
118
|
+
if ! command -v hu &> /dev/null; then
|
|
119
|
+
echo "{}"
|
|
120
|
+
exit 0
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
hu data sync --quiet 2>/dev/null || true
|
|
124
|
+
echo "{}"
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
// Create plugin subcommand
|
|
128
|
+
const createCommand = defineCommand({
|
|
129
|
+
meta: {
|
|
130
|
+
name: 'create',
|
|
131
|
+
description: 'Create a Claude Code plugin from hu hooks',
|
|
132
|
+
},
|
|
133
|
+
args: {
|
|
134
|
+
output: {
|
|
135
|
+
type: 'positional',
|
|
136
|
+
description: 'Output directory for the plugin',
|
|
137
|
+
required: true,
|
|
138
|
+
},
|
|
139
|
+
name: {
|
|
140
|
+
type: 'string',
|
|
141
|
+
alias: 'n',
|
|
142
|
+
description: 'Plugin name',
|
|
143
|
+
default: 'hu-data',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
run: ({ args }) => {
|
|
147
|
+
const outputDir = path.resolve(args.output);
|
|
148
|
+
const hooksDir = path.join(outputDir, 'hooks');
|
|
149
|
+
|
|
150
|
+
// Create directories
|
|
151
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
152
|
+
|
|
153
|
+
// Write manifest
|
|
154
|
+
const manifestPath = path.join(outputDir, 'manifest.json');
|
|
155
|
+
const manifest = getManifest(args.name);
|
|
156
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
157
|
+
console.log(`${c.green}✓${c.reset} Created ${manifestPath}`);
|
|
158
|
+
|
|
159
|
+
// Write hooks
|
|
160
|
+
const sessionStartPath = path.join(hooksDir, 'session-start.sh');
|
|
161
|
+
fs.writeFileSync(sessionStartPath, sessionStartHook);
|
|
162
|
+
fs.chmodSync(sessionStartPath, 0o755);
|
|
163
|
+
console.log(`${c.green}✓${c.reset} Created ${sessionStartPath}`);
|
|
164
|
+
|
|
165
|
+
const stopPath = path.join(hooksDir, 'stop.sh');
|
|
166
|
+
fs.writeFileSync(stopPath, stopHook);
|
|
167
|
+
fs.chmodSync(stopPath, 0o755);
|
|
168
|
+
console.log(`${c.green}✓${c.reset} Created ${stopPath}`);
|
|
169
|
+
|
|
170
|
+
// Write README
|
|
171
|
+
const readmePath = path.join(outputDir, 'README.md');
|
|
172
|
+
const readme = `# ${args.name}
|
|
173
|
+
|
|
174
|
+
Claude Code data integration plugin.
|
|
175
|
+
|
|
176
|
+
## Features
|
|
177
|
+
|
|
178
|
+
- **Session Context** - Loads recent sessions and pending todos on session start
|
|
179
|
+
- **Auto Sync** - Syncs session data to SQLite on session end
|
|
180
|
+
|
|
181
|
+
## Requirements
|
|
182
|
+
|
|
183
|
+
- \`hu\` CLI installed and in PATH (\`npm install -g @aladac/hu\`)
|
|
184
|
+
- \`jq\` for JSON processing
|
|
185
|
+
|
|
186
|
+
## Installation
|
|
187
|
+
|
|
188
|
+
1. Copy this directory to your Claude Code plugins folder
|
|
189
|
+
2. Or symlink: \`ln -s ${outputDir} ~/.claude/plugins/${args.name}\`
|
|
190
|
+
|
|
191
|
+
## Configuration
|
|
192
|
+
|
|
193
|
+
The plugin uses \`~/.config/hu/settings.toml\` for configuration.
|
|
194
|
+
See \`hu data config\` for current settings.
|
|
195
|
+
`;
|
|
196
|
+
fs.writeFileSync(readmePath, readme);
|
|
197
|
+
console.log(`${c.green}✓${c.reset} Created ${readmePath}`);
|
|
198
|
+
|
|
199
|
+
console.log(`\n${c.bold}Plugin created at ${outputDir}${c.reset}`);
|
|
200
|
+
console.log(`\nTo install, add to ~/.claude/settings.json:`);
|
|
201
|
+
console.log(`${c.dim} "pluginDirectory": "${outputDir}"${c.reset}`);
|
|
202
|
+
console.log(`\nOr symlink to plugins folder:`);
|
|
203
|
+
console.log(`${c.dim} ln -s ${outputDir} ~/.claude/plugins/${args.name}${c.reset}`);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Main plugin command
|
|
208
|
+
export const pluginCommand = defineCommand({
|
|
209
|
+
meta: {
|
|
210
|
+
name: 'plugin',
|
|
211
|
+
description: 'Plugin management commands',
|
|
212
|
+
},
|
|
213
|
+
subCommands: {
|
|
214
|
+
create: createCommand,
|
|
215
|
+
},
|
|
216
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,21 +4,25 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { defineCommand, runMain } from 'citty';
|
|
7
|
+
import { dataCommand } from './commands/data.ts';
|
|
7
8
|
import { diskCommand } from './commands/disk.ts';
|
|
8
9
|
import { docsCommand } from './commands/docs.ts';
|
|
9
10
|
import { plansCommand } from './commands/plans.ts';
|
|
11
|
+
import { pluginCommand } from './commands/plugin.ts';
|
|
10
12
|
import { utilsCommand } from './commands/utils.ts';
|
|
11
13
|
|
|
12
14
|
const main = defineCommand({
|
|
13
15
|
meta: {
|
|
14
16
|
name: 'hu',
|
|
15
|
-
version: '0.1.0-
|
|
17
|
+
version: '0.1.0-a2',
|
|
16
18
|
description: 'CLI tools for Claude Code workflows',
|
|
17
19
|
},
|
|
18
20
|
subCommands: {
|
|
21
|
+
data: dataCommand,
|
|
19
22
|
disk: diskCommand,
|
|
20
23
|
docs: docsCommand,
|
|
21
24
|
plans: plansCommand,
|
|
25
|
+
plugin: pluginCommand,
|
|
22
26
|
utils: utilsCommand,
|
|
23
27
|
},
|
|
24
28
|
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities to locate Claude Code data files
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { getClaudeDir } from './config.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the projects directory containing session transcripts
|
|
11
|
+
* Each project is stored in a base64-encoded subdirectory
|
|
12
|
+
*/
|
|
13
|
+
export function getProjectsDir(): string {
|
|
14
|
+
return path.join(getClaudeDir(), 'projects');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the history.jsonl file path
|
|
19
|
+
*/
|
|
20
|
+
export function getHistoryPath(): string {
|
|
21
|
+
return path.join(getClaudeDir(), 'history.jsonl');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get the stats-cache.json file path
|
|
26
|
+
*/
|
|
27
|
+
export function getStatsPath(): string {
|
|
28
|
+
return path.join(getClaudeDir(), 'stats-cache.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the todos directory
|
|
33
|
+
*/
|
|
34
|
+
export function getTodosDir(): string {
|
|
35
|
+
return path.join(getClaudeDir(), 'todos');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the debug logs directory
|
|
40
|
+
*/
|
|
41
|
+
export function getDebugDir(): string {
|
|
42
|
+
return path.join(getClaudeDir(), 'debug');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the file-history directory
|
|
47
|
+
*/
|
|
48
|
+
export function getFileHistoryDir(): string {
|
|
49
|
+
return path.join(getClaudeDir(), 'file-history');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Encode a project path for directory name
|
|
54
|
+
* Claude Code uses dash-based encoding:
|
|
55
|
+
* - / becomes -
|
|
56
|
+
* - /. (slash followed by dot) becomes --
|
|
57
|
+
*/
|
|
58
|
+
export function encodeProjectPath(projectPath: string): string {
|
|
59
|
+
// Replace /. with -- first, then / with -
|
|
60
|
+
return projectPath.replace(/\/\./g, '--').replace(/\//g, '-');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decode a project directory name back to path
|
|
65
|
+
* Claude Code uses dash-based encoding:
|
|
66
|
+
* - -- becomes /.
|
|
67
|
+
* - - becomes /
|
|
68
|
+
*/
|
|
69
|
+
export function decodeProjectPath(encoded: string): string {
|
|
70
|
+
// Replace -- with /. first (dot-prefixed directories), then - with /
|
|
71
|
+
return encoded.replace(/--/g, '/.').replace(/-/g, '/');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get session file path for a given session ID
|
|
76
|
+
* Searches through all project directories
|
|
77
|
+
*/
|
|
78
|
+
export function findSessionPath(sessionId: string): string | null {
|
|
79
|
+
const projectsDir = getProjectsDir();
|
|
80
|
+
|
|
81
|
+
if (!fs.existsSync(projectsDir)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const projectDirs = fs.readdirSync(projectsDir, { withFileTypes: true })
|
|
86
|
+
.filter(d => d.isDirectory())
|
|
87
|
+
.map(d => d.name);
|
|
88
|
+
|
|
89
|
+
for (const projectDir of projectDirs) {
|
|
90
|
+
const sessionPath = path.join(projectsDir, projectDir, `${sessionId}.jsonl`);
|
|
91
|
+
if (fs.existsSync(sessionPath)) {
|
|
92
|
+
return sessionPath;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* List all project directories with their decoded paths
|
|
101
|
+
*/
|
|
102
|
+
export function listProjects(): Array<{ encoded: string; path: string; dir: string }> {
|
|
103
|
+
const projectsDir = getProjectsDir();
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(projectsDir)) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return fs.readdirSync(projectsDir, { withFileTypes: true })
|
|
110
|
+
.filter(d => d.isDirectory())
|
|
111
|
+
.map(d => ({
|
|
112
|
+
encoded: d.name,
|
|
113
|
+
path: decodeProjectPath(d.name),
|
|
114
|
+
dir: path.join(projectsDir, d.name),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List all session files in a project directory
|
|
120
|
+
*/
|
|
121
|
+
export function listSessionsInProject(projectDir: string): string[] {
|
|
122
|
+
if (!fs.existsSync(projectDir)) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return fs.readdirSync(projectDir)
|
|
127
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
128
|
+
.map(f => f.replace('.jsonl', ''));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get todos file for a session
|
|
133
|
+
*/
|
|
134
|
+
export function getTodosPath(sessionId: string): string {
|
|
135
|
+
return path.join(getTodosDir(), `${sessionId}.json`);
|
|
136
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration system for hu
|
|
3
|
+
* Location: ~/.config/hu/settings.toml
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import { parse, stringify } from 'smol-toml';
|
|
10
|
+
|
|
11
|
+
export interface HuConfig {
|
|
12
|
+
general: {
|
|
13
|
+
claude_dir: string;
|
|
14
|
+
database: string;
|
|
15
|
+
};
|
|
16
|
+
sync: {
|
|
17
|
+
auto_sync_interval: number;
|
|
18
|
+
sync_on_start: boolean;
|
|
19
|
+
};
|
|
20
|
+
hooks: {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
temp_dir: string;
|
|
23
|
+
temp_file_ttl: number;
|
|
24
|
+
};
|
|
25
|
+
search: {
|
|
26
|
+
default_limit: number;
|
|
27
|
+
show_snippets: boolean;
|
|
28
|
+
};
|
|
29
|
+
output: {
|
|
30
|
+
default_format: 'table' | 'json' | 'markdown';
|
|
31
|
+
colors: boolean;
|
|
32
|
+
date_format: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_CONFIG: HuConfig = {
|
|
37
|
+
general: {
|
|
38
|
+
claude_dir: '~/.claude',
|
|
39
|
+
database: 'hu.db',
|
|
40
|
+
},
|
|
41
|
+
sync: {
|
|
42
|
+
auto_sync_interval: 300,
|
|
43
|
+
sync_on_start: true,
|
|
44
|
+
},
|
|
45
|
+
hooks: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
temp_dir: '/tmp',
|
|
48
|
+
temp_file_ttl: 3600,
|
|
49
|
+
},
|
|
50
|
+
search: {
|
|
51
|
+
default_limit: 20,
|
|
52
|
+
show_snippets: true,
|
|
53
|
+
},
|
|
54
|
+
output: {
|
|
55
|
+
default_format: 'table',
|
|
56
|
+
colors: true,
|
|
57
|
+
date_format: '%Y-%m-%d %H:%M',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const DEFAULT_CONFIG_TOML = `# hu configuration
|
|
62
|
+
# Location: ~/.config/hu/settings.toml
|
|
63
|
+
|
|
64
|
+
[general]
|
|
65
|
+
# Claude Code data directory
|
|
66
|
+
claude_dir = "~/.claude"
|
|
67
|
+
|
|
68
|
+
# Database location (relative to config dir or absolute)
|
|
69
|
+
database = "hu.db"
|
|
70
|
+
|
|
71
|
+
[sync]
|
|
72
|
+
# Auto-sync interval in seconds (0 = manual only)
|
|
73
|
+
auto_sync_interval = 300
|
|
74
|
+
|
|
75
|
+
# Sync on startup
|
|
76
|
+
sync_on_start = true
|
|
77
|
+
|
|
78
|
+
[hooks]
|
|
79
|
+
# Enable hook system
|
|
80
|
+
enabled = true
|
|
81
|
+
|
|
82
|
+
# Temp file directory for hook data exchange
|
|
83
|
+
temp_dir = "/tmp"
|
|
84
|
+
|
|
85
|
+
# Cleanup temp files older than (seconds)
|
|
86
|
+
temp_file_ttl = 3600
|
|
87
|
+
|
|
88
|
+
[search]
|
|
89
|
+
# Default search result limit
|
|
90
|
+
default_limit = 20
|
|
91
|
+
|
|
92
|
+
# Enable FTS5 snippets in search results
|
|
93
|
+
show_snippets = true
|
|
94
|
+
|
|
95
|
+
[output]
|
|
96
|
+
# Default output format: table, json, markdown
|
|
97
|
+
default_format = "table"
|
|
98
|
+
|
|
99
|
+
# Colorize output
|
|
100
|
+
colors = true
|
|
101
|
+
|
|
102
|
+
# Date format (strftime)
|
|
103
|
+
date_format = "%Y-%m-%d %H:%M"
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
let cachedConfig: HuConfig | null = null;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Expand ~ to home directory
|
|
110
|
+
*/
|
|
111
|
+
export function expandPath(p: string): string {
|
|
112
|
+
if (p.startsWith('~/')) {
|
|
113
|
+
return path.join(os.homedir(), p.slice(2));
|
|
114
|
+
}
|
|
115
|
+
if (p === '~') {
|
|
116
|
+
return os.homedir();
|
|
117
|
+
}
|
|
118
|
+
return p;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the config directory path
|
|
123
|
+
*/
|
|
124
|
+
export function getConfigDir(): string {
|
|
125
|
+
return path.join(os.homedir(), '.config', 'hu');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the config file path
|
|
130
|
+
*/
|
|
131
|
+
export function getConfigPath(): string {
|
|
132
|
+
return path.join(getConfigDir(), 'settings.toml');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the database path (resolved)
|
|
137
|
+
*/
|
|
138
|
+
export function getDatabasePath(config?: HuConfig): string {
|
|
139
|
+
const cfg = config ?? getConfig();
|
|
140
|
+
const dbPath = cfg.general.database;
|
|
141
|
+
|
|
142
|
+
// If absolute path, use as-is
|
|
143
|
+
if (path.isAbsolute(dbPath)) {
|
|
144
|
+
return dbPath;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If starts with ~, expand it
|
|
148
|
+
if (dbPath.startsWith('~')) {
|
|
149
|
+
return expandPath(dbPath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Otherwise, relative to config dir
|
|
153
|
+
return path.join(getConfigDir(), dbPath);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ensure config directory and file exist
|
|
158
|
+
*/
|
|
159
|
+
export function ensureConfig(): void {
|
|
160
|
+
const configDir = getConfigDir();
|
|
161
|
+
const configPath = getConfigPath();
|
|
162
|
+
|
|
163
|
+
// Create config directory if missing
|
|
164
|
+
if (!fs.existsSync(configDir)) {
|
|
165
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create default config file if missing
|
|
169
|
+
if (!fs.existsSync(configPath)) {
|
|
170
|
+
fs.writeFileSync(configPath, DEFAULT_CONFIG_TOML, 'utf-8');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Deep merge two objects, with b taking precedence
|
|
176
|
+
*/
|
|
177
|
+
function deepMerge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T {
|
|
178
|
+
const result = { ...a };
|
|
179
|
+
|
|
180
|
+
for (const key of Object.keys(b) as (keyof T)[]) {
|
|
181
|
+
const bVal = b[key];
|
|
182
|
+
if (bVal !== undefined) {
|
|
183
|
+
if (
|
|
184
|
+
typeof bVal === 'object' &&
|
|
185
|
+
bVal !== null &&
|
|
186
|
+
!Array.isArray(bVal) &&
|
|
187
|
+
typeof a[key] === 'object' &&
|
|
188
|
+
a[key] !== null
|
|
189
|
+
) {
|
|
190
|
+
result[key] = deepMerge(
|
|
191
|
+
a[key] as Record<string, unknown>,
|
|
192
|
+
bVal as Record<string, unknown>
|
|
193
|
+
) as T[keyof T];
|
|
194
|
+
} else {
|
|
195
|
+
result[key] = bVal as T[keyof T];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read and parse config file
|
|
205
|
+
*/
|
|
206
|
+
export function getConfig(forceReload = false): HuConfig {
|
|
207
|
+
if (cachedConfig && !forceReload) {
|
|
208
|
+
return cachedConfig;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
ensureConfig();
|
|
212
|
+
|
|
213
|
+
const configPath = getConfigPath();
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
217
|
+
const parsed = parse(content) as Partial<HuConfig>;
|
|
218
|
+
|
|
219
|
+
// Merge with defaults to ensure all fields exist
|
|
220
|
+
cachedConfig = deepMerge(DEFAULT_CONFIG, parsed);
|
|
221
|
+
return cachedConfig;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// On parse error, log warning and use defaults
|
|
224
|
+
console.error(`Warning: Failed to parse config at ${configPath}, using defaults`);
|
|
225
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
226
|
+
cachedConfig = { ...DEFAULT_CONFIG };
|
|
227
|
+
return cachedConfig;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get Claude Code directory (resolved)
|
|
233
|
+
*/
|
|
234
|
+
export function getClaudeDir(config?: HuConfig): string {
|
|
235
|
+
const cfg = config ?? getConfig();
|
|
236
|
+
return expandPath(cfg.general.claude_dir);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Clear cached config (for testing)
|
|
241
|
+
*/
|
|
242
|
+
export function clearConfigCache(): void {
|
|
243
|
+
cachedConfig = null;
|
|
244
|
+
}
|
package/src/lib/db.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite database module for hu
|
|
3
|
+
* Uses better-sqlite3 with WAL mode for concurrent access
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import type { Database as DatabaseType } from 'better-sqlite3';
|
|
8
|
+
import { getDatabasePath, getConfig } from './config.ts';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
|
|
12
|
+
let db: DatabaseType | null = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get or create the database connection
|
|
16
|
+
*/
|
|
17
|
+
export function getDb(): DatabaseType {
|
|
18
|
+
if (db) {
|
|
19
|
+
return db;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dbPath = getDatabasePath();
|
|
23
|
+
|
|
24
|
+
// Ensure directory exists
|
|
25
|
+
const dbDir = path.dirname(dbPath);
|
|
26
|
+
if (!fs.existsSync(dbDir)) {
|
|
27
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
db = new Database(dbPath);
|
|
31
|
+
|
|
32
|
+
// Enable WAL mode for better concurrent access
|
|
33
|
+
db.pragma('journal_mode = WAL');
|
|
34
|
+
|
|
35
|
+
// Enable foreign keys
|
|
36
|
+
db.pragma('foreign_keys = ON');
|
|
37
|
+
|
|
38
|
+
return db;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Close the database connection
|
|
43
|
+
*/
|
|
44
|
+
export function closeDb(): void {
|
|
45
|
+
if (db) {
|
|
46
|
+
db.close();
|
|
47
|
+
db = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get database for testing (allows passing custom path)
|
|
53
|
+
*/
|
|
54
|
+
export function getTestDb(dbPath: string): DatabaseType {
|
|
55
|
+
const testDb = new Database(dbPath);
|
|
56
|
+
testDb.pragma('journal_mode = WAL');
|
|
57
|
+
testDb.pragma('foreign_keys = ON');
|
|
58
|
+
return testDb;
|
|
59
|
+
}
|