@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.
@@ -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-a1',
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
+ }