Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 130 additions & 39 deletions Sources/AIToolKit/AIToolAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,127 @@ public typealias AccountID = String

/// One account, as both sides agree to describe it.
///
/// A protocol rather than a struct, for the reason ``AITool`` is one: holding
/// only properties today does not mean nothing will ever need behaviour here,
/// and a struct forces a breaking change the first time one does. It is also
/// what lets a forwarding proxy conform without carrying values it does not have.
///
/// Defined here rather than in the host or in a plugin, because neither owns it:
/// the host depends on this type, a plugin supplies values of it, and the
/// dependency runs from both sides into the framework.
/// dependency runs from both sides inward.
///
/// ## What a conformer must supply
///
/// ``id`` and ``name``. Everything else defaults, so the smallest useful account
/// is two properties — that is the point of the protocol rather than decoration
/// on it.
///
/// ## The workspace an account is pinned to
///
/// An account carries its own workspace rather than the plugin keeping a table
/// keyed the other way. The relation is one workspace per account, so the account
/// is where it belongs, and a host that wants the accounts of one workspace
/// filters a listing instead of asking a second question.
///
/// ## What is not here
/// `nil` means not pinned to any. The framework has no default: "the first one",
/// "the main one" and whatever a host calls it are host vocabulary, and a
/// framework that shipped one of those names would be picking a side.
///
/// A workspace. Workspaces are a management layer of their own, and which
/// account is pinned to which workspace is a table the plugin keeps and the host
/// can read — not a field every account carries around.
/// ## Why the rest are optional
///
/// `email` and `plan` are optional and mean "the tool could not say", which is
/// distinct from there being no account: an API-key login is a real account with
/// no user attached, and reporting it as absent would hide an account that works.
public struct Account: Sendable, Equatable, Codable {
/// ``email`` and ``plan`` mean "the tool could not say", which is distinct from
/// there being no account: an API-key login is a real account with no user
/// attached, and reporting it as absent would hide an account that works.
public protocol Account: Sendable {
var id: AccountID { get }
var name: String { get }
var email: String? { get }
var plan: String? { get }
var workspace: String? { get }

/// Pin this account to a workspace.
///
/// On the account rather than on ``AIToolAccounts`` because it is the
/// account's own relation: one workspace per account, and the account is what
/// moves. A `pin(id:to:)` on the tool would make the caller name an account it
/// is already holding.
///
/// This is also what the protocol was for. An `Account` that only carried
/// properties could have been a struct; behaviour arriving on it is the case
/// a struct would have turned into a breaking change.
///
/// The workspace is an opaque string. A conformer records it and never has to
/// know what a workspace is, the same way it takes a directory path without
/// knowing what the host keeps in it.
///
/// - Throws: when the pin could not be recorded, so one that changed nothing
/// is never reported as done.
func pin(to workspace: String) async throws

/// Designate this account as the tool's current one.
///
/// On the account for the same reason as ``pin(to:)``: the caller is holding
/// the account it means, and a `setCurrent(id:)` on the tool would ask it to
/// name that account again. Reading which one is current stays on the tool,
/// because "which account is designated" is a question about the tool and
/// there may be no answer.
///
/// - Throws: when the designation could not be recorded.
func makeCurrent() async throws

/// Remove this account and everything the tool keeps for it.
///
/// If it was the current one, the conformer clears that: leaving the tool's
/// current account outside its own listing would have a host render a row for
/// something that is gone.
///
/// - Throws: when the account could not be removed, so a delete that removed
/// nothing is never reported as done.
func delete() async throws
}

extension Account {
public var email: String? { nil }
public var plan: String? { nil }
public var workspace: String? { nil }
}

/// An account's fields, in the shape they take on the wire.
///
/// The concrete `Codable` type ``Account`` deliberately is not: a protocol cannot
/// be `Decodable`, because decoding has to know what to build.
///
/// It does **not** conform to ``Account``, exactly as ``ToolDescription`` does not
/// conform to ``AITool``. An account can be pinned, and a decoded value has
/// nothing to pin *with* — no store, no connection. A record that conformed would
/// have to answer `pin(to:)` by failing, which is a conformance that lies. The
/// types that conform are the ones that can act: a plugin's own account, and a
/// host-side proxy holding the connection back to it.
public struct AccountRecord: Codable, Sendable, Equatable {
public let id: AccountID
public let name: String
public let email: String?
public let plan: String?
public let workspace: String?

public init(id: AccountID, name: String, email: String? = nil, plan: String? = nil) {
public init(
id: AccountID,
name: String,
email: String? = nil,
plan: String? = nil,
workspace: String? = nil
) {
self.id = id
self.name = name
self.email = email
self.plan = plan
self.workspace = workspace
}

/// A snapshot of any account, for sending or storing.
public init(_ account: any Account) {
self.init(id: account.id, name: account.name, email: account.email,
plan: account.plan, workspace: account.workspace)
}
}

Expand All @@ -55,9 +152,23 @@ public enum AccountError: Error, Sendable, Equatable {
/// A tool that owns its accounts.
///
/// The host decides *which* account and *when*; the plugin owns everything
/// behind that: where accounts live on disk, what a directory contains, how
/// credentials are stored, and which account is current. The host keeps no
/// second copy to reconcile against, which is what stops the two drifting.
/// behind that: where accounts live, what a directory contains, how credentials
/// are stored, and which account is current. The host keeps no second copy to
/// reconcile against, which is what stops the two drifting.
///
/// ## What is here, and what is on `Account`
///
/// Anything done *to* an account is on ``Account`` — pinning it, designating it,
/// deleting it. A caller doing one of those is already holding the account, and
/// a `verb(id:)` on the tool would ask it to name the thing in its hand.
///
/// What stays here is what only the tool can answer: which accounts exist, which
/// one is designated, and making a new one — the last because there is nothing
/// to call the method on until it is made.
///
/// A consequence worth noticing: "no such account" stops being an operation
/// error. You cannot act on an account you are not holding, so failing to find
/// one is a lookup that failed, earlier and somewhere else.
///
/// Every method is `async` because a plugin is usually another process, so every
/// call is a round trip. A synchronous requirement would leave a remote
Expand All @@ -68,24 +179,13 @@ public protocol AIToolAccounts: AITool {
///
/// - Returns: empty when there are none. A fresh install has no accounts and
/// that is not a failure.
func list() async throws -> [Account]

/// The account this tool is currently pinned to.
///
/// - Returns: `nil` when nothing is pinned yet — the state a fresh install
/// is in, and not worth making every caller handle as an error.
func current() async throws -> Account?
func list() async throws -> [any Account]

/// Pin this tool to an account.
///
/// The host decides the pin: it knows the workspace layout and this shell.
/// The plugin persists the decision and answers ``current()`` with it.
/// The account designated as current.
///
/// - Throws: ``AccountError/noSuchAccount(_:)`` when there is no such
/// account. Storing a pin to something that does not exist would make the
/// next ``current()`` either lie or fail, and the failure is better here,
/// where the caller still knows what it asked for.
func setCurrent(id: AccountID) async throws
/// - Returns: `nil` when nothing is designated yet — the state a fresh
/// install is in, and not worth making every caller handle as an error.
func current() async throws -> (any Account)?

/// Create an account under an id and name the host chose.
///
Expand All @@ -96,15 +196,6 @@ public protocol AIToolAccounts: AITool {
/// - Returns: the account as stored, so the host reads back what actually
/// exists rather than assuming its request was honoured verbatim.
/// - Throws: ``AccountError/alreadyExists(_:)`` when the id is taken.
func addAccount(id: AccountID, name: String) async throws -> Account
func addAccount(id: AccountID, name: String) async throws -> any Account

/// Remove an account and everything the plugin keeps for it.
///
/// If the deleted account was current, the plugin clears the pin: leaving
/// ``current()`` pointing outside ``list()`` would have a host render a row
/// for something that is gone.
///
/// - Throws: ``AccountError/noSuchAccount(_:)`` when there is no such
/// account, so a delete that removed nothing is never reported as done.
func deleteAccount(id: AccountID) async throws
}
29 changes: 26 additions & 3 deletions Sources/AIToolKit/RPC/PluginServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public enum PluginServer {
capabilities["tool/setCurrent"] = .bool(true)
capabilities["tool/addAccount"] = .bool(true)
capabilities["tool/deleteAccount"] = .bool(true)
capabilities["tool/pin"] = .bool(true)
}
response = JSONRPCResponse(id: request.id, result: .object([
"protocolVersion": .string(protocolVersion),
Expand Down Expand Up @@ -85,7 +86,7 @@ public enum PluginServer {
response = await perform(request.method, on: transfer, params: request.params, id: request.id)

case "tool/list", "tool/current", "tool/setCurrent",
"tool/addAccount", "tool/deleteAccount":
"tool/addAccount", "tool/deleteAccount", "tool/pin":
guard let accounts = tool as? any AIToolAccounts else {
response = JSONRPCResponse(
id: request.id, result: nil,
Expand Down Expand Up @@ -258,9 +259,23 @@ public enum PluginServer {
"name": .string(account.name),
"email": account.email.map(RPCValue.string) ?? .null,
"plan": account.plan.map(RPCValue.string) ?? .null,
// Explicit null, like the other optionals: an absent key cannot be
// told apart from a reply that lost it, and "pinned nowhere" is a
// real state a host renders differently from "unknown".
"workspace": account.workspace.map(RPCValue.string) ?? .null,
])
}

/// The wire names an account; the protocol asks the account to act on
/// itself. Resolving the id is therefore the server's step, and it is the
/// only place `noSuchAccount` can now arise — every operation beyond it
/// is called on an account already in hand.
func resolve(_ accountID: AccountID, in tool: any AIToolAccounts) async throws -> any Account {
guard let account = try await tool.list().first(where: { $0.id == accountID })
else { throw AccountError.noSuchAccount(accountID) }
return account
}

func requiredID() -> AccountID? {
guard case .string(let value)? = params?["id"], !value.isEmpty else { return nil }
return value
Expand All @@ -279,7 +294,7 @@ public enum PluginServer {

case "tool/setCurrent":
guard let accountID = requiredID() else { return invalid("\(method): id is required") }
try await tool.setCurrent(id: accountID)
try await resolve(accountID, in: tool).makeCurrent()
return JSONRPCResponse(id: id, result: .object([:]), error: nil)

case "tool/addAccount":
Expand All @@ -290,9 +305,17 @@ public enum PluginServer {
let created = try await tool.addAccount(id: accountID, name: name)
return JSONRPCResponse(id: id, result: .object(["account": encoded(created)]), error: nil)

case "tool/pin":
guard let accountID = requiredID() else { return invalid("\(method): id is required") }
guard case .string(let workspace)? = params?["workspace"], !workspace.isEmpty else {
return invalid("\(method): workspace is required")
}
try await resolve(accountID, in: tool).pin(to: workspace)
return JSONRPCResponse(id: id, result: .object([:]), error: nil)

default:
guard let accountID = requiredID() else { return invalid("\(method): id is required") }
try await tool.deleteAccount(id: accountID)
try await resolve(accountID, in: tool).delete()
return JSONRPCResponse(id: id, result: .object([:]), error: nil)
}
} catch {
Expand Down
Loading
Loading