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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,39 @@ jobs:

- name: Test
run: bun test

# The published artifact is dist/mna.js running on plain Node, which the
# unit tests never exercise. A dependency whose bundled output only breaks
# at runtime (e.g. a lazy `require` in a UMD build) is invisible without
# this step.
- name: Build and smoke-test the npm bundle on Node
run: |
bun run build
node dist/mna.js --version

# Seed a throwaway HOME with a JSONC config so the install actually
# reaches jsonc-parser's parse/modify/applyEdits. On a pristine HOME
# nothing is detected, no config is read, and a lazy require inside
# those functions would still ship — which is the exact class of bug
# this step exists to catch.
SMOKE_HOME="$(mktemp -d)"
export HOME="$SMOKE_HOME" XDG_CONFIG_HOME="$SMOKE_HOME/.config"
mkdir -p "$SMOKE_HOME/.cursor"
printf '%s\n' \
'{' \
' // a comment jsonc-parser must preserve' \
' "mcpServers": {' \
' "existing": { "url": "https://example.invalid/mcp" },' \
' }' \
'}' > "$SMOKE_HOME/.cursor/mcp.json"

node dist/mna.js skills list --all --json > /dev/null
node dist/mna.js skills install --client cursor --yes

echo "--- resulting config ---"
cat "$SMOKE_HOME/.cursor/mcp.json"
grep -q 'a comment jsonc-parser must preserve' "$SMOKE_HOME/.cursor/mcp.json"
grep -q 'my-next-adventure' "$SMOKE_HOME/.cursor/mcp.json"
grep -q 'existing' "$SMOKE_HOME/.cursor/mcp.json"
test -f "$SMOKE_HOME/.cursor/skills/mna/SKILL.md"
echo "bundle smoke test OK"
116 changes: 101 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
> Command-line tool for [My Next Adventure](https://mynextadventure.cloud).

`mna` is an open-source CLI for managing your trips on My Next Adventure.
It also ships as a [Claude Code](https://claude.com/claude-code) skill so Claude
can plan, research, build, cost, and manage whole trips on your behalf — see
[`skills/mna/SKILL.md`](./skills/mna/SKILL.md).
It also ships as an agent skill — run `mna skills install` and your AI coding
agent (Claude Code, Cursor, …) can plan, research, build, cost, and manage whole
trips on your behalf. See [`skills/mna/SKILL.md`](./skills/mna/SKILL.md).

Status: **pre-1.0, alpha.** Tagged releases publish binaries to GitHub
Releases. npm and Homebrew distribution land with the first tag.
Expand Down Expand Up @@ -75,28 +75,104 @@ If you prefer headless / paste-token login:
mna login --paste-token <key> # generate the key from the user menu on app.mynextadventure.cloud
```

## Use it as a Claude Code skill
## Use it from your AI coding agent

`mna` doubles as a [Claude Code](https://claude.com/claude-code) skill — install it and Claude
can plan, research, cost, and manage whole trips for you. **One command:**
`mna` doubles as an agent **skill** — install it and your agent can plan, research, cost, and
manage whole trips for you. **One command:**

```bash
git clone --depth 1 https://github.com/MantaCodeDevs/mna-cli /tmp/mna-cli && mkdir -p ~/.claude/skills && cp -r /tmp/mna-cli/skills/mna ~/.claude/skills/
mna skills install
```

This drops the skill at `~/.claude/skills/mna/`. Start (or `/reload`) Claude Code and just ask
it to plan a trip — the skill triggers on its own. (Already have the repo cloned? Just
`cp -r skills/mna ~/.claude/skills/`.)
It detects the AI clients on your machine, shows exactly what it will write where, and asks
once. After a successful `mna login` on an interactive terminal it offers the same thing with a
single `y` (silence it with `mna config set skills.prompt false`).

Installed via npm? The skill ships inside the package — no clone needed:
```bash
mna skills list # what's detected, and what's already installed
mna skills list --all # every client mna knows how to install into
mna skills install --dry-run # show the writes, change nothing
mna skills install --client cursor # one client only
mna skills install --all --yes # every supported client, no prompt
mna skills install --no-mcp # skill only, skip the MCP server entry
mna skills install --scope project # into ./.claude/skills etc. instead of $HOME
mna skills uninstall # remove the skill directory again
```

`--scope project` relocates the **skill** only; MCP servers have no project-level equivalent in
most clients, so they stay in the user-level config either way.

Installs are designed to be boring and reversible:

- **Idempotent** — identical files are left alone; a second run reports "already up to date".
- **Merged, never overwritten** — only the single `mcpServers.my-next-adventure` key is set.
Configs with `//` comments or trailing commas (VS Code, Gemini CLI) are edited in place, so
your comments and key order survive. The object being merged into may be re-indented;
nothing else in the file is touched.
- **Atomic** — every write goes to a temp file and is `rename()`d into place, so an interrupt
or a full disk cannot truncate a large config like `~/.claude.json`.
- **Backed up** — anything modified is copied to `<file>.mna-backup-<timestamp>` first (mode
`0600`, most recent 3 kept).
- **Refuses what it doesn't understand** — a config that won't parse, or that has a non-object
where `mcpServers` should be, is reported and skipped rather than replaced. That only skips
the MCP entry; the skill files still install.
- **Isolated failures** — an unwritable path for one client doesn't abandon the others, and the
summary reports what was actually written. Exit code is non-zero if anything failed.

### Supported clients

Every skill directory below is the one that vendor documents — sources linked, and `mna skills
list --json` reports the same URL per client so you can check without taking our word for it.

| Client | `--client` | Skill | MCP server config |
|---|---|---|---|
| Claude Code | `claude-code` | [`~/.claude/skills/mna/`](https://code.claude.com/docs/en/skills) | `~/.claude.json` → `mcpServers` |
| Cursor | `cursor` | [`~/.cursor/skills/mna/`](https://cursor.com/docs/skills) | `~/.cursor/mcp.json` → `mcpServers` |
| Claude Desktop | `claude-desktop` | — | `claude_desktop_config.json` (per-OS) → `mcpServers` |
| Windsurf / Devin Desktop | `windsurf` | [`~/.codeium/windsurf/skills/mna/`](https://docs.devin.ai/desktop/cascade/skills) | `~/.codeium/windsurf/mcp_config.json` |
| VS Code (Copilot agent mode) | `vscode` | — | `<VS Code user dir>/mcp.json` → `servers` |
| Shared agent directory | `agents`, `codex` | [`~/.agents/skills/mna/`](https://learn.chatgpt.com/docs/build-skills) | — |
| OpenCode | `opencode` | [`~/.config/opencode/skills/mna/`](https://opencode.ai/docs/skills/) | — |
| Gemini CLI | `gemini-cli` | [`~/.gemini/skills/mna/`](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md) | `~/.gemini/settings.json` → `mcpServers` |

`~/.agents/skills/` is documented as a read location by Codex CLI, Gemini CLI, Cursor, OpenCode
and Windsurf, so one copy there serves several agents. Worth being precise about what that is:
five vendors independently documenting the same path, **not** a spec guarantee — the Agent Skills
standard defines what a skill contains, not where clients look for one. Claude Code does not read
it. **Codex CLI reads only that path** — `~/.codex/skills/` is a third-party compatibility claim
that OpenAI's own docs do not make, so `--client codex` installs to `~/.agents/skills/`. Codex
keeps MCP servers in TOML, which `mna` does not edit.

Two caveats the CLI also prints, marked `(?)` in the plan:

- **Claude Desktop on Linux.** The Linux build is official (beta), but no Anthropic doc states
it reads `~/.config/Claude/claude_desktop_config.json` — only the macOS and Windows paths are
documented. On Linux `mna` writes the conventional path and tells you it is unverified.
- Anything else added on convention alone is flagged the same way rather than silently reported
as installed.

The MCP entry points at the hosted server `https://mcp.mynextadventure.cloud/mcp`, written in
whichever shape the client expects — `{"type":"http","url":…}` for Claude Code and VS Code,
`{"url":…}` for Cursor, `{"httpUrl":…}` for Gemini CLI, `{"serverUrl":…}` for Windsurf, and the
`npx -y mcp-remote` stdio bridge for Claude Desktop, which has no native remote transport. If
you're logged in, **your API key is written into that config file in plain text** — that is how
these clients take credentials. `mna` sets any config it writes a key into to mode `0600`, and
says so before writing. Prefer OAuth? Use `--no-mcp`, or run `mna skills install` before
`mna login` and let the client do its own OAuth handshake on first use.

### Manual install (fallback)

The skill is plain markdown driving the CLI, so any agent that reads `SKILL.md` files can use
it. Copy it wherever your agent looks:

```bash
# from a clone
mkdir -p ~/.claude/skills && cp -r skills/mna ~/.claude/skills/

# from the npm package
mkdir -p ~/.claude/skills && cp -r "$(npm root -g)/@mantacodedevs/mna-cli/skills/mna" ~/.claude/skills/
```

Other AI coding agents can use the same `skills/mna/SKILL.md` playbook — it's plain
markdown driving the CLI.

The skill drives the `mna` CLI, so install the CLI (above) and run `mna login` first.

## Commands
Expand All @@ -113,6 +189,15 @@ Every command supports `--json` for piping into `jq` or Claude.
| `mna keys list` | List active API keys with `current` flag on the calling key. |
| `mna keys revoke <name> [--yes]` | Revoke an API key by name. |
| `mna config get\|set apiBaseUrl [<url>]` | Read/override the API base URL locally. |
| `mna config get\|set skills.prompt [true\|false]` | Toggle the post-login "install the skill?" offer. |

### Agent integration

| Command | Description |
|---|---|
| `mna skills list [--all] [--scope=user\|project]` | Show detected AI clients and whether the skill/MCP server is installed. |
| `mna skills install [--client=<id>] [--all] [--scope=user\|project] [--no-mcp] [--dry-run] [--yes]` | Install the skill (and MCP server entry) into your AI clients. |
| `mna skills uninstall [--client=<id>] [--scope=user\|project] [--dry-run] [--yes]` | Remove the installed skill directory. |

### Trips

Expand Down Expand Up @@ -220,7 +305,8 @@ The contract of record is `https://api.mynextadventure.cloud/v1/openapi.json`. I
|---|---|
| `MNA_API_KEY` | Override the key from the credentials file. Useful for CI. |
| `MNA_API_BASE_URL` | Override the API base URL. Useful for local dev. |
| `XDG_CONFIG_HOME` | Where the credentials file is stored. Default: `~/.config`. |
| `XDG_CONFIG_HOME` | Where the credentials and settings files are stored. Default: `~/.config`. |
| `CI=1` | Suppresses all interactive prompts, including the post-login skill offer. |
| `MNA_DEBUG=1` | Print error stack traces. |
| `NO_COLOR=1` | Disable ANSI colors. |

Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"dependencies": {
"@inquirer/prompts": "^8.4.3",
"citty": "^0.1.6",
"jsonc-parser": "^3.3.1",
"openapi-fetch": "^0.13.0"
},
"devDependencies": {
Expand Down
16 changes: 16 additions & 0 deletions src/bin/mna.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import { collectionsAddGoalCommand } from '../commands/collections/add-goal'
import { collectionsRemoveGoalCommand } from '../commands/collections/remove-goal'
import { collectionsShareCommand } from '../commands/collections/share'
import { collectionsOpenSharedCommand } from '../commands/collections/open-shared'
import { skillsInstallCommand } from '../commands/skills/install'
import { skillsListCommand } from '../commands/skills/list'
import { skillsUninstallCommand } from '../commands/skills/uninstall'

const trips = defineCommand({
meta: { name: 'trips', description: 'View and manage trips.' },
Expand Down Expand Up @@ -184,6 +187,18 @@ const collections = defineCommand({
},
})

const skills = defineCommand({
meta: {
name: 'skills',
description: 'Install the mna skill and MCP server into your AI coding clients.',
},
subCommands: {
list: skillsListCommand,
install: skillsInstallCommand,
uninstall: skillsUninstallCommand,
},
})

const main = defineCommand({
meta: {
name: 'mna',
Expand All @@ -205,6 +220,7 @@ const main = defineCommand({
votes,
goals,
collections,
skills,
config: configCommand,
},
})
Expand Down
29 changes: 21 additions & 8 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { defineCommand } from 'citty'
import { loadCredentials, resolveBaseUrl, saveCredentials } from '../auth/credentials-store'
import { reportAndExit } from '../util/errors'
import { loadSettings, setSetting } from '../util/settings'
import { renderJson } from '../render/json'

const KNOWN_KEYS = ['apiBaseUrl'] as const
const KNOWN_KEYS = ['apiBaseUrl', 'skills.prompt'] as const
type ConfigKey = (typeof KNOWN_KEYS)[number]

function isKnownKey(key: string): key is ConfigKey {
return (KNOWN_KEYS as readonly string[]).includes(key)
}

function parseBoolean(value: string): boolean {
const normalized = value.trim().toLowerCase()
if (['true', '1', 'yes', 'on'].includes(normalized)) return true
if (['false', '0', 'no', 'off'].includes(normalized)) return false
throw new Error(`Expected a boolean (true|false) for skills.prompt, got: ${value}`)
}

export const configGet = defineCommand({
meta: { name: 'get', description: 'Print a configuration value.' },
args: {
Expand All @@ -22,13 +30,15 @@ export const configGet = defineCommand({
throw new Error(`Unknown config key: ${args.key}. Known: ${KNOWN_KEYS.join(', ')}.`)
}

const creds = await loadCredentials()
const value = args.key === 'apiBaseUrl' ? resolveBaseUrl(creds) : undefined
const value: string | boolean =
args.key === 'apiBaseUrl'
? resolveBaseUrl(await loadCredentials())
: ((await loadSettings())['skills.prompt'] ?? true)

if (args.json) {
renderJson({ [args.key]: value })
} else {
process.stdout.write(`${value ?? ''}\n`)
process.stdout.write(`${value}\n`)
}
} catch (err) {
reportAndExit(err)
Expand All @@ -48,15 +58,18 @@ export const configSet = defineCommand({
throw new Error(`Unknown config key: ${args.key}. Known: ${KNOWN_KEYS.join(', ')}.`)
}

if (args.key === 'skills.prompt') {
await setSetting('skills.prompt', parseBoolean(args.value))
process.stdout.write('Updated skills.prompt.\n')
return
}

const creds = await loadCredentials()
if (!creds) {
throw new Error('No credentials file found. Run `mna login --paste-token <key>` first.')
}

if (args.key === 'apiBaseUrl') {
creds.apiBaseUrl = args.value
}

creds.apiBaseUrl = args.value
await saveCredentials(creds)
process.stdout.write(`Updated ${args.key}.\n`)
} catch (err) {
Expand Down
36 changes: 27 additions & 9 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { defineCommand } from 'citty'
import { browserLogin } from '../auth/browser-login'
import { pasteTokenLogin } from '../auth/paste-token-login'
import { resolveBaseUrl } from '../auth/credentials-store'
import { resolveBaseUrl, type Credentials } from '../auth/credentials-store'
import { reportAndExit } from '../util/errors'
import { colors } from '../render/colors'
import { renderJson } from '../render/json'
import { maybeOfferSkillInstall } from '../skills/post-login-prompt'

const DEFAULT_WEB_BASE = 'https://app.mynextadventure.cloud'

Expand All @@ -21,27 +23,43 @@ export const loginCommand = defineCommand({
type: 'string',
description: 'Override the web app URL (rarely needed; defaults to app.mynextadventure.cloud).',
},
json: { type: 'boolean', default: false, description: 'Output as JSON (also suppresses prompts).' },
},
async run({ args }) {
try {
const apiBaseUrl = resolveBaseUrl(null)
const webAppBaseUrl = args['web-base-url'] ?? process.env.MNA_WEB_BASE_URL ?? DEFAULT_WEB_BASE

let credentials: Credentials

if (args['paste-token']) {
const creds = await pasteTokenLogin({ apiKey: args['paste-token'], apiBaseUrl })
process.stdout.write(`${colors.green('✓')} Logged in.\n`)
process.stdout.write(colors.dim(' Credentials saved to ~/.config/mna/credentials\n'))
process.stdout.write(colors.dim(` Base URL: ${creds.apiBaseUrl}\n`))
return
credentials = await pasteTokenLogin({ apiKey: args['paste-token'], apiBaseUrl })
} else {
if (!args.json) {
process.stdout.write(
'Opening browser for consent... If it does not open, copy the URL below.\n\n',
)
}
const result = await browserLogin({ apiBaseUrl, webAppBaseUrl })
credentials = result.credentials
if (!args.json) process.stdout.write(colors.dim(` ${result.consentUrl}\n\n`))
}

process.stdout.write('Opening browser for consent... If it does not open, copy the URL below.\n\n')
if (args.json) {
renderJson({
loggedIn: true,
user: credentials.user,
apiBaseUrl: credentials.apiBaseUrl,
})
return
}

const { credentials, consentUrl } = await browserLogin({ apiBaseUrl, webAppBaseUrl })
process.stdout.write(colors.dim(` ${consentUrl}\n\n`))
process.stdout.write(`${colors.green('✓')} Logged in.\n`)
process.stdout.write(colors.dim(' Credentials saved to ~/.config/mna/credentials\n'))
process.stdout.write(colors.dim(` Base URL: ${credentials.apiBaseUrl}\n`))

// Courtesy offer — no-ops in CI, pipes, or when switched off.
await maybeOfferSkillInstall({ json: args.json })
} catch (err) {
reportAndExit(err)
}
Expand Down
Loading
Loading