Release v3.9.11
Published from npm package build Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
+8
-3
@@ -224,9 +224,8 @@ program
|
||||
.description('Generate a session title and subtitle from a prompt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--oneline', 'Output as single line (title - subtitle)')
|
||||
.option('--save', 'Save title and subtitle to session metadata')
|
||||
.option('--project <name>', 'Project name (required with --save)')
|
||||
.option('--session <id>', 'Session ID (required with --save)')
|
||||
.option('--session-id <id>', 'Claude session ID to update')
|
||||
.option('--save', 'Save the generated title to the database (requires --session-id)')
|
||||
.action(generateTitle);
|
||||
|
||||
// </Block> =======================================
|
||||
@@ -268,3 +267,9 @@ try {
|
||||
// Parse arguments and execute
|
||||
program.parse();
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 1.12 ===================================
|
||||
// Module Exports for Programmatic Use
|
||||
// Export database and utility classes for hooks and external consumers
|
||||
export { DatabaseManager, StreamingSessionStore, migrations, initializeDatabase, getDatabase } from '../services/sqlite/index.js';
|
||||
// </Block> =======================================
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { getClaudePath } from '../shared/settings.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
|
||||
import { DatabaseManager } from '../services/sqlite/Database.js';
|
||||
import { StreamingSessionStore } from '../services/sqlite/StreamingSessionStore.js';
|
||||
import { migrations } from '../services/sqlite/migrations.js';
|
||||
|
||||
/**
|
||||
* Generate a session title and subtitle from a user prompt
|
||||
* CLI command that uses Agent SDK (like changelog.ts)
|
||||
*
|
||||
* Can be called in two modes:
|
||||
* 1. Standalone: generate-title "user prompt" --json
|
||||
* 2. With session: generate-title "user prompt" --session-id <id> --save
|
||||
*/
|
||||
export async function generateTitle(prompt: string, options: OptionValues): Promise<void> {
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
@@ -19,6 +22,36 @@ export async function generateTitle(prompt: string, options: OptionValues): Prom
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --session-id provided, validate that session exists
|
||||
let streamingStore: StreamingSessionStore | null = null;
|
||||
let sessionRecord = null;
|
||||
|
||||
if (options.sessionId) {
|
||||
try {
|
||||
const dbManager = DatabaseManager.getInstance();
|
||||
for (const migration of migrations) {
|
||||
dbManager.registerMigration(migration);
|
||||
}
|
||||
const db = await dbManager.initialize();
|
||||
streamingStore = new StreamingSessionStore(db);
|
||||
|
||||
sessionRecord = streamingStore.getByClaudeSessionId(options.sessionId);
|
||||
if (!sessionRecord) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Session not found: ${options.sessionId}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Database error: ${error.message}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a title and subtitle generator for claude-mem session metadata.
|
||||
|
||||
Your job is to analyze a user's request and generate:
|
||||
@@ -107,49 +140,17 @@ Now generate the title and subtitle (two lines exactly):`;
|
||||
const title = lines[0].trim();
|
||||
const subtitle = lines[1].trim();
|
||||
|
||||
// Save to session metadata if --save flag is provided
|
||||
if (options.save) {
|
||||
if (!options.project || !options.session) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: '--project and --session are required when using --save'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --save and we have a session, update the database
|
||||
if (options.save && streamingStore && sessionRecord) {
|
||||
try {
|
||||
const sessionFile = path.join(SESSION_DIR, `${options.project}_streaming.json`);
|
||||
|
||||
if (!fs.existsSync(sessionFile)) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Session file not found: ${sessionFile}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let sessionData: any = {};
|
||||
try {
|
||||
sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to parse session file'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
sessionData.promptTitle = title;
|
||||
sessionData.promptSubtitle = subtitle;
|
||||
sessionData.updatedAt = new Date().toISOString();
|
||||
|
||||
// Write back to file
|
||||
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
|
||||
streamingStore.update(sessionRecord.id, {
|
||||
title,
|
||||
subtitle
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to save metadata: ${error.message}`
|
||||
error: `Failed to save title: ${error.message}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -160,7 +161,8 @@ Now generate the title and subtitle (two lines exactly):`;
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
title,
|
||||
subtitle
|
||||
subtitle,
|
||||
sessionId: sessionRecord?.claude_session_id
|
||||
}, null, 2));
|
||||
} else if (options.oneline) {
|
||||
console.log(`${title} - ${subtitle}`);
|
||||
|
||||
+176
-764
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
import { Database } from 'better-sqlite3';
|
||||
import { getDatabase } from './Database.js';
|
||||
import { normalizeTimestamp } from './types.js';
|
||||
|
||||
/**
|
||||
* Represents a streaming session row in the database
|
||||
*/
|
||||
export interface StreamingSessionRow {
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
sdk_session_id?: string;
|
||||
project: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
user_prompt?: string;
|
||||
started_at: string;
|
||||
started_at_epoch: number;
|
||||
updated_at?: string;
|
||||
updated_at_epoch?: number;
|
||||
completed_at?: string;
|
||||
completed_at_epoch?: number;
|
||||
status: 'active' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for creating a new streaming session
|
||||
*/
|
||||
export interface StreamingSessionInput {
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
user_prompt?: string;
|
||||
started_at?: string | Date | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for updating a streaming session
|
||||
*/
|
||||
export interface StreamingSessionUpdate {
|
||||
sdk_session_id?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
status?: 'active' | 'completed' | 'failed';
|
||||
completed_at?: string | Date | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Access Object for streaming session records
|
||||
* Handles real-time session tracking during SDK compression
|
||||
*/
|
||||
export class StreamingSessionStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new streaming session record
|
||||
* This should be called immediately when the hook receives a user prompt
|
||||
*/
|
||||
create(input: StreamingSessionInput): StreamingSessionRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.started_at);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO streaming_sessions (
|
||||
claude_session_id, project, user_prompt, started_at, started_at_epoch, status
|
||||
) VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
input.claude_session_id,
|
||||
input.project,
|
||||
input.user_prompt || null,
|
||||
isoString,
|
||||
epoch
|
||||
);
|
||||
|
||||
return this.getById(info.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a streaming session by internal ID
|
||||
* Uses atomic transaction to prevent race conditions
|
||||
*/
|
||||
update(id: number, updates: StreamingSessionUpdate): StreamingSessionRow {
|
||||
const { isoString: updatedAt, epoch: updatedEpoch } = normalizeTimestamp(new Date());
|
||||
|
||||
const existing = this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Streaming session with id ${id} not found`);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.sdk_session_id !== undefined) {
|
||||
parts.push('sdk_session_id = ?');
|
||||
values.push(updates.sdk_session_id);
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
parts.push('title = ?');
|
||||
values.push(updates.title);
|
||||
}
|
||||
if (updates.subtitle !== undefined) {
|
||||
parts.push('subtitle = ?');
|
||||
values.push(updates.subtitle);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
parts.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.completed_at !== undefined) {
|
||||
const { isoString, epoch } = normalizeTimestamp(updates.completed_at);
|
||||
parts.push('completed_at = ?', 'completed_at_epoch = ?');
|
||||
values.push(isoString, epoch);
|
||||
}
|
||||
|
||||
// Always update the updated_at timestamp
|
||||
parts.push('updated_at = ?', 'updated_at_epoch = ?');
|
||||
values.push(updatedAt, updatedEpoch);
|
||||
|
||||
values.push(id);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE streaming_sessions
|
||||
SET ${parts.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(...values);
|
||||
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a streaming session by Claude session ID
|
||||
* Convenience method for hooks that only have the Claude session ID
|
||||
*/
|
||||
updateByClaudeSessionId(claudeSessionId: string, updates: StreamingSessionUpdate): StreamingSessionRow | null {
|
||||
const session = this.getByClaudeSessionId(claudeSessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return this.update(session.id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by internal ID
|
||||
*/
|
||||
getById(id: number): StreamingSessionRow | null {
|
||||
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE id = ?');
|
||||
return stmt.get(id) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by Claude session ID
|
||||
*/
|
||||
getByClaudeSessionId(claudeSessionId: string): StreamingSessionRow | null {
|
||||
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE claude_session_id = ?');
|
||||
return stmt.get(claudeSessionId) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by SDK session ID
|
||||
*/
|
||||
getBySdkSessionId(sdkSessionId: string): StreamingSessionRow | null {
|
||||
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE sdk_session_id = ?');
|
||||
return stmt.get(sdkSessionId) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a streaming session exists by Claude session ID
|
||||
*/
|
||||
has(claudeSessionId: string): boolean {
|
||||
const stmt = this.db.prepare('SELECT 1 FROM streaming_sessions WHERE claude_session_id = ? LIMIT 1');
|
||||
return Boolean(stmt.get(claudeSessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active streaming sessions for a project
|
||||
*/
|
||||
getActiveForProject(project: string): StreamingSessionRow[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM streaming_sessions
|
||||
WHERE project = ? AND status = 'active'
|
||||
ORDER BY started_at_epoch DESC
|
||||
`);
|
||||
return stmt.all(project) as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active streaming sessions
|
||||
*/
|
||||
getAllActive(): StreamingSessionRow[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM streaming_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY started_at_epoch DESC
|
||||
`);
|
||||
return stmt.all() as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent streaming sessions (completed or failed)
|
||||
*/
|
||||
getRecent(limit = 10): StreamingSessionRow[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM streaming_sessions
|
||||
ORDER BY started_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as completed
|
||||
*/
|
||||
markCompleted(id: number): StreamingSessionRow {
|
||||
return this.update(id, {
|
||||
status: 'completed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as failed
|
||||
*/
|
||||
markFailed(id: number): StreamingSessionRow {
|
||||
return this.update(id, {
|
||||
status: 'failed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a streaming session by ID
|
||||
*/
|
||||
deleteById(id: number): boolean {
|
||||
const stmt = this.db.prepare('DELETE FROM streaming_sessions WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a streaming session by Claude session ID
|
||||
*/
|
||||
deleteByClaudeSessionId(claudeSessionId: string): boolean {
|
||||
const stmt = this.db.prepare('DELETE FROM streaming_sessions WHERE claude_session_id = ?');
|
||||
const info = stmt.run(claudeSessionId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old completed/failed sessions (older than N days)
|
||||
*/
|
||||
cleanupOldSessions(daysOld = 30): number {
|
||||
const cutoffEpoch = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM streaming_sessions
|
||||
WHERE status IN ('completed', 'failed')
|
||||
AND completed_at_epoch < ?
|
||||
`);
|
||||
const info = stmt.run(cutoffEpoch);
|
||||
return info.changes;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export { MemoryStore } from './MemoryStore.js';
|
||||
export { OverviewStore } from './OverviewStore.js';
|
||||
export { DiagnosticsStore } from './DiagnosticsStore.js';
|
||||
export { TranscriptEventStore } from './TranscriptEventStore.js';
|
||||
export { StreamingSessionStore } from './StreamingSessionStore.js';
|
||||
|
||||
// Export types
|
||||
export * from './types.js';
|
||||
@@ -26,18 +27,20 @@ export async function createStores() {
|
||||
}
|
||||
|
||||
const db = await manager.initialize();
|
||||
|
||||
|
||||
const { SessionStore } = await import('./SessionStore.js');
|
||||
const { MemoryStore } = await import('./MemoryStore.js');
|
||||
const { OverviewStore } = await import('./OverviewStore.js');
|
||||
const { DiagnosticsStore } = await import('./DiagnosticsStore.js');
|
||||
const { TranscriptEventStore } = await import('./TranscriptEventStore.js');
|
||||
const { StreamingSessionStore } = await import('./StreamingSessionStore.js');
|
||||
|
||||
return {
|
||||
sessions: new SessionStore(db),
|
||||
memories: new MemoryStore(db),
|
||||
overviews: new OverviewStore(db),
|
||||
diagnostics: new DiagnosticsStore(db),
|
||||
transcriptEvents: new TranscriptEventStore(db)
|
||||
transcriptEvents: new TranscriptEventStore(db),
|
||||
streamingSessions: new StreamingSessionStore(db)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,10 +160,53 @@ export const migration002: Migration = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Migration 003 - Add streaming_sessions table for real-time session tracking
|
||||
*/
|
||||
export const migration003: Migration = {
|
||||
version: 3,
|
||||
up: (db: Database.Database) => {
|
||||
// Streaming sessions table - tracks active SDK compression sessions
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS streaming_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT UNIQUE NOT NULL,
|
||||
sdk_session_id TEXT,
|
||||
project TEXT NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
updated_at TEXT,
|
||||
updated_at_epoch INTEGER,
|
||||
completed_at TEXT,
|
||||
completed_at_epoch INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_claude_id ON streaming_sessions(claude_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_sdk_id ON streaming_sessions(sdk_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_project ON streaming_sessions(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_status ON streaming_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_started ON streaming_sessions(started_at_epoch DESC);
|
||||
`);
|
||||
|
||||
console.log('✅ Created streaming_sessions table for real-time session tracking');
|
||||
},
|
||||
|
||||
down: (db: Database.Database) => {
|
||||
db.exec(`
|
||||
DROP TABLE IF EXISTS streaming_sessions;
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* All migrations in order
|
||||
*/
|
||||
export const migrations: Migration[] = [
|
||||
migration001,
|
||||
migration002
|
||||
migration002,
|
||||
migration003
|
||||
];
|
||||
@@ -0,0 +1,112 @@
|
||||
import { platform, homedir } from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { chmodSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const isWindows = platform() === 'win32';
|
||||
|
||||
/**
|
||||
* Platform-specific utilities for cross-platform compatibility
|
||||
* Handles differences between Windows and Unix-like systems
|
||||
*/
|
||||
export const Platform = {
|
||||
/**
|
||||
* Returns the appropriate shell for the current platform
|
||||
*/
|
||||
getShell: (): string => {
|
||||
return isWindows ? 'powershell' : '/bin/sh';
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the file extension for hook scripts
|
||||
*/
|
||||
getHookExtension: (): string => {
|
||||
return '.js'; // Both platforms can execute Node.js scripts
|
||||
},
|
||||
|
||||
/**
|
||||
* Finds the path to an executable command
|
||||
* @param name - Name of the executable to find
|
||||
* @returns Full path to the executable
|
||||
*/
|
||||
findExecutable: (name: string): string => {
|
||||
const cmd = isWindows ? `where ${name}` : `which ${name}`;
|
||||
return execSync(cmd, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes a file executable (Unix only - no-op on Windows)
|
||||
* @param path - Path to the file to make executable
|
||||
*/
|
||||
makeExecutable: (path: string): void => {
|
||||
if (!isWindows) {
|
||||
chmodSync(path, 0o755);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Installs uv package manager using platform-specific method
|
||||
*/
|
||||
installUv: (): void => {
|
||||
if (isWindows) {
|
||||
execSync('powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"', {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
} else {
|
||||
execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', {
|
||||
stdio: 'pipe',
|
||||
shell: '/bin/sh'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns shell configuration file paths for the current platform
|
||||
* @returns Array of shell config file paths
|
||||
*/
|
||||
getShellConfigPaths: (): string[] => {
|
||||
const home = homedir();
|
||||
|
||||
if (isWindows) {
|
||||
return [
|
||||
join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
||||
join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1')
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
join(home, '.bashrc'),
|
||||
join(home, '.zshrc'),
|
||||
join(home, '.bash_profile')
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the appropriate alias syntax for the current platform's shell
|
||||
* @param aliasName - Name of the alias
|
||||
* @param command - Command to alias
|
||||
* @returns Alias definition string
|
||||
*/
|
||||
getAliasDefinition: (aliasName: string, command: string): string => {
|
||||
if (isWindows) {
|
||||
// PowerShell function syntax
|
||||
return `function ${aliasName} { ${command} $args }`;
|
||||
}
|
||||
|
||||
// Bash/Zsh alias syntax
|
||||
return `alias ${aliasName}='${command}'`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether the current platform is Windows
|
||||
*/
|
||||
isWindows: (): boolean => isWindows,
|
||||
|
||||
/**
|
||||
* Returns whether the current platform is Unix-like (macOS/Linux)
|
||||
*/
|
||||
isUnix: (): boolean => !isWindows
|
||||
};
|
||||
Reference in New Issue
Block a user