9664899795a10d2b487f84d6823b061f12e638573597e6171ab20e0756b2697f| Command | Method | Description |
|---|---|---|
land-status | GET | Show land overview (extensions, users, trees, peers) |
land-users | GET | List all users on this land |
1import log from "../../seed/log.js";
2import getTools, { setMetadata as setToolMetadata } from "./tools.js";
3import router from "./routes.js";
4
5export async function init(core) {
6 setToolMetadata(core.metadata);
7 // Register a custom mode for land management conversations
8 core.modes.registerMode("land:manager", {
9 emoji: "🏗️",
10 label: "Land Manager",
11 bigMode: "land",
12 toolNames: [
13 "land-status",
14 "land-config-read",
15 "land-config-set",
16 "land-users",
17 "land-peers",
18 "land-system-nodes",
19 "land-ext-list",
20 "land-ext-install",
21 "land-ext-disable",
22 "land-ext-enable",
23 "land-ext-search",
24 "ext-scope-read",
25 "ext-scope-set",
26 "execute-shell",
27 ],
28 buildSystemPrompt({ username }) {
29 return `You are the Land Manager for this TreeOS instance. ${username} is the operator.
30
31You have tools to inspect and manage the land:
32 land-status: overview (extensions, users, trees, peers)
33 land-config-read / land-config-set: read/write land configuration
34 land-users: list users with profile types and tree counts
35 land-peers: list federated peer lands
36 land-system-nodes: inspect system node tree
37
38Extension management:
39 land-ext-list: show loaded extensions and what they provide
40 land-ext-search: search the registry for available extensions
41 land-ext-install: install an extension from the registry (requires restart)
42 land-ext-disable / land-ext-enable: toggle extensions (requires restart)
43
44 execute-shell: run any shell command on the server (use carefully, god-only)
45
46Be direct. Show data. Suggest actions. When asked to do something, use the tools.
47When unsure, check land-status first for context.`;
48 },
49 }, "land-manager");
50
51 log.info("LandManager", "Land manager mode registered (home:land-manager)");
52
53 return {
54 router,
55 tools: getTools(),
56 // Inject scoping tools into tree modes so tree owners can manage extension access
57 modeTools: [
58 { modeKey: "tree:librarian", toolNames: ["ext-scope-read", "ext-scope-set"] },
59 { modeKey: "tree:structure", toolNames: ["ext-scope-read", "ext-scope-set"] },
60 ],
61 };
62}
631export default {
2 name: "land-manager",
3 version: "1.0.2",
4 builtFor: "TreeOS",
5 description:
6 "The administrative AI for the entire land. Registers the land:manager mode, which is the " +
7 "conversation mode activated when an admin navigates to the land root (/). The mode gives " +
8 "the AI a comprehensive set of tools for inspecting and managing the land: land-status for " +
9 "a full overview of extensions, users, trees, and federation peers; land-config-read and " +
10 "land-config-set for reading and writing land configuration; land-users and land-peers for " +
11 "listing users and federated peer lands; and land-system-nodes for inspecting the system " +
12 "node tree (.identity, .config, .peers, .extensions).\n\n" +
13 "Extension management is a core capability. land-ext-list shows all loaded extensions with " +
14 "version numbers and what they provide (routes, tools, jobs, modes). land-ext-search queries " +
15 "the Horizon registry at horizon.treeos.ai for available extensions. land-ext-install " +
16 "downloads an extension from the registry into the extensions directory. land-ext-disable " +
17 "and land-ext-enable toggle extensions on and off. All write operations are admin-only and " +
18 "require a land restart to take effect.\n\n" +
19 "The extension also provides spatial scoping tools (ext-scope-read, ext-scope-set) that are " +
20 "injected into tree modes (librarian and structure) so tree owners can control which extensions " +
21 "are active on their branches. ext-scope-read shows the inheritance chain of blocked and " +
22 "restricted extensions at any node. ext-scope-set writes blocking or restriction rules that " +
23 "inherit to all children. The shell extension's execute-shell tool is included in the land " +
24 "manager mode when available, giving admins direct server access through conversation. Routes " +
25 "expose land-status, land-users, and a chat endpoint for CLI and HTTP access.",
26
27 needs: {
28 models: ["Node", "User"],
29 },
30
31 optional: {
32 extensions: ["shell"],
33 },
34
35 provides: {
36 routes: "./routes.js",
37 tools: "./tools.js",
38 jobs: false,
39
40 cli: [
41 { command: "land-status", scope: ["land"], description: "Show land overview (extensions, users, trees, peers)", method: "GET", endpoint: "/land/status" },
42 { command: "land-users", scope: ["land"], description: "List all users on this land", method: "GET", endpoint: "/land/users" },
43 ],
44 },
45};
461import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import Node from "../../seed/models/node.js";
4import User from "../../seed/models/user.js";
5import log from "../../seed/log.js";
6import { sendOk, sendError, ERR, DELETED } from "../../seed/protocol.js";
7import { getUserMeta } from "../../seed/tree/userMetadata.js";
8
9const router = express.Router();
10
11// GET /land/status - land overview (admin only)
12router.get("/land/status", authenticate, async (req, res) => {
13 try {
14 const user = await User.findById(req.userId).select("isAdmin").lean();
15 if (!user?.isAdmin) {
16 return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
17 }
18
19 const { getLoadedManifests, getLoadedExtensionNames } = await import("../../extensions/loader.js");
20 const { getLandIdentity, getLandUrl } = await import("../../canopy/identity.js");
21
22 const land = getLandIdentity();
23 const loaded = getLoadedExtensionNames();
24 const manifests = getLoadedManifests();
25 const userCount = await User.countDocuments({ isRemote: { $ne: true } });
26 const treeCount = await Node.countDocuments({ rootOwner: { $ne: null }, parent: { $ne: DELETED } });
27
28 let peerCount = 0;
29 try {
30 const LandPeer = (await import("../../canopy/models/landPeer.js")).default;
31 peerCount = await LandPeer.countDocuments();
32 } catch (err) { log.debug("LandManager", "Canopy peer count unavailable:", err.message); }
33
34 sendOk(res, {
35 land: { name: land.name, domain: land.domain, url: getLandUrl() },
36 extensions: { count: loaded.length, list: manifests.map(m => ({ name: m.name, version: m.version })) },
37 stats: { users: userCount, trees: treeCount, peers: peerCount },
38 });
39 } catch (err) {
40 log.error("LandManager", "Status error:", err.message);
41 sendError(res, 500, ERR.INTERNAL, err.message);
42 }
43});
44
45// GET /land/users - list users (admin only)
46router.get("/land/users", authenticate, async (req, res) => {
47 try {
48 const user = await User.findById(req.userId).select("isAdmin").lean();
49 if (!user?.isAdmin) {
50 return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
51 }
52
53 const users = await User.find({ isRemote: { $ne: true } })
54 .select("username isAdmin metadata")
55 .lean();
56
57 sendOk(res, {
58 users: users.map(u => {
59 const nav = getUserMeta(u, "nav");
60 return {
61 username: u.username,
62 isAdmin: u.isAdmin || false,
63 trees: Array.isArray(nav.roots) ? nav.roots.length : 0,
64 };
65 }),
66 });
67 } catch (err) {
68 sendError(res, 500, ERR.INTERNAL, err.message);
69 }
70});
71
72// POST /land/chat - land management chat (admin only)
73// POST /land/chat - land management chat (admin only)
74router.post("/land/chat", authenticate, async (req, res) => {
75 try {
76 const user = await User.findById(req.userId).select("isAdmin username").lean();
77 if (!user?.isAdmin) {
78 return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
79 }
80
81 const { message } = req.body;
82 if (!message) return sendError(res, 400, ERR.INVALID_INPUT, "message required");
83
84 const { runChat } = await import("../../seed/llm/conversation.js");
85
86 const { answer, chatId } = await runChat({
87 userId: req.userId,
88 username: user.username,
89 message,
90 mode: "land:manager",
91 res,
92 });
93
94 sendOk(res, { answer, chatId });
95 } catch (err) {
96 log.error("LandManager", "Chat error:", err.message);
97 sendError(res, 500, ERR.INTERNAL, err.message);
98 }
99});
100
101// ── Extension management endpoints ──
102
103// GET /land/extensions - list loaded extensions
104router.get("/land/extensions", authenticate, async (req, res) => {
105 try {
106 const user = await User.findById(req.userId).select("isAdmin").lean();
107 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
108 const { getLoadedManifests } = await import("../../extensions/loader.js");
109 const { getLandConfigValue } = await import("../../seed/landConfig.js");
110 const manifests = getLoadedManifests();
111 const disabled = getLandConfigValue("disabledExtensions") || [];
112 sendOk(res, { loaded: manifests, count: manifests.length, disabled });
113 } catch (err) {
114 sendError(res, 500, ERR.INTERNAL, err.message);
115 }
116});
117
118// GET /land/extensions/:name - single extension info
119router.get("/land/extensions/:name", authenticate, async (req, res) => {
120 try {
121 const user = await User.findById(req.userId).select("isAdmin").lean();
122 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
123 const { getLoadedManifests } = await import("../../extensions/loader.js");
124 const manifests = getLoadedManifests();
125 const ext = manifests.find(m => m.name === req.params.name);
126 if (!ext) return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, `Extension "${req.params.name}" not found`);
127 sendOk(res, ext);
128 } catch (err) {
129 sendError(res, 500, ERR.INTERNAL, err.message);
130 }
131});
132
133// POST /land/extensions/:name/disable
134router.post("/land/extensions/:name/disable", authenticate, async (req, res) => {
135 try {
136 const user = await User.findById(req.userId).select("isAdmin").lean();
137 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
138 const { getLandConfigValue, setLandConfigValue } = await import("../../seed/landConfig.js");
139 const { hasExtension } = await import("../../extensions/loader.js");
140 const name = req.params.name;
141 if (!hasExtension(name)) {
142 return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, `Extension "${name}" is not loaded`);
143 }
144 const disabled = getLandConfigValue("disabledExtensions") || [];
145 if (!disabled.includes(name)) {
146 disabled.push(name);
147 await setLandConfigValue("disabledExtensions", disabled, { internal: true });
148 }
149 sendOk(res, { disabled: name, message: "Restart the land to apply." });
150 } catch (err) {
151 sendError(res, 500, ERR.INTERNAL, err.message);
152 }
153});
154
155// POST /land/extensions/:name/enable
156router.post("/land/extensions/:name/enable", authenticate, async (req, res) => {
157 try {
158 const user = await User.findById(req.userId).select("isAdmin").lean();
159 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
160 const { getLandConfigValue, setLandConfigValue } = await import("../../seed/landConfig.js");
161 const disabled = getLandConfigValue("disabledExtensions") || [];
162 const name = req.params.name;
163 if (!disabled.includes(name)) {
164 return sendError(res, 400, ERR.INVALID_INPUT, `Extension "${name}" is not disabled`);
165 }
166 const updated = disabled.filter(n => n !== name);
167 await setLandConfigValue("disabledExtensions", updated, { internal: true });
168 sendOk(res, { enabled: name, message: "Restart the land to apply." });
169 } catch (err) {
170 sendError(res, 500, ERR.INTERNAL, err.message);
171 }
172});
173
174export default router;
1751import { z } from "zod";
2import Node from "../../seed/models/node.js";
3import User from "../../seed/models/user.js";
4import log from "../../seed/log.js";
5import { SYSTEM_ROLE, DELETED } from "../../seed/protocol.js";
6import { setLandConfigValue } from "../../seed/landConfig.js";
7
8let _metadata = null;
9export function setMetadata(metadata) { _metadata = metadata; }
10
11async function requireAdmin(userId) {
12 const user = await User.findById(userId).select("isAdmin").lean();
13 return user?.isAdmin === true;
14}
15
16export default function getTools() {
17 return [
18 {
19 name: "land-status",
20 description: "Get land status: loaded extensions, system nodes, config, connected peers, user count, tree count.",
21 schema: {
22 userId: z.string().describe("Injected by server. Ignore."),
23 },
24 annotations: { readOnlyHint: true },
25 async handler({ userId }) {
26 try {
27 const { getLoadedManifests, getLoadedExtensionNames } = await import("../../extensions/loader.js");
28 const { getLandIdentity, getLandUrl } = await import("../../canopy/identity.js");
29
30 const land = getLandIdentity();
31 const loaded = getLoadedExtensionNames();
32 const manifests = getLoadedManifests();
33 const userCount = await User.countDocuments({ isRemote: { $ne: true } });
34 const treeCount = await Node.countDocuments({ rootOwner: { $ne: null }, parent: { $ne: DELETED } });
35
36 let peerCount = 0;
37 try {
38 const LandPeer = (await import("../../canopy/models/landPeer.js")).default;
39 peerCount = await LandPeer.countDocuments();
40 } catch (err) { log.debug("LandManager", "Canopy peer count unavailable:", err.message); }
41
42 const status = {
43 land: { name: land.name, domain: land.domain, url: getLandUrl() },
44 extensions: { count: loaded.length, loaded: manifests.map(m => `${m.name} v${m.version}`).join(", ") },
45 stats: { users: userCount, trees: treeCount, peers: peerCount },
46 };
47
48 return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
49 } catch (err) {
50 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
51 }
52 },
53 },
54
55 {
56 name: "land-config-read",
57 description:
58 "Read land configuration. Shows every config key, its effective value, the default, " +
59 "and whether it has been customized. Omit key for the full system overview.",
60 schema: {
61 key: z.string().optional().describe("Config key to read. Omit for full system config."),
62 userId: z.string().describe("Injected by server. Ignore."),
63 },
64 annotations: { readOnlyHint: true },
65 async handler({ key, userId }) {
66 try {
67 const { getConfigWithDefaults, getLandConfigValue, CONFIG_DEFAULTS } = await import("../../seed/landConfig.js");
68
69 if (key) {
70 const value = getLandConfigValue(key);
71 const def = CONFIG_DEFAULTS[key];
72 const isCustom = value !== null && value !== undefined;
73 return { content: [{ type: "text", text: `${key} = ${JSON.stringify(value ?? def ?? null)}${isCustom ? " (custom)" : " (default)"}` }] };
74 }
75
76 // Full config overview: group by category for readability
77 const full = getConfigWithDefaults();
78 const lines = [];
79 for (const [k, info] of Object.entries(full)) {
80 const val = JSON.stringify(info.value);
81 const tag = info.custom ? "*" : " ";
82 lines.push(`${tag} ${k} = ${val}`);
83 }
84 const header = "Full system configuration (* = custom, space = default):\n";
85 return { content: [{ type: "text", text: header + lines.join("\n") }] };
86 } catch (err) {
87 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
88 }
89 },
90 },
91
92 {
93 name: "land-config-set",
94 description: "Set a land configuration value. God-tier only.",
95 schema: {
96 key: z.string().describe("Config key"),
97 value: z.string().describe("Value to set"),
98 userId: z.string().describe("Injected by server. Ignore."),
99 },
100 annotations: { readOnlyHint: false, destructiveHint: true },
101 async handler({ key, value, userId }) {
102 if (!await requireAdmin(userId)) {
103 return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
104 }
105 try {
106 await setLandConfigValue(key, value);
107 log.info("LandManager", `Config set: ${key} = ${value} (by ${userId})`);
108 return { content: [{ type: "text", text: `Set ${key} = ${value}` }] };
109 } catch (err) {
110 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
111 }
112 },
113 },
114
115 {
116 name: "land-users",
117 description: "List users on this land with their profile type and tree count.",
118 schema: {
119 userId: z.string().describe("Injected by server. Ignore."),
120 },
121 annotations: { readOnlyHint: true },
122 async handler({ userId }) {
123 if (!await requireAdmin(userId)) {
124 return { content: [{ type: "text", text: "Permission denied." }] };
125 }
126 try {
127 const users = await User.find({ isRemote: { $ne: true } }).select("username isAdmin metadata").lean();
128 const { getUserMeta } = await import("../../seed/tree/userMetadata.js");
129 const lines = users.map(u => {
130 const nav = getUserMeta(u, "nav");
131 const treeCount = Array.isArray(nav.roots) ? nav.roots.length : 0;
132 return `${u.username} (${u.isAdmin ? "admin" : "user"}) . ${treeCount} trees`;
133 });
134 return { content: [{ type: "text", text: lines.join("\n") || "No users." }] };
135 } catch (err) {
136 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
137 }
138 },
139 },
140
141 {
142 name: "land-peers",
143 description: "List federated peers connected to this land.",
144 schema: {
145 userId: z.string().describe("Injected by server. Ignore."),
146 },
147 annotations: { readOnlyHint: true },
148 async handler({ userId }) {
149 try {
150 const LandPeer = (await import("../../canopy/models/landPeer.js")).default;
151 const peers = await LandPeer.find().lean();
152 if (!peers.length) return { content: [{ type: "text", text: "No peers." }] };
153 const lines = peers.map(p => `${p.domain} . ${p.status || "unknown"} . last seen ${p.lastSeenAt ? new Date(p.lastSeenAt).toLocaleString() : "never"}`);
154 return { content: [{ type: "text", text: lines.join("\n") }] };
155 } catch (err) {
156 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
157 }
158 },
159 },
160
161 {
162 name: "land-system-nodes",
163 description: "Read the system node tree (.identity, .config, .peers, .extensions).",
164 schema: {
165 userId: z.string().describe("Injected by server. Ignore."),
166 },
167 annotations: { readOnlyHint: true },
168 async handler({ userId }) {
169 try {
170 const systemNodes = await Node.find({ systemRole: { $ne: null } }).select("name systemRole children metadata").lean();
171 const result = systemNodes.map(n => ({
172 name: n.name,
173 role: n.systemRole,
174 children: n.children?.length || 0,
175 metadata: Object.keys(n.metadata instanceof Map ? Object.fromEntries(n.metadata) : (n.metadata || {})),
176 }));
177 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
178 } catch (err) {
179 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
180 }
181 },
182 },
183 // ── Extension Management ──
184
185 {
186 name: "land-ext-list",
187 description: "List ALL loaded extensions with version and what they provide. Always show the complete list. Never truncate.",
188 schema: {
189 userId: z.string().describe("Injected by server. Ignore."),
190 },
191 annotations: { readOnlyHint: true },
192 async handler({ userId }) {
193 try {
194 const { getLoadedManifests, getDisabledExtensions } = await import("../../extensions/loader.js");
195 const manifests = getLoadedManifests();
196 const disabled = getDisabledExtensions?.() || [];
197 // Compact format so the AI doesn't feel pressured to truncate
198 const lines = manifests.map(m => {
199 const parts = [];
200 if (m.provides?.routes) parts.push("R");
201 if (m.provides?.tools) parts.push("T");
202 if (m.provides?.jobs) parts.push("J");
203 if (m.provides?.modes) parts.push("M");
204 const provides = parts.length ? `[${parts.join("")}]` : "";
205 return `${m.name} ${m.version} ${provides}`;
206 });
207 const header = `${manifests.length} extensions loaded${disabled.length ? ` (${disabled.length} disabled: ${disabled.join(", ")})` : ""}:`;
208 return { content: [{ type: "text", text: `${header}\n${lines.join("\n")}` }] };
209 } catch (err) {
210 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
211 }
212 },
213 },
214
215 {
216 name: "land-ext-install",
217 description: "Install an extension from the registry. Downloads files to extensions/ directory. God-tier only. Requires restart.",
218 schema: {
219 name: z.string().describe("Extension name to install"),
220 userId: z.string().describe("Injected by server. Ignore."),
221 },
222 annotations: { readOnlyHint: false, destructiveHint: true },
223 async handler({ name: extName, userId }) {
224 if (!await requireAdmin(userId)) {
225 return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
226 }
227 try {
228 const { installExtension } = await import("../../extensions/loader.js");
229 const result = await installExtension(extName);
230 return { content: [{ type: "text", text: `Installed ${extName} v${result.version || "?"}. Restart the land to load it.` }] };
231 } catch (err) {
232 return { content: [{ type: "text", text: `Install failed: ${err.message}` }] };
233 }
234 },
235 },
236
237 {
238 name: "land-ext-upgrade",
239 description: "Upgrade an installed extension to the latest version from the registry. Compares installed version to registry, downloads if newer. God-tier only. Requires restart.",
240 schema: {
241 name: z.string().describe("Extension name to upgrade"),
242 userId: z.string().describe("Injected by server. Ignore."),
243 },
244 annotations: { readOnlyHint: false, destructiveHint: true },
245 async handler({ name: extName, userId }) {
246 if (!await requireAdmin(userId)) {
247 return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
248 }
249 try {
250 const { getLoadedManifests, installExtension } = await import("../../extensions/loader.js");
251 const manifests = getLoadedManifests();
252 const current = manifests.find(m => m.name === extName);
253 if (!current) {
254 return { content: [{ type: "text", text: `"${extName}" is not installed. Use land-ext-install instead.` }] };
255 }
256
257 const { getLandConfigValue } = await import("../../seed/landConfig.js");
258 const horizonUrl = getLandConfigValue("HORIZON_URL") || process.env.HORIZON_URL || "https://horizon.treeos.ai";
259 const res = await fetch(`${horizonUrl}/extensions/${encodeURIComponent(extName)}`);
260 if (!res.ok) {
261 return { content: [{ type: "text", text: `Registry lookup failed: HTTP ${res.status}` }] };
262 }
263 const data = await res.json();
264 const latest = data.latest || data;
265 if (!latest?.version) {
266 return { content: [{ type: "text", text: `"${extName}" not found in registry.` }] };
267 }
268
269 // Compare versions
270 const pa = String(current.version).match(/^(\d+)\.(\d+)\.(\d+)/);
271 const pb = String(latest.version).match(/^(\d+)\.(\d+)\.(\d+)/);
272 if (pa && pb) {
273 let isNewer = false;
274 for (let i = 1; i <= 3; i++) {
275 if (Number(pb[i]) > Number(pa[i])) { isNewer = true; break; }
276 if (Number(pb[i]) < Number(pa[i])) break;
277 }
278 if (!isNewer) {
279 return { content: [{ type: "text", text: `${extName} is already at v${current.version} (latest: v${latest.version}). No upgrade needed.` }] };
280 }
281 }
282
283 const result = await installExtension(extName, latest.version);
284 return { content: [{ type: "text", text: `Upgraded ${extName}: v${current.version} -> v${result.version}. Restart the land to load the new version.` }] };
285 } catch (err) {
286 return { content: [{ type: "text", text: `Upgrade failed: ${err.message}` }] };
287 }
288 },
289 },
290
291 {
292 name: "land-ext-check",
293 description: "Check all installed extensions against the registry for available updates. Read-only. No changes made.",
294 schema: {
295 userId: z.string().describe("Injected by server. Ignore."),
296 },
297 annotations: { readOnlyHint: true },
298 async handler({ userId }) {
299 try {
300 const { getLoadedManifests } = await import("../../extensions/loader.js");
301 const { getLandConfigValue } = await import("../../seed/landConfig.js");
302 const horizonUrl = getLandConfigValue("HORIZON_URL") || process.env.HORIZON_URL || "https://horizon.treeos.ai";
303 const manifests = getLoadedManifests();
304
305 // Batch fetch: get all extensions from registry
306 let registryExts = [];
307 try {
308 const res = await fetch(`${horizonUrl}/extensions?limit=100`);
309 if (res.ok) {
310 const data = await res.json();
311 registryExts = data.extensions || data || [];
312 }
313 } catch { /* registry unreachable */ }
314
315 if (registryExts.length === 0) {
316 return { content: [{ type: "text", text: "Could not reach the extension registry. Check HORIZON_URL." }] };
317 }
318
319 const registryMap = new Map();
320 for (const ext of registryExts) {
321 if (!registryMap.has(ext.name) || ext.version > registryMap.get(ext.name)) {
322 registryMap.set(ext.name, ext.version);
323 }
324 }
325
326 const updates = [];
327 const current = [];
328 const notInRegistry = [];
329
330 for (const m of manifests) {
331 const registryVersion = registryMap.get(m.name);
332 if (!registryVersion) {
333 notInRegistry.push(m.name);
334 continue;
335 }
336 const pa = String(m.version).match(/^(\d+)\.(\d+)\.(\d+)/);
337 const pb = String(registryVersion).match(/^(\d+)\.(\d+)\.(\d+)/);
338 if (pa && pb) {
339 let isNewer = false;
340 for (let i = 1; i <= 3; i++) {
341 if (Number(pb[i]) > Number(pa[i])) { isNewer = true; break; }
342 if (Number(pb[i]) < Number(pa[i])) break;
343 }
344 if (isNewer) {
345 updates.push({ name: m.name, installed: m.version, available: registryVersion });
346 } else {
347 current.push(m.name);
348 }
349 }
350 }
351
352 if (updates.length === 0) {
353 return { content: [{ type: "text", text: `All ${current.length} registered extensions are up to date.${notInRegistry.length > 0 ? ` ${notInRegistry.length} local-only (not in registry).` : ""}` }] };
354 }
355
356 const lines = updates.map(u => `${u.name}: v${u.installed} -> v${u.available}`);
357 return { content: [{ type: "text", text: `${updates.length} update(s) available:\n${lines.join("\n")}\n\nUse land-ext-upgrade <name> to upgrade.` }] };
358 } catch (err) {
359 return { content: [{ type: "text", text: `Check failed: ${err.message}` }] };
360 }
361 },
362 },
363
364 {
365 name: "land-ext-disable",
366 description: "Disable an extension. It won't load on next restart. Data stays. God-tier only.",
367 schema: {
368 name: z.string().describe("Extension name to disable"),
369 userId: z.string().describe("Injected by server. Ignore."),
370 },
371 annotations: { readOnlyHint: false, destructiveHint: false },
372 async handler({ name: extName, userId }) {
373 if (!await requireAdmin(userId)) {
374 return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
375 }
376 try {
377 const { disableExtension } = await import("../../extensions/loader.js");
378 disableExtension(extName);
379 return { content: [{ type: "text", text: `Disabled ${extName}. Restart to take effect.` }] };
380 } catch (err) {
381 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
382 }
383 },
384 },
385
386 {
387 name: "land-ext-enable",
388 description: "Re-enable a disabled extension. God-tier only. Requires restart.",
389 schema: {
390 name: z.string().describe("Extension name to enable"),
391 userId: z.string().describe("Injected by server. Ignore."),
392 },
393 annotations: { readOnlyHint: false, destructiveHint: false },
394 async handler({ name: extName, userId }) {
395 if (!await requireAdmin(userId)) {
396 return { content: [{ type: "text", text: "Permission denied. Requires god-tier." }] };
397 }
398 try {
399 const { enableExtension } = await import("../../extensions/loader.js");
400 enableExtension(extName);
401 return { content: [{ type: "text", text: `Enabled ${extName}. Restart to take effect.` }] };
402 } catch (err) {
403 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
404 }
405 },
406 },
407
408 {
409 name: "land-ext-search",
410 description: "Search the extension registry for available extensions to install.",
411 schema: {
412 query: z.string().optional().describe("Search query. Omit for all."),
413 userId: z.string().describe("Injected by server. Ignore."),
414 },
415 annotations: { readOnlyHint: true },
416 async handler({ query, userId }) {
417 try {
418 const { getLandConfigValue } = await import("../../seed/landConfig.js");
419 const horizonUrl = getLandConfigValue("HORIZON_URL") || "https://horizon.treeos.ai";
420 const url = query ? `${horizonUrl}/extensions?q=${encodeURIComponent(query)}` : `${horizonUrl}/extensions`;
421 const res = await fetch(url);
422 const data = await res.json();
423 const exts = data.extensions || data || [];
424 if (!exts.length) return { content: [{ type: "text", text: "No extensions found." }] };
425 const lines = exts.map(e => `${e.name} v${e.version} . ${e.description || ""}`);
426 return { content: [{ type: "text", text: lines.join("\n") }] };
427 } catch (err) {
428 return { content: [{ type: "text", text: `Registry error: ${err.message}` }] };
429 }
430 },
431 },
432 // ── Extension Scoping Tools ──
433
434 {
435 name: "ext-scope-read",
436 description: "Show which extensions are blocked or restricted at a node. Returns active, blocked, restricted, and inheritance chain.",
437 schema: {
438 nodeId: z.string().describe("Node ID to check extension scope at"),
439 userId: z.string().describe("Injected by server. Ignore."),
440 },
441 annotations: { readOnlyHint: true },
442 async handler({ nodeId, userId }) {
443 try {
444 const { getBlockedExtensionsAtNode } = await import("../../seed/tree/extensionScope.js");
445 const { getLoadedExtensionNames } = await import("../../extensions/loader.js");
446 const { blocked, restricted } = await getBlockedExtensionsAtNode(nodeId);
447 const installed = getLoadedExtensionNames();
448 const active = installed.filter(e => !blocked.has(e));
449 const restrictedObj = Object.fromEntries(restricted);
450 return { content: [{ type: "text", text: JSON.stringify({ nodeId, active, blocked: [...blocked], restricted: restrictedObj, installed }, null, 2) }] };
451 } catch (err) {
452 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
453 }
454 },
455 },
456
457 {
458 name: "ext-scope-set",
459 description: "Block or restrict extensions at a node. Inherits to all children. Use to control what extensions can do on specific branches.",
460 schema: {
461 nodeId: z.string().describe("Node ID to set extension scope on"),
462 blocked: z.array(z.string()).optional().describe("Extensions to fully block (no tools, hooks, modes, metadata)"),
463 restricted: z.record(z.string(), z.string()).optional().describe("Extensions to restrict. e.g. { \"food\": \"read\" } for read-only tools"),
464 userId: z.string().describe("Injected by server. Ignore."),
465 },
466 annotations: { readOnlyHint: false, destructiveHint: false },
467 async handler({ nodeId, blocked, restricted, userId }) {
468 try {
469 const node = await Node.findById(nodeId);
470 if (!node) return { content: [{ type: "text", text: "Node not found" }] };
471
472 const { clearScopeCache } = await import("../../seed/tree/extensionScope.js");
473
474 const config = {};
475 if (blocked?.length) config.blocked = blocked;
476 if (restricted && Object.keys(restricted).length) config.restricted = restricted;
477
478 if (Object.keys(config).length === 0) {
479 await _metadata.setExtMeta(node, "extensions", null);
480 } else {
481 await _metadata.setExtMeta(node, "extensions", config);
482 }
483 clearScopeCache();
484
485 return { content: [{ type: "text", text: `Extension scope updated on "${node.name}". ${config.blocked?.length ? `Blocked: ${config.blocked.join(", ")}. ` : ""}${config.restricted ? `Restricted: ${JSON.stringify(config.restricted)}. ` : ""}Inherits to all children.` }] };
486 } catch (err) {
487 return { content: [{ type: "text", text: `Error: ${err.message}` }] };
488 }
489 },
490 },
491 ];
492}
493
treeos ext star land-manager
Post comments from the CLI: treeos ext comment land-manager "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...