@ottocode/sdk 0.1.253 → 0.1.254
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.
- package/package.json +1 -1
- package/src/config/src/index.ts +13 -1
- package/src/config/src/manager.ts +9 -9
- package/src/core/src/tools/builtin/fs/copy-into.ts +270 -0
- package/src/core/src/tools/builtin/fs/copy-into.txt +11 -0
- package/src/core/src/tools/builtin/fs/index.ts +2 -0
- package/src/providers/src/model-catalog-cache.ts +23 -23
- package/src/providers/src/registry.ts +6 -31
- package/src/providers/src/validate.ts +51 -27
- package/src/skills/loader.ts +2 -32
package/package.json
CHANGED
package/src/config/src/index.ts
CHANGED
|
@@ -57,7 +57,11 @@ export async function loadConfig(
|
|
|
57
57
|
const projectCfg = await readJsonOptional(projectConfigPath);
|
|
58
58
|
const globalCfg = await readJsonOptional(globalConfigPath);
|
|
59
59
|
|
|
60
|
-
const merged = deepMerge(
|
|
60
|
+
const merged = deepMerge(
|
|
61
|
+
DEFAULTS,
|
|
62
|
+
globalCfg,
|
|
63
|
+
omitGlobalOnlySettings(projectCfg),
|
|
64
|
+
);
|
|
61
65
|
|
|
62
66
|
await ensureDir(dataDir);
|
|
63
67
|
|
|
@@ -81,6 +85,14 @@ export async function loadConfig(
|
|
|
81
85
|
|
|
82
86
|
type JsonObject = Record<string, unknown>;
|
|
83
87
|
|
|
88
|
+
function omitGlobalOnlySettings(
|
|
89
|
+
config: JsonObject | undefined,
|
|
90
|
+
): JsonObject | undefined {
|
|
91
|
+
if (!config) return undefined;
|
|
92
|
+
const { providers: _providers, skills: _skills, ...rest } = config;
|
|
93
|
+
return rest;
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
async function readJsonOptional(file: string): Promise<JsonObject | undefined> {
|
|
85
97
|
const f = Bun.file(file);
|
|
86
98
|
if (!(await f.exists())) return undefined;
|
|
@@ -104,12 +104,12 @@ export async function writeDefaults(
|
|
|
104
104
|
* Persist provider settings for a built-in or custom provider entry.
|
|
105
105
|
*/
|
|
106
106
|
export async function writeProviderSettings(
|
|
107
|
-
|
|
107
|
+
_scope: Scope,
|
|
108
108
|
provider: string,
|
|
109
109
|
updates: ProviderSettingsEntry,
|
|
110
|
-
|
|
110
|
+
_projectRoot?: string,
|
|
111
111
|
) {
|
|
112
|
-
const filePath = getConfigFilePath(
|
|
112
|
+
const filePath = getConfigFilePath('global');
|
|
113
113
|
const existing = await readJsonFile(filePath);
|
|
114
114
|
const prevProviders =
|
|
115
115
|
existing && typeof existing.providers === 'object'
|
|
@@ -133,11 +133,11 @@ export async function writeProviderSettings(
|
|
|
133
133
|
* Remove a provider override or custom provider entry from config.
|
|
134
134
|
*/
|
|
135
135
|
export async function removeProviderSettings(
|
|
136
|
-
|
|
136
|
+
_scope: Scope,
|
|
137
137
|
provider: string,
|
|
138
|
-
|
|
138
|
+
_projectRoot?: string,
|
|
139
139
|
) {
|
|
140
|
-
const filePath = getConfigFilePath(
|
|
140
|
+
const filePath = getConfigFilePath('global');
|
|
141
141
|
const existing = await readJsonFile(filePath);
|
|
142
142
|
if (!existing || typeof existing.providers !== 'object') return;
|
|
143
143
|
const providers = { ...(existing.providers as Record<string, unknown>) };
|
|
@@ -147,11 +147,11 @@ export async function removeProviderSettings(
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
export async function writeSkillSettings(
|
|
150
|
-
|
|
150
|
+
_scope: Scope,
|
|
151
151
|
updates: SkillSettings,
|
|
152
|
-
|
|
152
|
+
_projectRoot?: string,
|
|
153
153
|
) {
|
|
154
|
-
const filePath = getConfigFilePath(
|
|
154
|
+
const filePath = getConfigFilePath('global');
|
|
155
155
|
const existing = await readJsonFile(filePath);
|
|
156
156
|
const prevSkills =
|
|
157
157
|
existing && typeof existing.skills === 'object'
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tool, type Tool } from 'ai';
|
|
3
|
+
import { z } from 'zod/v3';
|
|
4
|
+
import DESCRIPTION from './copy-into.txt' with { type: 'text' };
|
|
5
|
+
import { buildWriteArtifact, isAbsoluteLike, resolveSafePath } from './util.ts';
|
|
6
|
+
import {
|
|
7
|
+
convertToLineEnding,
|
|
8
|
+
detectLineEnding,
|
|
9
|
+
normalizeLineEndings,
|
|
10
|
+
} from './edit-shared.ts';
|
|
11
|
+
import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
|
|
12
|
+
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
13
|
+
|
|
14
|
+
const copyIntoSchema = z.object({
|
|
15
|
+
sourcePath: z
|
|
16
|
+
.string()
|
|
17
|
+
.describe('Relative source file path within the project.'),
|
|
18
|
+
startLine: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.min(1)
|
|
22
|
+
.describe('First source line to copy, 1-indexed and inclusive.'),
|
|
23
|
+
endLine: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.min(1)
|
|
27
|
+
.describe('Last source line to copy, 1-indexed and inclusive.'),
|
|
28
|
+
targetPath: z
|
|
29
|
+
.string()
|
|
30
|
+
.describe('Relative target file path within the project.'),
|
|
31
|
+
insertAtLine: z
|
|
32
|
+
.number()
|
|
33
|
+
.int()
|
|
34
|
+
.min(1)
|
|
35
|
+
.optional()
|
|
36
|
+
.describe(
|
|
37
|
+
'Line to insert before, 1-indexed. Use totalLines + 1 to append.',
|
|
38
|
+
),
|
|
39
|
+
mode: z
|
|
40
|
+
.enum(['insert_before', 'insert_after', 'replace_range'])
|
|
41
|
+
.optional()
|
|
42
|
+
.default('insert_before')
|
|
43
|
+
.describe('How to apply copied content to the target file.'),
|
|
44
|
+
targetStartLine: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.min(1)
|
|
48
|
+
.optional()
|
|
49
|
+
.describe('First target line to replace when mode is replace_range.'),
|
|
50
|
+
targetEndLine: z
|
|
51
|
+
.number()
|
|
52
|
+
.int()
|
|
53
|
+
.min(1)
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Last target line to replace when mode is replace_range.'),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
type CopyIntoInput = z.infer<typeof copyIntoSchema>;
|
|
59
|
+
|
|
60
|
+
function splitLinesForEdit(content: string): {
|
|
61
|
+
lines: string[];
|
|
62
|
+
trailingNewline: boolean;
|
|
63
|
+
} {
|
|
64
|
+
const normalized = normalizeLineEndings(content);
|
|
65
|
+
const trailingNewline = normalized.endsWith('\n');
|
|
66
|
+
const lines = normalized.split('\n');
|
|
67
|
+
if (trailingNewline) lines.pop();
|
|
68
|
+
return { lines, trailingNewline };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function joinLinesForEdit(lines: string[], trailingNewline: boolean): string {
|
|
72
|
+
const joined = lines.join('\n');
|
|
73
|
+
return trailingNewline ? `${joined}\n` : joined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateRelativePath(path: string, label: string): string | undefined {
|
|
77
|
+
if (!path || path.trim().length === 0) {
|
|
78
|
+
return `Missing required parameter: ${label}`;
|
|
79
|
+
}
|
|
80
|
+
if (isAbsoluteLike(path)) {
|
|
81
|
+
return `Refusing to access outside project root: ${path}. Use a relative path within the project.`;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getLineRange(
|
|
87
|
+
lines: string[],
|
|
88
|
+
startLine: number,
|
|
89
|
+
endLine: number,
|
|
90
|
+
): string[] {
|
|
91
|
+
if (startLine > endLine) {
|
|
92
|
+
throw new Error('startLine must be less than or equal to endLine.');
|
|
93
|
+
}
|
|
94
|
+
if (endLine > lines.length) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Source range ${startLine}-${endLine} exceeds source file length (${lines.length} lines).`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return lines.slice(startLine - 1, endLine);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function applyCopiedLines(
|
|
103
|
+
input: CopyIntoInput,
|
|
104
|
+
targetLines: string[],
|
|
105
|
+
copied: string[],
|
|
106
|
+
): string[] {
|
|
107
|
+
const mode = input.mode ?? 'insert_before';
|
|
108
|
+
if (mode === 'replace_range') {
|
|
109
|
+
if (
|
|
110
|
+
input.targetStartLine === undefined ||
|
|
111
|
+
input.targetEndLine === undefined
|
|
112
|
+
) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
'targetStartLine and targetEndLine are required when mode is replace_range.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (input.targetStartLine > input.targetEndLine) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'targetStartLine must be less than or equal to targetEndLine.',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (input.targetEndLine > targetLines.length) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Target range ${input.targetStartLine}-${input.targetEndLine} exceeds target file length (${targetLines.length} lines).`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return [
|
|
128
|
+
...targetLines.slice(0, input.targetStartLine - 1),
|
|
129
|
+
...copied,
|
|
130
|
+
...targetLines.slice(input.targetEndLine),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (input.insertAtLine === undefined) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'insertAtLine is required for insert_before and insert_after modes.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (input.insertAtLine > targetLines.length + 1) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`insertAtLine ${input.insertAtLine} exceeds append position (${targetLines.length + 1}).`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const insertIndex =
|
|
146
|
+
mode === 'insert_after' ? input.insertAtLine : input.insertAtLine - 1;
|
|
147
|
+
if (insertIndex > targetLines.length) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`insertAtLine ${input.insertAtLine} with insert_after exceeds target file length (${targetLines.length} lines).`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
...targetLines.slice(0, insertIndex),
|
|
155
|
+
...copied,
|
|
156
|
+
...targetLines.slice(insertIndex),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function buildCopyIntoTool(projectRoot: string): {
|
|
161
|
+
name: string;
|
|
162
|
+
tool: Tool;
|
|
163
|
+
} {
|
|
164
|
+
const copyInto = tool({
|
|
165
|
+
description: DESCRIPTION,
|
|
166
|
+
inputSchema: copyIntoSchema,
|
|
167
|
+
async execute(input: CopyIntoInput): Promise<
|
|
168
|
+
ToolResponse<{
|
|
169
|
+
sourcePath: string;
|
|
170
|
+
targetPath: string;
|
|
171
|
+
linesCopied: number;
|
|
172
|
+
bytes: number;
|
|
173
|
+
artifact: unknown;
|
|
174
|
+
}>
|
|
175
|
+
> {
|
|
176
|
+
const sourcePathError = validateRelativePath(
|
|
177
|
+
input.sourcePath,
|
|
178
|
+
'sourcePath',
|
|
179
|
+
);
|
|
180
|
+
if (sourcePathError) {
|
|
181
|
+
return createToolError(sourcePathError, 'validation', {
|
|
182
|
+
parameter: 'sourcePath',
|
|
183
|
+
value: input.sourcePath,
|
|
184
|
+
suggestion: 'Use a relative path within the project',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const targetPathError = validateRelativePath(
|
|
188
|
+
input.targetPath,
|
|
189
|
+
'targetPath',
|
|
190
|
+
);
|
|
191
|
+
if (targetPathError) {
|
|
192
|
+
return createToolError(targetPathError, 'validation', {
|
|
193
|
+
parameter: 'targetPath',
|
|
194
|
+
value: input.targetPath,
|
|
195
|
+
suggestion: 'Use a relative path within the project',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const sourceAbs = resolveSafePath(projectRoot, input.sourcePath);
|
|
200
|
+
const targetAbs = resolveSafePath(projectRoot, input.targetPath);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await assertFreshRead(projectRoot, targetAbs, input.targetPath);
|
|
204
|
+
const [sourceContent, targetContent] = await Promise.all([
|
|
205
|
+
readFile(sourceAbs, 'utf-8'),
|
|
206
|
+
readFile(targetAbs, 'utf-8'),
|
|
207
|
+
]);
|
|
208
|
+
const source = splitLinesForEdit(sourceContent);
|
|
209
|
+
const copiedLines = getLineRange(
|
|
210
|
+
source.lines,
|
|
211
|
+
input.startLine,
|
|
212
|
+
input.endLine,
|
|
213
|
+
);
|
|
214
|
+
const target = splitLinesForEdit(targetContent);
|
|
215
|
+
const nextLines = applyCopiedLines(input, target.lines, copiedLines);
|
|
216
|
+
const nextNormalized = joinLinesForEdit(
|
|
217
|
+
nextLines,
|
|
218
|
+
target.trailingNewline,
|
|
219
|
+
);
|
|
220
|
+
const nextContent = convertToLineEnding(
|
|
221
|
+
nextNormalized,
|
|
222
|
+
detectLineEnding(targetContent),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (nextContent === targetContent) {
|
|
226
|
+
return createToolError('No changes applied.', 'validation', {
|
|
227
|
+
suggestion:
|
|
228
|
+
'Choose a source range or target location that changes the file',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await writeFile(targetAbs, nextContent, 'utf-8');
|
|
233
|
+
await rememberFileWrite(projectRoot, targetAbs);
|
|
234
|
+
const artifact = await buildWriteArtifact(
|
|
235
|
+
input.targetPath,
|
|
236
|
+
true,
|
|
237
|
+
targetContent,
|
|
238
|
+
nextContent,
|
|
239
|
+
);
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
sourcePath: input.sourcePath,
|
|
243
|
+
targetPath: input.targetPath,
|
|
244
|
+
linesCopied: copiedLines.length,
|
|
245
|
+
bytes: nextContent.length,
|
|
246
|
+
artifact,
|
|
247
|
+
};
|
|
248
|
+
} catch (error: unknown) {
|
|
249
|
+
const isEnoent =
|
|
250
|
+
error &&
|
|
251
|
+
typeof error === 'object' &&
|
|
252
|
+
'code' in error &&
|
|
253
|
+
error.code === 'ENOENT';
|
|
254
|
+
return createToolError(
|
|
255
|
+
isEnoent
|
|
256
|
+
? 'Source or target file not found.'
|
|
257
|
+
: `Failed to copy into file: ${error instanceof Error ? error.message : String(error)}`,
|
|
258
|
+
isEnoent ? 'not_found' : 'execution',
|
|
259
|
+
{
|
|
260
|
+
suggestion: isEnoent
|
|
261
|
+
? 'Use read, ls, or tree to confirm both file paths first'
|
|
262
|
+
: undefined,
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return { name: 'copy_into', tool: copyInto };
|
|
270
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Copy a line range from one project file into another project file without reprinting the copied content.
|
|
2
|
+
|
|
3
|
+
Use this when duplicating large existing content such as SVGs, licenses, generated blocks, examples, or repeated config snippets.
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- Source and target paths must be relative paths within the project.
|
|
7
|
+
- You must read the target file first in the current session before modifying it.
|
|
8
|
+
- Line numbers are 1-indexed and inclusive.
|
|
9
|
+
- `insertAtLine` inserts before that line. Use `insertAtLine: totalLines + 1` to append.
|
|
10
|
+
- Use `mode: "replace_range"` with `targetStartLine` and `targetEndLine` to replace target lines.
|
|
11
|
+
- This tool does not use the system clipboard.
|
|
@@ -3,6 +3,7 @@ import { buildEditTool } from './edit.ts';
|
|
|
3
3
|
import { buildReadTool } from './read.ts';
|
|
4
4
|
import { buildMultiEditTool } from './multiedit.ts';
|
|
5
5
|
import { buildWriteTool } from './write.ts';
|
|
6
|
+
import { buildCopyIntoTool } from './copy-into.ts';
|
|
6
7
|
import { buildLsTool } from './ls.ts';
|
|
7
8
|
import { buildTreeTool } from './tree.ts';
|
|
8
9
|
import { buildPwdTool } from './pwd.ts';
|
|
@@ -16,6 +17,7 @@ export function buildFsTools(
|
|
|
16
17
|
out.push(buildEditTool(projectRoot));
|
|
17
18
|
out.push(buildMultiEditTool(projectRoot));
|
|
18
19
|
out.push(buildWriteTool(projectRoot));
|
|
20
|
+
out.push(buildCopyIntoTool(projectRoot));
|
|
19
21
|
out.push(buildLsTool(projectRoot));
|
|
20
22
|
out.push(buildTreeTool(projectRoot));
|
|
21
23
|
out.push(buildPwdTool());
|
|
@@ -16,8 +16,10 @@ export type CachedModelCatalog = {
|
|
|
16
16
|
export const DEFAULT_REMOTE_MODEL_CATALOG_URL =
|
|
17
17
|
'https://ottocode.io/catalog/models.json';
|
|
18
18
|
|
|
19
|
+
const MODEL_CATALOG_CACHE_FILENAME = 'catalog-models.json';
|
|
20
|
+
|
|
19
21
|
export function getModelCatalogCachePath(): string {
|
|
20
|
-
return joinPath(getGlobalConfigDir(),
|
|
22
|
+
return joinPath(getGlobalConfigDir(), MODEL_CATALOG_CACHE_FILENAME);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -30,10 +32,10 @@ async function loadFsPromises(): Promise<typeof import('node:fs/promises')> {
|
|
|
30
32
|
|
|
31
33
|
function readFileSyncCompat(path: string): string | null {
|
|
32
34
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
35
|
+
const fs = globalThis.process?.getBuiltinModule?.('node:fs') as
|
|
36
|
+
| { readFileSync: (filePath: string, encoding: 'utf8') => string }
|
|
37
|
+
| undefined;
|
|
38
|
+
return fs?.readFileSync(path, 'utf8') ?? null;
|
|
37
39
|
} catch {
|
|
38
40
|
return null;
|
|
39
41
|
}
|
|
@@ -70,21 +72,27 @@ export function normalizeModelCatalogPayload(
|
|
|
70
72
|
return providers;
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
function normalizeCachedModelCatalogPayload(
|
|
76
|
+
payload: unknown,
|
|
77
|
+
): CachedModelCatalog {
|
|
78
|
+
const updatedAt =
|
|
79
|
+
isRecord(payload) && typeof payload.updatedAt === 'string'
|
|
80
|
+
? payload.updatedAt
|
|
81
|
+
: new Date(0).toISOString();
|
|
82
|
+
return {
|
|
83
|
+
version: 1,
|
|
84
|
+
updatedAt,
|
|
85
|
+
providers: normalizeModelCatalogPayload(payload),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
export async function readCachedModelCatalog(): Promise<CachedModelCatalog | null> {
|
|
74
90
|
try {
|
|
75
91
|
const { readFile } = await loadFsPromises();
|
|
76
92
|
const payload = JSON.parse(
|
|
77
93
|
await readFile(getModelCatalogCachePath(), 'utf8'),
|
|
78
94
|
);
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
version: 1,
|
|
82
|
-
updatedAt:
|
|
83
|
-
isRecord(payload) && typeof payload.updatedAt === 'string'
|
|
84
|
-
? payload.updatedAt
|
|
85
|
-
: new Date(0).toISOString(),
|
|
86
|
-
providers,
|
|
87
|
-
};
|
|
95
|
+
return normalizeCachedModelCatalogPayload(payload);
|
|
88
96
|
} catch {
|
|
89
97
|
return null;
|
|
90
98
|
}
|
|
@@ -95,15 +103,7 @@ export function readCachedModelCatalogSync(): CachedModelCatalog | null {
|
|
|
95
103
|
const text = readFileSyncCompat(getModelCatalogCachePath());
|
|
96
104
|
if (!text) return null;
|
|
97
105
|
const payload = JSON.parse(text);
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
version: 1,
|
|
101
|
-
updatedAt:
|
|
102
|
-
isRecord(payload) && typeof payload.updatedAt === 'string'
|
|
103
|
-
? payload.updatedAt
|
|
104
|
-
: new Date(0).toISOString(),
|
|
105
|
-
providers,
|
|
106
|
-
};
|
|
106
|
+
return normalizeCachedModelCatalogPayload(payload);
|
|
107
107
|
} catch {
|
|
108
108
|
return null;
|
|
109
109
|
}
|
|
@@ -56,25 +56,7 @@ const BUILTIN_FAMILY: Record<BuiltInProviderId, ProviderPromptFamily> = {
|
|
|
56
56
|
minimax: 'minimax',
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
models: Array<string | ModelInfo> | undefined,
|
|
61
|
-
): ModelInfo[] {
|
|
62
|
-
return (models ?? [])
|
|
63
|
-
.map((model) => {
|
|
64
|
-
if (typeof model === 'string') {
|
|
65
|
-
const id = String(model).trim();
|
|
66
|
-
return id ? ({ id, label: id } satisfies ModelInfo) : null;
|
|
67
|
-
}
|
|
68
|
-
const id = String(model.id ?? '').trim();
|
|
69
|
-
if (!id) return null;
|
|
70
|
-
return {
|
|
71
|
-
...model,
|
|
72
|
-
id,
|
|
73
|
-
label: model.label?.trim() || id,
|
|
74
|
-
} satisfies ModelInfo;
|
|
75
|
-
})
|
|
76
|
-
.filter((model): model is ModelInfo => Boolean(model));
|
|
77
|
-
}
|
|
59
|
+
const USE_BUILTIN_MODEL_CATALOG = process.env.CI === 'true';
|
|
78
60
|
|
|
79
61
|
function normalizeOptionalText(value: string | undefined): string | undefined {
|
|
80
62
|
if (!value) return undefined;
|
|
@@ -97,13 +79,6 @@ function resolveCustomFamily(
|
|
|
97
79
|
return settings.family ?? 'default';
|
|
98
80
|
}
|
|
99
81
|
|
|
100
|
-
function resolveCustomProviderLabel(
|
|
101
|
-
id: string,
|
|
102
|
-
settings: ProviderSettingsEntry,
|
|
103
|
-
): string {
|
|
104
|
-
return settings.label ?? id;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
82
|
export function isBuiltInProviderId(
|
|
108
83
|
value: unknown,
|
|
109
84
|
): value is BuiltInProviderId {
|
|
@@ -129,9 +104,8 @@ export function getProviderDefinition(
|
|
|
129
104
|
const entry = catalog[provider];
|
|
130
105
|
if (!entry) return undefined;
|
|
131
106
|
const cachedEntry = getCachedProviderCatalogEntry(provider);
|
|
132
|
-
const models =
|
|
133
|
-
?
|
|
134
|
-
: entry.models;
|
|
107
|
+
const models =
|
|
108
|
+
cachedEntry?.models ?? (USE_BUILTIN_MODEL_CATALOG ? entry.models : []);
|
|
135
109
|
return {
|
|
136
110
|
id: provider,
|
|
137
111
|
label: settings?.label ?? cachedEntry?.label ?? entry.label ?? provider,
|
|
@@ -148,10 +122,11 @@ export function getProviderDefinition(
|
|
|
148
122
|
}
|
|
149
123
|
|
|
150
124
|
if (!settings?.custom) return undefined;
|
|
151
|
-
const
|
|
125
|
+
const cachedEntry = getCachedProviderCatalogEntry(provider);
|
|
126
|
+
const models = cachedEntry?.models ?? [];
|
|
152
127
|
return {
|
|
153
128
|
id: provider,
|
|
154
|
-
label:
|
|
129
|
+
label: settings.label ?? cachedEntry?.label ?? provider,
|
|
155
130
|
source: 'custom',
|
|
156
131
|
compatibility: resolveCustomCompatibility(settings),
|
|
157
132
|
family: resolveCustomFamily(settings),
|
|
@@ -3,7 +3,6 @@ import { getCachedProviderCatalogEntry } from './model-catalog-cache.ts';
|
|
|
3
3
|
import type { OttoConfig, ProviderId } from '../../types/src/index.ts';
|
|
4
4
|
import {
|
|
5
5
|
getProviderDefinition,
|
|
6
|
-
getConfiguredProviderModels,
|
|
7
6
|
hasConfiguredModel,
|
|
8
7
|
providerAllowsAnyModel,
|
|
9
8
|
} from './registry.ts';
|
|
@@ -19,51 +18,76 @@ export function validateProviderModel(
|
|
|
19
18
|
cfgOrCap?: OttoConfig | CapabilityRequest,
|
|
20
19
|
cap?: CapabilityRequest,
|
|
21
20
|
) {
|
|
21
|
+
const providerId = provider.trim() as ProviderId;
|
|
22
|
+
const modelId = model.trim();
|
|
22
23
|
const cfg = isOttoConfigLike(cfgOrCap) ? cfgOrCap : undefined;
|
|
23
24
|
const effectiveCap = isOttoConfigLike(cfgOrCap) ? cap : cfgOrCap;
|
|
24
25
|
|
|
25
26
|
if (cfg) {
|
|
26
|
-
const definition = getProviderDefinition(cfg,
|
|
27
|
+
const definition = getProviderDefinition(cfg, providerId);
|
|
28
|
+
const cachedModels =
|
|
29
|
+
getCachedProviderCatalogEntry(providerId)?.models ?? [];
|
|
27
30
|
if (!definition) {
|
|
28
|
-
|
|
31
|
+
if (!cachedModels.length) {
|
|
32
|
+
throw new Error(`Provider not supported: ${providerId}`);
|
|
33
|
+
}
|
|
34
|
+
const entry = cachedModels.find((m) => m.id === modelId);
|
|
35
|
+
if (!entry) {
|
|
36
|
+
throwModelNotFound(providerId, modelId, cachedModels);
|
|
37
|
+
}
|
|
38
|
+
applyCapabilityValidation(modelId, entry, effectiveCap, {
|
|
39
|
+
strict: false,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
29
42
|
}
|
|
30
|
-
if (!providerAllowsAnyModel(cfg,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
43
|
+
if (!providerAllowsAnyModel(cfg, providerId)) {
|
|
44
|
+
const knownModels = definition.models.length
|
|
45
|
+
? definition.models
|
|
46
|
+
: cachedModels;
|
|
47
|
+
const hasModel =
|
|
48
|
+
hasConfiguredModel(cfg, providerId, modelId) ||
|
|
49
|
+
cachedModels.some((m) => m.id === modelId);
|
|
50
|
+
if (!hasModel) {
|
|
51
|
+
throwModelNotFound(providerId, modelId, knownModels);
|
|
39
52
|
}
|
|
40
53
|
}
|
|
41
54
|
|
|
42
|
-
const entry =
|
|
55
|
+
const entry =
|
|
56
|
+
definition.models.find((m) => m.id === modelId) ??
|
|
57
|
+
cachedModels.find((m) => m.id === modelId);
|
|
43
58
|
if (entry) {
|
|
44
|
-
applyCapabilityValidation(
|
|
59
|
+
applyCapabilityValidation(modelId, entry, effectiveCap, {
|
|
45
60
|
strict: definition.source !== 'custom',
|
|
46
61
|
});
|
|
47
62
|
}
|
|
48
63
|
return;
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
const p =
|
|
52
|
-
if (!catalog[p]) {
|
|
53
|
-
throw new Error(`Provider not supported: ${
|
|
66
|
+
const p = providerId;
|
|
67
|
+
if (!catalog[p] && !getCachedProviderCatalogEntry(p)) {
|
|
68
|
+
throw new Error(`Provider not supported: ${providerId}`);
|
|
54
69
|
}
|
|
55
|
-
const models =
|
|
56
|
-
|
|
70
|
+
const models =
|
|
71
|
+
getCachedProviderCatalogEntry(p)?.models ?? catalog[p]?.models ?? [];
|
|
72
|
+
const entry = models.find((m) => m.id === modelId);
|
|
57
73
|
if (!entry) {
|
|
58
|
-
|
|
59
|
-
.slice(0, 10)
|
|
60
|
-
.map((m) => m.id)
|
|
61
|
-
.join(', ');
|
|
62
|
-
throw new Error(
|
|
63
|
-
`Model not found for provider ${provider}: ${model}. Example models: ${list}${models.length > 10 ? ', ...' : ''}`,
|
|
64
|
-
);
|
|
74
|
+
throwModelNotFound(providerId, modelId, models);
|
|
65
75
|
}
|
|
66
|
-
applyCapabilityValidation(
|
|
76
|
+
applyCapabilityValidation(modelId, entry, effectiveCap, { strict: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function throwModelNotFound(
|
|
80
|
+
provider: ProviderId,
|
|
81
|
+
model: string,
|
|
82
|
+
models: Array<{ id: string }>,
|
|
83
|
+
): never {
|
|
84
|
+
const list = models
|
|
85
|
+
.slice(0, 10)
|
|
86
|
+
.map((m) => m.id)
|
|
87
|
+
.join(', ');
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Model not found for provider ${provider}: ${model}. Example models: ${list}${models.length > 10 ? ', ...' : ''}`,
|
|
90
|
+
);
|
|
67
91
|
}
|
|
68
92
|
|
|
69
93
|
function applyCapabilityValidation(
|
package/src/skills/loader.ts
CHANGED
|
@@ -12,15 +12,6 @@ import { getGlobalConfigDir, getHomeDir } from '../config/src/paths.ts';
|
|
|
12
12
|
|
|
13
13
|
const skillCache = new Map<string, SkillDefinition>();
|
|
14
14
|
|
|
15
|
-
const SKILL_DIRS = [
|
|
16
|
-
'.otto/skills',
|
|
17
|
-
'.agents/skills',
|
|
18
|
-
'.agenst/skills',
|
|
19
|
-
'.claude/skills',
|
|
20
|
-
'.opencode/skills',
|
|
21
|
-
'.codex/skills',
|
|
22
|
-
];
|
|
23
|
-
|
|
24
15
|
const ALLOWED_EXTENSIONS = new Set([
|
|
25
16
|
'.md',
|
|
26
17
|
'.txt',
|
|
@@ -47,8 +38,8 @@ const ALLOWED_EXTENSIONS = new Set([
|
|
|
47
38
|
const MAX_FILE_SIZE = 256 * 1024;
|
|
48
39
|
|
|
49
40
|
export async function discoverSkills(
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
_cwd: string,
|
|
42
|
+
_repoRoot?: string,
|
|
52
43
|
): Promise<DiscoveredSkill[]> {
|
|
53
44
|
const skills = new Map<string, SkillDefinition>();
|
|
54
45
|
const home = getHomeDir();
|
|
@@ -65,27 +56,6 @@ export async function discoverSkills(
|
|
|
65
56
|
await loadSkillsFromDir(dir, 'user', skills);
|
|
66
57
|
}
|
|
67
58
|
|
|
68
|
-
if (repoRoot && repoRoot !== cwd) {
|
|
69
|
-
for (const skillDir of SKILL_DIRS) {
|
|
70
|
-
await loadSkillsFromDir(join(repoRoot, skillDir), 'repo', skills);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let current = cwd;
|
|
75
|
-
const visited = new Set<string>();
|
|
76
|
-
while (current && !visited.has(current)) {
|
|
77
|
-
if (repoRoot && !current.startsWith(repoRoot)) break;
|
|
78
|
-
visited.add(current);
|
|
79
|
-
const scope: SkillScope =
|
|
80
|
-
current === cwd ? 'cwd' : current === repoRoot ? 'repo' : 'parent';
|
|
81
|
-
for (const skillDir of SKILL_DIRS) {
|
|
82
|
-
await loadSkillsFromDir(join(current, skillDir), scope, skills);
|
|
83
|
-
}
|
|
84
|
-
const parent = dirname(current);
|
|
85
|
-
if (parent === current) break;
|
|
86
|
-
current = parent;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
59
|
skillCache.clear();
|
|
90
60
|
for (const [name, def] of skills) {
|
|
91
61
|
skillCache.set(name, def);
|