diff --git a/README.md b/README.md index e825ff6..a3f4665 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,28 @@ npm install npm link ``` +### Claude Code Skill + +Install the packaged Jira skill into the current project: + +```bash +jira install-skill +``` + +This copies the skill to `./.claude/skills/jira/SKILL.md`. + +If the file already exists, the command errors by default. Use `--force` to overwrite: + +```bash +jira install-skill --force +``` + +Use a custom destination directory if needed: + +```bash +jira install-skill --dest ./custom/skills/jira +``` + ### Setup 1. **Configure using CLI options (Bearer auth - recommended):** diff --git a/bin/commands/install-skill.js b/bin/commands/install-skill.js new file mode 100644 index 0000000..48aa0d4 --- /dev/null +++ b/bin/commands/install-skill.js @@ -0,0 +1,41 @@ +const { Command } = require('commander'); +const fs = require('fs'); +const path = require('path'); +const { expandHomePath } = require('../../lib/utils'); + +function createInstallSkillCommand(factory) { + return new Command('install-skill') + .description('install the packaged Jira skill into the current project') + .option('--dest ', 'target directory', './.claude/skills/jira') + .option('-f, --force', 'overwrite existing skill file without prompting') + .action((options) => { + const io = factory.getIOStreams(); + const analytics = factory.getAnalytics(); + const sourceFile = path.join(__dirname, '..', '..', 'skills', 'jira', 'SKILL.md'); + const destinationDir = path.resolve(expandHomePath(options.dest)); + const destinationFile = path.join(destinationDir, 'SKILL.md'); + + try { + if (!fs.existsSync(sourceFile)) { + throw new Error('packaged skill file not found in this installation'); + } + + if (fs.existsSync(destinationFile) && !options.force) { + throw new Error(`skill file already exists at ${destinationFile}. Re-run with --force to overwrite.`); + } + + fs.mkdirSync(destinationDir, { recursive: true }); + fs.copyFileSync(sourceFile, destinationFile); + + io.success('Skill installed successfully'); + io.info(`Location: ${destinationFile}`); + analytics.track('install-skill', { overwritten: Boolean(options.force) }); + } catch (err) { + analytics.track('install-skill', { overwritten: Boolean(options.force), success: false }); + io.error(`Failed to install skill: ${err.message}`); + process.exit(1); + } + }); +} + +module.exports = createInstallSkillCommand; diff --git a/bin/root.js b/bin/root.js index e799e82..f51ccfe 100644 --- a/bin/root.js +++ b/bin/root.js @@ -7,6 +7,7 @@ const { Command } = require('commander'); // Import command modules const createConfigCommand = require('./commands/config'); +const createInstallSkillCommand = require('./commands/install-skill'); const createIssueCommand = require('./commands/issue'); const createProjectCommand = require('./commands/project'); const createSprintCommand = require('./commands/sprint'); @@ -49,6 +50,7 @@ async function createRootCommand(factory, version) { // Core commands group (alphabetically ordered for better UX) const coreCommands = [ createConfigCommand(factory), + createInstallSkillCommand(factory), createIssueCommand(factory), createProjectCommand(factory), createSprintCommand(factory) diff --git a/package.json b/package.json index f11d079..9189472 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,14 @@ "url": "https://github.com/pchuri/jira-cli/issues" }, "homepage": "https://github.com/pchuri/jira-cli#readme", + "files": [ + "bin/", + "lib/", + "skills/", + "README.md", + "LICENSE", + "SECURITY.md" + ], "publishConfig": { "access": "public" }, diff --git a/skills/jira/SKILL.md b/skills/jira/SKILL.md new file mode 100644 index 0000000..6253ed7 --- /dev/null +++ b/skills/jira/SKILL.md @@ -0,0 +1,509 @@ +--- +name: jira +description: Use jira-cli to configure Jira access and manage issues, comments, remote links, projects, and sprints from the terminal. +argument-hint: +allowed-tools: [Bash, Read, Write, Glob, Grep] +--- + +# jira-cli Skill + +A CLI tool for Atlassian Jira. Lets you configure authentication, inspect issues and projects, create and update issues, manage comments and remote links, inspect sprint state, and install this skill into a project. + +## Installation + +```sh +npm install -g @pchuri/jira-cli +jira --version +``` + +## Configuration + +`jira-cli` is intentionally non-interactive. Prefer explicit flags or environment variables. + +### Environment variables + +| Variable | Description | Example | +|---|---|---| +| `JIRA_HOST` | Jira hostname or full URL | `your-site.atlassian.net` | +| `JIRA_DOMAIN` | Legacy hostname variable | `your-site.atlassian.net` | +| `JIRA_API_TOKEN` | API token or PAT | `ATATT3x...` | +| `JIRA_USERNAME` | Email/username for Basic auth or scoped cloud tokens | `user@example.com` | +| `JIRA_CLOUD_ID` | Atlassian Cloud ID for scoped tokens | `abcd-1234-...` | +| `JIRA_AUTH_TYPE` | `basic`, `bearer`, or `mtls` | `bearer` | +| `JIRA_TLS_CLIENT_CERT` | mTLS client certificate path | `~/.certs/client.pem` | +| `JIRA_TLS_CLIENT_KEY` | mTLS client key path | `~/.certs/client.key` | +| `JIRA_TLS_CA_CERT` | Optional CA certificate path | `~/.certs/ca.pem` | +| `JIRA_API_VERSION` | API version behavior: `auto`, `2`, or `3` | `auto` | + +### CLI configuration + +Bearer auth: + +```sh +jira config --server https://your-site.atlassian.net --token +``` + +Basic auth: + +```sh +jira config --server https://your-site.atlassian.net --username user@example.com --token +``` + +Scoped cloud token: + +```sh +jira config --server https://your-site.atlassian.net --username user@example.com --token --cloud-id +``` + +mTLS: + +```sh +jira config --server https://jira.example.com --auth-type mtls --tls-client-cert ~/.certs/client.pem --tls-client-key ~/.certs/client.key --tls-ca-cert ~/.certs/ca.pem +``` + +Show current configuration: + +```sh +jira config --show +``` + +### Global options + +These apply at the root level: + +```sh +jira [--config ] [--verbose] [--no-color] +``` + +| Option | Description | +|---|---| +| `--config ` | Use a specific config file path | +| `--verbose` | Enable verbose output | +| `--no-color` | Disable ANSI color output | + +## CLI conventions + +- The CLI is non-interactive. +- Provide explicit flags instead of expecting prompts. +- Use `--force` for destructive or overwrite-style operations. +- Use `--description-file` for multiline issue content. +- Use `--file` where supported for multiline comment or payload input. +- If a command errors, prefer surfacing the exact follow-up command the user should run. + +## Commands Reference + +### `config` + +Show or set configuration values. + +```sh +jira config [--show] [--server ] [--username ] [--token ] [--cloud-id ] [--auth-type ] [--tls-client-cert ] [--tls-client-key ] [--tls-ca-cert ] +``` + +Important options: + +| Option | Description | +|---|---| +| `--show` | Display current configuration | +| `--server ` | Jira base URL | +| `--username ` | Username/email for Basic auth | +| `--token ` | API token | +| `--cloud-id ` | Enables Atlassian scoped token routing | +| `--auth-type ` | `basic`, `bearer`, or `mtls` | +| `--tls-client-cert ` | mTLS client certificate | +| `--tls-client-key ` | mTLS client key | +| `--tls-ca-cert ` | Optional mTLS CA certificate | + +```sh +jira config --show +jira config --server https://your-site.atlassian.net --token +``` + +### `config get [key]` + +Read a configuration value. + +```sh +jira config get [key] +``` + +```sh +jira config get +jira config get server +``` + +### `config set ` + +Set a single configuration value. + +```sh +jira config set +``` + +```sh +jira config set server https://your-site.atlassian.net +jira config set cloudId +jira config set apiVersion auto +``` + +### `config unset ` + +Remove a single configuration value. + +```sh +jira config unset +``` + +```sh +jira config unset cloudId +``` + +### `issue list` + +List issues with filtering. + +```sh +jira issue list [--project ] [--assignee ] [--status ] [--type ] [--reporter ] [--priority ] [--created ] [--updated ] [--limit ] [--jql ] +``` + +Important options: + +| Option | Description | +|---|---| +| `--project ` | Filter by project key | +| `--assignee ` | Filter by assignee, including `currentUser` | +| `--status ` | Filter by status | +| `--type ` | Filter by issue type | +| `--reporter ` | Filter by reporter | +| `--priority ` | Filter by priority | +| `--created ` | Created since date like `-7d` or `2023-01-01` | +| `--updated ` | Updated since date | +| `--limit ` | Result limit | +| `--jql ` | Extra JQL expression | + +```sh +jira issue list +jira issue list --assignee=currentUser --status=Open +jira issue list --project=TEST --type=Bug --limit=50 +``` + +### `issue view ` + +View issue details. + +```sh +jira issue view [--format terminal|markdown] [--output ] +``` + +| Option | Default | Description | +|---|---|---| +| `--format` | `terminal` | Output format | +| `--output ` | none | Save the rendered output to a file | + +```sh +jira issue view PROJ-123 +jira issue view PROJ-123 --format markdown --output ./issue.md +``` + +### `issue create` + +Create a new issue. + +```sh +jira issue create --project --type --summary [--description ] [--description-file ] [--assignee ] [--priority ] +``` + +Important options: + +| Option | Description | +|---|---| +| `--project ` | Required project key | +| `--type ` | Required issue type | +| `--summary ` | Required summary | +| `--description ` | Inline description | +| `--description-file ` | File-backed multiline description | +| `--assignee ` | Initial assignee | +| `--priority ` | Initial priority | + +```sh +jira issue create --project PROJ --type Bug --summary "Login fails" +jira issue create --project PROJ --type Story --summary "Add dashboard" --description-file ./dashboard.md +``` + +### `issue edit ` + +Update an existing issue. + +```sh +jira issue edit [--summary ] [--description ] [--description-file ] [--assignee ] [--priority ] +``` + +```sh +jira issue edit PROJ-123 --summary "Updated summary" +jira issue edit PROJ-123 --description-file ./new-description.md +``` + +### `issue delete ` + +Delete an issue. + +```sh +jira issue delete --force +``` + +`--force` is required; otherwise the command should fail without deleting anything. + +### `issue comment add [text]` + +Add a comment to an issue. + +```sh +jira issue comment add [text] [--file ] [--internal] +``` + +| Option | Description | +|---|---| +| `--file ` | Read the comment body from a file | +| `--internal` | Mark the comment as internal/private | + +```sh +jira issue comment add PROJ-123 "Investigation complete" +jira issue comment add PROJ-123 --file ./notes.md --internal +``` + +### `issue comment list ` + +List comments on an issue. + +```sh +jira issue comment list [--format table|json] +``` + +```sh +jira issue comment list PROJ-123 +jira issue comment list PROJ-123 --format json +``` + +### `issue comment edit [text]` + +Edit an existing comment. + +```sh +jira issue comment edit [text] [--file ] +``` + +```sh +jira issue comment edit 12345 "Updated comment" +jira issue comment edit 12345 --file ./updated-notes.md +``` + +### `issue comment delete ` + +Delete a comment. + +```sh +jira issue comment delete --force +``` + +### `issue remote-link list ` + +List remote links for an issue. + +```sh +jira issue remote-link list [--format table|json] [--global-id ] +``` + +```sh +jira issue remote-link list PROJ-123 +jira issue remote-link list PROJ-123 --global-id https://example.com/resource --format json +``` + +### `issue remote-link add ` + +Add a remote link. + +```sh +jira issue remote-link add --url --title [--global-id <id>] [--relationship <relationship>] [--summary <summary>] [--icon-url <url>] [--icon-title <title>] +``` + +Important options: + +| Option | Description | +|---|---| +| `--url <url>` | Required external resource URL | +| `--title <title>` | Required link title | +| `--global-id <id>` | Stable identifier for idempotent upserts | +| `--relationship <relationship>` | Relationship text | +| `--summary <summary>` | Optional summary | +| `--icon-url <url>` | Optional icon URL | +| `--icon-title <title>` | Optional icon alt text | + +```sh +jira issue remote-link add PROJ-123 --url https://example.com/spec --title "Spec" +``` + +### `issue remote-link update <key> <linkId>` + +Update an existing remote link. + +```sh +jira issue remote-link update <key> <linkId> [--url <url>] [--title <title>] [--relationship <relationship>] [--summary <summary>] [--icon-url <url>] [--icon-title <title>] +``` + +```sh +jira issue remote-link update PROJ-123 10001 --title "Updated spec" +``` + +### `issue remote-link delete <key> <linkId>` + +Delete a remote link. + +```sh +jira issue remote-link delete <key> <linkId> --force +``` + +### `project` + +List projects by default, or fetch a project directly with `--get`. + +```sh +jira project [--get <key>] +``` + +```sh +jira project +jira project --get PROJ +``` + +### `project list` + +List projects with optional filtering. + +```sh +jira project list [--type <type>] [--category <category>] +``` + +```sh +jira project list +jira project list --type software --category Platform +``` + +### `project view <key>` + +View project details. + +```sh +jira project view <key> +``` + +### `project components <key>` + +List project components. + +```sh +jira project components <key> +``` + +### `project versions <key>` + +List project versions. + +```sh +jira project versions <key> +``` + +### `sprint` + +List sprints by default, optionally constrained to a board or active-only view. + +If multiple boards exist, the CLI may require `--board <id>` instead of guessing. + +```sh +jira sprint [--board <id>] [--active] +``` + +```sh +jira sprint --board 12 +jira sprint --board 12 --active +``` + +### `sprint list` + +List sprints. + +```sh +jira sprint list [--board <id>] [--active] [--state <state>] +``` + +| Option | Description | +|---|---| +| `--board <id>` | Restrict to a board | +| `--active` | Show only active sprints | +| `--state <state>` | Filter by `active`, `future`, or `closed` | + +```sh +jira sprint list --board 12 +jira sprint list --board 12 --state active +``` + +### `sprint active` + +List active sprints. + +```sh +jira sprint active [--board <id>] +``` + +### `sprint boards` + +List available boards. + +```sh +jira sprint boards +``` + +### `install-skill` + +Copy this skill into the current project. + +```sh +jira install-skill [--dest <directory>] [--force] +``` + +| Option | Default | Description | +|---|---|---| +| `--dest <directory>` | `./.claude/skills/jira` | Target directory | +| `--force` | false | Overwrite an existing installed skill | + +```sh +jira install-skill +jira install-skill --force +jira install-skill --dest ./custom/skills/jira +``` + +## Workflow examples + +### Investigate open issues for the current user + +```sh +jira issue list --assignee=currentUser --status=Open +``` + +### Export an issue as markdown for offline review + +```sh +jira issue view PROJ-123 --format markdown --output ./issue.md +``` + +### Create an issue from a detailed spec file + +```sh +jira issue create --project PROJ --type Story --summary "Add dashboard" --description-file ./dashboard.md +``` + +### Inspect boards before querying sprint state + +```sh +jira sprint boards +jira sprint list --board 12 +``` diff --git a/tests/commands/install-skill.test.js b/tests/commands/install-skill.test.js new file mode 100644 index 0000000..986db1e --- /dev/null +++ b/tests/commands/install-skill.test.js @@ -0,0 +1,161 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const createInstallSkillCommand = require('../../bin/commands/install-skill'); + +describe('InstallSkillCommand', () => { + let mockFactory; + let mockIOStreams; + let installSkillCommand; + let tmpDir; + let cwd; + let exitSpy; + let mockAnalytics; + + beforeEach(() => { + mockIOStreams = { + out: jest.fn(), + println: jest.fn(), + printError: jest.fn(), + printSuccess: jest.fn(), + success: jest.fn(), + error: jest.fn(), + info: jest.fn(), + colorize: jest.fn(), + print: jest.fn(), + printErr: jest.fn() + }; + + mockAnalytics = { + track: jest.fn().mockResolvedValue() + }; + + mockFactory = { + getIOStreams: jest.fn(() => mockIOStreams), + getAnalytics: jest.fn(() => mockAnalytics) + }; + + installSkillCommand = createInstallSkillCommand(mockFactory); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jira-install-skill-')); + cwd = process.cwd(); + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + describe('command structure', () => { + it('should create install-skill command', () => { + expect(installSkillCommand.name()).toBe('install-skill'); + expect(installSkillCommand.description()).toContain('install the packaged Jira skill'); + }); + + it('should define dest and force options', () => { + const destOption = installSkillCommand.options.find((opt) => opt.long === '--dest'); + const forceOption = installSkillCommand.options.find((opt) => opt.long === '--force'); + + expect(destOption).toBeDefined(); + expect(destOption.defaultValue).toBe('./.claude/skills/jira'); + expect(forceOption).toBeDefined(); + expect(forceOption.short).toBe('-f'); + }); + }); + + describe('command execution', () => { + it('should install the packaged skill into the default destination', async () => { + process.chdir(tmpDir); + + await installSkillCommand.parseAsync(['node', 'jira']); + + const installedSkill = path.join(tmpDir, '.claude', 'skills', 'jira', 'SKILL.md'); + const resolvedInstalledSkill = fs.realpathSync(installedSkill); + + expect(fs.existsSync(installedSkill)).toBe(true); + expect(fs.readFileSync(installedSkill, 'utf8')).toContain('# jira'); + expect(mockIOStreams.success).toHaveBeenCalledWith('Skill installed successfully'); + expect(mockIOStreams.info).toHaveBeenCalledWith(`Location: ${resolvedInstalledSkill}`); + expect(mockAnalytics.track).toHaveBeenCalledWith('install-skill', { overwritten: false }); + }); + + it('should install the packaged skill into a custom destination', async () => { + const destination = path.join(tmpDir, 'custom-skill-dir'); + + await installSkillCommand.parseAsync(['node', 'jira', '--dest', destination]); + + expect(fs.existsSync(path.join(destination, 'SKILL.md'))).toBe(true); + }); + + it('should error when the packaged skill file is missing', async () => { + const sourceFile = path.join( + __dirname, + '..', + '..', + 'skills', + 'jira', + 'SKILL.md' + ); + const originalExistsSync = fs.existsSync; + jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { + if (filePath === sourceFile) { + return false; + } + + return originalExistsSync(filePath); + }); + + await installSkillCommand.parseAsync(['node', 'jira']); + + expect(mockIOStreams.error).toHaveBeenCalledWith( + expect.stringContaining('packaged skill file not found') + ); + expect(mockAnalytics.track).toHaveBeenCalledWith('install-skill', { + overwritten: false, + success: false + }); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when the destination already exists without --force', async () => { + const destination = path.join(tmpDir, 'existing-skill'); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(destination, 'SKILL.md'), 'existing'); + + await installSkillCommand.parseAsync(['node', 'jira', '--dest', destination]); + + expect(mockIOStreams.error).toHaveBeenCalledWith( + expect.stringContaining('Re-run with --force to overwrite') + ); + expect(mockAnalytics.track).toHaveBeenCalledWith('install-skill', { + overwritten: false, + success: false + }); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(fs.readFileSync(path.join(destination, 'SKILL.md'), 'utf8')).toBe('existing'); + }); + + it('should overwrite the destination when --force is provided', async () => { + const destination = path.join(tmpDir, 'existing-skill'); + const destinationFile = path.join(destination, 'SKILL.md'); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(destinationFile, 'existing'); + + await installSkillCommand.parseAsync(['node', 'jira', '--dest', destination, '--force']); + + expect(fs.readFileSync(destinationFile, 'utf8')).toContain('# jira'); + expect(mockIOStreams.success).toHaveBeenCalledWith('Skill installed successfully'); + expect(mockAnalytics.track).toHaveBeenCalledWith('install-skill', { overwritten: true }); + }); + + it('should expand ~/ in the destination path', async () => { + const homeDir = path.join(tmpDir, 'home'); + jest.spyOn(os, 'homedir').mockReturnValue(homeDir); + + await installSkillCommand.parseAsync(['node', 'jira', '--dest', '~/skills/jira']); + + expect(fs.existsSync(path.join(homeDir, 'skills', 'jira', 'SKILL.md'))).toBe(true); + }); + }); +});