@ottocode/server 0.1.213 → 0.1.216

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.
@@ -0,0 +1,163 @@
1
+ import { errorResponse } from '../helpers';
2
+
3
+ export const tunnelPaths = {
4
+ '/v1/tunnel/status': {
5
+ get: {
6
+ tags: ['tunnel'],
7
+ operationId: 'getTunnelStatus',
8
+ summary: 'Get tunnel status',
9
+ responses: {
10
+ 200: {
11
+ description: 'OK',
12
+ content: {
13
+ 'application/json': {
14
+ schema: {
15
+ type: 'object',
16
+ properties: {
17
+ status: {
18
+ type: 'string',
19
+ enum: ['idle', 'starting', 'connected', 'error'],
20
+ },
21
+ url: { type: 'string', nullable: true },
22
+ error: { type: 'string', nullable: true },
23
+ binaryInstalled: { type: 'boolean' },
24
+ isRunning: { type: 'boolean' },
25
+ },
26
+ required: ['status', 'binaryInstalled', 'isRunning'],
27
+ },
28
+ },
29
+ },
30
+ },
31
+ },
32
+ },
33
+ },
34
+ '/v1/tunnel/start': {
35
+ post: {
36
+ tags: ['tunnel'],
37
+ operationId: 'startTunnel',
38
+ summary: 'Start a tunnel',
39
+ requestBody: {
40
+ required: false,
41
+ content: {
42
+ 'application/json': {
43
+ schema: {
44
+ type: 'object',
45
+ properties: {
46
+ port: { type: 'integer' },
47
+ },
48
+ },
49
+ },
50
+ },
51
+ },
52
+ responses: {
53
+ 200: {
54
+ description: 'OK',
55
+ content: {
56
+ 'application/json': {
57
+ schema: {
58
+ type: 'object',
59
+ properties: {
60
+ ok: { type: 'boolean' },
61
+ url: { type: 'string' },
62
+ message: { type: 'string' },
63
+ error: { type: 'string' },
64
+ },
65
+ required: ['ok'],
66
+ },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ '/v1/tunnel/register': {
74
+ post: {
75
+ tags: ['tunnel'],
76
+ operationId: 'registerTunnel',
77
+ summary: 'Register an external tunnel URL',
78
+ requestBody: {
79
+ required: true,
80
+ content: {
81
+ 'application/json': {
82
+ schema: {
83
+ type: 'object',
84
+ properties: {
85
+ url: { type: 'string' },
86
+ },
87
+ required: ['url'],
88
+ },
89
+ },
90
+ },
91
+ },
92
+ responses: {
93
+ 200: {
94
+ description: 'OK',
95
+ content: {
96
+ 'application/json': {
97
+ schema: {
98
+ type: 'object',
99
+ properties: {
100
+ ok: { type: 'boolean' },
101
+ url: { type: 'string' },
102
+ message: { type: 'string' },
103
+ },
104
+ required: ['ok'],
105
+ },
106
+ },
107
+ },
108
+ },
109
+ 400: errorResponse(),
110
+ },
111
+ },
112
+ },
113
+ '/v1/tunnel/stop': {
114
+ post: {
115
+ tags: ['tunnel'],
116
+ operationId: 'stopTunnel',
117
+ summary: 'Stop the tunnel',
118
+ responses: {
119
+ 200: {
120
+ description: 'OK',
121
+ content: {
122
+ 'application/json': {
123
+ schema: {
124
+ type: 'object',
125
+ properties: {
126
+ ok: { type: 'boolean' },
127
+ message: { type: 'string' },
128
+ },
129
+ required: ['ok'],
130
+ },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ },
136
+ },
137
+ '/v1/tunnel/qr': {
138
+ get: {
139
+ tags: ['tunnel'],
140
+ operationId: 'getTunnelQR',
141
+ summary: 'Get QR code for tunnel URL',
142
+ responses: {
143
+ 200: {
144
+ description: 'OK',
145
+ content: {
146
+ 'application/json': {
147
+ schema: {
148
+ type: 'object',
149
+ properties: {
150
+ ok: { type: 'boolean' },
151
+ url: { type: 'string' },
152
+ qrCode: { type: 'string' },
153
+ },
154
+ required: ['ok'],
155
+ },
156
+ },
157
+ },
158
+ },
159
+ 400: errorResponse(),
160
+ },
161
+ },
162
+ },
163
+ } as const;
@@ -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,137 @@
1
+ import type { Hono } from 'hono';
2
+ import {
3
+ discoverSkills,
4
+ loadSkill,
5
+ loadSkillFile,
6
+ discoverSkillFiles,
7
+ findGitRoot,
8
+ validateSkillName,
9
+ parseSkillFile,
10
+ logger,
11
+ } from '@ottocode/sdk';
12
+ import { serializeError } from '../runtime/errors/api-error.ts';
13
+
14
+ export function registerSkillsRoutes(app: Hono) {
15
+ app.get('/v1/skills', async (c) => {
16
+ try {
17
+ const projectRoot = c.req.query('project') || process.cwd();
18
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
19
+ const skills = await discoverSkills(projectRoot, repoRoot);
20
+ return c.json({
21
+ skills: skills.map((s) => ({
22
+ name: s.name,
23
+ description: s.description,
24
+ scope: s.scope,
25
+ path: s.path,
26
+ })),
27
+ });
28
+ } catch (error) {
29
+ logger.error('Failed to list skills', error);
30
+ const errorResponse = serializeError(error);
31
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
32
+ }
33
+ });
34
+
35
+ app.get('/v1/skills/:name', async (c) => {
36
+ try {
37
+ const name = c.req.param('name');
38
+ const projectRoot = c.req.query('project') || process.cwd();
39
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
40
+ await discoverSkills(projectRoot, repoRoot);
41
+
42
+ const skill = await loadSkill(name);
43
+ if (!skill) {
44
+ return c.json({ error: `Skill '${name}' not found` }, 404);
45
+ }
46
+
47
+ return c.json({
48
+ name: skill.metadata.name,
49
+ description: skill.metadata.description,
50
+ license: skill.metadata.license ?? null,
51
+ compatibility: skill.metadata.compatibility ?? null,
52
+ metadata: skill.metadata.metadata ?? null,
53
+ allowedTools: skill.metadata.allowedTools ?? null,
54
+ path: skill.path,
55
+ scope: skill.scope,
56
+ content: skill.content,
57
+ });
58
+ } catch (error) {
59
+ logger.error('Failed to load skill', error);
60
+ const errorResponse = serializeError(error);
61
+ return c.json(errorResponse, (errorResponse.error.status || 500) as 500);
62
+ }
63
+ });
64
+
65
+ app.post('/v1/skills/validate', async (c) => {
66
+ app.get('/v1/skills/:name/files', async (c) => {
67
+ try {
68
+ const name = c.req.param('name');
69
+ const projectRoot = c.req.query('project') || process.cwd();
70
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
71
+ await discoverSkills(projectRoot, repoRoot);
72
+
73
+ const files = await discoverSkillFiles(name);
74
+ return c.json({ files });
75
+ } catch (error) {
76
+ logger.error('Failed to list skill files', error);
77
+ const errorResponse = serializeError(error);
78
+ return c.json(
79
+ errorResponse,
80
+ (errorResponse.error.status || 500) as 500,
81
+ );
82
+ }
83
+ });
84
+
85
+ app.get('/v1/skills/:name/files/*', async (c) => {
86
+ try {
87
+ const name = c.req.param('name');
88
+ const filePath = c.req.path.replace(`/v1/skills/${name}/files/`, '');
89
+ const projectRoot = c.req.query('project') || process.cwd();
90
+ const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
91
+ await discoverSkills(projectRoot, repoRoot);
92
+
93
+ const result = await loadSkillFile(name, filePath);
94
+ if (!result) {
95
+ return c.json(
96
+ { error: `File '${filePath}' not found in skill '${name}'` },
97
+ 404,
98
+ );
99
+ }
100
+ return c.json({ content: result.content, path: result.resolvedPath });
101
+ } catch (error) {
102
+ logger.error('Failed to load skill file', error);
103
+ const errorResponse = serializeError(error);
104
+ return c.json(
105
+ errorResponse,
106
+ (errorResponse.error.status || 500) as 500,
107
+ );
108
+ }
109
+ });
110
+
111
+ try {
112
+ const body = await c.req.json<{ content: string; path?: string }>();
113
+ if (!body.content) {
114
+ return c.json({ error: 'content is required' }, 400);
115
+ }
116
+
117
+ const skillPath = body.path ?? 'SKILL.md';
118
+ const skill = parseSkillFile(body.content, skillPath, 'cwd');
119
+ return c.json({
120
+ valid: true,
121
+ name: skill.metadata.name,
122
+ description: skill.metadata.description,
123
+ license: skill.metadata.license ?? null,
124
+ });
125
+ } catch (error) {
126
+ return c.json({
127
+ valid: false,
128
+ error: (error as Error).message,
129
+ });
130
+ }
131
+ });
132
+
133
+ app.get('/v1/skills/validate-name/:name', async (c) => {
134
+ const name = c.req.param('name');
135
+ return c.json({ valid: validateSkillName(name) });
136
+ });
137
+ }