Skip to content

Commit 2610393

Browse files
refactor(src): introduce centralized ConfigService and update config flow
- Centralize config handling via new ConfigService - Migrate provider/config logic to config service and tests - Update commands to rely on ConfigService for provider resolution introduce a new config service and migrate related logic from legacy modules, enabling centralized management of provider config and configuration IO; adjust command implementations to use the new service for provider resolution and field access Co-Authored-By: Ai Commit <41898282+github-actions[bot]@users.noreply.github.com>
1 parent ec1623d commit 2610393

23 files changed

Lines changed: 854 additions & 839 deletions

src/cli-app.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
55
import type { HttpClient } from "effect/unstable/http";
66
import PackageJson from "../package.json" with { type: "json" };
77
import { commandRoot } from "./commands/root.ts";
8+
import { ConfigServiceLive } from "./config/service.ts";
89
import { CommitLlmServicesLive, CommitServiceLive } from "./services/commit-service.ts";
910
import { GitignoreServiceLive } from "./services/gitignore-service.ts";
1011
import { HookServiceLive } from "./services/hooks.ts";
@@ -24,32 +25,38 @@ export const makePlatformLayer = (
2425
httpClientLayer: Layer.Layer<HttpClient.HttpClient> = FetchHttpClient.layer,
2526
) => Layer.mergeAll(NodeServices.layer, httpClientLayer);
2627

27-
export const makeServicesLayer = (
28+
const makeServicesLayer = (
29+
configProvider: Layer.Layer<never>,
2830
platformLayer?: Layer.Layer<HttpClient.HttpClient | NodeServices.NodeServices> | undefined,
2931
) => {
32+
const platform = platformLayer ?? makePlatformLayer();
3033
const coreServices = Layer.mergeAll(VcsLive, HookServiceLive, LlmClientLive).pipe(
31-
Layer.provideMerge(platformLayer ?? makePlatformLayer()),
34+
Layer.provideMerge(platform),
35+
);
36+
const configServices = ConfigServiceLive.pipe(
37+
Layer.provideMerge(Layer.mergeAll(platform, configProvider)),
3238
);
3339

3440
const featureServices = Layer.mergeAll(
3541
CommitLlmServicesLive,
3642
ScopeServiceLive,
3743
GitignoreServiceLive,
38-
).pipe(Layer.provideMerge(coreServices));
44+
).pipe(Layer.provideMerge(Layer.mergeAll(coreServices, configServices)));
3945

4046
const commitRuntime = CommitServiceLive.pipe(
41-
Layer.provideMerge(Layer.mergeAll(coreServices, featureServices)),
47+
Layer.provideMerge(Layer.mergeAll(coreServices, configServices, featureServices)),
4248
);
4349

4450
return Layer.mergeAll(
4551
coreServices,
52+
configServices,
4653
featureServices,
4754
commitRuntime,
4855
makeProgressLayer(gitAgentProgressRenderConfig),
4956
);
5057
};
5158

52-
export interface CliProgramOptions {
59+
interface CliProgramOptions {
5360
readonly env?: Record<string, string | undefined> | undefined;
5461
readonly platformLayer?:
5562
| Layer.Layer<HttpClient.HttpClient | NodeServices.NodeServices>
@@ -60,10 +67,10 @@ export const makeCliProgram = (
6067
args: ReadonlyArray<string>,
6168
options: CliProgramOptions = {},
6269
): Effect.Effect<number, never> => {
63-
const services = makeServicesLayer(options.platformLayer);
6470
const configProvider = ConfigProvider.layer(
6571
ConfigProvider.fromEnv({ env: toConfigEnv(options.env ?? process.env) }),
6672
);
73+
const services = makeServicesLayer(configProvider, options.platformLayer);
6774
const live = Layer.mergeAll(services, configProvider);
6875

6976
return Command.runWith(commandRoot, {

src/commands/commit.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Console, Effect } from "effect";
22
import { Command, Flag } from "effect/unstable/cli";
3-
import { loadProjectConfig } from "../config/project.ts";
4-
import { resolveProviderConfig } from "../config/provider.ts";
3+
import { ConfigService } from "../config/service.ts";
54
import { parseTrailerText, type Trailer } from "../domain/commit.ts";
65
import { emptyProjectConfig } from "../domain/project.ts";
76
import { CommitService } from "../services/commit-service.ts";
@@ -13,7 +12,6 @@ import {
1312
apiKeyFlag,
1413
baseUrlFlag,
1514
cwdFlag,
16-
freeFlag,
1715
modelFlag,
1816
toOptionalString,
1917
vcsFlag,
@@ -67,7 +65,6 @@ export const commandCommit = Command.make(
6765
apiKey: apiKeyFlag,
6866
baseUrl: baseUrlFlag,
6967
model: modelFlag,
70-
free: freeFlag,
7168
intent: Flag.optional(
7269
Flag.string("intent").pipe(Flag.withDescription("Describe the intent of the change.")),
7370
),
@@ -98,6 +95,7 @@ export const commandCommit = Command.make(
9895
),
9996
},
10097
Effect.fn("Command.Commit")(function* (input) {
98+
const configService = yield* ConfigService;
10199
yield* Effect.annotateCurrentSpan({
102100
amend: input.amend,
103101
dry_run: input.dryRun,
@@ -119,7 +117,7 @@ export const commandCommit = Command.make(
119117

120118
yield* Effect.annotateCurrentSpan({ vcs: vcsKind });
121119

122-
const provider = yield* resolveProviderConfig({
120+
const provider = yield* configService.resolveProviderConfig({
123121
cwd: input.cwd,
124122
vcs: vcsKind,
125123
apiKey: toOptionalString(input.apiKey),
@@ -130,12 +128,13 @@ export const commandCommit = Command.make(
130128
if (provider.apiKey.length === 0) {
131129
return yield* new ConfigError({
132130
message:
133-
"no API key configured (hint: set --api-key, add api_key to ~/.config/ai-commit/config.yml, or use build-time embedded credentials)",
131+
"no API key configured (hint: set --api-key, add api_key to ~/.config/ai-commit/config.json, or use build-time embedded credentials)",
134132
});
135133
}
136134

137135
const repoRoot = yield* vcs.repoRoot(input.cwd);
138-
const projectConfig = (yield* loadProjectConfig(repoRoot)) ?? emptyProjectConfig();
136+
const projectConfig =
137+
(yield* configService.loadProjectConfig(repoRoot)) ?? emptyProjectConfig();
139138

140139
const trailers = yield* parseTrailers(
141140
input.coAuthor,

src/commands/config.ts

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,13 @@ import {
1010
type ConfigScope,
1111
validateScope,
1212
} from "../config/keys.ts";
13-
import {
14-
readBuildProviderDefaults,
15-
resolveField,
16-
resolveProviderConfig,
17-
writeUserField,
18-
} from "../config/provider.ts";
19-
import { localConfigPath, projectConfigWritePath, writeProjectField } from "../config/project.ts";
13+
import { ConfigService } from "../config/service.ts";
2014
import { HookService } from "../services/hooks.ts";
2115
import { Vcs } from "../services/vcs.ts";
2216
import {
2317
apiKeyFlag,
2418
baseUrlFlag,
2519
cwdFlag,
26-
freeFlag,
2720
modelFlag,
2821
toOptionalString,
2922
vcsFlag,
@@ -37,29 +30,22 @@ const configShow = Command.make(
3730
apiKey: apiKeyFlag,
3831
baseUrl: baseUrlFlag,
3932
model: modelFlag,
40-
free: freeFlag,
4133
},
4234
Effect.fn("Command.ConfigShow")(function* (input) {
35+
const configService = yield* ConfigService;
4336
const vcsService = yield* Vcs;
4437
const vcsKind = yield* vcsService.detect(input.cwd, toOptionalString(input.vcs));
4538

4639
yield* Effect.annotateCurrentSpan({ vcs: vcsKind });
4740

48-
const config = yield* resolveProviderConfig({
41+
const config = yield* configService.resolveProviderConfig({
4942
cwd: input.cwd,
5043
vcs: vcsKind,
5144
apiKey: toOptionalString(input.apiKey),
5245
baseUrl: toOptionalString(input.baseUrl),
5346
model: toOptionalString(input.model),
5447
});
5548

56-
const build = yield* readBuildProviderDefaults;
57-
58-
if (build.apiKey.length > 0 && config.apiKey === build.apiKey) {
59-
yield* Console.log("mode: FREE (using built-in credentials)");
60-
return;
61-
}
62-
6349
const masked =
6450
config.apiKey.length === 0
6551
? "(not set)"
@@ -80,12 +66,13 @@ const configGet = Command.make(
8066
key: Argument.string("key").pipe(Argument.withDescription("Configuration key to read.")),
8167
},
8268
Effect.fn("Command.ConfigGet")(function* (input) {
83-
const key = resolveKey(input.key);
69+
const configService = yield* ConfigService;
70+
const key = yield* resolveKey(input.key);
8471
const vcsService = yield* Vcs;
8572
const { client: vcs } = yield* vcsService.resolve(input.cwd, toOptionalString(input.vcs));
8673
const isRepo = yield* vcs.isRepo(input.cwd);
8774
const repoRoot = isRepo ? yield* vcs.repoRoot(input.cwd) : undefined;
88-
const resolved = yield* resolveField(repoRoot, key);
75+
const resolved = yield* configService.resolveField(repoRoot, key);
8976
if (resolved == null) {
9077
yield* Console.log(`${key} is not set`);
9178
return;
@@ -110,13 +97,14 @@ const configSet = Command.make(
11097
),
11198
},
11299
Effect.fn("Command.ConfigSet")(function* (input) {
113-
const key = resolveKey(input.key);
114-
const value = normalizeValue(key, input.value);
100+
const configService = yield* ConfigService;
101+
const key = yield* resolveKey(input.key);
102+
const value = yield* normalizeValue(key, input.value);
115103
const scope = (toOptionalString(input.scope) ?? defaultScopeForKey(key)) as ConfigScope;
116-
validateScope(key, scope);
104+
yield* validateScope(key, scope);
117105

118106
if (scope === ScopeUser) {
119-
yield* writeUserField(key, value);
107+
yield* configService.writeUserField(key, value);
120108
yield* Console.log(`set ${key} = ${value} (user)`);
121109
return;
122110
}
@@ -130,9 +118,9 @@ const configSet = Command.make(
130118
yield* Console.log(`installed hook: ${prepared.installedFrom}`);
131119
}
132120
const path = yield* scope === ScopeLocal
133-
? localConfigPath(repoRoot)
134-
: projectConfigWritePath(repoRoot);
135-
yield* writeProjectField(path, key, prepared.value);
121+
? configService.localConfigPath(repoRoot)
122+
: configService.projectConfigPath(repoRoot);
123+
yield* configService.writeProjectField(path, key, prepared.value);
136124
yield* Console.log(`set ${key} = ${prepared.value} (${scope})`);
137125
}),
138126
).pipe(Command.withDescription("Write a configuration value."));

src/commands/init.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { Console, Effect, FileSystem } from "effect";
22
import { Command, Flag } from "effect/unstable/cli";
3-
import {
4-
localConfigPath,
5-
mergeScopes,
6-
projectConfigWritePath,
7-
writeProjectField,
8-
} from "../config/project.ts";
9-
import { resolveProviderConfig } from "../config/provider.ts";
3+
import { ConfigService } from "../config/service.ts";
104
import { emptyProjectConfig } from "../domain/project.ts";
115
import { GitignoreService } from "../services/gitignore-service.ts";
126
import { ScopeService } from "../services/scope-service.ts";
@@ -17,7 +11,6 @@ import {
1711
apiKeyFlag,
1812
baseUrlFlag,
1913
cwdFlag,
20-
freeFlag,
2114
modelFlag,
2215
toOptionalString,
2316
vcsFlag,
@@ -31,7 +24,6 @@ export const commandInit = Command.make(
3124
apiKey: apiKeyFlag,
3225
baseUrl: baseUrlFlag,
3326
model: modelFlag,
34-
free: freeFlag,
3527
scope: Flag.boolean("scope").pipe(Flag.withDescription("Generate scopes via AI.")),
3628
gitignore: Flag.boolean("gitignore").pipe(Flag.withDescription("Generate .gitignore via AI.")),
3729
force: Flag.boolean("force").pipe(
@@ -42,14 +34,15 @@ export const commandInit = Command.make(
4234
Flag.withDescription("Maximum commit count to analyze for scopes."),
4335
),
4436
local: Flag.boolean("local").pipe(
45-
Flag.withDescription("Write config to .ai-commit/config.local.yml."),
37+
Flag.withDescription("Write config to .ai-commit/config.local.json."),
4638
),
4739
hook: Flag.string("hook").pipe(
4840
Flag.withDescription("Hook to configure. Repeat the flag or use comma-separated values."),
4941
Flag.between(0, Number.MAX_SAFE_INTEGER),
5042
),
5143
},
5244
Effect.fn("Command.Init")(function* (input) {
45+
const configService = yield* ConfigService;
5346
yield* Effect.annotateCurrentSpan({
5447
force: input.force,
5548
gitignore: input.gitignore,
@@ -88,10 +81,10 @@ export const commandInit = Command.make(
8881
}
8982

9083
const configPath = yield* input.local
91-
? localConfigPath(repoRoot)
92-
: projectConfigWritePath(repoRoot);
84+
? configService.localConfigPath(repoRoot)
85+
: configService.projectConfigPath(repoRoot);
9386

94-
if (!input.force) {
87+
if (fullWizard && !input.force) {
9588
const exists = yield* fs.exists(configPath);
9689
if (exists) {
9790
return yield* new ConfigError({
@@ -100,7 +93,7 @@ export const commandInit = Command.make(
10093
}
10194
}
10295

103-
const provider = yield* resolveProviderConfig({
96+
const provider = yield* configService.resolveProviderConfig({
10497
cwd: input.cwd,
10598
vcs: vcsKind,
10699
apiKey: toOptionalString(input.apiKey),
@@ -111,7 +104,7 @@ export const commandInit = Command.make(
111104
if ((doGitignore || doScope || fullWizard) && provider.apiKey.length === 0) {
112105
return yield* new ConfigError({
113106
message:
114-
"no API key configured (hint: set --api-key or add api_key to ~/.config/ai-commit/config.yml)",
107+
"no API key configured (hint: set --api-key or add api_key to ~/.config/ai-commit/config.json)",
115108
});
116109
}
117110

@@ -134,24 +127,24 @@ export const commandInit = Command.make(
134127
cwd: repoRoot,
135128
maxCommits: input.maxCommits,
136129
});
137-
yield* mergeScopes(configPath, scopes);
130+
yield* configService.mergeScopes(configPath, scopes);
138131
yield* Console.log(`scopes written to ${configPath}`);
139132
}
140133

141134
if (fullWizard) {
142135
yield* Effect.withSpan(
143-
writeProjectField(configPath, "hook", "conventional"),
136+
configService.writeProjectField(configPath, "hook", "conventional"),
144137
"Init.WriteDefaultHook",
145138
);
146139
} else if (hooks.length > 0) {
147140
yield* Effect.withSpan(
148-
writeProjectField(configPath, "hook", hooks.join(",")),
141+
configService.writeProjectField(configPath, "hook", hooks.join(",")),
149142
"Init.WriteHook",
150143
);
151144
}
152145

153146
if (!doScope && !doGitignore && !fullWizard && hooks.length > 0) {
154-
yield* mergeScopes(configPath, emptyProjectConfig().scopes);
147+
yield* configService.mergeScopes(configPath, emptyProjectConfig().scopes);
155148
}
156149
}),
157150
).pipe(Command.withDescription("Initialize ai-commit in the current repository."));

src/commands/shared.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ export const modelFlag = Flag.optional(
2525
Flag.string("model").pipe(Flag.withDescription("Model name for generation.")),
2626
);
2727

28-
export const freeFlag = Flag.boolean("free").pipe(
29-
Flag.withDescription("Use only build-time embedded credentials."),
30-
);
31-
3228
export const toOptionalString = (value: Option.Option<string>): string | undefined =>
3329
Option.match(value, {
3430
onNone: () => undefined,

src/config/env.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import { Config, Effect, Option } from "effect";
22

33
export const DefaultBaseUrl = "https://api.openai.com/v1";
44
export const DefaultModel = "gpt-5-nano";
5-
export const DefaultGitignoreBaseUrl = "https://www.toptal.com/developers/gitignore/api";
5+
const DefaultGitignoreBaseUrl = "https://www.toptal.com/developers/gitignore/api";
66

7-
export const envString = (name: string) => Config.string(name);
7+
const envString = (name: string) => Config.string(name);
88

9-
export const envOptionalString = (name: string) => Config.option(envString(name));
9+
const envOptionalString = (name: string) => Config.option(envString(name));
1010

11-
export const envStringWithDefault = (name: string, fallback: string) =>
11+
const envStringWithDefault = (name: string, fallback: string) =>
1212
envString(name).pipe(Config.withDefault(fallback));
1313

1414
const readPreferredEnv = Effect.fn(function* (names: ReadonlyArray<string>, fallback: string) {
@@ -44,6 +44,6 @@ export const buildEnvironment = Effect.gen(function* () {
4444
gitignoreBaseUrl,
4545
xdgConfigHome,
4646
};
47-
}).pipe(Effect.orDie);
47+
});
4848

4949
export const cwdEnvironment = envStringWithDefault("PWD", process.cwd());

0 commit comments

Comments
 (0)