diff --git a/external/mcp/src/plugin.ts b/external/mcp/src/plugin.ts index 62af0b00..d6265e36 100644 --- a/external/mcp/src/plugin.ts +++ b/external/mcp/src/plugin.ts @@ -1,18 +1,82 @@ -import { ChatPrompt } from '@microsoft/spark.ai'; +import { Readable, Writable } from 'stream'; + +import { IChatPrompt } from '@microsoft/spark.ai'; import { App, IActivityContext, IPlugin, IPluginEvents } from '@microsoft/spark.apps'; import { ConsoleLogger, EventEmitter, EventHandler, ILogger } from '@microsoft/spark.common'; import { ServerOptions } from '@modelcontextprotocol/sdk/server/index.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; -import { Implementation, CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { jsonSchemaToZod } from 'json-schema-to-zod'; import { IConnection } from './connection'; -export class MCPPlugin implements IPlugin { +/** + * MCP transport options for sse + */ +export type McpSSETransportOptions = { + /** + * the transport type + */ + readonly type: 'sse'; + + /** + * the url path + * @default /mcp + */ + readonly path?: string; +}; + +/** + * MCP transport options for stdio + */ +export type McpStdioTransportOptions = { + /** + * the transport type + */ + readonly type: 'stdio'; + + /** + * stdin to use + */ + readonly stdin?: Readable; + + /** + * stdout to use + */ + readonly stdout?: Writable; +}; + +export type McpPluginOptions = ServerOptions & { + /** + * the MCP server name + * @default mcp + */ + readonly name?: string; + + /** + * the MCP server version + * @default 0.0.0 + */ + readonly version?: string; + + /** + * the transport or transport options + * @default sse + */ + readonly transport?: McpSSETransportOptions | McpStdioTransportOptions; +}; + +/** + * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. + * For advanced usage (like sending notifications or setting custom request handlers), + * use the underlying Server instance available via the server property. + */ +export class McpPlugin implements IPlugin { readonly name: string; readonly version: string; @@ -24,64 +88,81 @@ export class MCPPlugin implements IPlugin { protected log: ILogger; protected id: number = -1; protected connections: Record = {}; - protected readonly events = new EventEmitter(); + protected events = new EventEmitter(); + protected transport: McpSSETransportOptions | McpStdioTransportOptions = { type: 'sse' }; - constructor(serverInfo: Implementation | McpServer, options: ServerOptions = {}) { + constructor(options: McpServer | McpPluginOptions = {}) { this.log = new ConsoleLogger('@spark/mcp'); - this.name = serverInfo instanceof McpServer ? 'mcp' : `mcp.${serverInfo.name}`; - this.version = serverInfo instanceof McpServer ? '0.0.0' : serverInfo.version; - this.server = serverInfo instanceof McpServer ? serverInfo : new McpServer(serverInfo, options); + this.name = + options instanceof McpServer ? 'mcp' : `mcp${options.name ? `.${options.name}` : ''}`; + this.version = options instanceof McpServer ? '0.0.0' : options.version || '0.0.0'; + this.server = + options instanceof McpServer + ? options + : new McpServer( + { + name: this.name, + version: this.version, + }, + options + ); + + if (!(options instanceof McpServer) && options.transport) { + this.transport = options.transport; + } + this.prompt = this.server.prompt.bind(this.server); this.tool = this.server.tool.bind(this.server); this.resource = this.server.resource.bind(this.server); } - use(prompt: ChatPrompt) { + use(prompt: IChatPrompt) { for (const fn of prompt.functions) { const schema: z.AnyZodObject = eval(jsonSchemaToZod(fn.parameters, { module: 'cjs' })); - this.server.tool( - fn.name, - fn.description, - schema.shape, - async (args: any): Promise => { - try { - const res = await prompt.call(fn.name, args); - - return { - content: [ - { - type: 'text', - text: typeof res === 'string' ? res : JSON.stringify(res), - }, - ], - }; - } catch (err: any) { - this.log.error(err.toString()); - - return { - isError: true, - content: [ - { - type: 'text', - text: err.toString(), - }, - ], - }; - } - } - ); + this.server.tool(fn.name, fn.description, schema.shape, this.onToolCall(fn.name, prompt)); } return this; } - onInit(app: App) { + async onInit(app: App) { this.log = app.log.child(this.name); - app.http.get('/mcp', (_, res) => { + if (this.transport.type === 'sse') { + return this.onInitSSE(app, this.transport); + } + + await this.onInitStdio(app, this.transport); + } + + async onStart(port: number = 3000) { + this.events.emit('start', this.log); + + if (this.transport.type === 'sse') { + this.log.info(`listening at http://localhost:${port}${this.transport.path || '/mcp'}`); + } else { + this.log.info('listening on stdin'); + } + } + + on(name: Name, callback: EventHandler) { + this.events.on(name, callback); + } + + onActivity(_: IActivityContext) {} + + protected onInitStdio(_: App, options: McpStdioTransportOptions) { + const transport = new StdioServerTransport(options.stdin, options.stdout); + return this.server.connect(transport); + } + + protected onInitSSE(app: App, options: McpSSETransportOptions) { + const path = options.path || '/mcp'; + + app.http.get(path, (_, res) => { this.id++; this.log.debug('connecting...'); - const transport = new SSEServerTransport(`/mcp/${this.id}/messages`, res); + const transport = new SSEServerTransport(`${path}/${this.id}/messages`, res); this.connections[this.id] = { id: this.id, transport, @@ -91,7 +172,7 @@ export class MCPPlugin implements IPlugin { this.server.connect(transport); }); - app.http.post('/mcp/:id/messages', (req, res) => { + app.http.post(`${path}/:id/messages`, (req, res) => { const id = +req.params.id; const { transport } = this.connections[id]; @@ -104,14 +185,50 @@ export class MCPPlugin implements IPlugin { }); } - async onStart(port: number = 3000) { - this.events.emit('start', this.log); - this.log.info(`listening at http://localhost:${port}/mcp 🚀`); - } + protected onToolCall(name: string, prompt: IChatPrompt) { + return async (args: any): Promise => { + try { + const res = await prompt.call(name, args); - on(name: Name, callback: EventHandler) { - this.events.on(name, callback); + if (this.isCallToolResult(res)) { + return res; + } + + return { + content: [ + { + type: 'text', + text: typeof res === 'string' ? res : JSON.stringify(res), + }, + ], + }; + } catch (err: any) { + this.log.error(err.toString()); + + return { + isError: true, + content: [ + { + type: 'text', + text: err.toString(), + }, + ], + }; + } + }; } - onActivity(_: IActivityContext) {} + protected isCallToolResult(value: any): value is CallToolResult { + if (!!value || !('content' in value)) return false; + const { content } = value; + + return ( + Array.isArray(content) && + content.every( + (item) => + 'type' in item && + (item.type === 'text' || item.type === 'image' || item.type === 'resource') + ) + ); + } } diff --git a/packages/ai/src/prompts/audio.ts b/packages/ai/src/prompts/audio.ts index dd1dfcdf..2eb7fdf4 100644 --- a/packages/ai/src/prompts/audio.ts +++ b/packages/ai/src/prompts/audio.ts @@ -17,7 +17,37 @@ export type AudioPromptOptions = { readonly model: IAudioModel; }; -export class AudioPrompt { +/** + * a prompt that can interface with + * an audio model + */ +export interface IAudioPrompt { + /** + * the prompt name + */ + readonly name: string; + + /** + * the prompt description + */ + readonly description: string; + + /** + * convert text to audio + */ + textToAudio?(params: TextToAudioParams): Promise; + + /** + * transcribe audio to text + */ + audioToText?(params: AudioToTextParams): Promise; +} + +/** + * a prompt that can interface with + * an audio model + */ +export class AudioPrompt implements IAudioPrompt { get name() { return this._name; } diff --git a/packages/ai/src/prompts/chat.ts b/packages/ai/src/prompts/chat.ts index e0a445dc..7bc9500c 100644 --- a/packages/ai/src/prompts/chat.ts +++ b/packages/ai/src/prompts/chat.ts @@ -1,7 +1,7 @@ import { Function, FunctionHandler } from '../function'; import { LocalMemory } from '../local-memory'; import { IMemory } from '../memory'; -import { ContentPart, Message, SystemMessage, UserMessage } from '../message'; +import { ContentPart, Message, ModelMessage, SystemMessage, UserMessage } from '../message'; import { IChatModel, TextChunkHandler } from '../models'; import { Schema } from '../schema'; import { ITemplate } from '../template'; @@ -58,7 +58,67 @@ export type ChatPromptSendOptions = Record< readonly onChunk?: TextChunkHandler; }; -export class ChatPrompt = Record> { +/** + * a prompt that can interface with a + * chat model that provides utility like + * streaming and function calling + */ +export interface IChatPrompt = Record> { + /** + * the prompt name + */ + readonly name: string; + + /** + * the prompt description + */ + readonly description: string; + + /** + * the chat history + */ + readonly messages: IMemory; + + /** + * the registered functions + */ + readonly functions: Array; + + /** + * add another chat prompt as a + */ + use(prompt: IChatPrompt): this; + use(name: string, prompt: IChatPrompt): this; + + /** + * add a function that can be called + * by the model + */ + function(name: string, description: string, handler: FunctionHandler): this; + function(name: string, description: string, parameters: Schema, handler: FunctionHandler): this; + + /** + * call a function + */ + call, R = any>(name: string, args?: A): Promise; + + /** + * send a message to the model and get a response + */ + send( + input: string | ContentPart[], + options?: ChatPromptSendOptions + ): Promise & Omit>; +} + +/** + * a prompt that can interface with a + * chat model that provides utility like + * streaming and function calling + */ +export class ChatPrompt = Record> + implements IChatPrompt +{ get name() { return this._name; } diff --git a/packages/ai/src/prompts/index.ts b/packages/ai/src/prompts/index.ts index 66735df6..a8552c9a 100644 --- a/packages/ai/src/prompts/index.ts +++ b/packages/ai/src/prompts/index.ts @@ -1,2 +1,7 @@ +import { IAudioPrompt } from './audio'; +import { IChatPrompt } from './chat'; + +export type Prompt = IChatPrompt | IAudioPrompt; + export * from './chat'; export * from './audio'; diff --git a/packages/cli/src/commands/config/add.ts b/packages/cli/src/commands/config/add.ts index 8968d70e..78fbb8df 100644 --- a/packages/cli/src/commands/config/add.ts +++ b/packages/cli/src/commands/config/add.ts @@ -6,12 +6,13 @@ import { CommandModule } from 'yargs'; import { String } from '@microsoft/spark.common'; import { Project } from '../../project'; +import { IContext } from '../../context'; type Args = { name: string; }; -export function Add(): CommandModule<{}, Args> { +export function Add(_: IContext): CommandModule<{}, Args> { const configsPath = path.resolve(url.fileURLToPath(import.meta.url), '../..', 'configs'); return { diff --git a/packages/cli/src/commands/config/index.ts b/packages/cli/src/commands/config/index.ts index a4807b76..7432c20f 100644 --- a/packages/cli/src/commands/config/index.ts +++ b/packages/cli/src/commands/config/index.ts @@ -1,15 +1,16 @@ import { CommandModule } from 'yargs'; +import { IContext } from '../../context'; import { Add } from './add'; import { Remove } from './remove'; -export function Config(): CommandModule<{}, {}> { +export function Config(context: IContext): CommandModule<{}, {}> { return { command: 'config', aliases: 'c', describe: 'configure a project', builder: (b) => { - return b.command(Add()).command(Remove()).demandCommand(1); + return b.command(Add(context)).command(Remove(context)).demandCommand(1); }, handler: () => {}, }; diff --git a/packages/cli/src/commands/config/remove.ts b/packages/cli/src/commands/config/remove.ts index 54ae8226..b451267d 100644 --- a/packages/cli/src/commands/config/remove.ts +++ b/packages/cli/src/commands/config/remove.ts @@ -6,12 +6,13 @@ import { CommandModule } from 'yargs'; import { String } from '@microsoft/spark.common'; import { Project } from '../../project'; +import { IContext } from '../../context'; type Args = { name: string; }; -export function Remove(): CommandModule<{}, Args> { +export function Remove(_: IContext): CommandModule<{}, Args> { const configsPath = path.resolve(url.fileURLToPath(import.meta.url), '../..', 'configs'); return { diff --git a/packages/cli/src/commands/env/del.ts b/packages/cli/src/commands/env/del.ts new file mode 100644 index 00000000..70a819a4 --- /dev/null +++ b/packages/cli/src/commands/env/del.ts @@ -0,0 +1,30 @@ +import { CommandModule } from 'yargs'; + +import { IContext } from '../../context'; + +type Args = { + key: string; +}; + +export function Del({ envs }: IContext): CommandModule<{}, Args> { + return { + command: 'del ', + describe: 'delete an environment key', + builder: (b) => { + return b.positional('key', { + type: 'string', + demandOption: true, + }); + }, + handler: ({ key }) => { + envs.del(key); + + if (envs.active.list().length === 0 && envs.active.name !== 'dev') { + const toDelete = envs.active.name; + envs.select('dev'); + envs.remove(toDelete); + return; + } + }, + }; +} diff --git a/packages/cli/src/commands/env/export.ts b/packages/cli/src/commands/env/export.ts new file mode 100644 index 00000000..f17d7935 --- /dev/null +++ b/packages/cli/src/commands/env/export.ts @@ -0,0 +1,34 @@ +import fs from 'fs'; +import path from 'path'; + +import { CommandModule } from 'yargs'; + +import { IContext } from '../../context'; + +type Args = { + name?: string; +}; + +export function Export({ envs }: IContext): CommandModule<{}, Args> { + return { + command: 'export [name]', + describe: 'export an environment to a .env file in your cwd', + builder: (b) => { + return b.positional('name', { + type: 'string', + describe: 'the environment name to export (defaults to active)', + }); + }, + handler: ({ name = envs.active.name }) => { + const file = path.join(process.cwd(), '.env'); + const env = envs.getByName(name); + + if (!env) { + console.error('environment not found'); + return; + } + + fs.writeFileSync(file, env.toString(), 'utf8'); + }, + }; +} diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts new file mode 100644 index 00000000..a093cf31 --- /dev/null +++ b/packages/cli/src/commands/env/index.ts @@ -0,0 +1,26 @@ +import { CommandModule } from 'yargs'; + +import { IContext } from '../../context'; +import { Set } from './set'; +import { Del } from './del'; +import { List } from './list'; +import { Select } from './select'; +import { Export } from './export'; + +export function Env(context: IContext): CommandModule<{}, {}> { + return { + command: 'env', + aliases: 'e', + describe: 'manage environments', + builder: (b) => { + return b + .command(List(context)) + .command(Select(context)) + .command(Set(context)) + .command(Del(context)) + .command(Export(context)) + .demandCommand(1); + }, + handler: () => {}, + }; +} diff --git a/packages/cli/src/commands/env/list.ts b/packages/cli/src/commands/env/list.ts new file mode 100644 index 00000000..bb156546 --- /dev/null +++ b/packages/cli/src/commands/env/list.ts @@ -0,0 +1,18 @@ +import { CommandModule } from 'yargs'; + +import { IContext } from '../../context'; + +export function List({ envs }: IContext): CommandModule<{}, {}> { + return { + command: 'list', + aliases: 'ls', + describe: 'list environments', + handler: () => { + console.log(`active: ${envs.active.name}\n`); + + for (const env of envs.list()) { + console.log(`${env.name}: ${env.list().length}`); + } + }, + }; +} diff --git a/packages/cli/src/commands/env/select.ts b/packages/cli/src/commands/env/select.ts new file mode 100644 index 00000000..e93669fd --- /dev/null +++ b/packages/cli/src/commands/env/select.ts @@ -0,0 +1,31 @@ +import { CommandModule } from 'yargs'; + +import { IContext } from '../../context'; + +type Args = { + name: string; +}; + +export function Select({ envs }: IContext): CommandModule<{}, Args> { + return { + command: 'select ', + describe: 'select an environment', + builder: (b) => { + return b.positional('name', { + type: 'string', + demandOption: true, + coerce: (name: string) => { + return name + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/^[._]/, '') + .replace(/[^a-z\d\-~]+/g, '-'); + }, + }); + }, + handler: ({ name }) => { + envs.select(name); + }, + }; +} diff --git a/packages/cli/src/commands/env/set.ts b/packages/cli/src/commands/env/set.ts new file mode 100644 index 00000000..747074d0 --- /dev/null +++ b/packages/cli/src/commands/env/set.ts @@ -0,0 +1,29 @@ +import { CommandModule } from 'yargs'; + +import { IContext } from '../../context'; + +type Args = { + key: string; + value: string; +}; + +export function Set({ envs }: IContext): CommandModule<{}, Args> { + return { + command: 'set [value]', + describe: 'set an environment key', + builder: (b) => { + return b + .positional('key', { + type: 'string', + demandOption: true, + }) + .positional('value', { + type: 'string', + demandOption: true, + }); + }, + handler: ({ key, value }) => { + envs.set(key, value); + }, + }; +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 9be1d75d..c9f0dbdb 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,2 +1,3 @@ export * from './new'; export * from './config'; +export * from './env'; diff --git a/packages/cli/src/commands/new/csharp.ts b/packages/cli/src/commands/new/csharp.ts index 04a824d7..24022c84 100644 --- a/packages/cli/src/commands/new/csharp.ts +++ b/packages/cli/src/commands/new/csharp.ts @@ -5,6 +5,7 @@ import url from 'node:url'; import { CommandModule } from 'yargs'; import { Project } from '../../project'; +import { IContext } from '../../context'; type Args = { readonly name: string; @@ -13,7 +14,7 @@ type Args = { readonly start?: boolean; }; -export function CSharp(): CommandModule<{}, Args> { +export function CSharp(_: IContext): CommandModule<{}, Args> { return { command: 'csharp ', aliases: ['c#', 'dotnet', '.net'], diff --git a/packages/cli/src/commands/new/index.ts b/packages/cli/src/commands/new/index.ts index d7e8744a..2c4e81ac 100644 --- a/packages/cli/src/commands/new/index.ts +++ b/packages/cli/src/commands/new/index.ts @@ -1,18 +1,19 @@ import { CommandModule } from 'yargs'; +import { IContext } from '../../context'; import { Typescript } from './typescript'; import { CSharp } from './csharp'; -export function New(): CommandModule<{}, {}> { +export function New(context: IContext): CommandModule<{}, {}> { return { command: 'new', aliases: 'n', describe: 'create a new app project', builder: (b) => { - let args = b.command(Typescript()); + let args = b.command(Typescript(context)); if (process.env.TEAMS_CLI_ENV === 'development') { - args = args.command(CSharp()); + args = args.command(CSharp(context)); } return args; diff --git a/packages/cli/src/commands/new/typescript.ts b/packages/cli/src/commands/new/typescript.ts index ef6df12e..578c8af5 100644 --- a/packages/cli/src/commands/new/typescript.ts +++ b/packages/cli/src/commands/new/typescript.ts @@ -8,6 +8,7 @@ import { CommandModule } from 'yargs'; import { z } from 'zod'; import { Project } from '../../project'; +import { IContext } from '../../context'; const ArgsSchema = z.object({ name: z.string(), @@ -18,7 +19,7 @@ const ArgsSchema = z.object({ clientSecret: z.string().optional(), }); -export function Typescript(): CommandModule<{}, z.infer> { +export function Typescript(_: IContext): CommandModule<{}, z.infer> { return { command: ['$0 ', 'typescript '], aliases: 'ts', diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts new file mode 100644 index 00000000..dd4f2de0 --- /dev/null +++ b/packages/cli/src/context.ts @@ -0,0 +1,7 @@ +import { EnvStorage } from './environment'; +import { Settings } from './settings'; + +export interface IContext { + readonly settings: Settings; + readonly envs: EnvStorage; +} diff --git a/packages/cli/src/environment/env-storage.ts b/packages/cli/src/environment/env-storage.ts new file mode 100644 index 00000000..db582e42 --- /dev/null +++ b/packages/cli/src/environment/env-storage.ts @@ -0,0 +1,89 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { Settings } from '../settings'; +import { Env, IEnv } from './env'; + +export class EnvStorage { + get active() { + return this._active; + } + protected _active: IEnv; + protected _store: Map = new Map(); + protected _settings: Settings; + + constructor(settings: Settings) { + const dev = Env.load(settings.env); + this._store.set('dev', dev); + this._active = dev; + this._settings = settings; + } + + getByName(name: string) { + return this._store.get(name); + } + + select(name: string) { + let env = this._store.get(name); + + if (!env) { + env = new Env(name); + env.activate(); + env.save(); + this.add(env); + } + + this._active.deactivate(); + this._active = env; + this._settings.env = env.name; + this._settings.save(); + return env; + } + + add(env: IEnv) { + this._store.set(env.name, env); + } + + remove(name: string) { + this._store.get(name)?.delete(); + this._store.delete(name); + } + + get(key: string) { + return this._active.get(key); + } + + set(key: string, value: string) { + this._active.set(key, value); + this._active.save(); + } + + del(key: string) { + this._active.del(key); + this._active.save(); + } + + list(where?: (item: IEnv, i: number) => boolean) { + return this._store + .values() + .toArray() + .filter((item, i) => (where ? where(item, i) : true)); + } + + static load(settings: Settings) { + const storage = new EnvStorage(settings); + const base = path.join(os.homedir(), 'teams.sdk', 'environments'); + + if (!fs.existsSync(base)) { + return storage; + } + + for (const name of fs.readdirSync(base, { recursive: true })) { + const env = Env.load(path.basename(name.toString(), '.env')); + storage.add(env); + } + + return storage; + } +} diff --git a/packages/cli/src/environment/env.ts b/packages/cli/src/environment/env.ts new file mode 100644 index 00000000..9034e250 --- /dev/null +++ b/packages/cli/src/environment/env.ts @@ -0,0 +1,119 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +export type KeyValue = { + readonly key: string; + readonly value: string; +}; + +export interface IEnv { + readonly name: string; + + get(key: string): string | undefined; + set(key: string, value: string): void; + del(key: string): void; + list(where?: (item: KeyValue, i: number) => boolean): Array; + toString(): string; + + activate(): void; + deactivate(): void; + + save(): void; + delete(): void; +} + +export class Env implements IEnv { + readonly name: string; + + protected items: Map = new Map(); + + constructor(name: string) { + this.name = name; + } + + get(key: string) { + return this.items.get(key); + } + + set(key: string, value: string) { + this.items.set(key, value); + } + + del(key: string) { + this.items.delete(key); + } + + list(where?: (item: KeyValue, i: number) => boolean) { + return this.items + .entries() + .toArray() + .map(([key, value]) => ({ key, value })) + .filter((item, i) => (where ? where(item, i) : true)); + } + + toString() { + return this.list() + .map(({ key, value }) => `${key}=${value}`) + .join('\n'); + } + + activate() { + for (const { key, value } of this.list()) { + process.env[key] = value; + } + } + + deactivate() { + for (const { key } of this.list()) { + delete process.env[key]; + } + } + + save() { + const base = path.join(os.homedir(), 'teams.sdk', 'environments'); + + if (!fs.existsSync(base)) { + fs.mkdirSync(base, { recursive: true }); + } + + const file = path.join(base, `${this.name}.env`); + fs.writeFileSync(file, this.toString(), 'utf8'); + } + + static load(name: string) { + const env = new Env(name); + const base = path.join(os.homedir(), 'teams.sdk', 'environments'); + const file = path.join(base, `${name}.env`); + + if (!fs.existsSync(file)) { + return env; + } + + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split('\n'); + + for (const [key, value] of lines.map((line) => + line + .trim() + .split('=', 2) + .map((v) => v.trim()) + )) { + if (!key) continue; + env.set(key, value); + } + + return env; + } + + delete() { + const base = path.join(os.homedir(), 'teams.sdk', 'environments'); + const file = path.join(base, `${this.name}.env`); + + if (!fs.existsSync(file)) { + return; + } + + fs.rmSync(file); + } +} diff --git a/packages/cli/src/environment/index.ts b/packages/cli/src/environment/index.ts new file mode 100644 index 00000000..217f3807 --- /dev/null +++ b/packages/cli/src/environment/index.ts @@ -0,0 +1,2 @@ +export * from './env'; +export * from './env-storage'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5ace7736..35fb824e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,11 +4,20 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import * as commands from './commands'; +import { IContext } from './context'; +import { EnvStorage } from './environment'; +import { Settings } from './settings'; (async () => { + const settings = Settings.load(); + const envs = EnvStorage.load(settings); + const context: IContext = { settings, envs }; + + envs.select(settings.env); yargs(hideBin(process.argv)) .scriptName('spark') - .command(commands.New()) - .command(commands.Config()) + .command(commands.New(context)) + .command(commands.Env(context)) + .command(commands.Config(context)) .parse(); })(); diff --git a/packages/cli/src/settings.ts b/packages/cli/src/settings.ts new file mode 100644 index 00000000..119e260b --- /dev/null +++ b/packages/cli/src/settings.ts @@ -0,0 +1,42 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { z } from 'zod'; + +const Schema = z.object({ + env: z.string(), +}); + +export type ISettings = z.infer; +export class Settings implements ISettings { + env: string; + + constructor(value?: ISettings) { + this.env = value?.env || 'dev'; + } + + save() { + const base = path.join(os.homedir(), 'teams.sdk'); + const file = path.join(base, 'settings.json'); + + if (!fs.existsSync(base)) { + fs.mkdirSync(base, { recursive: true }); + return; + } + + fs.writeFileSync(file, JSON.stringify(this), 'utf8'); + } + + static load() { + const base = path.join(os.homedir(), 'teams.sdk'); + const file = path.join(base, 'settings.json'); + + if (!fs.existsSync(file)) { + return new Settings(); + } + + const value: ISettings = JSON.parse(fs.readFileSync(file, 'utf8')); + return new Settings(value); + } +} diff --git a/packages/cli/templates/typescript/mcp/package.json.hbs b/packages/cli/templates/typescript/mcp/package.json.hbs index a12228f9..60636024 100644 --- a/packages/cli/templates/typescript/mcp/package.json.hbs +++ b/packages/cli/templates/typescript/mcp/package.json.hbs @@ -14,7 +14,7 @@ "build": "npx tsc", "start": "node .", "dev": "npx nodemon -w \"./src/**\" -e ts --exec \"node -r ts-node/register -r dotenv/config ./src/index.ts\"", - "inspect": "npx cross-env SERVER_PORT=4000 npx @modelcontextprotocol/inspector node ." + "inspect": "npx cross-env SERVER_PORT=9000 npx @modelcontextprotocol/inspector -e NODE_NO_WARNINGS=1 -e PORT=3000 node -r dotenv/config ." }, "dependencies": { "@microsoft/spark.api": "latest", diff --git a/packages/cli/templates/typescript/mcp/src/index.ts.hbs b/packages/cli/templates/typescript/mcp/src/index.ts.hbs index 01a5b305..648660c6 100644 --- a/packages/cli/templates/typescript/mcp/src/index.ts.hbs +++ b/packages/cli/templates/typescript/mcp/src/index.ts.hbs @@ -1,7 +1,7 @@ import { ChatPrompt } from '@microsoft/spark.ai'; import { App } from '@microsoft/spark.apps'; import { DevtoolsPlugin } from '@microsoft/spark.dev'; -import { MCPPlugin } from '@microsoft/spark.mcp'; +import { McpPlugin } from '@microsoft/spark.mcp'; import { OpenAIChatModel } from '@microsoft/spark.openai'; const prompt = new ChatPrompt({ @@ -18,10 +18,7 @@ prompt.function('hello-world', 'print hello world', () => { const app = new App({ plugins: [ new DevtoolsPlugin(), - new MCPPlugin({ - name: '{{ toKebabCase name }}', - version: '0.0.0' - }).use(prompt) + new McpPlugin({ name: '{{ toKebabCase name }}' }).use(prompt) ], }); diff --git a/samples/mcp/package.json b/samples/mcp/package.json index 53d44d23..d053ce8a 100644 --- a/samples/mcp/package.json +++ b/samples/mcp/package.json @@ -12,9 +12,9 @@ "scripts": { "clean": "npx rimraf ./dist", "build": "npx tsc", - "start": "node .", + "start": "node -r dotenv/config .", "dev": "npx nodemon -w \"./src/**\" -e ts --exec \"node -r ts-node/register -r dotenv/config ./src/index.ts\"", - "inspect": "npx cross-env SERVER_PORT=4000 npx @modelcontextprotocol/inspector node ." + "inspect": "npx cross-env SERVER_PORT=9000 npx @modelcontextprotocol/inspector -e NODE_NO_WARNINGS=1 -e PORT=3000 node -r dotenv/config ." }, "dependencies": { "@microsoft/spark.api": "*", diff --git a/samples/mcp/src/index.ts b/samples/mcp/src/index.ts index 4f67d108..91f5464a 100644 --- a/samples/mcp/src/index.ts +++ b/samples/mcp/src/index.ts @@ -2,7 +2,7 @@ import { ChatPrompt } from '@microsoft/spark.ai'; import { App } from '@microsoft/spark.apps'; import { ConsoleLogger } from '@microsoft/spark.common/logging'; import { DevtoolsPlugin } from '@microsoft/spark.dev'; -import { MCPPlugin } from '@microsoft/spark.mcp'; +import { McpPlugin } from '@microsoft/spark.mcp'; import { OpenAIChatModel } from '@microsoft/spark.openai'; const prompt = new ChatPrompt({ @@ -18,7 +18,7 @@ prompt.function('hello-world', 'print hello world', () => { const app = new App({ logger: new ConsoleLogger('@samples/echo', { level: 'debug' }), - plugins: [new DevtoolsPlugin(), new MCPPlugin({ name: 'echo', version: '0.0.0' }).use(prompt)], + plugins: [new DevtoolsPlugin(), new McpPlugin({ name: 'echo' }).use(prompt)], }); app.on('message', async ({ send, activity }) => {