@ottocode/server 0.1.213 → 0.1.215

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.
@@ -349,4 +349,40 @@ export const schemas = {
349
349
  uptime: { type: 'integer' },
350
350
  },
351
351
  },
352
+ MCPServer: {
353
+ type: 'object',
354
+ properties: {
355
+ name: { type: 'string' },
356
+ transport: {
357
+ type: 'string',
358
+ enum: ['stdio', 'http', 'sse'],
359
+ },
360
+ command: { type: 'string' },
361
+ args: {
362
+ type: 'array',
363
+ items: { type: 'string' },
364
+ },
365
+ url: { type: 'string' },
366
+ disabled: { type: 'boolean' },
367
+ connected: { type: 'boolean' },
368
+ tools: {
369
+ type: 'array',
370
+ items: {
371
+ type: 'object',
372
+ properties: {
373
+ name: { type: 'string' },
374
+ description: { type: 'string' },
375
+ },
376
+ },
377
+ },
378
+ authRequired: { type: 'boolean' },
379
+ authenticated: { type: 'boolean' },
380
+ scope: {
381
+ type: 'string',
382
+ enum: ['global', 'project'],
383
+ },
384
+ authType: { type: 'string' },
385
+ },
386
+ required: ['name', 'transport', 'connected'],
387
+ },
352
388
  } as const;
@@ -1,14 +1,24 @@
1
1
  import { askPaths } from './paths/ask';
2
+ import { authPaths } from './paths/auth';
3
+ import { branchPaths } from './paths/branch';
2
4
  import { configPaths } from './paths/config';
5
+ import { doctorPaths } from './paths/doctor';
3
6
  import { filesPaths } from './paths/files';
4
7
  import { gitPaths } from './paths/git';
8
+ import { mcpPaths } from './paths/mcp';
5
9
  import { messagesPaths } from './paths/messages';
10
+ import { providerUsagePaths } from './paths/provider-usage';
11
+ import { researchPaths } from './paths/research';
12
+ import { sessionApprovalPaths } from './paths/session-approval';
13
+ import { sessionExtrasPaths } from './paths/session-extras';
14
+ import { sessionFilesPaths } from './paths/session-files';
6
15
  import { sessionsPaths } from './paths/sessions';
16
+ import { setuPaths } from './paths/setu';
17
+ import { skillsPaths } from './paths/skills';
7
18
  import { streamPaths } from './paths/stream';
8
- import { schemas } from './schemas';
9
-
10
19
  import { terminalsPath } from './paths/terminals';
11
- import { setuPaths } from './paths/setu';
20
+ import { tunnelPaths } from './paths/tunnel';
21
+ import { schemas } from './schemas';
12
22
 
13
23
  export function getOpenAPISpec() {
14
24
  const spec = {
@@ -29,17 +39,31 @@ export function getOpenAPISpec() {
29
39
  { name: 'git' },
30
40
  { name: 'terminals' },
31
41
  { name: 'setu' },
42
+ { name: 'auth' },
43
+ { name: 'mcp' },
44
+ { name: 'tunnel' },
32
45
  ],
33
46
  paths: {
34
47
  ...askPaths,
35
- ...sessionsPaths,
36
- ...messagesPaths,
37
- ...streamPaths,
48
+ ...authPaths,
49
+ ...branchPaths,
38
50
  ...configPaths,
51
+ ...doctorPaths,
39
52
  ...filesPaths,
40
53
  ...gitPaths,
41
- ...terminalsPath,
54
+ ...mcpPaths,
55
+ ...messagesPaths,
56
+ ...providerUsagePaths,
57
+ ...researchPaths,
58
+ ...sessionApprovalPaths,
59
+ ...sessionExtrasPaths,
60
+ ...sessionFilesPaths,
61
+ ...sessionsPaths,
42
62
  ...setuPaths,
63
+ ...skillsPaths,
64
+ ...streamPaths,
65
+ ...terminalsPath,
66
+ ...tunnelPaths,
43
67
  },
44
68
  components: {
45
69
  schemas,
@@ -0,0 +1,229 @@
1
+ import type { Hono } from 'hono';
2
+ import { readdir } from 'node:fs/promises';
3
+ import {
4
+ readConfig,
5
+ isAuthorized,
6
+ buildFsTools,
7
+ buildGitTools,
8
+ getSecureAuthPath,
9
+ getGlobalAgentsJsonPath,
10
+ getGlobalToolsDir,
11
+ getGlobalCommandsDir,
12
+ logger,
13
+ } from '@ottocode/sdk';
14
+ import type { ProviderId } from '@ottocode/sdk';
15
+ import { serializeError } from '../runtime/errors/api-error.ts';
16
+
17
+ const PROVIDERS: ProviderId[] = [
18
+ 'openai',
19
+ 'anthropic',
20
+ 'google',
21
+ 'openrouter',
22
+ 'opencode',
23
+ 'setu',
24
+ ];
25
+
26
+ function providerEnvVar(p: ProviderId): string | null {
27
+ if (p === 'openai') return 'OPENAI_API_KEY';
28
+ if (p === 'anthropic') return 'ANTHROPIC_API_KEY';
29
+ if (p === 'google') return 'GOOGLE_GENERATIVE_AI_API_KEY';
30
+ if (p === 'opencode') return 'OPENCODE_API_KEY';
31
+ if (p === 'setu') return 'SETU_PRIVATE_KEY';
32
+ return null;
33
+ }
34
+
35
+ async function fileExists(path: string | null): Promise<boolean> {
36
+ if (!path) return false;
37
+ try {
38
+ return await Bun.file(path).exists();
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ async function readJsonSafe<T>(path: string | null): Promise<T | null> {
45
+ if (!path) return null;
46
+ try {
47
+ const file = Bun.file(path);
48
+ if (!(await file.exists())) return null;
49
+ return (await file.json()) as T;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ async function listDir(dir: string | null): Promise<string[]> {
56
+ if (!dir) return [];
57
+ try {
58
+ return await readdir(dir);
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ export function registerDoctorRoutes(app: Hono) {
65
+ app.get('/v1/doctor', async (c) => {
66
+ try {
67
+ const projectRoot = c.req.query('project') || process.cwd();
68
+ const { cfg, auth } = await readConfig(projectRoot);
69
+
70
+ const providers = await Promise.all(
71
+ PROVIDERS.map(async (id) => {
72
+ const ok = await isAuthorized(id, projectRoot);
73
+ const envVar = providerEnvVar(id);
74
+ const envConfigured = envVar ? !!process.env[envVar] : false;
75
+
76
+ const globalAuthPath = getSecureAuthPath();
77
+ let hasGlobalAuth = false;
78
+ if (globalAuthPath) {
79
+ const contents =
80
+ await readJsonSafe<Record<string, unknown>>(globalAuthPath);
81
+ hasGlobalAuth = Boolean(contents?.[id]);
82
+ }
83
+
84
+ const authInfo = auth?.[id];
85
+ const hasStoredSecret = (() => {
86
+ if (!authInfo) return false;
87
+ if (authInfo.type === 'api')
88
+ return Boolean((authInfo as { key?: string }).key);
89
+ if (authInfo.type === 'wallet')
90
+ return Boolean((authInfo as { secret?: string }).secret);
91
+ if (authInfo.type === 'oauth')
92
+ return Boolean(
93
+ (authInfo as { access?: string; refresh?: string }).access ||
94
+ (authInfo as { access?: string; refresh?: string }).refresh,
95
+ );
96
+ return false;
97
+ })();
98
+
99
+ const sources: string[] = [];
100
+ if (envConfigured && envVar) sources.push(`env:${envVar}`);
101
+ if (hasGlobalAuth) sources.push('auth.json');
102
+
103
+ const configured =
104
+ envConfigured ||
105
+ hasGlobalAuth ||
106
+ cfg.defaults.provider === id ||
107
+ hasStoredSecret;
108
+
109
+ return { id, ok, configured, sources };
110
+ }),
111
+ );
112
+
113
+ const defaults = {
114
+ agent: cfg.defaults.agent,
115
+ provider: cfg.defaults.provider,
116
+ model: cfg.defaults.model,
117
+ providerAuthorized: await isAuthorized(
118
+ cfg.defaults.provider as ProviderId,
119
+ projectRoot,
120
+ ),
121
+ };
122
+
123
+ const globalAgentsPath = getGlobalAgentsJsonPath();
124
+ const localAgentsPath = `${projectRoot}/.otto/agents.json`;
125
+ const globalAgents =
126
+ (await readJsonSafe<Record<string, unknown>>(globalAgentsPath)) ?? {};
127
+ const localAgents =
128
+ (await readJsonSafe<Record<string, unknown>>(localAgentsPath)) ?? {};
129
+
130
+ const agents = {
131
+ globalPath: (await fileExists(globalAgentsPath))
132
+ ? globalAgentsPath
133
+ : null,
134
+ localPath: (await fileExists(localAgentsPath)) ? localAgentsPath : null,
135
+ globalNames: Object.keys(globalAgents).sort(),
136
+ localNames: Object.keys(localAgents).sort(),
137
+ };
138
+
139
+ const defaultToolNames = Array.from(
140
+ new Set([
141
+ ...buildFsTools(projectRoot).map((t) => t.name),
142
+ ...buildGitTools(projectRoot).map((t) => t.name),
143
+ 'finish',
144
+ ]),
145
+ ).sort();
146
+
147
+ const globalToolsDir = getGlobalToolsDir();
148
+ const localToolsDir = `${projectRoot}/.otto/tools`;
149
+ const globalToolNames = await listDir(globalToolsDir);
150
+ const localToolNames = await listDir(localToolsDir);
151
+
152
+ const tools = {
153
+ defaultNames: defaultToolNames,
154
+ globalPath: globalToolNames.length ? globalToolsDir : null,
155
+ globalNames: globalToolNames.sort(),
156
+ localPath: localToolNames.length ? localToolsDir : null,
157
+ localNames: localToolNames.sort(),
158
+ effectiveNames: Array.from(
159
+ new Set([...defaultToolNames, ...globalToolNames, ...localToolNames]),
160
+ ).sort(),
161
+ };
162
+
163
+ const globalCommandsDir = getGlobalCommandsDir();
164
+ const localCommandsDir = `${projectRoot}/.otto/commands`;
165
+ const globalCommandFiles = await listDir(globalCommandsDir);
166
+ const localCommandFiles = await listDir(localCommandsDir);
167
+
168
+ const commands = {
169
+ globalPath: globalCommandFiles.length ? globalCommandsDir : null,
170
+ globalNames: globalCommandFiles
171
+ .filter((f) => f.endsWith('.json'))
172
+ .map((f) => f.replace(/\.json$/, ''))
173
+ .sort(),
174
+ localPath: localCommandFiles.length ? localCommandsDir : null,
175
+ localNames: localCommandFiles
176
+ .filter((f) => f.endsWith('.json'))
177
+ .map((f) => f.replace(/\.json$/, ''))
178
+ .sort(),
179
+ };
180
+
181
+ const issues: string[] = [];
182
+ if (!defaults.providerAuthorized) {
183
+ issues.push(
184
+ `Default provider '${defaults.provider}' is not authorized`,
185
+ );
186
+ }
187
+ for (const [scope, entries] of [
188
+ ['global', globalAgents],
189
+ ['local', localAgents],
190
+ ] as const) {
191
+ for (const [name, entry] of Object.entries(entries)) {
192
+ if (
193
+ entry &&
194
+ typeof entry === 'object' &&
195
+ Object.hasOwn(entry, 'tools') &&
196
+ !Array.isArray((entry as { tools?: unknown }).tools)
197
+ ) {
198
+ issues.push(`${scope}:${name} tools field must be an array`);
199
+ }
200
+ }
201
+ }
202
+
203
+ const suggestions: string[] = [];
204
+ if (!defaults.providerAuthorized) {
205
+ suggestions.push(
206
+ `Run: otto auth login ${defaults.provider} — or switch defaults with: otto models`,
207
+ );
208
+ }
209
+ if (issues.length) {
210
+ suggestions.push('Review agents.json fields.');
211
+ }
212
+
213
+ return c.json({
214
+ providers,
215
+ defaults,
216
+ agents,
217
+ tools,
218
+ commands,
219
+ issues,
220
+ suggestions,
221
+ globalAuthPath: getSecureAuthPath(),
222
+ });
223
+ } catch (error) {
224
+ logger.error('Failed to run doctor', error);
225
+ const errorResponse = serializeError(error);
226
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
227
+ }
228
+ });
229
+ }
@@ -721,6 +721,61 @@ export function registerSessionsRoutes(app: Hono) {
721
721
  });
722
722
  });
723
723
 
724
+ app.delete('/v1/sessions/:sessionId/share', async (c) => {
725
+ const sessionId = c.req.param('sessionId');
726
+ const projectRoot = c.req.query('project') || process.cwd();
727
+ const cfg = await loadConfig(projectRoot);
728
+ const db = await getDb(cfg.projectRoot);
729
+
730
+ const share = await db
731
+ .select()
732
+ .from(shares)
733
+ .where(eq(shares.sessionId, sessionId))
734
+ .limit(1);
735
+
736
+ if (!share.length) {
737
+ return c.json({ error: 'Session is not shared' }, 404);
738
+ }
739
+
740
+ try {
741
+ const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
742
+ method: 'DELETE',
743
+ headers: { 'X-Share-Secret': share[0].secret },
744
+ });
745
+
746
+ if (!res.ok && res.status !== 404) {
747
+ const err = await res.text();
748
+ return c.json({ error: `Failed to delete share: ${err}` }, 500);
749
+ }
750
+ } catch {}
751
+
752
+ await db.delete(shares).where(eq(shares.sessionId, sessionId));
753
+
754
+ return c.json({ deleted: true, sessionId });
755
+ });
756
+
757
+ app.get('/v1/shares', async (c) => {
758
+ const projectRoot = c.req.query('project') || process.cwd();
759
+ const cfg = await loadConfig(projectRoot);
760
+ const db = await getDb(cfg.projectRoot);
761
+
762
+ const rows = await db
763
+ .select({
764
+ sessionId: shares.sessionId,
765
+ shareId: shares.shareId,
766
+ url: shares.url,
767
+ title: shares.title,
768
+ createdAt: shares.createdAt,
769
+ lastSyncedAt: shares.lastSyncedAt,
770
+ })
771
+ .from(shares)
772
+ .innerJoin(sessions, eq(shares.sessionId, sessions.id))
773
+ .where(eq(sessions.projectPath, cfg.projectRoot))
774
+ .orderBy(desc(shares.lastSyncedAt));
775
+
776
+ return c.json({ shares: rows });
777
+ });
778
+
724
779
  // Retry a failed assistant message
725
780
  app.post('/v1/sessions/:sessionId/messages/:messageId/retry', async (c) => {
726
781
  try {
@@ -0,0 +1,90 @@
1
+ import type { Hono } from 'hono';
2
+ import {
3
+ discoverSkills,
4
+ loadSkill,
5
+ findGitRoot,
6
+ validateSkillName,
7
+ parseSkillFile,
8
+ logger,
9
+ } from '@ottocode/sdk';
10
+ import { serializeError } from '../runtime/errors/api-error.ts';
11
+
12
+ export function registerSkillsRoutes(app: Hono) {
13
+ app.get('/v1/skills', async (c) => {
14
+ try {
15
+ const projectRoot = c.req.query('project') || process.cwd();
16
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
17
+ const skills = await discoverSkills(projectRoot, repoRoot);
18
+ return c.json({
19
+ skills: skills.map((s) => ({
20
+ name: s.name,
21
+ description: s.description,
22
+ scope: s.scope,
23
+ path: s.path,
24
+ })),
25
+ });
26
+ } catch (error) {
27
+ logger.error('Failed to list skills', error);
28
+ const errorResponse = serializeError(error);
29
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
30
+ }
31
+ });
32
+
33
+ app.get('/v1/skills/:name', async (c) => {
34
+ try {
35
+ const name = c.req.param('name');
36
+ const projectRoot = c.req.query('project') || process.cwd();
37
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
38
+ await discoverSkills(projectRoot, repoRoot);
39
+
40
+ const skill = await loadSkill(name);
41
+ if (!skill) {
42
+ return c.json({ error: `Skill '${name}' not found` }, 404);
43
+ }
44
+
45
+ return c.json({
46
+ name: skill.metadata.name,
47
+ description: skill.metadata.description,
48
+ license: skill.metadata.license ?? null,
49
+ compatibility: skill.metadata.compatibility ?? null,
50
+ metadata: skill.metadata.metadata ?? null,
51
+ allowedTools: skill.metadata.allowedTools ?? null,
52
+ path: skill.path,
53
+ scope: skill.scope,
54
+ content: skill.content,
55
+ });
56
+ } catch (error) {
57
+ logger.error('Failed to load skill', error);
58
+ const errorResponse = serializeError(error);
59
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
60
+ }
61
+ });
62
+
63
+ app.post('/v1/skills/validate', async (c) => {
64
+ try {
65
+ const body = await c.req.json<{ content: string; path?: string }>();
66
+ if (!body.content) {
67
+ return c.json({ error: 'content is required' }, 400);
68
+ }
69
+
70
+ const skillPath = body.path ?? 'SKILL.md';
71
+ const skill = parseSkillFile(body.content, skillPath, 'cwd');
72
+ return c.json({
73
+ valid: true,
74
+ name: skill.metadata.name,
75
+ description: skill.metadata.description,
76
+ license: skill.metadata.license ?? null,
77
+ });
78
+ } catch (error) {
79
+ return c.json({
80
+ valid: false,
81
+ error: (error as Error).message,
82
+ });
83
+ }
84
+ });
85
+
86
+ app.get('/v1/skills/validate-name/:name', async (c) => {
87
+ const name = c.req.param('name');
88
+ return c.json({ valid: validateSkillName(name) });
89
+ });
90
+ }
@@ -11,7 +11,8 @@ import { discoverProjectTools } from '@ottocode/sdk';
11
11
  import type { Tool } from 'ai';
12
12
  import { adaptTools } from '../../tools/adapter.ts';
13
13
  import { buildDatabaseTools } from '../../tools/database/index.ts';
14
- import { debugLog, time, isDebugEnabled } from '../debug/index.ts';
14
+ import { debugLog, time } from '../debug/index.ts';
15
+ import { isDevtoolsEnabled } from '../debug/state.ts';
15
16
  import { buildHistoryMessages } from '../message/history-builder.ts';
16
17
  import { getMaxOutputTokens } from '../utils/token.ts';
17
18
  import { setupToolContext } from '../tools/setup.ts';
@@ -184,7 +185,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
184
185
  sessionId: opts.sessionId,
185
186
  messageId: opts.assistantMessageId,
186
187
  });
187
- const wrappedModel = isDebugEnabled()
188
+ const wrappedModel = isDevtoolsEnabled()
188
189
  ? wrapLanguageModel({
189
190
  // biome-ignore lint/suspicious/noExplicitAny: OpenRouter provider uses v2 spec
190
191
  model: model as any,
@@ -65,6 +65,13 @@ function checkEnvTrace(): boolean {
65
65
  return false;
66
66
  }
67
67
 
68
+ function checkEnvDevtools(): boolean {
69
+ const raw = process.env.OTTO_DEVTOOLS;
70
+ if (!raw) return false;
71
+ const trimmed = raw.trim().toLowerCase();
72
+ return TRUTHY.has(trimmed);
73
+ }
74
+
68
75
  /**
69
76
  * Initialize debug state from environment
70
77
  */
@@ -96,6 +103,10 @@ export function isTraceEnabled(): boolean {
96
103
  return state.enabled && state.traceEnabled;
97
104
  }
98
105
 
106
+ export function isDevtoolsEnabled(): boolean {
107
+ return checkEnvDevtools();
108
+ }
109
+
99
110
  /**
100
111
  * Enable or disable debug mode at runtime
101
112
  * Overrides environment variable settings
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  } from '@ottocode/sdk';
7
7
  import { devToolsMiddleware } from '@ai-sdk/devtools';
8
- import { isDebugEnabled } from '../debug/index.ts';
8
+ import { isDevtoolsEnabled } from '../debug/state.ts';
9
9
  import { publish } from '../../events/bus.ts';
10
10
  import {
11
11
  waitForTopupMethodSelection,
@@ -123,7 +123,7 @@ export async function resolveSetuModel(
123
123
  baseURL,
124
124
  rpcURL,
125
125
  callbacks,
126
- middleware: isDebugEnabled() ? devToolsMiddleware() : undefined,
126
+ middleware: isDevtoolsEnabled() ? devToolsMiddleware() : undefined,
127
127
  payment: {
128
128
  topupApprovalMode,
129
129
  autoPayThresholdUsd,
@@ -140,7 +140,7 @@ const SENSITIVE_WRITE_PATHS: Array<{ pattern: RegExp; reason: string }> = [
140
140
  ];
141
141
 
142
142
  function guardWritePath(
143
- toolName: string,
143
+ _toolName: string,
144
144
  args: Record<string, unknown>,
145
145
  ): GuardAction {
146
146
  const path =