File size: 5,702 Bytes
9b906ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import type {
  MarketplacePlugin,
  LocalPlugin,
  PluginBundledSkill,
} from "#/api/plugins-service";
import type { InstalledPluginInfo } from "#/api/plugins-management-service";

export type PluginStatusFilter = "all" | "installed" | "available" | "local";

/**
 * A single row in the plugins management list, reconciling the dynamic
 * marketplace catalog with the locally-installed plugins.
 */
export interface PluginViewModel {
  name: string;
  description: string | null;
  source: string | null;
  ref: string | null;
  repoPath: string | null;
  /** Installed on the local agent-server. */
  installed: boolean;
  /** Enabled (only meaningful when installed); enabled plugins auto-load. */
  enabled: boolean;
  /** Installed version, when known. */
  version: string | null;
  /** Present in the marketplace catalog. */
  inCatalog: boolean;
  /**
   * Discovered from a local ambient directory (e.g. `~/.agents/plugins`).
   * Read-only: it auto-loads into conversations and is not installed/managed.
   */
  isLocal: boolean;
  /**
   * Plugin contents (`skills` bundled in the plugin, `files` relative to
   * `path`). The three fields travel as a unit from one server response so the
   * file viewer never joins a base path with another copy's file list; null
   * when the server has no local copy or predates the contents fields.
   */
  path: string | null;
  skills: PluginBundledSkill[] | null;
  files: string[] | null;
}

/**
 * Merge the marketplace catalog with the installed plugins into one
 * de-duplicated list keyed by plugin name. The installed list is the source of
 * truth for install/enable state and coordinates; the catalog supplies
 * description/coordinates as a fallback. Installed plugins sort first, then
 * alphabetically.
 */
export function buildPluginsViewModel(
  marketplace: MarketplacePlugin[] | undefined,
  installed: InstalledPluginInfo[] | undefined,
  local: LocalPlugin[] | undefined = undefined,
): PluginViewModel[] {
  const byName = new Map<string, PluginViewModel>();

  for (const entry of marketplace ?? []) {
    byName.set(entry.name, {
      name: entry.name,
      description: entry.description,
      source: entry.source,
      ref: entry.ref ?? null,
      repoPath: entry.repo_path ?? null,
      installed: entry.installed,
      enabled: false,
      version: null,
      inCatalog: true,
      isLocal: false,
      path: entry.path ?? null,
      skills: entry.skills ?? null,
      files: entry.files ?? null,
    });
  }

  for (const entry of installed ?? []) {
    const existing = byName.get(entry.name);
    // Contents move as a unit: the installed copy is authoritative when it
    // carries contents; otherwise (older agent-server) keep the catalog's.
    const hasContents = entry.skills != null || entry.files != null;
    byName.set(entry.name, {
      name: entry.name,
      description: existing?.description ?? entry.description ?? null,
      source: entry.source ?? existing?.source ?? null,
      ref: entry.resolved_ref ?? existing?.ref ?? null,
      repoPath: entry.repo_path ?? existing?.repoPath ?? null,
      installed: true,
      enabled: entry.enabled,
      version: entry.version ?? null,
      inCatalog: existing?.inCatalog ?? false,
      isLocal: false,
      path: hasContents ? entry.install_path : (existing?.path ?? null),
      skills: hasContents ? (entry.skills ?? null) : (existing?.skills ?? null),
      files: hasContents ? (entry.files ?? null) : (existing?.files ?? null),
    });
  }

  // Ambient local plugins are additive and read-only. A local plugin whose name
  // is already installed or in the catalog is skipped — that managed entry is
  // authoritative (the agent-server also folds enabled installed plugins into
  // this list), so it must not spawn a duplicate read-only "Local" card.
  for (const entry of local ?? []) {
    if (byName.has(entry.name)) continue;
    byName.set(entry.name, {
      name: entry.name,
      description: entry.description || null,
      source: null,
      ref: null,
      repoPath: null,
      installed: false,
      enabled: false,
      version: entry.version || null,
      inCatalog: false,
      isLocal: true,
      path: entry.path ?? null,
      skills: entry.skills ?? null,
      files: entry.files ?? null,
    });
  }

  // Sort installed first, then local, then available; alphabetical within each.
  const rank = (plugin: PluginViewModel): number =>
    plugin.installed ? 0 : plugin.isLocal ? 1 : 2;

  return Array.from(byName.values()).sort((a, b) => {
    const rankDelta = rank(a) - rank(b);
    if (rankDelta !== 0) return rankDelta;
    return a.name.localeCompare(b.name);
  });
}

/** True when the plugin matches a free-text search query (empty query matches). */
export function matchesPluginSearch(
  plugin: PluginViewModel,
  query: string,
): boolean {
  const trimmed = query.trim().toLowerCase();
  if (!trimmed) return true;
  const haystacks = [
    plugin.name,
    plugin.description ?? "",
    plugin.source ?? "",
    plugin.repoPath ?? "",
    plugin.ref ?? "",
  ];
  return haystacks.some((value) => value.toLowerCase().includes(trimmed));
}

/** True when the plugin matches the install-state filter. */
export function matchesPluginStatus(
  plugin: PluginViewModel,
  filter: PluginStatusFilter,
): boolean {
  if (filter === "installed") return plugin.installed;
  // "Available" means installable from the catalog; local plugins are neither
  // installed nor installable, so they are excluded here and grouped under
  // "local" instead.
  if (filter === "available") return !plugin.installed && !plugin.isLocal;
  if (filter === "local") return plugin.isLocal;
  return true;
}