@oh-my-tool/cli 0.2.0
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/LICENSE +21 -0
- package/README.md +13 -0
- package/assets/skills/oh-my-tool/SKILL.md +77 -0
- package/bin/ohmytool.cjs +15 -0
- package/bin/ohmytool.ts +5 -0
- package/package.json +34 -0
- package/src/cli/commands/describe.ts +24 -0
- package/src/cli/commands/extension.ts +24 -0
- package/src/cli/commands/index.ts +7 -0
- package/src/cli/commands/integrate.ts +64 -0
- package/src/cli/commands/run.ts +39 -0
- package/src/cli/commands/search.ts +16 -0
- package/src/cli/commands/secret.ts +72 -0
- package/src/cli/context.ts +44 -0
- package/src/cli/index.ts +296 -0
- package/src/cli/parseArgs.ts +44 -0
- package/src/config/config.ts +63 -0
- package/src/core/executor.ts +99 -0
- package/src/core/registry.ts +33 -0
- package/src/core/result.ts +14 -0
- package/src/core/schema.ts +2 -0
- package/src/extension/discovery.ts +62 -0
- package/src/extension/install.ts +24 -0
- package/src/extension/loader.ts +32 -0
- package/src/extension/manifest.ts +115 -0
- package/src/integration/adapters.ts +98 -0
- package/src/integration/index.ts +4 -0
- package/src/integration/manager.ts +375 -0
- package/src/integration/skill.ts +84 -0
- package/src/integration/types.ts +55 -0
- package/src/migration.ts +44 -0
- package/src/paths.ts +41 -0
- package/src/policy/policy.ts +139 -0
- package/src/runtime/errors.ts +8 -0
- package/src/runtime/executor.ts +66 -0
- package/src/runtime/provider-registry.ts +23 -0
- package/src/runtime/provider.ts +29 -0
- package/src/runtime/providers/native/discovery.ts +2 -0
- package/src/runtime/providers/native/install.ts +2 -0
- package/src/runtime/providers/native/loader.ts +1 -0
- package/src/runtime/providers/native/manifest.ts +6 -0
- package/src/runtime/providers/native/provider.ts +57 -0
- package/src/runtime/result.ts +12 -0
- package/src/runtime/runtime.ts +71 -0
- package/src/runtime/schema.ts +49 -0
- package/src/runtime/tool-registry.ts +54 -0
- package/src/search/search.ts +78 -0
- package/src/secrets/secrets.ts +45 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type AgentId = "codex" | "omp" | "qoder" | "pi" | "cursor" | "claude";
|
|
2
|
+
|
|
3
|
+
export const AGENT_IDS: readonly AgentId[] = [
|
|
4
|
+
"codex",
|
|
5
|
+
"omp",
|
|
6
|
+
"qoder",
|
|
7
|
+
"pi",
|
|
8
|
+
"cursor",
|
|
9
|
+
"claude",
|
|
10
|
+
];
|
|
11
|
+
export type IntegrationStatus =
|
|
12
|
+
| "not-installed"
|
|
13
|
+
| "installed"
|
|
14
|
+
| "current"
|
|
15
|
+
| "update-available"
|
|
16
|
+
| "broken"
|
|
17
|
+
| "conflict"
|
|
18
|
+
| "repaired"
|
|
19
|
+
| "uninstalled";
|
|
20
|
+
|
|
21
|
+
export interface AgentDetection {
|
|
22
|
+
id: AgentId;
|
|
23
|
+
displayName: string;
|
|
24
|
+
command: string;
|
|
25
|
+
target: string;
|
|
26
|
+
variant?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IntegrationResult {
|
|
30
|
+
agent: AgentId;
|
|
31
|
+
displayName: string;
|
|
32
|
+
target: string;
|
|
33
|
+
status: IntegrationStatus;
|
|
34
|
+
detail?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManagedAgentState {
|
|
38
|
+
target: string;
|
|
39
|
+
canonical: string;
|
|
40
|
+
version: string;
|
|
41
|
+
digest: string;
|
|
42
|
+
mode: "junction" | "symlink";
|
|
43
|
+
backup?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IntegrationState {
|
|
47
|
+
schemaVersion: 1;
|
|
48
|
+
skills: {
|
|
49
|
+
"oh-my-tool"?: {
|
|
50
|
+
version: string;
|
|
51
|
+
digest: string;
|
|
52
|
+
agents: Partial<Record<AgentId, ManagedAgentState>>;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/migration.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { OhMyToolPaths } from "./paths";
|
|
4
|
+
|
|
5
|
+
export type MigrationStatus =
|
|
6
|
+
| "migrated"
|
|
7
|
+
| "skipped-custom-home"
|
|
8
|
+
| "skipped-no-legacy"
|
|
9
|
+
| "skipped-destination-exists";
|
|
10
|
+
|
|
11
|
+
export interface MigrationResult {
|
|
12
|
+
readonly status: MigrationStatus;
|
|
13
|
+
readonly legacyHome: string;
|
|
14
|
+
readonly home: string;
|
|
15
|
+
readonly legacyPreserved: true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const KNOWN_STATE = ["config.toml", "extensions", "integrations", "cache", "logs"];
|
|
19
|
+
|
|
20
|
+
export async function migrateLegacyHome(paths: OhMyToolPaths): Promise<MigrationResult> {
|
|
21
|
+
const base = { legacyHome: paths.legacyHome, home: paths.home, legacyPreserved: true as const };
|
|
22
|
+
if (paths.isCustomHome) return { ...base, status: "skipped-custom-home" };
|
|
23
|
+
if (!existsSync(paths.legacyHome)) return { ...base, status: "skipped-no-legacy" };
|
|
24
|
+
if (existsSync(paths.home)) return { ...base, status: "skipped-destination-exists" };
|
|
25
|
+
|
|
26
|
+
mkdirSync(paths.home, { recursive: true });
|
|
27
|
+
for (const name of KNOWN_STATE) {
|
|
28
|
+
const source = join(paths.legacyHome, name);
|
|
29
|
+
if (!existsSync(source)) continue;
|
|
30
|
+
const destination = join(paths.home, name);
|
|
31
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
32
|
+
cpSync(source, destination, { recursive: true, errorOnExist: true });
|
|
33
|
+
}
|
|
34
|
+
return { ...base, status: "migrated" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function prepareHome(paths: OhMyToolPaths): Promise<MigrationResult> {
|
|
38
|
+
const migration = await migrateLegacyHome(paths);
|
|
39
|
+
mkdirSync(paths.home, { recursive: true });
|
|
40
|
+
for (const directory of [paths.extensions, paths.integrations, dirname(paths.audit)]) {
|
|
41
|
+
mkdirSync(directory, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
return migration;
|
|
44
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface CreatePathsOptions {
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
platform?: NodeJS.Platform;
|
|
7
|
+
userHome?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OhMyToolPaths {
|
|
11
|
+
readonly userHome: string;
|
|
12
|
+
readonly home: string;
|
|
13
|
+
readonly legacyHome: string;
|
|
14
|
+
readonly config: string;
|
|
15
|
+
readonly extensions: string;
|
|
16
|
+
readonly integrations: string;
|
|
17
|
+
readonly cache: string;
|
|
18
|
+
readonly audit: string;
|
|
19
|
+
readonly isCustomHome: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createPaths(options: CreatePathsOptions = {}): OhMyToolPaths {
|
|
23
|
+
const platform = options.platform ?? process.platform;
|
|
24
|
+
const env = options.env ?? process.env;
|
|
25
|
+
const userHome = options.userHome ?? homedir();
|
|
26
|
+
const path = platform === "win32" ? win32 : posix;
|
|
27
|
+
const customHome = env.OH_MY_TOOL_HOME;
|
|
28
|
+
const home = customHome || path.join(userHome, ".oh-my-tool");
|
|
29
|
+
const legacyHome = path.join(userHome, ".omt");
|
|
30
|
+
return {
|
|
31
|
+
userHome,
|
|
32
|
+
home,
|
|
33
|
+
legacyHome,
|
|
34
|
+
config: path.join(home, "config.toml"),
|
|
35
|
+
extensions: path.join(home, "extensions"),
|
|
36
|
+
integrations: path.join(home, "integrations"),
|
|
37
|
+
cache: path.join(home, "cache"),
|
|
38
|
+
audit: path.join(home, "logs", "audit.jsonl"),
|
|
39
|
+
isCustomHome: Boolean(customHome),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Config } from "../config/config";
|
|
2
|
+
import { getConnectionConfig } from "../config/config";
|
|
3
|
+
|
|
4
|
+
export class PolicyError extends Error {
|
|
5
|
+
readonly code = "POLICY_VIOLATION";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const FORBIDDEN = /(?:^|[^a-z_])(insert|update|delete|drop|alter|create|truncate|rename|grant|revoke|replace|call|set|commit|rollback|load\s+data|lock\s+tables|unlock\s+tables|create\s+database|drop\s+database)(?:[^a-z_]|$)/i;
|
|
9
|
+
|
|
10
|
+
function stripLiteralsAndComments(sql: string): string {
|
|
11
|
+
let out = "";
|
|
12
|
+
let i = 0;
|
|
13
|
+
const n = sql.length;
|
|
14
|
+
while (i < n) {
|
|
15
|
+
const ch = sql[i];
|
|
16
|
+
const next = sql[i + 1];
|
|
17
|
+
// line comments
|
|
18
|
+
if (ch === "-" && next === "-") {
|
|
19
|
+
while (i < n && sql[i] !== "\n") i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "#") {
|
|
23
|
+
while (i < n && sql[i] !== "\n") i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// block comments
|
|
27
|
+
if (ch === "/" && next === "*") {
|
|
28
|
+
i += 2;
|
|
29
|
+
while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
|
30
|
+
i += 2;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// string literals
|
|
34
|
+
if (ch === "'" || ch === '"') {
|
|
35
|
+
const quote = ch;
|
|
36
|
+
i++;
|
|
37
|
+
while (i < n) {
|
|
38
|
+
if (sql[i] === "\\") {
|
|
39
|
+
i += 2;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (sql[i] === quote) {
|
|
43
|
+
if (sql[i + 1] === quote) {
|
|
44
|
+
i += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
i++;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
out += " ";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out += ch;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function countStatements(sql: string): number {
|
|
62
|
+
const cleaned = stripLiteralsAndComments(sql);
|
|
63
|
+
const parts = cleaned
|
|
64
|
+
.split(";")
|
|
65
|
+
.map((p) => p.trim())
|
|
66
|
+
.filter((p) => p.length > 0);
|
|
67
|
+
return parts.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assertReadOnly(sql: string): void {
|
|
71
|
+
if (typeof sql !== "string" || sql.trim().length === 0) {
|
|
72
|
+
throw new PolicyError("sql must be a non-empty string");
|
|
73
|
+
}
|
|
74
|
+
if (countStatements(sql) > 1) {
|
|
75
|
+
throw new PolicyError("only a single read-only statement is allowed");
|
|
76
|
+
}
|
|
77
|
+
const cleaned = stripLiteralsAndComments(sql);
|
|
78
|
+
if (FORBIDDEN.test(cleaned)) {
|
|
79
|
+
throw new PolicyError("sql contains a non read-only statement");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const FORBIDDEN_INPUT = new Set([
|
|
84
|
+
"host",
|
|
85
|
+
"username",
|
|
86
|
+
"password",
|
|
87
|
+
"port",
|
|
88
|
+
"database",
|
|
89
|
+
"secret",
|
|
90
|
+
"tls",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
export function validateConnectionInput(
|
|
94
|
+
input: Record<string, unknown>,
|
|
95
|
+
config: Config,
|
|
96
|
+
extensionId: string,
|
|
97
|
+
): void {
|
|
98
|
+
for (const key of FORBIDDEN_INPUT) {
|
|
99
|
+
if (key in input && input[key] !== undefined && input[key] !== null) {
|
|
100
|
+
throw new PolicyError(`agent input must not contain '${key}'`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const connection = input["connection"];
|
|
104
|
+
if (typeof connection !== "string" || connection.length === 0) {
|
|
105
|
+
throw new PolicyError("input must specify a configured 'connection' name");
|
|
106
|
+
}
|
|
107
|
+
if (!getConnectionConfig(config, extensionId, connection)) {
|
|
108
|
+
throw new PolicyError(`unknown connection '${connection}' for extension '${extensionId}'`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Limits {
|
|
113
|
+
maxRows: number;
|
|
114
|
+
timeoutMs: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const DEFAULT_MAX_ROWS = 100;
|
|
118
|
+
export const MAX_MAX_ROWS = 1000;
|
|
119
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
120
|
+
export const MAX_TIMEOUT_MS = 30000;
|
|
121
|
+
|
|
122
|
+
export function applyLimits(input: Record<string, unknown>): Limits {
|
|
123
|
+
const maxRows = clamp(
|
|
124
|
+
typeof input.maxRows === "number" ? input.maxRows : DEFAULT_MAX_ROWS,
|
|
125
|
+
1,
|
|
126
|
+
MAX_MAX_ROWS,
|
|
127
|
+
);
|
|
128
|
+
const timeoutMs = clamp(
|
|
129
|
+
typeof input.timeoutMs === "number" ? input.timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
130
|
+
1,
|
|
131
|
+
MAX_TIMEOUT_MS,
|
|
132
|
+
);
|
|
133
|
+
return { maxRows, timeoutMs };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clamp(value: number, min: number, max: number): number {
|
|
137
|
+
return Math.min(Math.max(value, min), max);
|
|
138
|
+
}
|
|
139
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Logger, SecretStore } from "@oh-my-tool/sdk";
|
|
2
|
+
import type { ToolDescriptor, ToolProvider } from "./provider";
|
|
3
|
+
import type { ExecutionContext } from "./provider";
|
|
4
|
+
import type { ExecutionResult } from "./result";
|
|
5
|
+
import { validateInput } from "./schema";
|
|
6
|
+
|
|
7
|
+
export interface PolicyPreflight {
|
|
8
|
+
preflight(descriptor: ToolDescriptor, input: Record<string, unknown>): void | Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CreateExecutionContext = (
|
|
12
|
+
descriptor: ToolDescriptor,
|
|
13
|
+
input: Record<string, unknown>,
|
|
14
|
+
) => ExecutionContext | Promise<ExecutionContext>;
|
|
15
|
+
|
|
16
|
+
export interface RuntimeExecutionDeps {
|
|
17
|
+
descriptor: ToolDescriptor;
|
|
18
|
+
provider: ToolProvider;
|
|
19
|
+
policy: PolicyPreflight;
|
|
20
|
+
createExecutionContext: CreateExecutionContext;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const noopLogger: Logger = {
|
|
24
|
+
debug: () => {},
|
|
25
|
+
info: () => {},
|
|
26
|
+
warn: () => {},
|
|
27
|
+
error: () => {},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const emptySecrets: SecretStore = {
|
|
31
|
+
async get() { return undefined; },
|
|
32
|
+
async set() {},
|
|
33
|
+
async delete() {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function executeRuntimeTool(
|
|
37
|
+
deps: RuntimeExecutionDeps,
|
|
38
|
+
rawInput: Record<string, unknown>,
|
|
39
|
+
): Promise<ExecutionResult> {
|
|
40
|
+
try {
|
|
41
|
+
const input = validateInput(deps.descriptor.inputSchema as any, rawInput);
|
|
42
|
+
await deps.policy.preflight(deps.descriptor, input);
|
|
43
|
+
const context = await deps.createExecutionContext(deps.descriptor, input);
|
|
44
|
+
const result = await deps.provider.execute(deps.descriptor.id, input, context);
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
toolId: deps.descriptor.id,
|
|
48
|
+
output: result.data,
|
|
49
|
+
meta: result.meta ?? {},
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const typed = error as { code?: string; message?: string };
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
toolId: deps.descriptor.id,
|
|
56
|
+
error: {
|
|
57
|
+
code: typed.code ?? "EXECUTION_FAILED",
|
|
58
|
+
message: typed.message ?? String(error),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function defaultExecutionContext(): ExecutionContext {
|
|
65
|
+
return { logger: noopLogger, config: {}, secrets: emptySecrets };
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolProvider } from "./provider";
|
|
2
|
+
import { RuntimeError } from "./errors";
|
|
3
|
+
|
|
4
|
+
export class ProviderRegistry {
|
|
5
|
+
private readonly providers = new Map<string, ToolProvider>();
|
|
6
|
+
|
|
7
|
+
register(provider: ToolProvider): void {
|
|
8
|
+
if (this.providers.has(provider.id)) {
|
|
9
|
+
throw new RuntimeError("DUPLICATE_PROVIDER_ID", `duplicate provider '${provider.id}'`);
|
|
10
|
+
}
|
|
11
|
+
this.providers.set(provider.id, provider);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get(id: string): ToolProvider | undefined {
|
|
15
|
+
return this.providers.get(id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
require(id: string): ToolProvider {
|
|
19
|
+
const provider = this.get(id);
|
|
20
|
+
if (!provider) throw new RuntimeError("PROVIDER_NOT_FOUND", `provider '${id}' was not found`);
|
|
21
|
+
return provider;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SecretStore, Logger } from "@oh-my-tool/sdk";
|
|
2
|
+
import type { ToolResult } from "./result";
|
|
3
|
+
|
|
4
|
+
export interface ToolDescriptor {
|
|
5
|
+
id: string;
|
|
6
|
+
description: string;
|
|
7
|
+
keywords?: string[];
|
|
8
|
+
risk: "read" | "write" | "admin";
|
|
9
|
+
inputSchema?: Record<string, unknown>;
|
|
10
|
+
provider: { id: string; kind: string };
|
|
11
|
+
source: { id: string; kind: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ToolSearchResult = Omit<ToolDescriptor, "inputSchema">;
|
|
15
|
+
|
|
16
|
+
export interface ExecutionContext {
|
|
17
|
+
logger: Logger;
|
|
18
|
+
config: Record<string, unknown>;
|
|
19
|
+
secrets: SecretStore;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToolProvider {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly kind: string;
|
|
25
|
+
listTools(): Promise<readonly ToolDescriptor[]>;
|
|
26
|
+
execute(toolId: string, input: unknown, context: ExecutionContext): Promise<ToolResult>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type { ToolResult } from "./result";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { loadExtension } from "../../../extension/loader";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { discoverExtensions, type InstalledExtension } from "../../../extension/discovery";
|
|
2
|
+
import { loadExtension } from "../../../extension/loader";
|
|
3
|
+
import type { OhMyToolPaths } from "../../../paths";
|
|
4
|
+
import type { ExecutionContext, ToolDescriptor, ToolProvider } from "../../provider";
|
|
5
|
+
import type { ToolResult } from "../../result";
|
|
6
|
+
|
|
7
|
+
export class NativeExtensionProvider implements ToolProvider {
|
|
8
|
+
readonly id = "native";
|
|
9
|
+
readonly kind = "native";
|
|
10
|
+
|
|
11
|
+
constructor(private readonly homeOrPaths: string | Pick<OhMyToolPaths, "home">) {}
|
|
12
|
+
|
|
13
|
+
private home(): string {
|
|
14
|
+
return typeof this.homeOrPaths === "string" ? this.homeOrPaths : this.homeOrPaths.home;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async listTools(): Promise<readonly ToolDescriptor[]> {
|
|
18
|
+
const descriptors: ToolDescriptor[] = [];
|
|
19
|
+
for (const extension of discoverExtensions(this.home())) {
|
|
20
|
+
for (const tool of extension.manifest.tools) {
|
|
21
|
+
descriptors.push({
|
|
22
|
+
id: tool.name,
|
|
23
|
+
description: tool.description,
|
|
24
|
+
// Preserve extension-level discovery terms from the legacy search
|
|
25
|
+
// contract while keeping ToolRegistry independent of manifests.
|
|
26
|
+
keywords: [...new Set([
|
|
27
|
+
...(tool.keywords ?? []),
|
|
28
|
+
extension.manifest.id,
|
|
29
|
+
extension.manifest.name,
|
|
30
|
+
...(extension.manifest.keywords ?? []),
|
|
31
|
+
])],
|
|
32
|
+
risk: tool.risk ?? "read",
|
|
33
|
+
inputSchema: tool.inputSchema,
|
|
34
|
+
provider: { id: this.id, kind: this.kind },
|
|
35
|
+
source: { id: extension.manifest.id, kind: "extension" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return descriptors;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async execute(toolId: string, input: unknown, context: ExecutionContext): Promise<ToolResult> {
|
|
43
|
+
const extension = this.findExtension(toolId);
|
|
44
|
+
const definition = await loadExtension(extension);
|
|
45
|
+
const handler = definition.handlers[toolId];
|
|
46
|
+
if (!handler) throw new Error(`no handler for ${toolId}`);
|
|
47
|
+
return handler({ toolName: toolId, logger: context.logger, config: context.config, secrets: context.secrets }, input);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private findExtension(toolId: string): InstalledExtension {
|
|
51
|
+
const extension = discoverExtensions(this.home()).find((candidate) =>
|
|
52
|
+
candidate.manifest.tools.some((tool) => tool.name === toolId),
|
|
53
|
+
);
|
|
54
|
+
if (!extension) throw new Error(`unknown native tool '${toolId}'`);
|
|
55
|
+
return extension;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ToolResult {
|
|
2
|
+
data: unknown;
|
|
3
|
+
meta?: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ExecutionResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
toolId: string;
|
|
9
|
+
output?: unknown;
|
|
10
|
+
meta?: Record<string, unknown>;
|
|
11
|
+
error?: { code: string; message: string };
|
|
12
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ExecutionContext, ToolDescriptor, ToolProvider, ToolSearchResult } from "./provider";
|
|
2
|
+
import type { ExecutionResult } from "./result";
|
|
3
|
+
import { executeRuntimeTool, type CreateExecutionContext, type PolicyPreflight } from "./executor";
|
|
4
|
+
import { RuntimeError } from "./errors";
|
|
5
|
+
import { ProviderRegistry } from "./provider-registry";
|
|
6
|
+
import { ToolRegistry } from "./tool-registry";
|
|
7
|
+
|
|
8
|
+
export interface ToolRuntimeOptions {
|
|
9
|
+
readonly providers: readonly ToolProvider[];
|
|
10
|
+
readonly policy: PolicyPreflight;
|
|
11
|
+
readonly createExecutionContext: CreateExecutionContext;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RuntimeState {
|
|
15
|
+
providers: ProviderRegistry;
|
|
16
|
+
tools: ToolRegistry;
|
|
17
|
+
policy: PolicyPreflight;
|
|
18
|
+
createExecutionContext: CreateExecutionContext;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ToolRuntime {
|
|
22
|
+
constructor(private readonly state: RuntimeState) {}
|
|
23
|
+
|
|
24
|
+
search(query: string): Promise<ToolSearchResult[]> {
|
|
25
|
+
return Promise.resolve(this.state.tools.search(query));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe(toolId: string): Promise<ToolDescriptor> {
|
|
29
|
+
const descriptor = this.state.tools.get(toolId);
|
|
30
|
+
if (!descriptor) return Promise.reject(new RuntimeError("TOOL_NOT_FOUND", `unknown tool '${toolId}'`));
|
|
31
|
+
return Promise.resolve(descriptor);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async run(toolId: string, input: unknown): Promise<ExecutionResult> {
|
|
35
|
+
const descriptor = this.state.tools.get(toolId);
|
|
36
|
+
if (!descriptor) {
|
|
37
|
+
return { ok: false, toolId, error: { code: "TOOL_NOT_FOUND", message: `unknown tool '${toolId}'` } };
|
|
38
|
+
}
|
|
39
|
+
const provider = this.state.providers.require(descriptor.provider.id);
|
|
40
|
+
return executeRuntimeTool({
|
|
41
|
+
descriptor,
|
|
42
|
+
provider,
|
|
43
|
+
policy: this.state.policy,
|
|
44
|
+
createExecutionContext: this.state.createExecutionContext,
|
|
45
|
+
}, (input ?? {}) as Record<string, unknown>);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createToolRuntime(options: ToolRuntimeOptions): Promise<ToolRuntime> {
|
|
50
|
+
const providers = new ProviderRegistry();
|
|
51
|
+
const tools = new ToolRegistry();
|
|
52
|
+
for (const provider of options.providers) {
|
|
53
|
+
providers.register(provider);
|
|
54
|
+
const descriptors = await provider.listTools();
|
|
55
|
+
for (const descriptor of descriptors) {
|
|
56
|
+
if (descriptor.provider.id !== provider.id || descriptor.provider.kind !== provider.kind) {
|
|
57
|
+
throw new RuntimeError(
|
|
58
|
+
"PROVIDER_DESCRIPTOR_MISMATCH",
|
|
59
|
+
`tool '${descriptor.id}' does not identify provider '${provider.id}/${provider.kind}'`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
tools.register(descriptors);
|
|
64
|
+
}
|
|
65
|
+
return new ToolRuntime({
|
|
66
|
+
providers,
|
|
67
|
+
tools,
|
|
68
|
+
policy: options.policy,
|
|
69
|
+
createExecutionContext: options.createExecutionContext,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { OmtError } from "./errors";
|
|
2
|
+
|
|
3
|
+
export type Schema = {
|
|
4
|
+
type?: string;
|
|
5
|
+
required?: string[];
|
|
6
|
+
properties?: Record<string, Schema>;
|
|
7
|
+
items?: Schema;
|
|
8
|
+
default?: unknown;
|
|
9
|
+
maximum?: number;
|
|
10
|
+
minimum?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function checkType(value: unknown, schema: Schema, path: string): void {
|
|
14
|
+
const type = schema.type;
|
|
15
|
+
if (!type || value === undefined) return;
|
|
16
|
+
let ok = false;
|
|
17
|
+
switch (type) {
|
|
18
|
+
case "string": ok = typeof value === "string"; break;
|
|
19
|
+
case "integer": ok = typeof value === "number" && Number.isInteger(value); break;
|
|
20
|
+
case "number": ok = typeof value === "number"; break;
|
|
21
|
+
case "boolean": ok = typeof value === "boolean"; break;
|
|
22
|
+
case "array":
|
|
23
|
+
ok = Array.isArray(value);
|
|
24
|
+
if (ok && schema.items) (value as unknown[]).forEach((item, i) => checkType(item, schema.items!, `${path}[${i}]`));
|
|
25
|
+
break;
|
|
26
|
+
case "object": ok = value !== null && typeof value === "object" && !Array.isArray(value); break;
|
|
27
|
+
default: ok = true;
|
|
28
|
+
}
|
|
29
|
+
if (!ok) throw new OmtError("INVALID_INPUT", `'${path}' must be of type ${type}`);
|
|
30
|
+
if (typeof value === "number") {
|
|
31
|
+
if (schema.maximum !== undefined && value > schema.maximum) throw new OmtError("INVALID_INPUT", `'${path}' must be <= ${schema.maximum}`);
|
|
32
|
+
if (schema.minimum !== undefined && value < schema.minimum) throw new OmtError("INVALID_INPUT", `'${path}' must be >= ${schema.minimum}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateInput(schema: Schema | undefined, input: Record<string, unknown>): Record<string, unknown> {
|
|
37
|
+
if (!schema) return { ...input };
|
|
38
|
+
const out: Record<string, unknown> = { ...input };
|
|
39
|
+
const props = schema.properties ?? {};
|
|
40
|
+
for (const key of Object.keys(props)) {
|
|
41
|
+
const prop = props[key];
|
|
42
|
+
if (out[key] === undefined && prop.default !== undefined) out[key] = prop.default;
|
|
43
|
+
}
|
|
44
|
+
for (const key of schema.required ?? []) {
|
|
45
|
+
if (out[key] === undefined || out[key] === null) throw new OmtError("INVALID_INPUT", `missing required field '${key}'`);
|
|
46
|
+
}
|
|
47
|
+
for (const key of Object.keys(out)) if (props[key]) checkType(out[key], props[key], key);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ToolDescriptor, ToolSearchResult } from "./provider";
|
|
2
|
+
import { RuntimeError } from "./errors";
|
|
3
|
+
|
|
4
|
+
const NAME_WEIGHT = 3;
|
|
5
|
+
const KEYWORD_WEIGHT = 2;
|
|
6
|
+
const DESCRIPTION_WEIGHT = 1;
|
|
7
|
+
|
|
8
|
+
export class ToolRegistry {
|
|
9
|
+
private readonly tools = new Map<string, ToolDescriptor>();
|
|
10
|
+
|
|
11
|
+
register(descriptors: readonly ToolDescriptor[]): void {
|
|
12
|
+
const ids = new Set<string>();
|
|
13
|
+
for (const descriptor of descriptors) {
|
|
14
|
+
if (this.tools.has(descriptor.id) || ids.has(descriptor.id)) {
|
|
15
|
+
throw new RuntimeError("DUPLICATE_TOOL_ID", `duplicate tool '${descriptor.id}'`);
|
|
16
|
+
}
|
|
17
|
+
ids.add(descriptor.id);
|
|
18
|
+
}
|
|
19
|
+
for (const descriptor of descriptors) {
|
|
20
|
+
this.tools.set(descriptor.id, descriptor);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(toolId: string): ToolDescriptor | undefined {
|
|
25
|
+
return this.tools.get(toolId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
search(query: string): ToolSearchResult[] {
|
|
29
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
30
|
+
if (tokens.length === 0) return [];
|
|
31
|
+
return [...this.tools.values()]
|
|
32
|
+
.map((descriptor) => ({ descriptor, score: this.score(descriptor, tokens) }))
|
|
33
|
+
.filter((hit) => hit.score > 0)
|
|
34
|
+
.sort((a, b) => b.score - a.score)
|
|
35
|
+
.map(({ descriptor }) => {
|
|
36
|
+
const { inputSchema: _inputSchema, ...summary } = descriptor;
|
|
37
|
+
return summary;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private score(descriptor: ToolDescriptor, tokens: string[]): number {
|
|
42
|
+
const id = descriptor.id.toLowerCase();
|
|
43
|
+
const description = descriptor.description.toLowerCase();
|
|
44
|
+
const keywords = (descriptor.keywords ?? []).map((keyword) => keyword.toLowerCase());
|
|
45
|
+
return tokens.reduce((score, token) => {
|
|
46
|
+
if (id.includes(token)) return score + NAME_WEIGHT;
|
|
47
|
+
if (keywords.some((keyword) => keyword.includes(token) || token.includes(keyword))) {
|
|
48
|
+
return score + KEYWORD_WEIGHT;
|
|
49
|
+
}
|
|
50
|
+
if (description.includes(token)) return score + DESCRIPTION_WEIGHT;
|
|
51
|
+
return score;
|
|
52
|
+
}, 0);
|
|
53
|
+
}
|
|
54
|
+
}
|