Skip to content

Commit 6a6458e

Browse files
committed
Stop auto-migrating legacy quota; add --sync flag
Don't auto-migrate or create experimental.quotaToast on config load anymore. The loader now prefers the plugin sidecar (opencode-quota/quota-toast.json) and only falls back to experimental.quotaToast when the sidecar is absent; normal runtime does not write or migrate legacy blocks. Added an explicit init option and CLI flag (--sync-legacy-config) and installer support (syncLegacyConfig) to write/sync the legacy experimental.quotaToast when requested. Refactored config loading (removed migration/pending-migration code paths), improved invalid-plugin JSON error reporting, and updated tests and README to reflect the new behavior. Also added small type/comment and utility changes used by the installer sync logic.
1 parent 1aeb061 commit 6a6458e

8 files changed

Lines changed: 246 additions & 216 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ npx @slkiser/opencode-quota init
2828
> [!IMPORTANT]
2929
> OpenCode `>= 1.4.3` and Node.js `>= 18` are required.
3030
31-
The installer is append-only and preserves existing config values. It asks where to install, which quota UI to enable, whether providers should be auto-detected, which quota display style to use, how percentages should be labeled, and whether session input/output tokens should appear in quota displays.
31+
The installer is append-only and preserves existing config values. It asks where to install, which quota UI to enable, whether providers should be auto-detected, which quota display style to use, how percentages should be labeled, and whether session input/output tokens should appear in quota displays. By default it writes quota settings to `opencode-quota/quota-toast.json` only; if you also need the legacy OpenCode `experimental.quotaToast` block, run `npx @slkiser/opencode-quota init --sync-legacy-config`.
3232

3333
After install:
3434

@@ -67,7 +67,7 @@ If you also want the sidebar, add the same package to the `tui.json` or `tui.jso
6767
}
6868
```
6969

70-
Quota settings live in `opencode-quota/quota-toast.json` next to the OpenCode config file chosen by the installer (project or global). Existing `experimental.quotaToast` settings in `opencode.json` / `opencode.jsonc` still work and are copied into the plugin-owned file when possible; if both exist, `opencode-quota/quota-toast.json` wins. Quota settings do not live in `tui.json`.
70+
Quota settings live in `opencode-quota/quota-toast.json` next to the OpenCode config file chosen by the installer (project or global). On load, OpenCode Quota reads that sidecar first and falls back to existing `experimental.quotaToast` settings in `opencode.json` / `opencode.jsonc` only when the sidecar is absent. Normal runtime/load does not create, re-add, or migrate `experimental.quotaToast`; use `init --sync-legacy-config` when you explicitly want the installer to also write/sync that legacy block. Quota settings do not live in `tui.json`.
7171

7272
### What plugin adds
7373

src/bin/opencode-quota.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import { runInitInstaller } from "../lib/init-installer.js";
88

99
const USAGE = [
1010
"Usage:",
11-
" npx @slkiser/opencode-quota init",
11+
" npx @slkiser/opencode-quota init [--sync-legacy-config]",
1212
" npx @slkiser/opencode-quota show [--provider <provider-id>]",
1313
" npx @slkiser/opencode-quota --help",
1414
"",
1515
"Commands:",
1616
" init Run the interactive quota installer",
17+
" --sync-legacy-config also writes experimental.quotaToast",
1718
" show Print a quick quota glance",
1819
].join("\n");
1920

@@ -54,8 +55,13 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
5455
return 0;
5556
}
5657

57-
if (command === "init" && rest.length === 0) {
58-
return await runInitInstaller();
58+
if (command === "init") {
59+
if (rest.length === 0) {
60+
return await runInitInstaller();
61+
}
62+
if (rest.length === 1 && rest[0] === "--sync-legacy-config") {
63+
return await runInitInstaller({ syncLegacyConfig: true });
64+
}
5965
}
6066

6167
if (command === "show") {

src/lib/config.ts

Lines changed: 35 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { existsSync } from "fs";
2323
import { readFile } from "fs/promises";
2424
import { join } from "path";
2525

26-
import { writeJsonAtomic } from "./atomic-json.js";
2726
import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";
2827

2928
export const QUOTA_TOAST_CONFIG_RELATIVE_PATH = "opencode-quota/quota-toast.json";
@@ -147,16 +146,6 @@ interface ConfigLayerCandidate {
147146
pluginPath: string;
148147
}
149148

150-
interface PendingLegacyMigration {
151-
quotaToastConfig: Record<string, unknown>;
152-
sourcePath: string;
153-
sourcePaths: Set<string>;
154-
scope: ConfigLayerScope;
155-
config: QuotaToastConfig;
156-
settingSources: QuotaToastSettingSources;
157-
configIssues: LoadConfigIssue[];
158-
}
159-
160149
export function getQuotaToastConfigPath(configRootDir: string): string {
161150
return join(configRootDir, QUOTA_TOAST_CONFIG_RELATIVE_PATH);
162151
}
@@ -705,18 +694,18 @@ function buildConfigLayerCandidatesForRoot(
705694
): ConfigLayerCandidate[] {
706695
const pluginPath = getQuotaToastConfigPath(dir);
707696
return [
708-
...CONFIG_FILENAMES.map((filename) => ({
709-
path: join(dir, filename),
710-
scope,
711-
kind: "legacy" as const,
712-
pluginPath,
713-
})),
714697
{
715698
path: pluginPath,
716699
scope,
717700
kind: "plugin" as const,
718701
pluginPath,
719702
},
703+
...CONFIG_FILENAMES.map((filename) => ({
704+
path: join(dir, filename),
705+
scope,
706+
kind: "legacy" as const,
707+
pluginPath,
708+
})),
720709
];
721710
}
722711

@@ -742,85 +731,6 @@ function getConfigLayerSourceLabel(candidate: ConfigLayerCandidate): string {
742731
return `${candidate.path} (${suffix})`;
743732
}
744733

745-
async function migrateLegacyQuotaToastConfig(params: {
746-
pluginPath: string;
747-
quotaToastConfig: Record<string, unknown>;
748-
}): Promise<boolean> {
749-
if (existsSync(params.pluginPath)) {
750-
return true;
751-
}
752-
753-
try {
754-
await writeJsonAtomic(params.pluginPath, params.quotaToastConfig, { trailingNewline: true });
755-
return true;
756-
} catch {
757-
// Best effort only. The legacy config remains readable for backward compatibility.
758-
return false;
759-
}
760-
}
761-
762-
function buildQuotaToastMigrationPayload(
763-
config: QuotaToastConfig,
764-
settingSources: QuotaToastSettingSources,
765-
includeSource: (source: string) => boolean,
766-
): Record<string, unknown> {
767-
const payload: Record<string, unknown> = {};
768-
const hasSource = (key: QuotaToastSettingSourceKey): boolean => {
769-
const source = settingSources[key];
770-
return typeof source === "string" && includeSource(source);
771-
};
772-
773-
if (hasSource("enabled")) payload.enabled = config.enabled;
774-
if (hasSource("enableToast")) payload.enableToast = config.enableToast;
775-
if (hasSource("formatStyle")) payload.formatStyle = config.formatStyle;
776-
if (hasSource("percentDisplayMode")) payload.percentDisplayMode = config.percentDisplayMode;
777-
if (hasSource("minIntervalMs")) payload.minIntervalMs = config.minIntervalMs;
778-
if (hasSource("debug")) payload.debug = config.debug;
779-
if (hasSource("enabledProviders")) {
780-
payload.enabledProviders =
781-
config.enabledProviders === "auto" ? "auto" : [...config.enabledProviders];
782-
}
783-
if (hasSource("anthropicBinaryPath")) payload.anthropicBinaryPath = config.anthropicBinaryPath;
784-
if (hasSource("googleModels")) payload.googleModels = [...config.googleModels];
785-
if (hasSource("alibabaCodingPlanTier")) {
786-
payload.alibabaCodingPlanTier = config.alibabaCodingPlanTier;
787-
}
788-
if (hasSource("cursorPlan")) payload.cursorPlan = config.cursorPlan;
789-
if (hasSource("cursorIncludedApiUsd")) {
790-
payload.cursorIncludedApiUsd = config.cursorIncludedApiUsd;
791-
}
792-
if (hasSource("cursorBillingCycleStartDay")) {
793-
payload.cursorBillingCycleStartDay = config.cursorBillingCycleStartDay;
794-
}
795-
if (hasSource("opencodeGoWindows")) payload.opencodeGoWindows = [...config.opencodeGoWindows];
796-
if (hasSource("pricingSnapshot.source") || hasSource("pricingSnapshot.autoRefresh")) {
797-
const pricingSnapshot: Record<string, unknown> = {};
798-
if (hasSource("pricingSnapshot.source")) {
799-
pricingSnapshot.source = config.pricingSnapshot.source;
800-
}
801-
if (hasSource("pricingSnapshot.autoRefresh")) {
802-
pricingSnapshot.autoRefresh = config.pricingSnapshot.autoRefresh;
803-
}
804-
payload.pricingSnapshot = pricingSnapshot;
805-
}
806-
if (hasSource("showOnIdle")) payload.showOnIdle = config.showOnIdle;
807-
if (hasSource("showOnQuestion")) payload.showOnQuestion = config.showOnQuestion;
808-
if (hasSource("showOnCompact")) payload.showOnCompact = config.showOnCompact;
809-
if (hasSource("showOnBothFail")) payload.showOnBothFail = config.showOnBothFail;
810-
if (hasSource("toastDurationMs")) payload.toastDurationMs = config.toastDurationMs;
811-
if (hasSource("onlyCurrentModel")) payload.onlyCurrentModel = config.onlyCurrentModel;
812-
if (hasSource("showSessionTokens")) payload.showSessionTokens = config.showSessionTokens;
813-
if (hasSource("layout.maxWidth") || hasSource("layout.narrowAt") || hasSource("layout.tinyAt")) {
814-
const layout: Record<string, unknown> = {};
815-
if (hasSource("layout.maxWidth")) layout.maxWidth = config.layout.maxWidth;
816-
if (hasSource("layout.narrowAt")) layout.narrowAt = config.layout.narrowAt;
817-
if (hasSource("layout.tinyAt")) layout.tinyAt = config.layout.tinyAt;
818-
payload.layout = layout;
819-
}
820-
821-
return payload;
822-
}
823-
824734
/**
825735
* Load plugin configuration from OpenCode config
826736
*
@@ -866,95 +776,56 @@ export async function loadConfig(
866776
const workspaceConfigPaths: string[] = [];
867777
const settingSources: QuotaToastSettingSources = {};
868778
const configIssues: LoadConfigIssue[] = [];
869-
const pendingLegacyMigrations = new Map<string, PendingLegacyMigration>();
870779

871780
for (const candidate of buildConfigLayerCandidates(configDirs, configRootDir)) {
872-
const pendingMigration =
873-
candidate.kind === "plugin" ? pendingLegacyMigrations.get(candidate.path) : undefined;
874-
875-
if (candidate.kind === "plugin") {
876-
if (pendingMigration && !existsSync(candidate.path)) {
877-
await migrateLegacyQuotaToastConfig({
878-
pluginPath: candidate.path,
879-
quotaToastConfig: pendingMigration.quotaToastConfig,
880-
});
881-
}
882-
} else if (existsSync(candidate.pluginPath)) {
781+
if (candidate.kind === "legacy" && existsSync(candidate.pluginPath)) {
883782
continue;
884783
}
885784

886-
let rawQuotaToast: Record<string, unknown> | undefined;
887-
let sourcePath = getConfigLayerSourceLabel(candidate);
888-
let scope = candidate.scope;
889-
890-
if (existsSync(candidate.path)) {
891-
const parsed = await readJson(candidate.path);
892-
if (!isPlainObject(parsed)) {
893-
continue;
894-
}
895-
896-
const extractedQuotaToast =
897-
candidate.kind === "plugin"
898-
? parsed
899-
: isPlainObject(parsed.experimental)
900-
? parsed.experimental.quotaToast
901-
: undefined;
902-
if (!isPlainObject(extractedQuotaToast)) {
903-
continue;
904-
}
905-
rawQuotaToast = extractedQuotaToast;
906-
} else if (candidate.kind === "plugin" && pendingMigration) {
907-
rawQuotaToast = pendingMigration.quotaToastConfig;
908-
sourcePath = pendingMigration.sourcePath;
909-
scope = pendingMigration.scope;
910-
} else {
785+
if (!existsSync(candidate.path)) {
911786
continue;
912787
}
913788

914-
if (candidate.kind === "legacy") {
915-
const pending = pendingLegacyMigrations.get(candidate.pluginPath) ?? {
916-
quotaToastConfig: {},
917-
sourcePath,
918-
sourcePaths: new Set<string>(),
919-
scope,
920-
config: cloneConfig(config),
921-
settingSources: { ...settingSources },
922-
configIssues: [],
923-
};
924-
pending.sourcePaths.add(sourcePath);
925-
applyValidatedQuotaToastPatch(
926-
pending.config,
927-
extractValidatedQuotaToastPatch(rawQuotaToast, (key, message) => {
928-
pending.configIssues.push({ path: sourcePath, key, message });
929-
}),
930-
sourcePath,
931-
pending.settingSources,
932-
);
933-
pending.quotaToastConfig = buildQuotaToastMigrationPayload(
934-
pending.config,
935-
pending.settingSources,
936-
(source) => pending.sourcePaths.has(source),
937-
);
938-
pending.sourcePath = sourcePath;
939-
pending.scope = scope;
940-
pendingLegacyMigrations.set(candidate.pluginPath, pending);
789+
const parsed = await readJson(candidate.path);
790+
if (!isPlainObject(parsed)) {
791+
if (candidate.kind === "plugin") {
792+
const sourcePath = getConfigLayerSourceLabel(candidate);
793+
usedPaths.push(sourcePath);
794+
if (candidate.scope === "global") {
795+
globalConfigPaths.push(sourcePath);
796+
} else {
797+
workspaceConfigPaths.push(sourcePath);
798+
}
799+
configIssues.push({
800+
path: sourcePath,
801+
key: "$root",
802+
message: "expected readable JSON object",
803+
});
804+
}
941805
continue;
942806
}
943807

944-
if (candidate.kind === "plugin" && pendingMigration?.configIssues.length) {
945-
configIssues.push(...pendingMigration.configIssues);
808+
const extractedQuotaToast =
809+
candidate.kind === "plugin"
810+
? parsed
811+
: isPlainObject(parsed.experimental)
812+
? parsed.experimental.quotaToast
813+
: undefined;
814+
if (!isPlainObject(extractedQuotaToast)) {
815+
continue;
946816
}
947817

818+
const sourcePath = getConfigLayerSourceLabel(candidate);
948819
usedPaths.push(sourcePath);
949-
if (scope === "global") {
820+
if (candidate.scope === "global") {
950821
globalConfigPaths.push(sourcePath);
951822
} else {
952823
workspaceConfigPaths.push(sourcePath);
953824
}
954825

955826
applyValidatedQuotaToastPatch(
956827
config,
957-
extractValidatedQuotaToastPatch(rawQuotaToast, (key, message) => {
828+
extractValidatedQuotaToastPatch(extractedQuotaToast, (key, message) => {
958829
configIssues.push({ path: sourcePath, key, message });
959830
}),
960831
sourcePath,

0 commit comments

Comments
 (0)