@knpkv/jira-cli 0.1.1 → 1.0.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 (143) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +66 -4
  3. package/dist/IssueService.d.ts +2 -2
  4. package/dist/IssueService.d.ts.map +1 -1
  5. package/dist/IssueService.js +3 -3
  6. package/dist/IssueService.js.map +1 -1
  7. package/dist/JiraAuth.d.ts +14 -14
  8. package/dist/JiraAuth.d.ts.map +1 -1
  9. package/dist/JiraAuth.js +18 -10
  10. package/dist/JiraAuth.js.map +1 -1
  11. package/dist/JiraCliError.d.ts +30 -0
  12. package/dist/JiraCliError.d.ts.map +1 -1
  13. package/dist/JiraCliError.js +14 -0
  14. package/dist/JiraCliError.js.map +1 -1
  15. package/dist/MarkdownWriter.d.ts +4 -4
  16. package/dist/MarkdownWriter.d.ts.map +1 -1
  17. package/dist/MarkdownWriter.js +6 -6
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +34 -0
  20. package/dist/SyncWorkspace.d.ts.map +1 -0
  21. package/dist/SyncWorkspace.js +105 -0
  22. package/dist/SyncWorkspace.js.map +1 -0
  23. package/dist/VersionService.d.ts +206 -0
  24. package/dist/VersionService.d.ts.map +1 -0
  25. package/dist/VersionService.js +426 -0
  26. package/dist/VersionService.js.map +1 -0
  27. package/dist/bin.js +29 -22
  28. package/dist/bin.js.map +1 -1
  29. package/dist/commands/auth.d.ts +2 -21
  30. package/dist/commands/auth.d.ts.map +1 -1
  31. package/dist/commands/auth.js +6 -6
  32. package/dist/commands/auth.js.map +1 -1
  33. package/dist/commands/get.d.ts +3 -8
  34. package/dist/commands/get.d.ts.map +1 -1
  35. package/dist/commands/get.js +4 -4
  36. package/dist/commands/get.js.map +1 -1
  37. package/dist/commands/index.d.ts +2 -2
  38. package/dist/commands/index.d.ts.map +1 -1
  39. package/dist/commands/index.js +2 -2
  40. package/dist/commands/index.js.map +1 -1
  41. package/dist/commands/issue.d.ts +8 -0
  42. package/dist/commands/issue.d.ts.map +1 -0
  43. package/dist/commands/issue.js +10 -0
  44. package/dist/commands/issue.js.map +1 -0
  45. package/dist/commands/layers.d.ts +6 -18
  46. package/dist/commands/layers.d.ts.map +1 -1
  47. package/dist/commands/layers.js +35 -24
  48. package/dist/commands/layers.js.map +1 -1
  49. package/dist/commands/search.d.ts +3 -8
  50. package/dist/commands/search.d.ts.map +1 -1
  51. package/dist/commands/search.js +8 -8
  52. package/dist/commands/search.js.map +1 -1
  53. package/dist/commands/version.d.ts +12 -0
  54. package/dist/commands/version.d.ts.map +1 -0
  55. package/dist/commands/version.js +179 -0
  56. package/dist/commands/version.js.map +1 -0
  57. package/dist/internal/frontmatter.d.ts.map +1 -1
  58. package/dist/internal/frontmatter.js +14 -1
  59. package/dist/internal/frontmatter.js.map +1 -1
  60. package/dist/internal/oauthServer.d.ts +17 -5
  61. package/dist/internal/oauthServer.d.ts.map +1 -1
  62. package/dist/internal/oauthServer.js +23 -40
  63. package/dist/internal/oauthServer.js.map +1 -1
  64. package/dist/internal/openBrowser.d.ts +10 -0
  65. package/dist/internal/openBrowser.d.ts.map +1 -0
  66. package/dist/internal/openBrowser.js +17 -0
  67. package/dist/internal/openBrowser.js.map +1 -0
  68. package/dist/internal/sync/baseline.d.ts +11 -0
  69. package/dist/internal/sync/baseline.d.ts.map +1 -0
  70. package/dist/internal/sync/baseline.js +18 -0
  71. package/dist/internal/sync/baseline.js.map +1 -0
  72. package/dist/internal/sync/changes.d.ts +15 -0
  73. package/dist/internal/sync/changes.d.ts.map +1 -0
  74. package/dist/internal/sync/changes.js +72 -0
  75. package/dist/internal/sync/changes.js.map +1 -0
  76. package/dist/internal/sync/config.d.ts +12 -0
  77. package/dist/internal/sync/config.d.ts.map +1 -0
  78. package/dist/internal/sync/config.js +53 -0
  79. package/dist/internal/sync/config.js.map +1 -0
  80. package/dist/internal/sync/document.d.ts +9 -0
  81. package/dist/internal/sync/document.d.ts.map +1 -0
  82. package/dist/internal/sync/document.js +173 -0
  83. package/dist/internal/sync/document.js.map +1 -0
  84. package/dist/internal/sync/fieldValues.d.ts +30 -0
  85. package/dist/internal/sync/fieldValues.d.ts.map +1 -0
  86. package/dist/internal/sync/fieldValues.js +91 -0
  87. package/dist/internal/sync/fieldValues.js.map +1 -0
  88. package/dist/internal/sync/manifest.d.ts +12 -0
  89. package/dist/internal/sync/manifest.d.ts.map +1 -0
  90. package/dist/internal/sync/manifest.js +23 -0
  91. package/dist/internal/sync/manifest.js.map +1 -0
  92. package/dist/internal/sync/paths.d.ts +26 -0
  93. package/dist/internal/sync/paths.d.ts.map +1 -0
  94. package/dist/internal/sync/paths.js +22 -0
  95. package/dist/internal/sync/paths.js.map +1 -0
  96. package/dist/internal/sync/schemas.d.ts +128 -0
  97. package/dist/internal/sync/schemas.d.ts.map +1 -0
  98. package/dist/internal/sync/schemas.js +82 -0
  99. package/dist/internal/sync/schemas.js.map +1 -0
  100. package/dist/internal/sync/types.d.ts +144 -0
  101. package/dist/internal/sync/types.d.ts.map +1 -0
  102. package/dist/internal/sync/types.js +17 -0
  103. package/dist/internal/sync/types.js.map +1 -0
  104. package/package.json +13 -12
  105. package/skills/jira/SKILL.md +90 -0
  106. package/skills/jira/agents/openai.yaml +4 -0
  107. package/src/IssueService.ts +34 -28
  108. package/src/JiraAuth.ts +53 -39
  109. package/src/JiraCliError.ts +24 -0
  110. package/src/MarkdownWriter.ts +7 -11
  111. package/src/SyncWorkspace.ts +185 -0
  112. package/src/VersionService.ts +647 -0
  113. package/src/bin.ts +39 -29
  114. package/src/commands/auth.ts +6 -12
  115. package/src/commands/get.ts +4 -4
  116. package/src/commands/index.ts +2 -2
  117. package/src/commands/issue.ts +13 -0
  118. package/src/commands/layers.ts +44 -26
  119. package/src/commands/search.ts +8 -8
  120. package/src/commands/version.ts +267 -0
  121. package/src/internal/frontmatter.ts +15 -1
  122. package/src/internal/oauthServer.ts +43 -70
  123. package/src/internal/openBrowser.ts +31 -0
  124. package/src/internal/sync/baseline.ts +27 -0
  125. package/src/internal/sync/changes.ts +118 -0
  126. package/src/internal/sync/config.ts +76 -0
  127. package/src/internal/sync/document.ts +201 -0
  128. package/src/internal/sync/fieldValues.ts +145 -0
  129. package/src/internal/sync/manifest.ts +32 -0
  130. package/src/internal/sync/paths.ts +48 -0
  131. package/src/internal/sync/schemas.ts +103 -0
  132. package/src/internal/sync/types.ts +192 -0
  133. package/test/SyncWorkspace.test.ts +76 -0
  134. package/test/VersionService.test.ts +266 -0
  135. package/test/commandTree.test.ts +266 -0
  136. package/test/frontmatter.test.ts +69 -0
  137. package/test/integration.test.ts +187 -0
  138. package/test/syncChanges.test.ts +106 -0
  139. package/test/syncConfig.test.ts +138 -0
  140. package/test/syncDocument.test.ts +69 -0
  141. package/test/syncFieldValues.test.ts +101 -0
  142. package/vitest.config.integration.ts +17 -0
  143. package/vitest.config.ts +6 -0
package/src/bin.ts CHANGED
@@ -2,61 +2,71 @@
2
2
  /**
3
3
  * CLI entry point — assembles commands, selects layer by subcommand, runs via `NodeRuntime`.
4
4
  *
5
- * `process.argv` is read once at the edge and passed to Effect no globals in effectful code.
5
+ * Arguments are read from Effect's `Stdio` service at the runtime edge.
6
6
  *
7
7
  * @module
8
8
  */
9
- import { Command } from "@effect/cli"
10
- import { NodeRuntime } from "@effect/platform-node"
9
+ import { NodeRuntime, NodeStdio } from "@effect/platform-node"
10
+ import { makeInstallCommand } from "@knpkv/agent-skills"
11
+ import * as Console from "effect/Console"
11
12
  import * as Effect from "effect/Effect"
12
- import * as Logger from "effect/Logger"
13
- import * as LogLevel from "effect/LogLevel"
13
+ import * as Stdio from "effect/Stdio"
14
+ import { Command } from "effect/unstable/cli"
14
15
  import pkg from "../package.json" with { type: "json" }
15
16
  import {
16
17
  AppLayer,
17
18
  authCommand,
18
19
  AuthOnlyLayer,
19
- getCommand,
20
20
  getLayerType,
21
21
  handleError,
22
+ issueCommand,
22
23
  MinimalLayer,
23
- searchCommand
24
+ versionCommand
24
25
  } from "./commands/index.js"
25
26
 
27
+ const skillsInstall = makeInstallCommand({
28
+ description: "Install the Jira agent skill",
29
+ name: "install",
30
+ skills: ["jira"]
31
+ })
32
+
33
+ const skillsCommand = Command.make("skills", {}, () => Console.log("Usage: jira skills install")).pipe(
34
+ Command.withDescription("Local write agent skill commands"),
35
+ Command.withSubcommands([skillsInstall])
36
+ )
37
+
26
38
  // === Main command ===
27
39
  const jira = Command.make("jira").pipe(
28
- Command.withDescription("Fetch Jira tickets and export to markdown"),
40
+ Command.withDescription("Jira CLI commands"),
29
41
  Command.withSubcommands([
30
42
  authCommand,
31
- getCommand,
32
- searchCommand
43
+ issueCommand,
44
+ skillsCommand,
45
+ versionCommand
33
46
  ])
34
47
  )
35
48
 
36
49
  // === Run CLI ===
37
- const cli = Command.run(jira, {
38
- name: pkg.name,
50
+ const cli = Command.runWith(jira, {
39
51
  version: pkg.version
40
52
  })
41
53
 
42
- // Read argv once at the edge
43
- const argv = globalThis.process.argv
44
-
45
- const layerType = getLayerType(argv)
46
- const layer = layerType === "full"
47
- ? AppLayer
48
- : layerType === "auth"
49
- ? AuthOnlyLayer
50
- : MinimalLayer
51
-
52
- // Suppress verbose Effect logs
53
- const SilentLogger = Logger.replace(Logger.defaultLogger, Logger.none)
54
+ const program = Effect.gen(function*() {
55
+ const stdio = yield* Stdio.Stdio
56
+ const args = yield* stdio.args
57
+ const layerType = getLayerType(args)
58
+ const layer = layerType === "full"
59
+ ? AppLayer
60
+ : layerType === "auth"
61
+ ? AuthOnlyLayer
62
+ : MinimalLayer
54
63
 
55
- const program = cli(argv).pipe(
56
- Effect.provide(layer),
57
- Effect.provide(SilentLogger),
58
- Logger.withMinimumLogLevel(LogLevel.None),
59
- Effect.catchAllCause((cause) => handleError(cause))
64
+ return yield* cli(args).pipe(
65
+ Effect.provide(layer)
66
+ )
67
+ }).pipe(
68
+ Effect.provide(NodeStdio.layer),
69
+ Effect.catchCause((cause) => handleError(cause))
60
70
  )
61
71
 
62
72
  NodeRuntime.runMain(program)
@@ -8,11 +8,11 @@
8
8
  *
9
9
  * @internal
10
10
  */
11
- import { Command, Options, Prompt } from "@effect/cli"
12
- import * as PlatformCommand from "@effect/platform/Command"
13
11
  import * as Console from "effect/Console"
14
12
  import * as Effect from "effect/Effect"
15
13
  import * as Option from "effect/Option"
14
+ import { Command, Flag as Options, Prompt } from "effect/unstable/cli"
15
+ import { openBrowser } from "../internal/openBrowser.js"
16
16
  import { JiraAuth } from "../JiraAuth.js"
17
17
 
18
18
  // === Auth create command ===
@@ -38,21 +38,15 @@ Creating OAuth app in Atlassian Developer Console...
38
38
  6. Run: jira auth configure --client-id <ID> --client-secret <SECRET>
39
39
  `)
40
40
  const url = "https://developer.atlassian.com/console/myapps/create-3lo-app/"
41
- yield* PlatformCommand.make("open", url).pipe(
42
- PlatformCommand.exitCode,
43
- Effect.catchAll(() => PlatformCommand.make("xdg-open", url).pipe(PlatformCommand.exitCode)),
44
- Effect.catchAll(() => PlatformCommand.make("cmd", "/c", "start", "", url).pipe(PlatformCommand.exitCode)),
45
- Effect.asVoid,
46
- Effect.catchAll(() => Effect.void)
47
- )
41
+ yield* openBrowser(url).pipe(Effect.catch(() => Effect.void))
48
42
  })).pipe(Command.withDescription("Create OAuth app in Atlassian Developer Console"))
49
43
 
50
44
  // === Auth configure command ===
51
- const clientIdOption = Options.text("client-id").pipe(
45
+ const clientIdOption = Options.string("client-id").pipe(
52
46
  Options.withDescription("OAuth client ID from Atlassian Developer Console"),
53
47
  Options.optional
54
48
  )
55
- const clientSecretOption = Options.text("client-secret").pipe(
49
+ const clientSecretOption = Options.string("client-secret").pipe(
56
50
  Options.withDescription("OAuth client secret"),
57
51
  Options.optional
58
52
  )
@@ -77,7 +71,7 @@ const configureCommand = Command.make(
77
71
  ).pipe(Command.withDescription("Configure OAuth client credentials"))
78
72
 
79
73
  // === Auth login command ===
80
- const siteOption = Options.text("site").pipe(
74
+ const siteOption = Options.string("site").pipe(
81
75
  Options.withDescription("Jira site URL to use (for accounts with multiple sites)"),
82
76
  Options.optional
83
77
  )
@@ -1,15 +1,15 @@
1
1
  /**
2
- * `jira get <key>` command — fetches a single issue and writes to Markdown.
2
+ * `jira issue get <key>` command — fetches a single issue and writes to Markdown.
3
3
  *
4
4
  * @internal
5
5
  */
6
- import { Args, Command, Options } from "@effect/cli"
7
6
  import * as Console from "effect/Console"
8
7
  import * as Effect from "effect/Effect"
8
+ import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
9
9
  import { IssueService } from "../IssueService.js"
10
10
  import { MarkdownWriter } from "../MarkdownWriter.js"
11
11
 
12
- const keyArg = Args.text({ name: "key" }).pipe(
12
+ const keyArg = Args.string("key").pipe(
13
13
  Args.withDescription("Issue key (e.g., PROJ-123)")
14
14
  )
15
15
 
@@ -39,4 +39,4 @@ export const getCommand = Command.make(
39
39
 
40
40
  yield* Console.log(`Done.`)
41
41
  })
42
- ).pipe(Command.withDescription("Get a single Jira issue by key"))
42
+ ).pipe(Command.withDescription("Read-only: get a single Jira issue by key"))
@@ -6,6 +6,6 @@
6
6
 
7
7
  export { authCommand } from "./auth.js"
8
8
  export { handleError } from "./errorHandler.js"
9
- export { getCommand } from "./get.js"
9
+ export { issueCommand } from "./issue.js"
10
10
  export { AppLayer, AuthOnlyLayer, getLayerType, MinimalLayer } from "./layers.js"
11
- export { searchCommand } from "./search.js"
11
+ export { versionCommand } from "./version.js"
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `jira issue` command namespace.
3
+ *
4
+ * @internal
5
+ */
6
+ import { Command } from "effect/unstable/cli"
7
+ import { getCommand } from "./get.js"
8
+ import { searchCommand } from "./search.js"
9
+
10
+ export const issueCommand = Command.make("issue").pipe(
11
+ Command.withDescription("Read-only Jira issue commands"),
12
+ Command.withSubcommands([getCommand, searchCommand])
13
+ )
@@ -3,61 +3,73 @@
3
3
  *
4
4
  * **Mental model**
5
5
  *
6
- * - **Lazy layer selection**: {@link getLayerType} inspects `process.argv[2]` to pick
6
+ * - **Lazy layer selection**: {@link getLayerType} inspects CLI arguments from `Stdio` to pick
7
7
  * the smallest layer needed — `"minimal"` for help/version, `"auth"` for auth commands,
8
- * `"full"` for search/get (which needs API client + issue service).
9
- * - **Dummy services**: Auth-only and minimal layers provide `Effect.dieMessage` stubs
8
+ * `"full"` for issue/version reads and writes.
9
+ * - **Dummy services**: Auth-only and minimal layers provide dying stubs
10
10
  * for unused services to satisfy the type system without initialization cost.
11
11
  *
12
12
  * @internal
13
13
  */
14
- import * as NodeContext from "@effect/platform-node/NodeContext"
15
14
  import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
15
+ import * as NodeServices from "@effect/platform-node/NodeServices"
16
16
  import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
17
17
  import * as Effect from "effect/Effect"
18
18
  import * as Layer from "effect/Layer"
19
19
  import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../IssueService.js"
20
20
  import { JiraAuth, layer as JiraAuthLayer } from "../JiraAuth.js"
21
21
  import { layer as MarkdownWriterLayer, MarkdownWriter } from "../MarkdownWriter.js"
22
+ import { layer as VersionServiceLayer, VersionService } from "../VersionService.js"
22
23
 
23
24
  // Dummy services for auth-only commands
24
25
  const DummyIssueServiceLayer = Layer.succeed(
25
26
  IssueService,
26
27
  IssueService.of({
27
- getByKey: () => Effect.dieMessage("Not configured - run 'jira auth login' first"),
28
- search: () => Effect.dieMessage("Not configured - run 'jira auth login' first"),
29
- searchAll: () => Effect.dieMessage("Not configured - run 'jira auth login' first")
28
+ getByKey: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
29
+ search: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
30
+ searchAll: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
30
31
  })
31
32
  )
32
33
 
33
34
  const DummyMarkdownWriterLayer = Layer.succeed(
34
35
  MarkdownWriter,
35
36
  MarkdownWriter.of({
36
- writeMulti: () => Effect.dieMessage("Not configured"),
37
- writeSingle: () => Effect.dieMessage("Not configured")
37
+ writeMulti: () => Effect.die(new Error("Not configured")),
38
+ writeSingle: () => Effect.die(new Error("Not configured"))
39
+ })
40
+ )
41
+
42
+ const DummyVersionServiceLayer = Layer.succeed(
43
+ VersionService,
44
+ VersionService.of({
45
+ listProjectVersions: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
46
+ getVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
47
+ updateVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
48
+ listRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
49
+ addRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
38
50
  })
39
51
  )
40
52
 
41
53
  const DummyJiraAuthLayer = Layer.succeed(
42
54
  JiraAuth,
43
55
  JiraAuth.of({
44
- configure: () => Effect.dieMessage("Not configured"),
56
+ configure: () => Effect.die(new Error("Not configured")),
45
57
  isConfigured: () => Effect.succeed(false),
46
- login: () => Effect.dieMessage("Not configured"),
47
- logout: () => Effect.dieMessage("Not configured"),
48
- getAccessToken: () => Effect.dieMessage("Not configured"),
49
- getCloudId: () => Effect.dieMessage("Not configured"),
50
- getSiteUrl: () => Effect.dieMessage("Not configured"),
58
+ login: () => Effect.die(new Error("Not configured")),
59
+ logout: () => Effect.die(new Error("Not configured")),
60
+ getAccessToken: () => Effect.die(new Error("Not configured")),
61
+ getCloudId: () => Effect.die(new Error("Not configured")),
62
+ getSiteUrl: () => Effect.die(new Error("Not configured")),
51
63
  getCurrentUser: () => Effect.succeed(null),
52
64
  isLoggedIn: () => Effect.succeed(false)
53
65
  })
54
66
  )
55
67
 
56
68
  // Auth layer with HTTP client
57
- const AuthLive = JiraAuthLayer.pipe(Layer.provide(NodeHttpClient.layer))
69
+ const AuthLive = JiraAuthLayer.pipe(Layer.provide(Layer.mergeAll(NodeHttpClient.layerUndici, NodeServices.layer)))
58
70
 
59
71
  // Build Jira API config layer dynamically based on auth
60
- const JiraConfigLive = Layer.unwrapEffect(
72
+ const JiraConfigLive = Layer.unwrap(
61
73
  Effect.gen(function*() {
62
74
  const auth = yield* JiraAuth
63
75
  const accessToken = yield* auth.getAccessToken()
@@ -80,7 +92,7 @@ const JiraClientLive = JiraApiClient.layer.pipe(
80
92
  )
81
93
 
82
94
  // Build SiteUrl layer from auth
83
- const SiteUrlLive = Layer.unwrapEffect(
95
+ const SiteUrlLive = Layer.unwrap(
84
96
  Effect.gen(function*() {
85
97
  const auth = yield* JiraAuth
86
98
  const siteUrl = yield* auth.getSiteUrl()
@@ -95,11 +107,12 @@ const SiteUrlLive = Layer.unwrapEffect(
95
107
  */
96
108
  export const AppLayer = MarkdownWriterLayer.pipe(
97
109
  Layer.provideMerge(IssueServiceLayer),
110
+ Layer.provideMerge(VersionServiceLayer),
98
111
  Layer.provideMerge(SiteUrlLive),
99
112
  Layer.provideMerge(JiraClientLive),
100
113
  Layer.provideMerge(AuthLive),
101
- Layer.provideMerge(NodeHttpClient.layer),
102
- Layer.provideMerge(NodeContext.layer)
114
+ Layer.provideMerge(NodeHttpClient.layerUndici),
115
+ Layer.provideMerge(NodeServices.layer)
103
116
  )
104
117
 
105
118
  /**
@@ -109,9 +122,10 @@ export const AppLayer = MarkdownWriterLayer.pipe(
109
122
  */
110
123
  export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
111
124
  Layer.provideMerge(DummyMarkdownWriterLayer),
125
+ Layer.provideMerge(DummyVersionServiceLayer),
112
126
  Layer.provideMerge(AuthLive),
113
- Layer.provideMerge(NodeHttpClient.layer),
114
- Layer.provideMerge(NodeContext.layer)
127
+ Layer.provideMerge(NodeHttpClient.layerUndici),
128
+ Layer.provideMerge(NodeServices.layer)
115
129
  )
116
130
 
117
131
  /**
@@ -121,8 +135,9 @@ export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
121
135
  */
122
136
  export const MinimalLayer = DummyIssueServiceLayer.pipe(
123
137
  Layer.provideMerge(DummyMarkdownWriterLayer),
138
+ Layer.provideMerge(DummyVersionServiceLayer),
124
139
  Layer.provideMerge(DummyJiraAuthLayer),
125
- Layer.provideMerge(NodeContext.layer)
140
+ Layer.provideMerge(NodeServices.layer)
126
141
  )
127
142
 
128
143
  /**
@@ -130,12 +145,15 @@ export const MinimalLayer = DummyIssueServiceLayer.pipe(
130
145
  *
131
146
  * @category Utilities
132
147
  */
133
- export const getLayerType = (argv: ReadonlyArray<string>): "full" | "auth" | "minimal" => {
134
- const cmd = argv[2]
148
+ export const getLayerType = (args: ReadonlyArray<string>): "full" | "auth" | "minimal" => {
149
+ const cmd = args[0]
150
+ if (args.includes("--help") || args.includes("-h")) {
151
+ return "minimal"
152
+ }
135
153
  if (cmd === "auth") {
136
154
  return "auth"
137
155
  }
138
- if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "--version") {
156
+ if (!cmd || cmd === "skills" || cmd === "--help" || cmd === "-h" || cmd === "--version") {
139
157
  return "minimal"
140
158
  }
141
159
  return "full"
@@ -1,29 +1,29 @@
1
1
  /**
2
- * `jira search` command — JQL search with multi/single Markdown output.
2
+ * `jira issue search` command — JQL search with multi/single Markdown output.
3
3
  *
4
4
  * @internal
5
5
  */
6
- import { Args, Command, Options } from "@effect/cli"
7
6
  import * as Console from "effect/Console"
8
7
  import * as Effect from "effect/Effect"
9
8
  import * as Option from "effect/Option"
9
+ import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
10
10
  import { buildByVersionJql } from "../internal/jqlBuilder.js"
11
11
  import { IssueService } from "../IssueService.js"
12
12
  import { MarkdownWriter } from "../MarkdownWriter.js"
13
13
 
14
14
  // === Options ===
15
- const jqlArg = Args.text({ name: "jql" }).pipe(
15
+ const jqlArg = Args.string("jql").pipe(
16
16
  Args.withDescription("JQL query to search for issues"),
17
17
  Args.optional
18
18
  )
19
19
 
20
- const byVersionOption = Options.text("by-version").pipe(
20
+ const byVersionOption = Options.string("by-version").pipe(
21
21
  Options.withAlias("v"),
22
22
  Options.withDescription("Search by fix version (pre-defined query)"),
23
23
  Options.optional
24
24
  )
25
25
 
26
- const projectOption = Options.text("project").pipe(
26
+ const projectOption = Options.string("project").pipe(
27
27
  Options.withAlias("p"),
28
28
  Options.withDescription("Filter by project key"),
29
29
  Options.optional
@@ -74,8 +74,8 @@ export const searchCommand = Command.make(
74
74
  query = jql.value
75
75
  } else {
76
76
  yield* Console.log("Error: Either a JQL query or --by-version must be provided.")
77
- yield* Console.log("Usage: jira search <jql>")
78
- yield* Console.log(" jira search --by-version <version>")
77
+ yield* Console.log("Usage: jira issue search <jql>")
78
+ yield* Console.log(" jira issue search --by-version <version>")
79
79
  return
80
80
  }
81
81
 
@@ -99,4 +99,4 @@ export const searchCommand = Command.make(
99
99
  yield* Console.log(`Exported ${issues.length} file(s) to ${outputDir}/`)
100
100
  }
101
101
  })
102
- ).pipe(Command.withDescription("Search Jira issues and export to markdown"))
102
+ ).pipe(Command.withDescription("Read-only: search Jira issues and export to markdown"))
@@ -0,0 +1,267 @@
1
+ /**
2
+ * `jira version` command — list / get Jira project versions (releases) with
3
+ * Driver, Contributors and Approver fields resolved to display names, plus
4
+ * mutations: edit the description and manage "Related work" links (the
5
+ * Confluence pages surfaced on a release report).
6
+ *
7
+ * @internal
8
+ */
9
+ import * as Console from "effect/Console"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Option from "effect/Option"
12
+ import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
13
+ import { JiraApiError } from "../JiraCliError.js"
14
+ import type { Person, Version } from "../VersionService.js"
15
+ import { VersionService } from "../VersionService.js"
16
+
17
+ /**
18
+ * Return a copy of `version` with every resolved {@link Person.emailAddress}
19
+ * (PII) set to null — covering driver, contributors, approvers[].person and
20
+ * tickets[].assignee. Used to keep emails out of `--json` output unless the
21
+ * caller opts in with `--emails`.
22
+ */
23
+ export const stripEmails = (version: Version): Version => {
24
+ const stripPerson = <P extends Person>(person: P): P => ({ ...person, emailAddress: null })
25
+ return {
26
+ ...version,
27
+ driver: version.driver ? stripPerson(version.driver) : null,
28
+ contributors: version.contributors.map(stripPerson),
29
+ approvers: version.approvers.map((a) => ({ ...a, person: stripPerson(a.person) })),
30
+ tickets: version.tickets.map((t) => ({
31
+ ...t,
32
+ assignee: t.assignee ? stripPerson(t.assignee) : null
33
+ }))
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Jira version ids are numeric (e.g. `10042`). Passing a name/key 404s with an
39
+ * opaque error, so validate early and emit a hint pointing at `version list`.
40
+ */
41
+ const ensureNumericId = (id: string): Effect.Effect<void, JiraApiError> =>
42
+ /^\d+$/.test(id)
43
+ ? Effect.void
44
+ : Effect.fail(
45
+ new JiraApiError({
46
+ message: `Invalid version id "${id}". The version id is numeric (e.g. 10042); ` +
47
+ `use 'jira version list --project <KEY>' to find it.`
48
+ })
49
+ )
50
+
51
+ const projectOption = Options.string("project").pipe(
52
+ Options.withAlias("p"),
53
+ Options.withDescription("Jira project key (e.g. RPS)")
54
+ )
55
+ const releasedOption = Options.boolean("released").pipe(
56
+ Options.withDescription("Only list released versions"),
57
+ Options.withDefault(false)
58
+ )
59
+ const unreleasedOption = Options.boolean("unreleased").pipe(
60
+ Options.withDescription("Only list unreleased versions"),
61
+ Options.withDefault(false)
62
+ )
63
+ const jsonOption = Options.boolean("json").pipe(
64
+ Options.withDescription("Output as JSON"),
65
+ Options.withDefault(false)
66
+ )
67
+ const emailsOption = Options.boolean("emails").pipe(
68
+ Options.withDescription("Include resolved user email addresses in --json output"),
69
+ Options.withDefault(false)
70
+ )
71
+ const customFieldOption = Options.string("custom-field").pipe(
72
+ Options.withDescription(
73
+ "Custom field display name to include on each ticket (repeatable, e.g. " +
74
+ "--custom-field \"Security & Compliance Impact\"). Values are exposed in " +
75
+ "ticket.customFields[<name>]."
76
+ ),
77
+ Options.atLeast(0)
78
+ )
79
+ const maxOption = Options.integer("max").pipe(
80
+ Options.withAlias("m"),
81
+ Options.withDescription("Maximum number of versions to fetch (default: all)"),
82
+ Options.optional
83
+ )
84
+
85
+ const idArg = Args.string("id").pipe(Args.withDescription("Version id (numeric)"))
86
+
87
+ const listCommand = Command.make("list", {
88
+ project: projectOption,
89
+ released: releasedOption,
90
+ unreleased: unreleasedOption,
91
+ customFields: customFieldOption,
92
+ max: maxOption,
93
+ json: jsonOption,
94
+ emails: emailsOption
95
+ }, ({ customFields, emails, json, max, project, released, unreleased }) =>
96
+ Effect.gen(function*() {
97
+ if (released && unreleased) {
98
+ return yield* Effect.fail(
99
+ new JiraApiError({
100
+ message: "--released and --unreleased are mutually exclusive; pass at most one (omit both to list all)."
101
+ })
102
+ )
103
+ }
104
+ const service = yield* VersionService
105
+ const versions = yield* service.listProjectVersions(project, {
106
+ released,
107
+ unreleased,
108
+ ...(Option.isSome(max) ? { maxResults: max.value } : {}),
109
+ customFieldNames: customFields
110
+ })
111
+ if (json) {
112
+ const output = emails ? versions : versions.map(stripEmails)
113
+ yield* Console.log(JSON.stringify(output, null, 2))
114
+ return
115
+ }
116
+ const sep = " "
117
+ yield* Console.log(["id", "name", "released", "releaseDate", "driver", "contributors", "approvers"].join(sep))
118
+ for (const v of versions) {
119
+ yield* Console.log([
120
+ v.id,
121
+ v.name,
122
+ String(v.released),
123
+ v.releaseDate ?? "-",
124
+ v.driver?.displayName ?? "-",
125
+ v.contributors.map((c) => c.displayName).join("|") || "-",
126
+ v.approvers.map((a) => `${a.person.displayName}:${a.status}`).join("|") || "-"
127
+ ].join(sep))
128
+ }
129
+ })).pipe(Command.withDescription("Read-only: list versions for a Jira project"))
130
+
131
+ /** Cap on the number of ticket keys listed in the human `get` output. */
132
+ const TICKET_KEYS_LIMIT = 20
133
+
134
+ const getCommand = Command.make(
135
+ "get",
136
+ { id: idArg, json: jsonOption, emails: emailsOption },
137
+ ({ emails, id, json }) =>
138
+ Effect.gen(function*() {
139
+ yield* ensureNumericId(id)
140
+ const service = yield* VersionService
141
+ const version = yield* service.getVersion(id)
142
+ if (json) {
143
+ const output = emails ? version : stripEmails(version)
144
+ yield* Console.log(JSON.stringify(output, null, 2))
145
+ return
146
+ }
147
+ yield* Console.log(`# ${version.name} (${version.id})`)
148
+ yield* Console.log(`released: ${version.released}`)
149
+ yield* Console.log(`releaseDate: ${version.releaseDate ?? "-"}`)
150
+ yield* Console.log(`driver: ${version.driver?.displayName ?? "-"}`)
151
+ yield* Console.log(`contributors: ${version.contributors.map((c) => c.displayName).join(", ") || "-"}`)
152
+ yield* Console.log(
153
+ `approvers: ${version.approvers.map((a) => `${a.person.displayName}:${a.status}`).join(", ") || "-"}`
154
+ )
155
+ yield* Console.log(`tickets (${version.tickets.length}): ${formatTicketKeys(version.tickets)}`)
156
+ })
157
+ ).pipe(Command.withDescription("Read-only: get a single Jira version"))
158
+
159
+ /**
160
+ * Render a version's ticket keys for the human `get`: the first
161
+ * {@link TICKET_KEYS_LIMIT} keys, with a `(+M more)` suffix when truncated, or
162
+ * `-` when there are none.
163
+ */
164
+ const formatTicketKeys = (tickets: Version["tickets"]): string => {
165
+ if (tickets.length === 0) return "-"
166
+ const keys = tickets.map((t) => t.key)
167
+ const shown = keys.slice(0, TICKET_KEYS_LIMIT).join(", ")
168
+ const remaining = keys.length - TICKET_KEYS_LIMIT
169
+ return remaining > 0 ? `${shown} (+${remaining} more)` : shown
170
+ }
171
+
172
+ const descriptionOption = Options.string("description").pipe(
173
+ Options.withAlias("d"),
174
+ Options.withDescription("New version description")
175
+ )
176
+
177
+ const updateCommand = Command.make("update", { id: idArg, description: descriptionOption, json: jsonOption }, ({
178
+ description,
179
+ id,
180
+ json
181
+ }) =>
182
+ Effect.gen(function*() {
183
+ yield* ensureNumericId(id)
184
+ const service = yield* VersionService
185
+ const version = yield* service.updateVersion(id, { description })
186
+ if (json) {
187
+ yield* Console.log(JSON.stringify(version, null, 2))
188
+ return
189
+ }
190
+ yield* Console.log(`Updated version ${version.name} (${version.id})`)
191
+ yield* Console.log(`description: ${version.description ?? "-"}`)
192
+ })).pipe(
193
+ Command.withDescription("Remote write: update a version's description (requires manage:jira-project scope)")
194
+ )
195
+
196
+ // === related-work ===
197
+
198
+ const titleOption = Options.string("title").pipe(
199
+ Options.withAlias("t"),
200
+ Options.withDescription("Related-work link title (e.g. \"Release notes\")")
201
+ )
202
+ const urlOption = Options.string("url").pipe(
203
+ Options.withAlias("u"),
204
+ Options.withDescription("Related-work link URL (e.g. a Confluence page)")
205
+ )
206
+ const categoryOption = Options.string("category").pipe(
207
+ Options.withAlias("c"),
208
+ Options.withDescription("Related-work category (Jira groups by this; e.g. Communication, Testing, Design)"),
209
+ Options.withDefault("Communication")
210
+ )
211
+
212
+ const relatedWorkListCommand = Command.make(
213
+ "list",
214
+ { id: idArg, json: jsonOption },
215
+ ({ id, json }) =>
216
+ Effect.gen(function*() {
217
+ yield* ensureNumericId(id)
218
+ const service = yield* VersionService
219
+ const items = yield* service.listRelatedWork(id)
220
+ if (json) {
221
+ yield* Console.log(JSON.stringify(items, null, 2))
222
+ return
223
+ }
224
+ if (items.length === 0) {
225
+ yield* Console.log("(no related work)")
226
+ return
227
+ }
228
+ const sep = " "
229
+ yield* Console.log(["category", "title", "url"].join(sep))
230
+ for (const w of items) {
231
+ yield* Console.log([w.category || "-", w.title ?? "-", w.url ?? "-"].join(sep))
232
+ }
233
+ })
234
+ ).pipe(Command.withDescription("Read-only: list a version's related-work links"))
235
+
236
+ const relatedWorkAddCommand = Command.make("add", {
237
+ id: idArg,
238
+ title: titleOption,
239
+ url: urlOption,
240
+ category: categoryOption,
241
+ json: jsonOption
242
+ }, ({ category, id, json, title, url }) =>
243
+ Effect.gen(function*() {
244
+ yield* ensureNumericId(id)
245
+ const service = yield* VersionService
246
+ const created = yield* service.addRelatedWork(id, { title, category, url })
247
+ if (json) {
248
+ yield* Console.log(JSON.stringify(created, null, 2))
249
+ return
250
+ }
251
+ yield* Console.log(`Attached "${created.title ?? title}" (${created.category}) to version ${id}`)
252
+ yield* Console.log(`url: ${created.url ?? url}`)
253
+ })).pipe(
254
+ Command.withDescription(
255
+ "Remote write: attach a related-work link (e.g. a Confluence page) to a version (requires manage:jira-project scope)"
256
+ )
257
+ )
258
+
259
+ const relatedWorkCommand = Command.make("related-work").pipe(
260
+ Command.withDescription("List or attach version related-work links (Confluence pages on the release report)"),
261
+ Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand])
262
+ )
263
+
264
+ export const versionCommand = Command.make("version").pipe(
265
+ Command.withDescription("Jira version commands"),
266
+ Command.withSubcommands([listCommand, getCommand, updateCommand, relatedWorkCommand])
267
+ )