@agentprojectcontext/apx 1.59.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_asana.js +34 -0
  3. package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
  4. package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
  5. package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
  6. package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
  7. package/src/core/agent/tools/names.js +10 -0
  8. package/src/core/agent/tools/registry.js +11 -0
  9. package/src/core/integrations/catalog.js +66 -0
  10. package/src/core/integrations/index.js +10 -0
  11. package/src/core/integrations/plugins/asana.js +231 -0
  12. package/src/core/integrations/sources.js +56 -0
  13. package/src/core/integrations/store.js +118 -0
  14. package/src/host/daemon/api/integrations.js +191 -0
  15. package/src/host/daemon/api.js +2 -0
  16. package/src/interfaces/web/dist/assets/{index-CnQb4N6C.js → index-DFNV6BWh.js} +193 -163
  17. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +1 -0
  18. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
  19. package/src/interfaces/web/dist/index.html +2 -2
  20. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
  21. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
  22. package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
  23. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
  24. package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
  25. package/src/interfaces/web/src/lib/api.ts +1 -0
  26. package/src/interfaces/web/src/screens/ProjectScreen.tsx +6 -2
  27. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
  28. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +0 -1
  29. package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +0 -1
@@ -0,0 +1,118 @@
1
+ // IntegrationStore: CRUD over one project's integrations.json, plus the
2
+ // cross-scope resolution used by agent tools. Pure filesystem + data logic —
3
+ // no Express, no plugin HTTP calls (those live in ./plugins/*). This keeps the
4
+ // storage layer testable in isolation (see tests/integrations.test.js).
5
+ import {
6
+ readIntegrations,
7
+ writeIntegrations,
8
+ defaultIntegrationsStorage,
9
+ } from "./sources.js";
10
+
11
+ // Keys inside `config` that hold secrets — never sent to the web UI in clear.
12
+ const SECRET_KEYS = new Set([
13
+ "personal_access_token",
14
+ "token",
15
+ "api_key",
16
+ "client_secret",
17
+ ]);
18
+
19
+ // Return a shallow copy of a record with secret config values masked, safe to
20
+ // send to the browser. A present secret becomes `true` under `config.<key>_set`
21
+ // so the UI can show "configured" without leaking the value.
22
+ export function redactRecord(record) {
23
+ if (!record) return record;
24
+ const config = { ...(record.config || {}) };
25
+ for (const key of Object.keys(config)) {
26
+ if (SECRET_KEYS.has(key)) {
27
+ const hasValue = typeof config[key] === "string" && config[key].length > 0;
28
+ delete config[key];
29
+ config[`${key}_set`] = hasValue;
30
+ }
31
+ }
32
+ return { ...record, config };
33
+ }
34
+
35
+ export class IntegrationStore {
36
+ // `storagePath` is a project's ~/.apx/projects/<apxId>/ directory.
37
+ constructor(storagePath) {
38
+ if (!storagePath) throw new Error("IntegrationStore: storagePath required");
39
+ this.storagePath = storagePath;
40
+ }
41
+
42
+ list() {
43
+ return readIntegrations(this.storagePath);
44
+ }
45
+
46
+ get(slug) {
47
+ return this.list().find((r) => r.slug === slug) || null;
48
+ }
49
+
50
+ // Insert or merge a record by slug. `patch` is shallow-merged; `patch.config`
51
+ // is deep-merged one level so callers can update a single config key without
52
+ // clobbering the token. Returns the persisted record.
53
+ upsert(slug, patch = {}) {
54
+ const entries = this.list();
55
+ const idx = entries.findIndex((r) => r.slug === slug);
56
+ const now = new Date().toISOString();
57
+ if (idx === -1) {
58
+ const record = {
59
+ slug,
60
+ name: patch.name || slug,
61
+ type: patch.type || "custom",
62
+ description: patch.description || "",
63
+ source: patch.source || "builtin",
64
+ status: patch.status || "disconnected",
65
+ is_enabled: patch.is_enabled ?? false,
66
+ config: patch.config || {},
67
+ created_at: now,
68
+ updated_at: now,
69
+ };
70
+ entries.push(record);
71
+ writeIntegrations(this.storagePath, entries);
72
+ return record;
73
+ }
74
+ const prev = entries[idx];
75
+ const merged = {
76
+ ...prev,
77
+ ...patch,
78
+ config: { ...(prev.config || {}), ...(patch.config || {}) },
79
+ updated_at: now,
80
+ };
81
+ entries[idx] = merged;
82
+ writeIntegrations(this.storagePath, entries);
83
+ return merged;
84
+ }
85
+
86
+ remove(slug) {
87
+ const entries = this.list();
88
+ const next = entries.filter((r) => r.slug !== slug);
89
+ if (next.length === entries.length) return false;
90
+ writeIntegrations(this.storagePath, next);
91
+ return true;
92
+ }
93
+ }
94
+
95
+ // Resolve the effective integration for `slug` in a project, applying the
96
+ // project→default precedence: a project's OWN enabled record wins; otherwise
97
+ // the default project's enabled record is used. Returns { record, scope } or
98
+ // null when neither is usable. `defaultStorage` defaults to the default
99
+ // project's storage so callers only need the current project's storagePath.
100
+ export function resolveIntegration({
101
+ projectStorage,
102
+ slug,
103
+ defaultStorage = defaultIntegrationsStorage(),
104
+ requireEnabled = true,
105
+ }) {
106
+ const usable = (record) =>
107
+ !!record && (!requireEnabled || (record.is_enabled && record.status === "active"));
108
+
109
+ if (projectStorage) {
110
+ const own = new IntegrationStore(projectStorage).get(slug);
111
+ if (usable(own)) return { record: own, scope: "project", storagePath: projectStorage };
112
+ }
113
+ if (defaultStorage && defaultStorage !== projectStorage) {
114
+ const fallback = new IntegrationStore(defaultStorage).get(slug);
115
+ if (usable(fallback)) return { record: fallback, scope: "global", storagePath: defaultStorage };
116
+ }
117
+ return null;
118
+ }
@@ -0,0 +1,191 @@
1
+ // Integration plugins per project. Companion to api/mcps.js: MCP servers are
2
+ // raw tool endpoints; integrations are higher-level plugins (Asana today) that
3
+ // own a credential + lifecycle and expose named tools to agents.
4
+ //
5
+ // Scoping (see core/integrations/store.js): every record lives in one project's
6
+ // integrations.json. `?scope=global` targets the DEFAULT project's store, so
7
+ // "global" integrations are literally the default project's — a project without
8
+ // its own record falls back to that one. This keeps "which Asana runs here?"
9
+ // unambiguous when you have both a base Asana and a per-project Asana.
10
+ //
11
+ // GET /projects/:pid/integrations?scope=project|global list stored (redacted)
12
+ // GET /projects/:pid/integrations/catalog roster + resolved status
13
+ // GET /projects/:pid/integrations/:slug?scope=… one plugin status
14
+ // POST /projects/:pid/integrations/:slug/configure?scope=… save credentials
15
+ // POST /projects/:pid/integrations/:slug/validate?scope=… verify against provider
16
+ // POST /projects/:pid/integrations/:slug/deactivate?scope=… disable
17
+ // POST /projects/:pid/integrations/:slug/action/:action?scope=… plugin read action
18
+ // DELETE /projects/:pid/integrations/:slug?scope=… remove
19
+ import {
20
+ IntegrationStore,
21
+ resolveIntegration,
22
+ redactRecord,
23
+ defaultIntegrationsStorage,
24
+ listCatalog,
25
+ getPluginService,
26
+ } from "#core/integrations/index.js";
27
+
28
+ function normalizeScope(raw) {
29
+ if (!raw) return "project";
30
+ const s = String(raw).toLowerCase();
31
+ if (s === "global" || s === "default") return "global";
32
+ if (s === "project" || s === "shared" || s === "runtime") return "project";
33
+ return null;
34
+ }
35
+
36
+ // Resolve the storagePath for the requested scope. `global` → default project
37
+ // store; `project` → the current project's store.
38
+ function storagePathForScope(scope, p, projects) {
39
+ if (scope === "global") {
40
+ const base = projects.get(0);
41
+ return base?.storagePath || defaultIntegrationsStorage();
42
+ }
43
+ return p.storagePath || null;
44
+ }
45
+
46
+ export function register(app, { projects, project }) {
47
+ // List stored integrations in the chosen scope (secrets redacted).
48
+ app.get("/projects/:pid/integrations", (req, res) => {
49
+ const p = project(req, res);
50
+ if (!p) return;
51
+ const scope = normalizeScope(req.query?.scope);
52
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
53
+ const storagePath = storagePathForScope(scope, p, projects);
54
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
55
+ const records = new IntegrationStore(storagePath).list().map(redactRecord);
56
+ res.json(records);
57
+ });
58
+
59
+ // The full plugin roster with each plugin's resolved status for this project
60
+ // (project record wins over the default/global one).
61
+ app.get("/projects/:pid/integrations/catalog", (req, res) => {
62
+ const p = project(req, res);
63
+ if (!p) return;
64
+ const catalog = listCatalog().map((entry) => {
65
+ const svc = getPluginService(entry.slug);
66
+ let status = { slug: entry.slug, status: "disconnected", is_enabled: false };
67
+ let scope = null;
68
+ if (svc) {
69
+ const resolved = resolveIntegration({
70
+ projectStorage: p.storagePath,
71
+ slug: entry.slug,
72
+ requireEnabled: false,
73
+ });
74
+ status = svc.status(resolved?.record || null);
75
+ scope = resolved?.scope || null;
76
+ }
77
+ return { ...entry, status, resolved_scope: scope };
78
+ });
79
+ res.json(catalog);
80
+ });
81
+
82
+ // Status for a single plugin in the chosen scope.
83
+ app.get("/projects/:pid/integrations/:slug", (req, res) => {
84
+ const p = project(req, res);
85
+ if (!p) return;
86
+ const svc = getPluginService(req.params.slug);
87
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
88
+ const scope = normalizeScope(req.query?.scope);
89
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
90
+ const storagePath = storagePathForScope(scope, p, projects);
91
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
92
+ const record = new IntegrationStore(storagePath).get(req.params.slug);
93
+ res.json(svc.status(record));
94
+ });
95
+
96
+ // Save credentials / config. Creates the record if missing.
97
+ app.post("/projects/:pid/integrations/:slug/configure", (req, res) => {
98
+ const p = project(req, res);
99
+ if (!p) return;
100
+ const svc = getPluginService(req.params.slug);
101
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
102
+ const scope = normalizeScope(req.query?.scope);
103
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
104
+ const storagePath = storagePathForScope(scope, p, projects);
105
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
106
+ const store = new IntegrationStore(storagePath);
107
+ try {
108
+ const { patch } = svc.configure(store.get(req.params.slug), req.body || {});
109
+ const record = store.upsert(req.params.slug, patch);
110
+ res.status(201).json(redactRecord(record));
111
+ } catch (e) {
112
+ res.status(400).json({ error: e.message });
113
+ }
114
+ });
115
+
116
+ // Verify the stored credentials against the provider, then persist the result.
117
+ app.post("/projects/:pid/integrations/:slug/validate", async (req, res) => {
118
+ const p = project(req, res);
119
+ if (!p) return;
120
+ const svc = getPluginService(req.params.slug);
121
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
122
+ const scope = normalizeScope(req.query?.scope);
123
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
124
+ const storagePath = storagePathForScope(scope, p, projects);
125
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
126
+ const store = new IntegrationStore(storagePath);
127
+ const record = store.get(req.params.slug);
128
+ if (!record) return res.status(404).json({ error: "integration not configured" });
129
+ try {
130
+ const { patch, result } = await svc.validate(record);
131
+ store.upsert(req.params.slug, patch);
132
+ if (result && result.ok === false) return res.status(400).json(result);
133
+ res.json(result);
134
+ } catch (e) {
135
+ res.status(400).json({ error: e.message });
136
+ }
137
+ });
138
+
139
+ // Disable a plugin without deleting its stored credentials.
140
+ app.post("/projects/:pid/integrations/:slug/deactivate", (req, res) => {
141
+ const p = project(req, res);
142
+ if (!p) return;
143
+ const svc = getPluginService(req.params.slug);
144
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
145
+ const scope = normalizeScope(req.query?.scope);
146
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
147
+ const storagePath = storagePathForScope(scope, p, projects);
148
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
149
+ const store = new IntegrationStore(storagePath);
150
+ if (!store.get(req.params.slug)) return res.status(404).json({ error: "integration not configured" });
151
+ const { patch } = svc.deactivate(store.get(req.params.slug));
152
+ const record = store.upsert(req.params.slug, patch);
153
+ res.json(svc.status(record));
154
+ });
155
+
156
+ // Plugin-specific read action (e.g. Asana → list workspaces for the token).
157
+ app.post("/projects/:pid/integrations/:slug/action/:action", async (req, res) => {
158
+ const p = project(req, res);
159
+ if (!p) return;
160
+ const svc = getPluginService(req.params.slug);
161
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
162
+ const fn = svc.actions?.[req.params.action];
163
+ if (typeof fn !== "function") {
164
+ return res.status(404).json({ error: `unknown action "${req.params.action}"` });
165
+ }
166
+ const scope = normalizeScope(req.query?.scope);
167
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
168
+ const storagePath = storagePathForScope(scope, p, projects);
169
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
170
+ const record = new IntegrationStore(storagePath).get(req.params.slug);
171
+ if (!record) return res.status(404).json({ error: "integration not configured" });
172
+ try {
173
+ res.json(await fn.call(svc.actions, record));
174
+ } catch (e) {
175
+ res.status(400).json({ error: e.message });
176
+ }
177
+ });
178
+
179
+ // Remove a stored integration entirely.
180
+ app.delete("/projects/:pid/integrations/:slug", (req, res) => {
181
+ const p = project(req, res);
182
+ if (!p) return;
183
+ const scope = normalizeScope(req.query?.scope);
184
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
185
+ const storagePath = storagePathForScope(scope, p, projects);
186
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
187
+ const removed = new IntegrationStore(storagePath).remove(req.params.slug);
188
+ if (!removed) return res.status(404).end();
189
+ res.status(204).end();
190
+ });
191
+ }
@@ -18,6 +18,7 @@ import { register as registerProjects } from "./api/projects.js";
18
18
  import { register as registerAgents } from "./api/agents.js";
19
19
  import { register as registerSessions } from "./api/sessions.js";
20
20
  import { register as registerMcps } from "./api/mcps.js";
21
+ import { register as registerIntegrations } from "./api/integrations.js";
21
22
  import { register as registerVars } from "./api/vars.js";
22
23
  import { register as registerMessages } from "./api/messages.js";
23
24
  import { register as registerTelegram } from "./api/telegram.js";
@@ -109,6 +110,7 @@ export function buildApi({
109
110
  registerAgents(app, ctx);
110
111
  registerSessions(app, ctx);
111
112
  registerMcps(app, ctx);
113
+ registerIntegrations(app, ctx);
112
114
  registerVars(app, ctx);
113
115
  registerMessages(app, ctx);
114
116
  registerEngines(app, ctx);