@biznetgio/neo-app 0.0.0-stage → 0.3.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.
@@ -0,0 +1,100 @@
1
+ // `neo-app agent setup` — the one-command onboarding. Detects agents, authenticates
2
+ // (API key), installs the skill, registers the MCP server, optionally links a project,
3
+ // runs health checks, and prints a verification prompt. Idempotent; safe to rerun.
4
+
5
+ import { resolveConfig } from '../config.js';
6
+ import { getCredential } from '../creds.js';
7
+ import { NeoApi, ApiError } from '../api.js';
8
+ import { detectAgents } from '../agents.js';
9
+ import { installSkill, skillVersion } from '../skill.js';
10
+ import { writeLink, readLink } from '../link.js';
11
+ import { installMcp } from './install-mcp.js';
12
+ import { login } from './login.js';
13
+ import { log, ok, warn, fail, step, c } from '../util.js';
14
+
15
+ const NODE_MAJOR = Number(process.versions.node.split('.')[0]);
16
+
17
+ export async function setup(opts = {}) {
18
+ const cfg = resolveConfig(opts);
19
+ const dry = !!opts['dry-run'];
20
+
21
+ log(c.bold(`NEO App agent setup`) + c.dim(` (${cfg.env} · ${cfg.baseUrl})`));
22
+
23
+ // 1. environment
24
+ if (NODE_MAJOR < 18) {
25
+ fail(`Node.js ${process.versions.node} detected; Node 18+ is required`);
26
+ process.exit(1);
27
+ }
28
+ const agents = detectAgents();
29
+ const present = agents.filter((a) => a.present);
30
+ step(`platform ${process.platform}/${process.arch}, node ${process.versions.node}`);
31
+ step(`agents detected: ${present.map((a) => a.name).join(', ') || 'none'}`);
32
+
33
+ if (dry) {
34
+ log(c.yellow('\n[dry-run] no changes will be written. Intended actions:'));
35
+ step('authenticate with your NEO App API key (stored 0600, not in agent config)');
36
+ step(`install skill ${skillVersion()} into user agent skill dirs`);
37
+ for (const a of present) step(`register neo-app MCP in ${a.name} (${a.configPath})`);
38
+ if (opts.project) step(`link this repo to project ${opts.project} (.neo-app.json)`);
39
+ step('run health checks + print verification prompt');
40
+ return;
41
+ }
42
+
43
+ // 2. authenticate — reuse a valid stored credential, else run login.
44
+ let api;
45
+ let account;
46
+ const cred = getCredential(cfg.baseUrl);
47
+ if (cred?.token && !opts.token) {
48
+ api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
49
+ try {
50
+ const who = await api.whoami();
51
+ account = who.user?.email || who.user?.name || 'unknown';
52
+ ok(`already authenticated as ${account} (${cfg.env})`);
53
+ } catch {
54
+ warn('stored credential is invalid — re-authenticating');
55
+ ({ api, account } = await login(opts));
56
+ }
57
+ } else {
58
+ ({ api, account } = await login(opts));
59
+ }
60
+
61
+ // 3. skill
62
+ installSkill();
63
+ ok(`skill ${skillVersion()} installed`);
64
+
65
+ // 4. MCP registration
66
+ const mcpResults = await installMcp(opts);
67
+ if (!mcpResults.length) warn('no agent MCP configured (no supported agent detected)');
68
+
69
+ // 5. project link (optional)
70
+ let link = readLink();
71
+ const environment = cfg.env === 'staging' ? 'staging' : 'production';
72
+ if (opts.project) {
73
+ try {
74
+ const proj = (await api.get(`/api/v1/projects/${opts.project}`)).data;
75
+ const name = proj?.name || proj?.application?.name || opts.project;
76
+ link = { projectId: opts.project, environment, baseUrl: cfg.baseUrl, env: cfg.env, name };
77
+ writeLink(link);
78
+ ok(`project linked: ${name} / ${environment} ${c.dim('(.neo-app.json)')}`);
79
+ } catch (e) {
80
+ const why = e instanceof ApiError && e.status === 404 ? 'not found or no access' : e.message;
81
+ fail(`could not link project ${opts.project}: ${why}`);
82
+ }
83
+ }
84
+
85
+ // 6. summary
86
+ log('');
87
+ log(c.bold('NEO App agent setup complete.'));
88
+ log('');
89
+ log(`CLI: connected as ${account}`);
90
+ log(`Project: ${link?.projectId ? `${link.name || link.projectId} / ${link.environment}` : c.dim('not linked')}`);
91
+ for (const a of agents) {
92
+ const r = mcpResults.find((m) => m.agent === a.id);
93
+ const stateLabel = r?.ok ? 'configured' : a.present ? c.red('failed') : c.dim('not detected');
94
+ log(`${a.name} MCP:`.padEnd(16) + stateLabel);
95
+ }
96
+ log(`Skill: installed (${skillVersion()})`);
97
+ log('');
98
+ log('Restart your coding agent, then ask:');
99
+ log(c.dim('"Check my NEO App setup: run neo-app status and call neo_whoami."'));
100
+ }
@@ -0,0 +1,39 @@
1
+ // `neo-app status` / `neo-app whoami` — show the connected account, org, environment,
2
+ // and any linked project. Matches what the neo_whoami MCP tool returns.
3
+
4
+ import { resolveConfig } from '../config.js';
5
+ import { getCredential } from '../creds.js';
6
+ import { NeoApi } from '../api.js';
7
+ import { readLink } from '../link.js';
8
+ import { log, ok, fail, c } from '../util.js';
9
+
10
+ export async function status(opts = {}) {
11
+ const cfg = resolveConfig(opts);
12
+ const cred = getCredential(cfg.baseUrl);
13
+ if (!cred?.token) {
14
+ fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`);
15
+ process.exit(1);
16
+ }
17
+ const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
18
+ let who;
19
+ try {
20
+ who = await api.whoami();
21
+ } catch (e) {
22
+ fail(`token invalid for ${cfg.baseUrl}: ${e.message}`);
23
+ process.exit(1);
24
+ }
25
+
26
+ const account = who.user?.email || who.user?.name || 'unknown';
27
+ const org = who.orgs?.[0];
28
+ const link = readLink();
29
+
30
+ log(`${c.bold('NEO App')} ${c.dim(cfg.env + ' · ' + cfg.baseUrl)}`);
31
+ ok(`account ${account}${who.user?.isAdmin ? c.dim(' (platform admin)') : ''}`);
32
+ if (org) ok(`org ${org.name} ${c.dim('(' + org.role + ')')}`);
33
+ if (link?.projectId) {
34
+ ok(`project ${link.projectId}${link.environment ? ' / ' + link.environment : ''}`);
35
+ } else {
36
+ log(`${c.dim('·')} no linked project (run 'neo-app agent setup --project <id>')`);
37
+ }
38
+ return { cfg, who, account, org, link };
39
+ }
@@ -0,0 +1,34 @@
1
+ // `neo-app agent uninstall` — remove only what NEO App installed: the MCP entry in
2
+ // each agent's config, the skill, and (on confirm) stored credentials + repo link.
3
+
4
+ import { detectAgents, unregisterMcp } from '../agents.js';
5
+ import { removeSkill } from '../skill.js';
6
+ import { removeStore } from '../creds.js';
7
+ import { removeLink, LINK_FILE } from '../link.js';
8
+ import { log, ok, step, confirm } from '../util.js';
9
+
10
+ export async function uninstall(opts = {}) {
11
+ log('NEO App uninstall — removing only NEO App-owned files and config.');
12
+
13
+ for (const agent of detectAgents()) {
14
+ const { removed, bak } = unregisterMcp(agent);
15
+ if (removed) ok(`${agent.name} MCP removed${bak ? ` (backup: ${bak})` : ''}`);
16
+ else step(`${agent.name}: no neo-app MCP entry`);
17
+ }
18
+
19
+ const dirs = removeSkill();
20
+ if (dirs.length) ok(`skill removed (${dirs.length} location(s))`);
21
+ else step('no skill installed');
22
+
23
+ if (removeLink()) ok(`project link removed (${LINK_FILE})`);
24
+
25
+ const rmCreds = opts.yes || (await confirm('Also remove stored API credentials?'));
26
+ if (rmCreds) {
27
+ if (removeStore()) ok('stored credentials removed');
28
+ else step('no credentials file');
29
+ } else {
30
+ step('kept stored credentials');
31
+ }
32
+
33
+ log("Done. Restart your coding agent so it drops the neo-app MCP server.");
34
+ }
package/src/config.js ADDED
@@ -0,0 +1,50 @@
1
+ // Environment + endpoint resolution.
2
+ //
3
+ // Portals:
4
+ // staging → https://portal.neoapp.biznetgio.dev
5
+ // production → https://portal.app.neoapp.id
6
+ // NODE_ENV=development points at staging. Everything is overridable with NEO_APP_URL /
7
+ // NEO_APP_ENV or a --staging / --prod / --url flag so the same CLI works against a
8
+ // custom or local control-plane.
9
+
10
+ const ENDPOINTS = {
11
+ staging: {
12
+ url: 'https://portal.neoapp.biznetgio.dev',
13
+ mcpUrl: 'https://portal.neoapp.biznetgio.dev/api/v1/mcp', // hosted HTTP MCP (Phase 2)
14
+ },
15
+ production: {
16
+ url: 'https://portal.app.neoapp.id',
17
+ mcpUrl: 'https://portal.app.neoapp.id/api/v1/mcp',
18
+ },
19
+ };
20
+
21
+ // resolveEnv decides staging vs production from (in order): explicit --staging/--prod,
22
+ // --env/NEO_APP_ENV, then NODE_ENV=development => staging, else production.
23
+ export function resolveEnv(opts = {}) {
24
+ if (opts.staging) return 'staging';
25
+ if (opts.prod) return 'production';
26
+ const flag = (opts.env || '').toLowerCase();
27
+ if (flag === 'staging' || flag === 'dev' || flag === 'development') return 'staging';
28
+ if (flag === 'production' || flag === 'prod') return 'production';
29
+ const envVar = (process.env.NEO_APP_ENV || '').toLowerCase();
30
+ if (envVar === 'staging' || envVar === 'dev') return 'staging';
31
+ if (envVar === 'production' || envVar === 'prod') return 'production';
32
+ if ((process.env.NODE_ENV || '').toLowerCase() === 'development') return 'staging';
33
+ return 'production';
34
+ }
35
+
36
+ // resolveConfig returns { env, baseUrl, mcpUrl }. An explicit URL (flag or
37
+ // NEO_APP_URL) wins over the env-derived default but keeps the env label for
38
+ // credential scoping and messaging.
39
+ export function resolveConfig(opts = {}) {
40
+ const env = resolveEnv(opts);
41
+ const base = ENDPOINTS[env];
42
+ const override = (opts.url || process.env.NEO_APP_URL || '').trim().replace(/\/+$/, '');
43
+ return {
44
+ env,
45
+ baseUrl: override || base.url,
46
+ mcpUrl: base.mcpUrl,
47
+ };
48
+ }
49
+
50
+ export { ENDPOINTS };
package/src/creds.js ADDED
@@ -0,0 +1,94 @@
1
+ // Credential storage for the NEO App CLI.
2
+ //
3
+ // API keys (kap_ personal tokens) are kept OUT of any agent/MCP config file. They
4
+ // live in a user-only (0600) JSON store, keyed by control-plane base URL so staging
5
+ // and production tokens never clash. NEO_APP_TOKEN (env) always overrides the store,
6
+ // which is how headless/CI runs authenticate without writing anything to disk.
7
+ //
8
+ // ponytail: file-0600 store only. OS keychain (macOS `security`, Linux libsecret) is
9
+ // the hardening upgrade — add a backend here if plaintext-at-rest becomes a concern.
10
+
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, rmSync, statSync } from 'node:fs';
14
+
15
+ function storeDir() {
16
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
17
+ return join(base, 'neo-app');
18
+ }
19
+
20
+ function storePath() {
21
+ return join(storeDir(), 'credentials.json');
22
+ }
23
+
24
+ function readStore() {
25
+ try {
26
+ return JSON.parse(readFileSync(storePath(), 'utf8'));
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ function writeStore(store) {
33
+ const dir = storeDir();
34
+ mkdirSync(dir, { recursive: true });
35
+ const path = storePath();
36
+ writeFileSync(path, JSON.stringify(store, null, 2), { mode: 0o600 });
37
+ try {
38
+ chmodSync(path, 0o600); // enforce even if the file pre-existed with looser perms
39
+ } catch {
40
+ /* best effort */
41
+ }
42
+ }
43
+
44
+ // getCredential returns { token, account, env, source } for a base URL, or null.
45
+ // NEO_APP_TOKEN wins so CI can authenticate without a stored file.
46
+ export function getCredential(baseUrl) {
47
+ if (process.env.NEO_APP_TOKEN) {
48
+ return { token: process.env.NEO_APP_TOKEN, account: null, env: null, source: 'env' };
49
+ }
50
+ const entry = readStore()[baseUrl];
51
+ if (entry && entry.token) return { ...entry, source: 'file' };
52
+ return null;
53
+ }
54
+
55
+ export function setCredential(baseUrl, { token, account, env }) {
56
+ const store = readStore();
57
+ store[baseUrl] = { token, account: account || null, env: env || null, savedAt: new Date().toISOString() };
58
+ writeStore(store);
59
+ }
60
+
61
+ export function deleteCredential(baseUrl) {
62
+ const store = readStore();
63
+ if (store[baseUrl]) {
64
+ delete store[baseUrl];
65
+ writeStore(store);
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ // storeInfo reports where creds live and whether the file is user-only (for doctor).
72
+ export function storeInfo() {
73
+ const path = storePath();
74
+ const info = { path, exists: existsSync(path), secure: null };
75
+ if (info.exists) {
76
+ try {
77
+ const mode = statSync(path).mode & 0o777;
78
+ info.secure = mode === 0o600;
79
+ info.mode = mode.toString(8);
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ }
84
+ return info;
85
+ }
86
+
87
+ export function removeStore() {
88
+ try {
89
+ rmSync(storePath());
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
package/src/index.js ADDED
@@ -0,0 +1,105 @@
1
+ // CLI entrypoint: parse args and dispatch. Keep this thin — logic lives in commands/.
2
+
3
+ import { parseArgs } from 'node:util';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, join } from 'node:path';
7
+ import { c, fail } from './util.js';
8
+
9
+ function version() {
10
+ try {
11
+ const p = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
12
+ return JSON.parse(readFileSync(p, 'utf8')).version;
13
+ } catch {
14
+ return 'dev';
15
+ }
16
+ }
17
+
18
+ const HELP = `${c.bold('neo-app')} — NEO App CLI for coding agents
19
+
20
+ ${c.bold('Usage')}
21
+ npx -y @biznetgio/neo-app@latest <command> [options]
22
+
23
+ ${c.bold('Commands')}
24
+ agent setup Install skill, register MCP, authenticate, link project
25
+ agent doctor Diagnose CLI, auth, API, agent MCP wiring, skill, perms
26
+ agent uninstall Remove NEO App MCP entries, skill, (optionally) credentials
27
+ config install-mcp Register the neo-app MCP server with detected agents
28
+ login Authenticate with a NEO App API key (kap_...)
29
+ link <project-id> Link this repo to a project (writes .neo-app.json)
30
+ project list List projects in your organization (find ids to link)
31
+ status | whoami Show connected account, org, environment, linked project
32
+ mcp Run the local stdio MCP server (used by agents)
33
+
34
+ ${c.bold('Options')}
35
+ --project <id> Link this repo to a project (agent setup)
36
+ --agent <codex|claude-code> Target one agent (config install-mcp)
37
+ --staging | --prod Target staging (portal.neoapp.biznetgio.dev) or production (portal.app.neoapp.id)
38
+ --url <url> Override the control-plane URL (also NEO_APP_URL)
39
+ --token <kap_...> API key (also NEO_APP_TOKEN; prompted if omitted)
40
+ --yes Non-interactive (headless/CI)
41
+ --dry-run Print intended changes without writing
42
+ -v, --version Print version
43
+ -h, --help Show this help
44
+
45
+ ${c.bold('Environment')}
46
+ Production is the default. Set NODE_ENV=development (or pass --staging) for staging.
47
+ NEO_APP_URL / NEO_APP_TOKEN override the target and credential.`;
48
+
49
+ const options = {
50
+ project: { type: 'string' },
51
+ agent: { type: 'string' },
52
+ org: { type: 'string' },
53
+ env: { type: 'string' },
54
+ url: { type: 'string' },
55
+ token: { type: 'string' },
56
+ staging: { type: 'boolean' },
57
+ dev: { type: 'boolean' },
58
+ prod: { type: 'boolean' },
59
+ yes: { type: 'boolean' },
60
+ 'dry-run': { type: 'boolean' },
61
+ version: { type: 'boolean', short: 'v' },
62
+ help: { type: 'boolean', short: 'h' },
63
+ };
64
+
65
+ export async function main(argv = process.argv.slice(2)) {
66
+ let parsed;
67
+ try {
68
+ parsed = parseArgs({ args: argv, options, allowPositionals: true });
69
+ } catch (e) {
70
+ fail(e.message);
71
+ process.exit(2);
72
+ }
73
+ const { values: opts, positionals } = parsed;
74
+ if (opts.dev) opts.staging = true;
75
+
76
+ if (opts.version && positionals.length === 0) return void console.log(version());
77
+ if (opts.help || positionals.length === 0) return void console.log(HELP);
78
+
79
+ const cmd = positionals[0];
80
+ const sub = positionals[1];
81
+
82
+ try {
83
+ // `agent setup` and `setup agent` both work (parity with InstaCloud's `setup agent`).
84
+ if ((cmd === 'agent' && sub === 'setup') || (cmd === 'setup' && sub === 'agent')) return void (await (await import('./commands/setup.js')).setup(opts));
85
+ if ((cmd === 'agent' && sub === 'doctor') || (cmd === 'doctor')) return void (await (await import('./commands/doctor.js')).doctor(opts));
86
+ if ((cmd === 'agent' && sub === 'uninstall') || (cmd === 'uninstall')) return void (await (await import('./commands/uninstall.js')).uninstall(opts));
87
+ if (cmd === 'config' && sub === 'install-mcp') return void (await (await import('./commands/install-mcp.js')).installMcp(opts));
88
+ if (cmd === 'login') return void (await (await import('./commands/login.js')).login(opts));
89
+ if (cmd === 'link') return void (await (await import('./commands/link.js')).link(sub, opts));
90
+ if (cmd === 'project' && sub === 'link') return void (await (await import('./commands/link.js')).link(positionals[2], opts));
91
+ if (cmd === 'project' && (sub === 'list' || sub === 'ls')) return void (await (await import('./commands/link.js')).listProjects(opts));
92
+ if (cmd === 'status' || cmd === 'whoami') return void (await (await import('./commands/status.js')).status(opts));
93
+ if (cmd === 'mcp') return void (await (await import('./mcp/server.js')).runMcpServer());
94
+ if (cmd === 'version') return void console.log(version());
95
+ if (cmd === 'help') return void console.log(HELP);
96
+
97
+ fail(`unknown command: ${[cmd, sub].filter(Boolean).join(' ')}`);
98
+ console.log(`Run ${c.bold('neo-app --help')} for usage.`);
99
+ process.exit(2);
100
+ } catch (e) {
101
+ fail(e.message || String(e));
102
+ if (process.env.NEO_APP_DEBUG) console.error(e);
103
+ process.exit(1);
104
+ }
105
+ }
package/src/link.js ADDED
@@ -0,0 +1,34 @@
1
+ // Repository <-> NEO App project link, stored as an untracked `.neo-app.json` at the
2
+ // working directory root. Holds only non-secret routing (project id + environment).
3
+
4
+ import { join } from 'node:path';
5
+ import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
6
+
7
+ const LINK_FILE = '.neo-app.json';
8
+
9
+ function linkPath() {
10
+ return join(process.cwd(), LINK_FILE);
11
+ }
12
+
13
+ export function readLink() {
14
+ try {
15
+ return JSON.parse(readFileSync(linkPath(), 'utf8'));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function writeLink(link) {
22
+ writeFileSync(linkPath(), JSON.stringify(link, null, 2) + '\n');
23
+ return linkPath();
24
+ }
25
+
26
+ export function removeLink() {
27
+ if (existsSync(linkPath())) {
28
+ rmSync(linkPath());
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ export { LINK_FILE };
@@ -0,0 +1,56 @@
1
+ // NEO App local stdio MCP server (`neo-app mcp`). Reuses the authenticated CLI
2
+ // credential for the configured control-plane — the token is NEVER passed in agent
3
+ // config, it is read here from the credential store (or NEO_APP_TOKEN). All logging
4
+ // goes to stderr; stdout is the JSON-RPC channel.
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ import { resolveConfig } from '../config.js';
10
+ import { getCredential } from '../creds.js';
11
+ import { NeoApi } from '../api.js';
12
+ import { TOOLS } from './tools.js';
13
+
14
+ export async function runMcpServer() {
15
+ const cfg = resolveConfig({});
16
+ const cred = getCredential(cfg.baseUrl);
17
+ const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred?.token });
18
+ if (!cred?.token) {
19
+ console.error(`[neo-app mcp] no credential for ${cfg.baseUrl} — run 'neo-app login' or set NEO_APP_TOKEN`);
20
+ }
21
+
22
+ const server = new Server({ name: 'neo-app', version: '0.3.0' }, { capabilities: { tools: {} } });
23
+
24
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
25
+ tools: TOOLS.map((t) => ({
26
+ name: t.name,
27
+ description: t.description,
28
+ inputSchema: t.schema,
29
+ annotations: { readOnlyHint: !t.mutating, destructiveHint: false },
30
+ })),
31
+ }));
32
+
33
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
34
+ const tool = TOOLS.find((t) => t.name === req.params.name);
35
+ if (!tool) {
36
+ return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true };
37
+ }
38
+ if (!api.token) {
39
+ return {
40
+ content: [{ type: 'text', text: 'Not authenticated. Run `neo-app login` or set NEO_APP_TOKEN.' }],
41
+ isError: true,
42
+ };
43
+ }
44
+ try {
45
+ const out = await tool.call(api, req.params.arguments || {});
46
+ const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
47
+ return { content: [{ type: 'text', text }] };
48
+ } catch (e) {
49
+ const suffix = e.status ? ` (HTTP ${e.status})` : '';
50
+ return { content: [{ type: 'text', text: `Error: ${e.message}${suffix}` }], isError: true };
51
+ }
52
+ });
53
+
54
+ await server.connect(new StdioServerTransport());
55
+ console.error(`[neo-app mcp] ready: ${cfg.env} ${cfg.baseUrl}`);
56
+ }