Architecture documentation for the global settings service implementation.
The Settings Service provides global application configuration accessible throughout the CLI. It implements a singleton pattern with SQLite persistence, using Clean Architecture principles and dependency injection.
┌─────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ CLI Entry Point (src/presentation/cli/index.ts) │ │
│ │ │ │
│ │ async function bootstrap() { │ │
│ │ 1. initializeContainer() → DI setup + migrations │ │
│ │ 2. container.resolve(InitializeSettingsUseCase) │ │
│ │ 3. initializeSettings(settings) → Singleton │ │
│ │ 4. program.parse() → Command execution │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ↓ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ InitializeSettingsUseCase │ │
│ │ execute() → Settings │ │
│ │ - Load existing settings OR │ │
│ │ - Create defaults + persist │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Port: ISettingsRepository │ │
│ │ initialize(settings): Promise<void> │ │
│ │ load(): Promise<Settings | null> │ │
│ │ update(settings): Promise<void> │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SQLiteSettingsRepository │ │
│ │ @injectable() (tsyringe DI) │ │
│ │ constructor(db: Database.Database) │ │
│ │ - Uses prepared statements (SQL injection safe) │ │
│ │ - Delegates to SettingsMapper for DB ↔ TS conversion │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SettingsMapper (settings.mapper.ts) │ │
│ │ toDatabase(settings): SettingsRow │ │
│ │ fromDatabase(row): Settings │ │
│ │ - Flattens nested objects (models.default → model_default) │
│ │ - Converts types (boolean → integer, Date → ISO string) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SQLite Database (~/.shep/data) │ │
│ │ Table: settings (singleton constraint on 'id') │ │
│ │ Columns: snake_case (model_default, sys_log_level, ...) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
↑
┌─────────────────────────────────────────────────────────────────┐
│ Singleton Service │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ settings.service.ts │ │
│ │ let settingsInstance: Settings | null = null; │ │
│ │ │ │
│ │ initializeSettings(settings): void │ │
│ │ getSettings(): Settings │ │
│ │ hasSettings(): boolean │ │
│ │ resetSettings(): void (testing only) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Usage: │
│ import { getSettings } from '@/infrastructure/services/settings.service'; │
│ const settings = getSettings(); │
│ console.log(settings.models.default); // 'claude-sonnet-4-6' │
│ │
└─────────────────────────────────────────────────────────────────┘
Decision: Define Settings in TypeSpec, generate TypeScript types.
Rationale:
.tsp → regenerate types)Implementation:
// Generated: packages/core/src/domain/generated/output.ts
export type Settings = BaseEntity & {
models: ModelConfiguration;
user: UserProfile;
environment: EnvironmentConfig;
system: SystemConfig;
agent: AgentConfig;
notifications: NotificationPreferences;
workflow: WorkflowConfig;
featureFlags?: FeatureFlags;
onboardingComplete: boolean;
};
export type ModelConfiguration = {
default: string; // Default model identifier for all agents
};
Trade-offs:
Decision: Global singleton service for settings access.
Rationale:
Implementation:
// packages/core/src/infrastructure/services/settings.service.ts
let settingsInstance: Settings | null = null;
export function initializeSettings(settings: Settings): void {
if (settingsInstance !== null) {
throw new Error('Settings already initialized.');
}
settingsInstance = settings;
}
export function getSettings(): Settings {
if (settingsInstance === null) {
throw new Error('Settings not initialized.');
}
return settingsInstance;
}
Trade-offs:
resetSettings() mitigates testing issueDecision: Enforce single Settings record in database via SQLite constraint.
Rationale:
Implementation:
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY CHECK (id = 'singleton'),
-- other columns...
);
Trade-offs:
Decision: SQLite repository using prepared statements with named parameters.
Rationale:
Implementation:
// packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts
@injectable()
export class SQLiteSettingsRepository implements ISettingsRepository {
constructor(private readonly db: Database.Database) {}
async initialize(settings: Settings): Promise<void> {
const row = toDatabase(settings);
const stmt = this.db.prepare(`
INSERT INTO settings (
id, created_at, updated_at,
model_default, agent_type, agent_auth_method, ...
) VALUES (
@id, @created_at, @updated_at,
@model_default, @agent_type, @agent_auth_method, ...
)
`);
stmt.run(row); // Named parameters prevent SQL injection
}
}
Trade-offs:
Decision: Separate mapper functions for TypeScript ↔ SQLite conversion.
Rationale:
Implementation:
// packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts
export interface SettingsRow {
id: string;
created_at: string; // ISO 8601 string (SQLite TEXT)
updated_at: string;
model_default: string; // Flattened from models.default
sys_auto_update: number; // Boolean -> Integer (SQLite limitation)
agent_type: string;
// ... many more flattened columns
}
export function toDatabase(settings: Settings): SettingsRow {
return {
id: settings.id,
created_at: settings.createdAt.toISOString(),
model_default: settings.models.default,
sys_auto_update: settings.system.autoUpdate ? 1 : 0,
agent_type: settings.agent.type,
// ...
};
}
export function fromDatabase(row: SettingsRow): Settings {
return {
id: row.id,
createdAt: new Date(row.created_at),
models: {
default: row.model_default,
},
system: {
autoUpdate: row.sys_auto_update === 1,
// ...
},
agent: {
type: row.agent_type as AgentType,
// ...
},
// ...
};
}
Trade-offs:
Decision: Use tsyringe IoC container for dependency management.
Rationale:
Implementation:
// packages/core/src/infrastructure/di/container.ts
import 'reflect-metadata';
import { container } from 'tsyringe';
export async function initializeContainer(): Promise<typeof container> {
const db = await getSQLiteConnection();
await runSQLiteMigrations(db);
// Register database instance
container.registerInstance<Database.Database>('Database', db);
// Register repository implementations
container.register<ISettingsRepository>('ISettingsRepository', {
useFactory: (c) => {
const database = c.resolve<Database.Database>('Database');
return new SQLiteSettingsRepository(database);
},
});
// Register use cases as singletons
container.registerSingleton(InitializeSettingsUseCase);
container.registerSingleton(LoadSettingsUseCase);
container.registerSingleton(UpdateSettingsUseCase);
return container;
}
// src/presentation/cli/index.ts
import 'reflect-metadata'; // MUST be first import
async function bootstrap() {
await initializeContainer();
const useCase = container.resolve(InitializeSettingsUseCase);
const settings = await useCase.execute();
initializeSettings(settings);
}
Trade-offs:
reflect-metadata import (boilerplate)@injectable()) requiredDecision: CLI uses async bootstrap function before parsing commands.
Rationale:
Implementation:
// src/presentation/cli/index.ts
async function bootstrap() {
try {
// Step 1: Initialize DI container (database + migrations)
await initializeContainer();
// Step 2: Initialize settings (load or create defaults)
const initializeSettingsUseCase = container.resolve(InitializeSettingsUseCase);
const settings = await initializeSettingsUseCase.execute();
initializeSettings(settings);
// Step 3: Set up Commander CLI and parse arguments
const program = new Command().name('shep').version(version);
program.parse();
} catch (error) {
messages.error('Failed to initialize CLI', error);
process.exit(1);
}
}
bootstrap();
Trade-offs:
1. User runs: shep version
↓
2. bootstrap() → initializeContainer()
↓
3. getSQLiteConnection() → ~/.shep/data
↓
4. runSQLiteMigrations() → CREATE TABLE settings
↓
5. container.resolve(InitializeSettingsUseCase)
↓
6. useCase.execute()
├─ repository.load() → null (no settings exist)
├─ Create defaults: { models, user, environment, system }
└─ repository.initialize(defaults)
↓
7. initializeSettings(defaults) → Singleton instance
↓
8. program.parse() → Execute 'version' command
↓
9. Command can call getSettings() → Access singleton
1. User runs: shep <command>
↓
2. bootstrap() → initializeContainer()
↓
3. getSQLiteConnection() → ~/.shep/data (already exists)
↓
4. runSQLiteMigrations() → Check user_version (no changes)
↓
5. container.resolve(InitializeSettingsUseCase)
↓
6. useCase.execute()
├─ repository.load() → Settings (existing record)
└─ Return loaded settings (no database write)
↓
7. initializeSettings(settings) → Singleton instance
↓
8. program.parse() → Execute command
↓
9. Command calls getSettings() → Access singleton
1. User runs: shep settings update --model claude-opus-4-5
|
2. Command handler:
+-- settings = getSettings() (load from singleton)
+-- settings.models.default = 'claude-opus-4-5'
+-- container.resolve(UpdateSettingsUseCase)
↓
3. useCase.execute(settings)
├─ repository.update(settings) → SQL UPDATE
└─ Return updated settings
↓
4. Singleton instance is already updated (by reference)
↓
5. CLI continues with new settings for remaining commands
1. User runs: shep settings agent --agent cursor
↓
2. ConfigureAgentUseCase:
├─ AgentValidatorService.isAvailable('cursor') → checks `agent --version`
├─ Load current settings
├─ Update settings.agent.type = 'cursor'
└─ repository.update(settings) → SQL UPDATE
↓
3. Singleton reset + reinitialize (agent.command.ts)
↓
4. Any subsequent command that needs an executor:
├─ Inject IAgentExecutorProvider from DI container
└─ provider.getExecutor()
→ internally reads getSettings().agent.type → 'cursor'
→ delegates to AgentExecutorFactory.createExecutor('cursor', settings.agent)
→ CursorExecutorService
ARCHITECTURAL RULE: The
settings.agent.typefield is the single source of truth for which agent executor runs. All code paths that need anIAgentExecutorMUST go throughIAgentExecutorProvider.getExecutor()— never call the factory directly or hardcode the agent type. See AGENTS.md — Settings-Driven Agent Resolution.
packages/core/src/
├── domain/
│ └── generated/
│ └── output.ts # TypeSpec-generated types (Settings interface)
│
├── application/
│ ├── ports/
│ │ └── output/
│ └── settings.repository.interface.ts # ISettingsRepository port
│ └── use-cases/
│ └── settings/
│ ├── initialize-settings.use-case.ts # Load or create defaults
│ ├── load-settings.use-case.ts # Load existing
│ └── update-settings.use-case.ts # Update existing
│
└── infrastructure/
├── di/
│ └── container.ts # tsyringe DI container setup
├── persistence/
│ └── sqlite/
│ ├── connection.ts # Database connection (~/.shep/data)
│ ├── migrations.ts # Schema migrations (user_version)
│ └── mappers/
│ └── settings.mapper.ts # TS ↔ SQL conversion
├── repositories/
│ └── sqlite-settings.repository.ts # SQLiteSettingsRepository impl
└── services/
└── settings.service.ts # Singleton service (getSettings, initializeSettings)
src/presentation/
└── cli/
└── index.ts # CLI entry point (bootstrap function)
Mock the repository interface:
// tests/unit/application/use-cases/settings/initialize-settings.test.ts
describe('InitializeSettingsUseCase', () => {
it('should load existing settings', async () => {
const mockRepo = {
load: vi.fn().mockResolvedValue(existingSettings),
initialize: vi.fn(),
};
const useCase = new InitializeSettingsUseCase(mockRepo);
const result = await useCase.execute();
expect(result).toBe(existingSettings);
expect(mockRepo.initialize).not.toHaveBeenCalled();
});
});
Use in-memory SQLite:
// tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts
import 'reflect-metadata'; // IMPORTANT: Required for tsyringe
describe('SQLiteSettingsRepository', () => {
let db: Database.Database;
let repository: SQLiteSettingsRepository;
beforeEach(async () => {
db = createInMemoryDatabase(); // :memory:
await runSQLiteMigrations(db);
repository = new SQLiteSettingsRepository(db);
});
afterEach(() => db.close());
it('should persist settings', async () => {
await repository.initialize(settings);
const loaded = await repository.load();
expect(loaded).toMatchObject(settings);
});
});
Use temporary directory:
// tests/e2e/cli/settings-initialization.test.ts
describe('CLI: settings initialization', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'shep-cli-test-'));
});
it('should create ~/.shep/ directory on first run', () => {
const runner = createCliRunner({ env: { HOME: tempDir } });
const result = runner.run('version');
expect(result.success).toBe(true);
expect(existsSync(join(tempDir, '.shep'))).toBe(true);
});
});
~/.shep/data (user home directory)✅ Prepared statements with named parameters prevent SQL injection
~/.shep/ created with 0700 (owner-only access)From SQLite to PostgreSQL:
From singleton to context-aware:
context parameter to getSettings(context)Update when:
Related files:
tsp/domain/entities/settings.tsp - TypeSpec model definitionpackages/core/src/infrastructure/di/container.ts - DI container setuppackages/core/src/infrastructure/services/settings.service.ts - Singleton servicepackages/core/src/infrastructure/repositories/sqlite-settings.repository.ts - Repository implementation