@agentprojectcontext/apx 1.60.1 → 1.61.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.60.1",
3
+ "version": "1.61.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,22 @@
1
+ // Shared helper for the GitHub agent tools (github-*.js). Underscore file — no
2
+ // tool `name:` of its own. Resolves the project's effective GitHub integration
3
+ // (its own record wins over the default project's) and hands back the token.
4
+ import { resolveProject } from "../helpers.js";
5
+ import { resolveIntegration } from "#core/integrations/index.js";
6
+
7
+ export function resolveGithub(projects, project) {
8
+ const p = resolveProject(projects, project);
9
+ const resolved = resolveIntegration({ projectStorage: p.storagePath, slug: "github" });
10
+ if (!resolved) {
11
+ throw new Error(
12
+ "GitHub is not connected for this project. Ask the user to connect it in the web panel → Integrations → Plugins → GitHub.",
13
+ );
14
+ }
15
+ const token = resolved.record.config?.token;
16
+ if (!token) throw new Error("GitHub integration has no token configured");
17
+ return { token, scope: resolved.scope };
18
+ }
19
+
20
+ export const PROJECT_ARG = {
21
+ project: { type: "string", description: "APX project id/name (optional; defaults to current)" },
22
+ };
@@ -0,0 +1,30 @@
1
+ import * as github from "#core/integrations/plugins/github.js";
2
+ import { resolveGithub, PROJECT_ARG } from "./_github.js";
3
+
4
+ export default {
5
+ name: "github_create_issue",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "github_create_issue",
11
+ description: "Open an issue in a GitHub repository.",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ owner: { type: "string", description: "Repo owner (user or org)" },
16
+ repo: { type: "string", description: "Repo name" },
17
+ title: { type: "string", description: "Issue title" },
18
+ body: { type: "string", description: "Issue body (markdown)" },
19
+ ...PROJECT_ARG,
20
+ },
21
+ required: ["owner", "repo", "title"],
22
+ },
23
+ },
24
+ },
25
+ makeHandler: ({ projects }) => async ({ project, owner, repo, title, body } = {}) => {
26
+ const { token } = resolveGithub(projects, project);
27
+ const issue = await github.createIssue(token, { owner, repo, title, body });
28
+ return { issue: { number: issue.number, url: issue.html_url, title: issue.title } };
29
+ },
30
+ };
@@ -0,0 +1,20 @@
1
+ import * as github from "#core/integrations/plugins/github.js";
2
+ import { resolveGithub, PROJECT_ARG } from "./_github.js";
3
+
4
+ export default {
5
+ name: "github_list_repos",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "github_list_repos",
11
+ description: "List GitHub repositories accessible to the connected token.",
12
+ parameters: { type: "object", properties: { ...PROJECT_ARG } },
13
+ },
14
+ },
15
+ makeHandler: ({ projects }) => async ({ project } = {}) => {
16
+ const { token } = resolveGithub(projects, project);
17
+ const repos = await github.listRepos(token);
18
+ return { repos: repos.map((r) => ({ full_name: r.full_name, private: r.private, url: r.html_url, description: r.description })) };
19
+ },
20
+ };
@@ -56,6 +56,10 @@ export const TOOLS = Object.freeze({
56
56
  ASANA_CREATE_TASK: "asana_create_task",
57
57
  ASANA_UPDATE_TASK: "asana_update_task",
58
58
 
59
+ // Integrations — GitHub plugin (see core/integrations/plugins/github.js)
60
+ GITHUB_LIST_REPOS: "github_list_repos",
61
+ GITHUB_CREATE_ISSUE: "github_create_issue",
62
+
59
63
  // Side-effects
60
64
  SEND_TELEGRAM: "send_telegram",
61
65
  SET_IDENTITY: "set_identity",
@@ -104,6 +108,8 @@ export const NATIVE_TOOL_NAMES = new Set([
104
108
  TOOLS.ASANA_LIST_TASKS,
105
109
  TOOLS.ASANA_CREATE_TASK,
106
110
  TOOLS.ASANA_UPDATE_TASK,
111
+ TOOLS.GITHUB_LIST_REPOS,
112
+ TOOLS.GITHUB_CREATE_ISSUE,
107
113
  TOOLS.SEND_TELEGRAM,
108
114
  TOOLS.SET_IDENTITY,
109
115
  TOOLS.SET_PERMISSION_MODE,
@@ -37,6 +37,8 @@ import asanaListProjects from "./handlers/asana-list-projects.js";
37
37
  import asanaListTasks from "./handlers/asana-list-tasks.js";
38
38
  import asanaCreateTask from "./handlers/asana-create-task.js";
39
39
  import asanaUpdateTask from "./handlers/asana-update-task.js";
40
+ import githubListRepos from "./handlers/github-list-repos.js";
41
+ import githubCreateIssue from "./handlers/github-create-issue.js";
40
42
  import { createPermissionGuard } from "./helpers.js";
41
43
  import { buildBridgedTools, DEFAULT_CATEGORIES } from "./registry-bridge.js";
42
44
  import { TOOLS, CODE_CHANNEL_TOOLS } from "./names.js";
@@ -85,6 +87,8 @@ const NATIVE_TOOLS = [
85
87
  asanaListTasks,
86
88
  asanaCreateTask,
87
89
  asanaUpdateTask,
90
+ githubListRepos,
91
+ githubCreateIssue,
88
92
  ];
89
93
 
90
94
  // Registry-backed bridges. Categories can be overridden per-process via env
@@ -2,32 +2,27 @@
2
2
  // which are wired end-to-end. The daemon API + web "Plugins" tab read this.
3
3
  //
4
4
  // A plugin is "implemented" when it has a service module in ./plugins/ that
5
- // satisfies the lifecycle contract (configure/validate/status/deactivate). The
6
- // rest are declared here with `coming_soon: true` so the Integrations page shows
7
- // the full roster (matching PandaProject) while remaining honest about what is
8
- // actually connectable. Adding a new plugin = drop a module in ./plugins/ and
9
- // register it in PLUGIN_SERVICES — no API or route changes (open/closed).
5
+ // satisfies the lifecycle contract (configure/validate/status/deactivate) and
6
+ // declares a `ui` descriptor the generic PluginConnect component renders. The
7
+ // rest carry `coming_soon: true`. Adding a plugin = drop a module in ./plugins/
8
+ // + register it in PLUGIN_SERVICES (open/closed) no API/route changes.
9
+ //
10
+ // Scope of this catalog (per product decision): Asana, GitHub, WhatsApp only.
11
+ // Telegram is intentionally absent — it's a channel, configured under its own
12
+ // surface, not a service plugin. Transcription lives with the desktop STT stack.
10
13
  import { asanaPlugin } from "./plugins/asana.js";
14
+ import { githubPlugin } from "./plugins/github.js";
11
15
 
12
16
  // slug -> live plugin service (must implement the lifecycle contract).
13
17
  export const PLUGIN_SERVICES = Object.freeze({
14
18
  asana: asanaPlugin,
19
+ github: githubPlugin,
15
20
  });
16
21
 
17
- // Static descriptors for the catalog UI. Implemented plugins derive their
18
- // metadata from the service module; coming-soon ones are declared inline.
19
- // `coming_soon` entries in PandaProject depend on domain/channel infrastructure
20
- // (GitHub outputs↔repos, a WhatsApp-Web bridge, Telegram-voice whisper) that
21
- // APX models differently — they are placeholders until ported natively.
22
+ // Static descriptors for plugins that are declared but not yet connectable.
23
+ // WhatsApp needs a WhatsApp-Web bridge (QR pairing) that APX doesn't ship yet,
24
+ // so it stays coming-soon rather than pretending to connect.
22
25
  const COMING_SOON = [
23
- {
24
- slug: "github",
25
- name: "GitHub",
26
- type: "source_control",
27
- description: "Vinculá repos, issues y PRs para que los agentes trabajen sobre tu código",
28
- auth: "token",
29
- coming_soon: true,
30
- },
31
26
  {
32
27
  slug: "whatsapp",
33
28
  name: "WhatsApp",
@@ -36,17 +31,11 @@ const COMING_SOON = [
36
31
  auth: "qr",
37
32
  coming_soon: true,
38
33
  },
39
- {
40
- slug: "local-transcription",
41
- name: "Transcripción Local",
42
- type: "transcription",
43
- description: "Transcripción de audio con faster-whisper local — sin depender de una API externa",
44
- auth: "none",
45
- coming_soon: true,
46
- },
47
34
  ];
48
35
 
49
- // The full catalog: implemented plugins first, then coming-soon.
36
+ // The full catalog: implemented plugins first, then coming-soon. Implemented
37
+ // entries carry their `ui` descriptor + tools so the generic component can
38
+ // render their config form.
50
39
  export function listCatalog() {
51
40
  const implemented = Object.values(PLUGIN_SERVICES).map((p) => ({
52
41
  slug: p.slug,
@@ -55,6 +44,7 @@ export function listCatalog() {
55
44
  description: p.description,
56
45
  auth: p.auth,
57
46
  tools: p.tools || [],
47
+ ui: p.ui || null,
58
48
  coming_soon: false,
59
49
  }));
60
50
  return [...implemented, ...COMING_SOON];
@@ -142,6 +142,48 @@ export const asanaPlugin = {
142
142
  { slug: "asana_update_task", desc: "Actualizar estado o campos de una tarea" },
143
143
  ],
144
144
 
145
+ // Declarative UI descriptor consumed by the generic PluginConnect component
146
+ // (see web/components/integrations/PluginConnect.tsx). configFields render as
147
+ // inputs; `select` is a post-validate picker sourced from an action; and
148
+ // connectedFields are the status keys shown once connected.
149
+ ui: {
150
+ accent: "rose",
151
+ configFields: [
152
+ {
153
+ key: "personal_access_token",
154
+ label: "Personal Access Token",
155
+ type: "password",
156
+ placeholder: "1/1234567890abcdef:...",
157
+ help: {
158
+ label: "¿Cómo obtener el token?",
159
+ url: "https://app.asana.com/0/my-apps",
160
+ urlLabel: "app.asana.com/0/my-apps",
161
+ steps: [
162
+ "Abrí app.asana.com/0/my-apps en el navegador.",
163
+ 'Bajá hasta la sección "Personal access tokens" (no tus apps OAuth).',
164
+ 'Hacé clic en "+ New access token".',
165
+ "Dale un nombre y confirmá.",
166
+ 'Copiá el token completo — empieza con "1/..." y tiene un ":" en el medio.',
167
+ "Pegalo en el campo de abajo.",
168
+ ],
169
+ },
170
+ },
171
+ ],
172
+ select: {
173
+ key: "workspace_gid",
174
+ label: "Seleccioná el workspace a usar",
175
+ action: "workspaces",
176
+ listKey: "workspaces",
177
+ valueKey: "gid",
178
+ labelKey: "name",
179
+ },
180
+ connectedFields: [
181
+ { key: "user_name", label: "Conectado como" },
182
+ { key: "user_email", label: "Email" },
183
+ { key: "workspace_name", label: "Workspace" },
184
+ ],
185
+ },
186
+
145
187
  // Save the PAT and/or the target workspace. Returns a patch to persist.
146
188
  configure(record, body = {}) {
147
189
  const pat = (body.personal_access_token || "").trim();
@@ -0,0 +1,172 @@
1
+ // GitHub integration plugin — Personal Access Token auth. Same shape as the
2
+ // Asana plugin: a pure REST client + a lifecycle descriptor the daemon API
3
+ // dispatches to. Self-contained (GitHub REST over fetch); no GitHub App / OAuth
4
+ // broker yet — a PAT is enough to connect, list repos and open issues.
5
+
6
+ const GITHUB_API_BASE = "https://api.github.com";
7
+ const REQUEST_TIMEOUT_MS = 15_000;
8
+
9
+ function headers(token) {
10
+ return {
11
+ Authorization: `Bearer ${token}`,
12
+ Accept: "application/vnd.github+json",
13
+ "X-GitHub-Api-Version": "2022-11-28",
14
+ "User-Agent": "apx-integrations",
15
+ };
16
+ }
17
+
18
+ async function request(token, method, apiPath, { params, payload } = {}) {
19
+ let url = `${GITHUB_API_BASE}${apiPath}`;
20
+ if (params) {
21
+ const qs = new URLSearchParams();
22
+ for (const [k, v] of Object.entries(params)) {
23
+ if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
24
+ }
25
+ const s = qs.toString();
26
+ if (s) url += `?${s}`;
27
+ }
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
30
+ let res;
31
+ try {
32
+ res = await fetch(url, {
33
+ method,
34
+ headers: headers(token),
35
+ body: payload !== undefined ? JSON.stringify(payload) : undefined,
36
+ signal: controller.signal,
37
+ });
38
+ } catch (e) {
39
+ clearTimeout(timer);
40
+ if (e.name === "AbortError") throw new Error("GitHub request timed out");
41
+ throw new Error(`GitHub request failed: ${e.message}`);
42
+ }
43
+ clearTimeout(timer);
44
+ if (!res.ok) {
45
+ let detail = `${res.status}`;
46
+ try {
47
+ const body = await res.json();
48
+ detail = body?.message || JSON.stringify(body);
49
+ } catch {
50
+ /* non-JSON error body */
51
+ }
52
+ throw new Error(`GitHub API ${res.status}: ${detail}`);
53
+ }
54
+ return res.json();
55
+ }
56
+
57
+ // ─── REST client ──────────────────────────────────────────────────────────────
58
+
59
+ export async function validateToken(token) {
60
+ const user = await request(token, "GET", "/user");
61
+ if (!user?.login) throw new Error("GitHub returned an empty user response");
62
+ return user;
63
+ }
64
+
65
+ export async function listRepos(token, { affiliation = "owner,collaborator,organization_member", perPage = 30 } = {}) {
66
+ return request(token, "GET", "/user/repos", {
67
+ params: { affiliation, per_page: perPage, sort: "updated" },
68
+ });
69
+ }
70
+
71
+ export async function createIssue(token, { owner, repo, title, body }) {
72
+ return request(token, "POST", `/repos/${owner}/${repo}/issues`, { payload: { title, body } });
73
+ }
74
+
75
+ // ─── Plugin descriptor + lifecycle ────────────────────────────────────────────
76
+
77
+ function safeToken(record) {
78
+ const token = record?.config?.token || "";
79
+ if (!token) throw new Error("GitHub token not configured");
80
+ return token;
81
+ }
82
+
83
+ export const githubPlugin = {
84
+ slug: "github",
85
+ name: "GitHub",
86
+ type: "source_control",
87
+ description: "Conectá GitHub con un token para que los agentes listen repos y abran issues",
88
+ auth: "token",
89
+ tools: [
90
+ { slug: "github_list_repos", desc: "Listar repositorios accesibles" },
91
+ { slug: "github_create_issue", desc: "Crear un issue en un repo" },
92
+ ],
93
+ ui: {
94
+ accent: "slate",
95
+ configFields: [
96
+ {
97
+ key: "token",
98
+ label: "Personal Access Token",
99
+ type: "password",
100
+ placeholder: "ghp_... o github_pat_...",
101
+ help: {
102
+ label: "¿Cómo obtener el token?",
103
+ url: "https://github.com/settings/tokens",
104
+ urlLabel: "github.com/settings/tokens",
105
+ steps: [
106
+ "Abrí github.com/settings/tokens.",
107
+ 'Generá un token (classic o fine-grained) con scope "repo".',
108
+ "Copiá el token — empieza con ghp_ o github_pat_.",
109
+ "Pegalo en el campo de abajo.",
110
+ ],
111
+ },
112
+ },
113
+ ],
114
+ connectedFields: [
115
+ { key: "user_login", label: "Conectado como" },
116
+ { key: "user_name", label: "Nombre" },
117
+ ],
118
+ },
119
+
120
+ configure(record, body = {}) {
121
+ const token = (body.token || "").trim();
122
+ if (!token && !record) throw new Error("Provide a GitHub token");
123
+ const config = {};
124
+ if (token) config.token = token;
125
+ const patch = { name: "GitHub", type: this.type, description: this.description, config };
126
+ if (token) patch.status = "pending_validation";
127
+ return { patch };
128
+ },
129
+
130
+ async validate(record) {
131
+ const token = safeToken(record);
132
+ let user;
133
+ try {
134
+ user = await validateToken(token);
135
+ } catch (e) {
136
+ return {
137
+ patch: { status: "error", is_enabled: false, config: { last_error: String(e.message || e) } },
138
+ result: { ok: false, error: String(e.message || e) },
139
+ };
140
+ }
141
+ const config = { user_login: user.login, user_name: user.name || null, last_error: null };
142
+ return {
143
+ patch: { status: "active", is_enabled: true, config },
144
+ result: { ok: true, user_login: user.login, user_name: user.name || null },
145
+ };
146
+ },
147
+
148
+ status(record) {
149
+ const config = record?.config || {};
150
+ return {
151
+ slug: this.slug,
152
+ status: record?.status || "disconnected",
153
+ is_enabled: !!record?.is_enabled,
154
+ user_login: config.user_login || null,
155
+ user_name: config.user_name || null,
156
+ };
157
+ },
158
+
159
+ deactivate() {
160
+ return { patch: { status: "inactive", is_enabled: false } };
161
+ },
162
+
163
+ actions: {
164
+ async repos(record) {
165
+ const token = safeToken(record);
166
+ const repos = await listRepos(token);
167
+ return { repos: repos.map((r) => ({ full_name: r.full_name, private: r.private, url: r.html_url })) };
168
+ },
169
+ },
170
+ };
171
+
172
+ export default githubPlugin;