FFFF feat(marketplace): agents find, install and update marketplace plugins by adeelahmad · Pull Request #599 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
ones that run as a program (`npx`, `uvx`) run in a sandboxed container of their own, off until you turn it on. Add
one with a form or by pasting another app's config, its tokens sealed and each of its tools switched on or off.
Install skills and plugins from marketplaces in the open plugin format, or from a repository or an archive, and keep
your own as files in a folder with `lens ext` (pull, push, check).
your own as files in a folder with `lens ext` (pull, push, check). Agents find and install them too, and an
installed plugin says when a new version is listed.
([Extending the assistant](docs/assistant.md#extending-the-assistant-tools-skills-hooks-and-plugins),
[adding an MCP server](docs/assistant.md#adding-an-mcp-server), [the MCP runner](docs/assistant.md#the-mcp-runner);
[checks](docs/requirements.md#extend-the-assistant))
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/extensions-marketplace-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- **Agents find, install and update marketplace plugins.** `find_plugins` and `install_plugin` (over MCP, and in
chat, where installing waits for your yes) look across every marketplace added and install a plugin as your own
extension. A plugin you installed now says which version you have, and its button reads **Update to** the version
its marketplace lists when that's another; `GET /api/v1/marketplaces/plugins` finds plugins across marketplaces
(`?updates=true` for those with another version)
([Skills and plugins from marketplaces](docs/assistant.md#skills-and-plugins-from-marketplaces)).
7 changes: 7 additions & 0 deletions docs/assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,13 @@ Lens reads Anthropic's open plugin and skill formats as they are, so what's made
and any other is added by its address (`owner/repo`, a GitHub or GitLab link, or a link to its marketplace.json),
which is how a team runs a custom one. Everyone who can edit browses them, previews a plugin (what it adds and what
Lens leaves out) and installs it.
- **Finding and updating**: `GET /api/v1/marketplaces/plugins?q=` looks for a plugin across every marketplace added,
by the words in its name, description, category or author. A plugin you installed says which version you have, and
when its marketplace (read again on **Refresh**) lists another, its button reads **Update to** that version;
`?updates=true` lists only those.
- **For agents**: over [MCP](mcp.md), `find_plugins` finds them (`updates` for the ones with another version) and
`install_plugin` installs one, or with `preview` says what it would add. In chat the assistant has the same two
tools, and installing waits for your yes, like saving an extension.
- **Links and files** (`POST /api/v1/extensions/import`, `/import/archive`): a plugin or a skill from a GitHub or
GitLab repository or folder, or a `.zip`, `.skill` or `.tar.gz`.

Expand Down
5 changes: 4 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ For an app that speaks OpenAI's API rather than MCP, the namespace can also answ
| `set_picture_descriptions` | choose what describes pictures (`engine` `llm`, `florence-2` or `moondream`), the provider's `vision_model`, Florence-2's size (`florence_model`), which Moondream (`moondream_model`) and `describe_max`. Admins only, with a write-scope token |
| `mcp_servers` | the MCP servers the assistant calls ([Adding an MCP server](assistant.md#adding-an-mcp-server)): each one's address or command, the names of its headers and env (never their values), whether it's on, its tools and those switched off, and its page; and the ready-made ones (browser, github), whether each is added and what it needs. Admins only |
| `add_mcp_server` | add one: a ready-made one (`preset`, with `values` for its token) or your own (`server`: `{url, headers}` or `{command, args, env}`, with `off`, `ask` and `keep`); Lens lists its tools first, so it must answer, and seals its tokens. Admins only, with an API key that has the write scope (not an app given access) |
| `find_plugins` | plugins and skills in the marketplaces an admin added ([Skills and plugins from marketplaces](assistant.md#skills-and-plugins-from-marketplaces)) that have every word of `query`, or with `updates` only those you installed that their marketplace lists another version of: each one's marketplace, version, whether Lens can install it and your extension installed from it |
| `install_plugin` | install one as your own extension, private until you share it, or bring yours to the version listed now; `preview` installs nothing and says what it would add and what Lens leaves out. Needs a write-scope token |
| `install_settings` | whether other devices on the network reach Lens and its edition (full or lean), whether the machine's upgrade agent can change them, and the install line to run when it can't. Admins only |
| `set_install_settings` | open Lens to the network or keep it to this machine, or change the edition: the machine sets itself up again and Lens restarts ([Updates](updates.md#setting-the-machine-up-again)). Admins only, with a write-scope token |
| `news_feeds` | the news feeds (RSS or Atom) Lens follows, the namespaces each brings posts into and how the last scan went; addresses stay sealed. Admins only |
Expand All @@ -177,7 +179,8 @@ app (merges and deletes can be undone), `write_note`, which adds a note, `create
queues an episode, `create_feed` and `publish_to_feed`, which make and fill a [published feed](feeds.md),
`write_spec`, which queues a spec, `set_nsfw` and `mark_nsfw`, which change [NSFW classification](nsfw.md) (owners or admins, and
editors), `enhance`, which enhances an image and reads it again (the process permission),
`set_namespace_integrations`, which turns a namespace's own MCP server or chat completions on or off (owners), `set_picture_descriptions`, which changes what describes pictures (admins), `set_file_storage`, which changes where new files are kept (admins), `set_secret_reference`, which points a secret field at a password manager's item (admins), `add_mcp_server`, which adds an
`set_namespace_integrations`, which turns a namespace's own MCP server or chat completions on or off (owners), `set_picture_descriptions`, which changes what describes pictures (admins), `set_file_storage`, which changes where new files are kept (admins), `set_secret_reference`, which points a secret field at a password manager's item (admins), `install_plugin`, which
installs a plugin from a marketplace as your own extension, `add_mcp_server`, which adds an
MCP server (admins), `subscribe_feed` and `unsubscribe_feed`, which follow and stop following news feeds (admins), and `set_monitoring`, `set_service`,
`set_sandbox_gateway` (the [code sandbox](assistant.md#code-sandbox)'s gateway) and `set_install_settings`, which have the machine change how Lens runs (admins).
Importing stays in the web app and the API.
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ request, to another check that proves the same thing; loosening a requirement or
| An MCP server added by form or a pasted config, its tokens sealed and each tool switched on or off | [test_mcp_servers.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_mcp_servers.py) `test_a_pasted_config_reads_as_servers_to_add`, `test_its_secrets_are_sealed_and_never_shown`, `test_each_tool_is_switched_and_says_whether_it_asks`; [mcp-server-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/mcp-server-ui.test.tsx) `adding an MCP server` |
| Extensions kept as files: `lens ext` lists, pulls, checks and pushes manifests, here or over the API, losing neither side's changes | [test_cli_ext.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_cli_ext.py) `test_pull_edit_and_push_on_this_install`, `test_over_the_api_with_a_token` |
| Skills and plugins from marketplaces in the open plugin format, a repository or an archive | [test_marketplaces.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_marketplaces.py) `test_a_marketplace_an_admin_adds`, `test_imported_from_a_link_or_a_file` |
| Plugins found across marketplaces, updates shown, and installed by agents over MCP, the API or chat | [test_marketplaces.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_marketplaces.py) `test_found_updated_and_installed_by_agents`; [marketplace-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/marketplace-ui.test.tsx) `an installed plugin with another version listed offers the update` |

## Bring in everything

Expand Down
64 changes: 64 additions & 0 deletions fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
from app.domain import classify as classifymod
from app.domain import entities as ents
from app.domain import find as findmod
from app.domain import marketplace as marketmod
from app.domain import sandbox as sandboxmod
from app.domain import speakers as spk
from app.domain import topics as topicmod
Expand Down Expand Up @@ -3152,6 +3153,69 @@ def add_mcp_server(
return _server_line(ctx, extensions.visible_one(ctx.db, _extension_me(ctx), eid))


# ---------- plugins and skills from the marketplaces an admin added (marketplace.py) ----------
@tool(
"find_plugins",
"Find plugins",
"Plugins and skills in the marketplaces an admin added (Anthropic's open plugin format): those whose name, "
"description, category or author has every word of query, or all of them; updates true lists only the ones you "
"installed that their marketplace now lists another version of. Each says its marketplace, version, whether Lens "
"can install it (why_not when it can't), and your extension installed from it. Install one with install_plugin.",
Arg("query", "string", "words to look for", max_length=200),
Arg("marketplace", "integer", "only this marketplace's plugins"),
Arg("updates", "boolean", "only the ones you installed that have another version listed", default=False),
Arg("limit", "integer", "at most this many", default=50, minimum=1, maximum=500),
personal=True,
)
def find_plugins(ctx: Context, query: str | None, marketplace: int | None, updates: bool, limit: int) -> dict[str, Any]:
found, matched = marketmod.find(ctx.db, _extension_me(ctx), query, marketplace, bool(updates), limit)
out: dict[str, Any] = {"plugins": found, "matched": matched, "page": f"{ctx.web}/extensions/marketplace"}
if marketplace is None and not ctx.db.one("SELECT id FROM marketplace LIMIT 1"):
out["note"] = "no marketplace is added yet: an admin adds one in the web app (Anthropic's are one click there)"
return out


@tool(
"install_plugin",
"Install a plugin",
"Install a plugin or skill from a marketplace (find_plugins gives the marketplace and plugin) as your own "
"extension, private until you share it and switched on; installing it again brings the version the marketplace "
"lists now. preview true installs nothing and says what it would add to the assistant (tools, skills, MCP servers) "
"and what Lens leaves out. Needs an API key with the write scope.",
Arg("marketplace", "integer", "the marketplace's id", required=True),
Arg("plugin", "string", "the plugin's name in it", required=True, max_length=200),
Arg("preview", "boolean", "only say what it would add", default=False),
writes=True,
personal=True,
)
def install_plugin(ctx: Context, marketplace: int, plugin: str, preview: bool) -> dict[str, Any]:
_writer(ctx, "install plugins")
me = _extension_me(ctx)
try:
if preview:
got = marketmod.preview(ctx.db, ctx.cfg, me, marketplace, plugin)
m = got["manifest"]
items = [store.clean({"kind": i["kind"], "name": i["name"], "description": i.get("description")}) for i in m.get("items") or []]
return {
"name": m["name"],
"description": m.get("description"),
"adds": items,
"leaves_out": got["skipped"],
"source": got["source"],
}
eid, n = marketmod.install(ctx.db, ctx.cfg, me, marketplace, plugin)
except PermissionError as e:
raise ToolError(str(e)) from None
auth.audit(
ctx.db,
ctx.user.as_audit(),
"extension.import",
f"extension:{eid}",
{"marketplace": marketplace, "plugin": plugin, "version": n, "via": "mcp"},
)
return {"id": eid, "version": n, "page": f"{ctx.web}/extensions/{eid}"}


# ---------- how install.sh set this machine up: reachable on the network, and the edition (admins) ----------
@tool(
"install_settings",
Expand Down
15 changes: 14 additions & 1 deletion fastapi_backend/app/api/v1/routes/marketplaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from __future__ import annotations

from fastapi import APIRouter
from fastapi import APIRouter, Query

from app.api.deps import AdminWriter, Cfg, CurrentUser, Db, Writer
from app.api.v1.routes.extensions import _errors, me
Expand All @@ -15,6 +15,7 @@
Marketplace,
MarketplaceCreate,
MarketplacePlugin,
MarketplacePluginFound,
MarketplaceSuggestion,
)

Expand All @@ -32,6 +33,18 @@ def suggested_marketplaces(user: CurrentUser, db: Db) -> list[MarketplaceSuggest
return marketplace.suggested(db)


@router.get("/plugins")
def find_marketplace_plugins(
user: CurrentUser,
db: Db,
q: str | None = Query(None, max_length=200, description="words its name, description, category or author has"),
updates: bool = Query(False, description="only plugins you installed that their marketplace lists another version of"),
limit: int = Query(50, ge=1, le=500),
) -> list[MarketplacePluginFound]:
"""Plugins across every marketplace added, to find one to install or see which have updates."""
return marketplace.find(db, me(user), q, updates=updates, limit=limit)[0]


@router.post("")
def add_marketplace(body: MarketplaceCreate, user: AdminWriter, db: Db, cfg: Cfg) -> Created:
"""Add a marketplace by its address; its list of plugins is read now."""
Expand Down
37 changes: 37 additions & 0 deletions fastapi_backend/app/domain/ai_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
graph_history,
llm,
manual,
marketplace as marketmod,
notebook,
ns_assistant,
ops_tools,
Expand Down Expand Up @@ -481,6 +482,23 @@
["name", "enabled"],
True,
),
(
"find_plugins",
"Plugins and skills in the marketplaces an admin added that have every word of query in their name, "
"description, category or author (or all of them); updates true: only the person's installed ones with another "
"version listed. Each gives its marketplace id, version, whether Lens can install it and whether it's installed.",
{"query": _S, "marketplace": _I, "updates": {"type": "boolean"}},
[],
False,
),
(
"install_plugin",
"Install a plugin or skill from a marketplace (find_plugins gives marketplace and plugin) as the person's own "
"extension, or bring an installed one to the version listed now. Needs the person's approval.",
{"marketplace": _I, "plugin": _S},
["marketplace", "plugin"],
True,
),
]

# automations (automations.py): saving one is made at once, switched off; switching one on or running it now acts, so
Expand Down Expand Up @@ -1377,6 +1395,20 @@ def t_switch_extension(self, name, enabled):
what = f"Switch {g['kind']} {name} {'on' if enabled else 'off'}"
return self._approval("switch_extension", {"extension": g["id"], "name": name, "enabled": bool(enabled)}, what)

def t_find_plugins(self, query=None, marketplace=None, updates=False):
found, n = marketmod.find(self.db, self.me, query, marketplace, bool(updates), 30)
keep = ("marketplace", "marketplace_name", "name", "description", "version", "installable", "why_not", "installed", "update")
out = [{k: p[k] for k in keep if k in p} for p in found]
return {"plugins": out, "matched": n}, f"Found {n} plugin{'' if n == 1 else 's'}"

def t_install_plugin(self, marketplace, plugin):
got = marketmod.preview(self.db, self.cfg, self.me, int(marketplace), plugin)
items = ", ".join(f"{i['kind']} {i['name']}" for i in got["manifest"].get("items") or []) or "nothing"
have = next((p for p in marketmod.plugins(self.db, self.me, int(marketplace)) if p["name"] == plugin), {})
verb = "Update" if have.get("installed") else "Install"
what = f"{verb} the plugin {plugin} from {got['source']}: {items}"
return self._approval("install_plugin", {"marketplace": int(marketplace), "plugin": plugin}, what[:300])

# ---- the namespace's memory (ns_assistant.py) ----
def t_remember(self, fact, source_ref=None):
sid, name, _ = self.home
Expand Down Expand Up @@ -2418,6 +2450,11 @@ def _carry_out(db, cfg, a, user, editable, decision, base, admin, readable, via=
result = run_extension(db, cfg, args, user, admin, box.readable, box)
elif a["tool"] in ("save_extension", "switch_extension"):
result = author_extension(db, a["tool"], args, user, admin, readable if readable is not None else editable, editable)
elif a["tool"] == "install_plugin":
rd = readable if readable is not None else editable
me = extensions.who(user["id"], user.get("email"), admin, {s: "editor" if s in editable else "viewer" for s in rd})
eid, n = marketmod.install(db, cfg, me, args["marketplace"], args["plugin"])
result = {"extension": eid, "version": n}
elif a["tool"] in ("switch_automation", "run_automation"):
result = _automation_approved(db, cfg, a["tool"], args, user, admin)
elif a["tool"] in ("run_python", "run_bash"): # a run that can reach another host, asked first (Toolbox._sandbox_run)
Expand Down
Loading
Loading
0