forgeo-cli 0.11.0__tar.gz → 0.12.0__tar.gz

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 (91) hide show
  1. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/CHANGELOG.md +12 -1
  2. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/PKG-INFO +2 -2
  3. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/README.md +1 -1
  4. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/backlog.md +17 -2
  5. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/cli-reference.md +23 -0
  6. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/configuration.md +37 -2
  7. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/getting-started.md +6 -2
  8. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/install.sh +1 -1
  9. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/pyproject.toml +4 -1
  10. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/__init__.py +1 -1
  11. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_github.py +71 -20
  12. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_gitlab.py +64 -17
  13. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_jira.py +105 -26
  14. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/cli.py +642 -1
  15. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/models.py +133 -10
  16. forgeo_cli-0.12.0/src/forgeo/oauth_common.py +163 -0
  17. forgeo_cli-0.12.0/src/forgeo/oauth_github.py +486 -0
  18. forgeo_cli-0.12.0/src/forgeo/oauth_gitlab.py +409 -0
  19. forgeo_cli-0.12.0/src/forgeo/oauth_jira.py +388 -0
  20. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/setup.py +268 -16
  21. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_github.py +4 -4
  22. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_install.py +2 -2
  23. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/.github/workflows/ci.yml +0 -0
  24. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/.gitignore +0 -0
  25. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/CONTRIBUTING.md +0 -0
  26. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/LICENSE +0 -0
  27. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/config/nginx-forgeo.conf +0 -0
  28. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/agent-contract.md +0 -0
  29. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/console.png +0 -0
  30. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/demo.gif +0 -0
  31. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/logo.png +0 -0
  32. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/og.png +0 -0
  33. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/title.svg +0 -0
  34. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/index.md +0 -0
  35. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/web-console-api.md +0 -0
  36. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/forgeo.spec +0 -0
  37. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/mkdocs.yml +0 -0
  38. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/scripts/__init__.py +0 -0
  39. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/scripts/render_homebrew_formula.py +0 -0
  40. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/scripts/test-github-backlog-e2e.sh +0 -0
  41. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/__main__.py +0 -0
  42. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/agent.py +0 -0
  43. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog.py +0 -0
  44. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_http.py +0 -0
  45. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_issue_base.py +0 -0
  46. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/central.py +0 -0
  47. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/config.py +0 -0
  48. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/daemon.py +0 -0
  49. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/daemon_control.py +0 -0
  50. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/forgeo.py +0 -0
  51. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/git.py +0 -0
  52. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/instances.py +0 -0
  53. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/io.py +0 -0
  54. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/notify.py +0 -0
  55. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/oauth.py +0 -0
  56. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/paths.py +0 -0
  57. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/runs.py +0 -0
  58. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/update.py +0 -0
  59. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/validate.py +0 -0
  60. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/central.css +0 -0
  61. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/central.js +0 -0
  62. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/index.html +0 -0
  63. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/instance.html +0 -0
  64. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/login.html +0 -0
  65. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/style.css +0 -0
  66. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web_common.py +0 -0
  67. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/conftest.py +0 -0
  68. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_agent.py +0 -0
  69. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog.py +0 -0
  70. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_gitlab.py +0 -0
  71. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_http.py +0 -0
  72. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_jira.py +0 -0
  73. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_cli.py +0 -0
  74. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_daemon.py +0 -0
  75. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_factory.py +0 -0
  76. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_git.py +0 -0
  77. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_instances.py +0 -0
  78. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_io.py +0 -0
  79. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_models.py +0 -0
  80. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_oauth.py +0 -0
  81. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_paths.py +0 -0
  82. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_remote_backlog_cycle.py +0 -0
  83. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_render_homebrew.py +0 -0
  84. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_runs.py +0 -0
  85. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_setup.py +0 -0
  86. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_update.py +0 -0
  87. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_web.py +0 -0
  88. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_web_common.py +0 -0
  89. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_web_lock.py +0 -0
  90. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/www/404.html +0 -0
  91. {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/www/index.html +0 -0
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-09-03
11
+
12
+ ### Added
13
+
14
+ - Browser-based OAuth login for GitHub, GitLab, and Jira, with cached tokens and setup guidance.
15
+
16
+ ### Fixed
17
+
18
+ - Stabilized the CI lint configuration.
19
+
10
20
  ## [0.11.0] - 2026-08-31
11
21
 
12
22
  ### Added
@@ -429,7 +439,8 @@ Initial release of the scheduled, agent-driven software forgeo.
429
439
  overlapping-run skipping.
430
440
  - Dogfooding docs removed; local configs kept out of the repository.
431
441
 
432
- [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.11.0...HEAD
442
+ [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.12.0...HEAD
443
+ [0.12.0]: https://github.com/lucaGazzola/forgeo/compare/v0.11.0...v0.12.0
433
444
  [0.11.0]: https://github.com/lucaGazzola/forgeo/compare/v0.10.0...v0.11.0
434
445
  [0.10.0]: https://github.com/lucaGazzola/forgeo/compare/v0.9.0...v0.10.0
435
446
  [0.9.0]: https://github.com/lucaGazzola/forgeo/compare/v0.8.0...v0.9.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: forgeo-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input.
5
5
  Project-URL: Homepage, https://forgeo.org
6
6
  Project-URL: Documentation, https://forgeo.org
@@ -99,7 +99,7 @@ pipx install forgeo-cli # Python 3.11+
99
99
  forgeo init
100
100
  ```
101
101
 
102
- Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github` it auto-detects `owner/repo` and can persist `GITHUB_TOKEN` to `~/.config/forgeo/`.
102
+ Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github`/`gitlab`/`jira` it auto-detects `owner/repo` and offers `PAT` (`GITHUB_TOKEN` → `~/.config/forgeo/`) or browser OAuth (`forgeo auth login` → `~/.config/forgeo/tokens/`).
103
103
 
104
104
  ### 3. Fill the backlog
105
105
 
@@ -45,7 +45,7 @@ pipx install forgeo-cli # Python 3.11+
45
45
  forgeo init
46
46
  ```
47
47
 
48
- Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github` it auto-detects `owner/repo` and can persist `GITHUB_TOKEN` to `~/.config/forgeo/`.
48
+ Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github`/`gitlab`/`jira` it auto-detects `owner/repo` and offers `PAT` (`GITHUB_TOKEN` → `~/.config/forgeo/`) or browser OAuth (`forgeo auth login` → `~/.config/forgeo/tokens/`).
49
49
 
50
50
  ### 3. Fill the backlog
51
51
 
@@ -166,6 +166,7 @@ Runtime files (`backlog.lock`, `runs.jsonl`, etc.) go in `state_dir` (defaults t
166
166
  ## A Jira backlog
167
167
 
168
168
  ```yaml
169
+ # PAT:
169
170
  backlog_provider: jira
170
171
  backlog: https://jira.example.com
171
172
  jira:
@@ -173,6 +174,12 @@ jira:
173
174
  project_key: APP
174
175
  auth: {scheme: basic, username_env: JIRA_USER, token_env: JIRA_TOKEN}
175
176
  workflow: {open_statuses: ["10000", "10001"], open_status: "10000", running_status: "3", completed_status: "10002"}
177
+ # OAuth 3LO (Cloud):
178
+ # jira:
179
+ # jql: 'project = APP AND labels = forgeo'
180
+ # auth:
181
+ # oauth: {client_id: xxxx, client_secret_env: JIRA_CLIENT_SECRET, scope: "offline_access read:jira-user read:jira-work"}
182
+ # # then: forgeo auth login --provider jira --client-id xxxx
176
183
  ```
177
184
 
178
185
  - Jira keys → task IDs; `summary`/`description`/`created`/`updated` → task fields.
@@ -185,9 +192,13 @@ jira:
185
192
  ## A GitHub backlog
186
193
 
187
194
  ```yaml
195
+ # PAT:
188
196
  backlog_provider: github
189
197
  backlog: https://api.github.com # or https://github.example.com/api/v3
190
- github: {repo: owner/repo, token_env: GITHUB_TOKEN}
198
+ github: {repo: owner/repo, auth: {token_env: GITHUB_TOKEN}}
199
+ # OAuth (browser/device):
200
+ # github: {repo: owner/repo, auth: {oauth: {client_id: Iv1.xxxx, flow: device, scope: repo}}}
201
+ # # then: forgeo auth login --provider github --client-id Iv1.xxxx
191
202
  ```
192
203
 
193
204
  - Issue numbers → task IDs; `title`/`body`/`created_at`/`updated_at` → task fields.
@@ -204,9 +215,13 @@ GITHUB_TOKEN=... ./scripts/test-github-backlog-e2e.sh
204
215
  ## A GitLab backlog
205
216
 
206
217
  ```yaml
218
+ # PAT:
207
219
  backlog_provider: gitlab
208
220
  backlog: https://gitlab.example.com # instance root, /api/v4 appended
209
- gitlab: {repo: group/project, token_env: GITLAB_TOKEN}
221
+ gitlab: {repo: group/project, auth: {token_env: GITLAB_TOKEN}}
222
+ # OAuth (browser PKCE):
223
+ # gitlab: {repo: group/project, auth: {oauth: {client_id: abc, flow: browser, scope: api}}}
224
+ # # then: forgeo auth login --provider gitlab --client-id abc
210
225
  ```
211
226
 
212
227
  - `iid` → task ID; `opened`/`closed` → `OPEN`/`COMPLETED`; same hidden-block and label mechanics as GitHub.
@@ -125,6 +125,29 @@ Unregister (never touches config/repo). Errors if unknown.
125
125
 
126
126
  Table of all instances: name, daemon state, last outcome (from `runs.jsonl`). Exits `0` with hint if none.
127
127
 
128
+ ## `forgeo auth`
129
+
130
+ Browser/OAuth login for `github`/`gitlab`/`jira` (alternative to `*_TOKEN` PAT). Tokens stored `0600` in `~/.config/forgeo/tokens/` and read by `forgeo validate`/`start`/`once`.
131
+
132
+ ### `forgeo auth login --provider <github|gitlab|jira>`
133
+
134
+ | Flag | Description |
135
+ | --- | --- |
136
+ | `--provider` | `github` (default) / `gitlab` / `jira`. |
137
+ | `--config <file>` | `forgeo.yaml` to read `auth.oauth.client_id` (defaults to `./forgeo.yaml`). |
138
+ | `--client-id <id>` | OAuth client ID (overrides config; required if not in `forgeo.yaml`). |
139
+ | `--flow <device|browser>` | `device` (GitHub CLI) / `browser` (PKCE loopback `127.0.0.1:0/callback`). Defaults: `device` for GitHub, `browser` for GitLab/Jira. |
140
+ | `--scope <scope>` | Scope (default `repo` / `api` / `offline_access read:jira-user read:jira-work`). |
141
+ | `--token-file <path>` | Where to store token (default per-provider per-host `~/.config/forgeo/tokens/<provider>.json`). |
142
+ | `--api-base <url>` | Provider API base (default from `forgeo.yaml` backlog or `https://api.github.com`/`https://gitlab.com`). |
143
+ | `--no-open-browser` | Print URL instead of `webbrowser.open()`. |
144
+
145
+ Device flow: prints `https://github.com/login/device` + `user_code`, polls `…/login/oauth/access_token`. Browser flow: opens `…/oauth/authorize` + PKCE `code_challenge`, listens on loopback, exchanges `code` for `access_token` (`refresh_token` for Jira). `forgeo init` with `browser` writes `auth.oauth` and offers `Run browser login now?`.
146
+
147
+ ### `forgeo auth status --provider <p>` / `forgeo auth logout --provider <p>`
148
+
149
+ `status` masks token (`ghp_…1234`), shows `scope`/`expires_in`/`cloud_id` (Jira). `logout` removes the file. Both honour `--token-file`/`--api-base`/`--config`.
150
+
128
151
  ## `forgeo web`
129
152
 
130
153
  Central dashboard aggregating every registered instance (reads files directly, works whether daemons are running).
@@ -70,6 +70,7 @@ Add `backlog_auth` for OAuth2-protected HTTP endpoints (see below). A failed HTT
70
70
  ### Jira
71
71
 
72
72
  ```yaml
73
+ # PAT / basic (Server/DC):
73
74
  backlog_provider: jira
74
75
  backlog: https://jira.example.com
75
76
  state_dir: .forgeo
@@ -86,6 +87,16 @@ jira:
86
87
  open_status: "10000"
87
88
  running_status: "3"
88
89
  completed_status: "10002"
90
+ # OAuth 3LO (Cloud, browser):
91
+ # jira:
92
+ # jql: 'project = APP AND labels = forgeo'
93
+ # auth:
94
+ # oauth:
95
+ # client_id: xxxx
96
+ # client_secret_env: JIRA_CLIENT_SECRET
97
+ # scope: offline_access read:jira-user read:jira-work
98
+ # token_file: ~/.config/forgeo/tokens/jira.json # optional
99
+ # # then: forgeo auth login --provider jira --client-id xxxx
89
100
  ```
90
101
 
91
102
  | Key | Default | Description |
@@ -109,11 +120,23 @@ Status values can be names or IDs (IDs preferred). Jira keys become task IDs. En
109
120
  ### GitHub
110
121
 
111
122
  ```yaml
123
+ # PAT:
112
124
  backlog_provider: github
113
125
  backlog: https://api.github.com # or https://github.example.com/api/v3
114
126
  github:
115
127
  repo: owner/repo
116
- token_env: GITHUB_TOKEN
128
+ auth:
129
+ token_env: GITHUB_TOKEN
130
+ # OAuth / browser (device flow for CLI, browser PKCE):
131
+ # github:
132
+ # repo: owner/repo
133
+ # auth:
134
+ # oauth:
135
+ # client_id: Iv1.xxxx
136
+ # flow: device # device | browser
137
+ # scope: repo
138
+ # token_file: ~/.config/forgeo/tokens/github.json
139
+ # # then: forgeo auth login --provider github --client-id Iv1.xxxx
117
140
  ```
118
141
 
119
142
  | Key | Default | Description |
@@ -134,11 +157,23 @@ Issue numbers become task IDs; `open`/`closed` maps to `OPEN`/`COMPLETED`; `forg
134
157
  ### GitLab
135
158
 
136
159
  ```yaml
160
+ # PAT:
137
161
  backlog_provider: gitlab
138
162
  backlog: https://gitlab.example.com # instance root, /api/v4 appended
139
163
  gitlab:
140
164
  repo: group/project
141
- token_env: GITLAB_TOKEN
165
+ auth:
166
+ token_env: GITLAB_TOKEN
167
+ # OAuth (browser PKCE, device if enabled):
168
+ # gitlab:
169
+ # repo: group/project
170
+ # auth:
171
+ # oauth:
172
+ # client_id: abc123
173
+ # flow: browser # browser | device
174
+ # scope: api
175
+ # token_file: ~/.config/forgeo/tokens/gitlab.json
176
+ # # then: forgeo auth login --provider gitlab --client-id abc123
142
177
  ```
143
178
 
144
179
  Same keys as GitHub (`gitlab.*`), including `workflow` and `fields` for the same 7 mappings. Issue `iid` becomes task ID; `opened`/`closed` maps to `OPEN`/`COMPLETED`; hidden `<!-- forgeo: {...} -->` block for engine state.
@@ -32,7 +32,7 @@ The wizard asks for:
32
32
  3. **Agent command** — bare command for your agent (default `opencode run --auto`). Forgeo appends the task prompt (`$FORGEO_TASK`); if your command already references `$FORGEO_TASK` it is kept verbatim.
33
33
  4. **Refactor prompt** — used when the backlog is empty.
34
34
 
35
- Writes `forgeo.yaml`, creates the folder, and appends it to `.gitignore` (opt-out available). With `github`/`gitlab`/`jira` it also sets `backlog_provider`, `backlog` URL, provider block, and `state_dir`.
35
+ Writes `forgeo.yaml`, creates the folder, and appends it to `.gitignore` (opt-out available). With `github`/`gitlab`/`jira` it also sets `backlog_provider`, `backlog` URL, provider block, and `state_dir`. For `github`/`gitlab`/`jira` you can pick `PAT` or `browser` (OAuth) — browser stores a token in `~/.config/forgeo/tokens/`.
36
36
 
37
37
  ```bash
38
38
  forgeo init --force # overwrite existing config
@@ -58,10 +58,14 @@ forgeo init --force # overwrite existing config
58
58
 
59
59
  You can also add tasks from the dashboard once Forgeo is running — no file editing needed.
60
60
 
61
- **Remote providers** — `forgeo.yaml` already points at the provider. Export credentials and validate:
61
+ **Remote providers** — `forgeo.yaml` already points at the provider. Authenticate with PAT **or** browser:
62
62
 
63
63
  ```bash
64
+ # PAT:
64
65
  export GITHUB_TOKEN=ghp_... # or GITLAB_TOKEN / JIRA_USER + JIRA_TOKEN
66
+ # Browser OAuth (device flow preferred for CLI):
67
+ forgeo auth login --provider github --client-id Iv1.xxx # or gitlab/jira
68
+ forgeo auth status # forgeo auth logout to clear
65
69
  forgeo validate
66
70
  ```
67
71
 
@@ -14,7 +14,7 @@ set -eu
14
14
 
15
15
  REPO_OWNER="lucaGazzola"
16
16
  REPO_NAME="forgeo"
17
- DEFAULT_VERSION="0.11.0"
17
+ DEFAULT_VERSION="0.12.0"
18
18
  MIN_PYTHON="3.11"
19
19
  PYPI_PACKAGE="forgeo-cli"
20
20
  PREFIX="${FORGEO_PREFIX:-${HOME:-}/.local}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forgeo-cli"
7
- version = "0.11.0"
7
+ version = "0.12.0"
8
8
  description = "A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -61,6 +61,9 @@ asyncio_default_fixture_loop_scope = "function"
61
61
  line-length = 100
62
62
  target-version = "py311"
63
63
 
64
+ [tool.ruff.lint]
65
+ select = ["E4", "E7", "E9", "F"]
66
+
64
67
  [tool.mypy]
65
68
  python_version = "3.11"
66
69
  mypy_path = "src"
@@ -7,4 +7,4 @@ try:
7
7
 
8
8
  __version__ = _pkg_version("forgeo-cli")
9
9
  except PackageNotFoundError: # standalone binary: no installed package metadata
10
- __version__ = "0.11.0"
10
+ __version__ = "0.12.0"
@@ -57,11 +57,43 @@ class GithubClient:
57
57
  def __init__(self, base_url: str, config: GithubBacklogConfig) -> None:
58
58
  self.base_url = base_url.rstrip("/")
59
59
  self.config = config
60
+ self._oauth_provider: Any | None = None # lazy GithubOAuthTokenProvider when oauth is used
61
+
62
+ def _oauth_token_provider(self) -> Any | None:
63
+ if self._oauth_provider is not None:
64
+ return self._oauth_provider
65
+ auth = self.config.auth
66
+ if auth.oauth is None:
67
+ return None
68
+ from forgeo.oauth_github import GithubOAuthTokenProvider, GithubTokenStore
69
+
70
+ token_file = auth.oauth.token_file
71
+ store = GithubTokenStore(path=token_file, api_base=self.base_url) if token_file is not None else GithubTokenStore(api_base=self.base_url)
72
+ provider = GithubOAuthTokenProvider(store)
73
+ self._oauth_provider = provider
74
+ return provider
60
75
 
61
76
  def _auth_header(self) -> str:
62
- token = require_env_token(self.config.auth.token_env, "GitHub", GithubRequestError)
63
- # GitHub accepts Bearer or token; use Bearer for consistency
64
- return f"Bearer {token}"
77
+ auth = self.config.auth
78
+ if auth.token_env is not None:
79
+ token = require_env_token(auth.token_env, "GitHub", GithubRequestError)
80
+ return f"Bearer {token}"
81
+ if auth.oauth is not None:
82
+ provider = self._oauth_token_provider()
83
+ assert provider is not None
84
+ try:
85
+ token = provider.token()
86
+ except Exception as exc:
87
+ # Wrap file errors as GithubRequestError so callers surface a clear message
88
+ if isinstance(exc, GithubRequestError):
89
+ raise
90
+ from forgeo.oauth_github import GithubOAuthError
91
+
92
+ if isinstance(exc, GithubOAuthError):
93
+ raise GithubRequestError(str(exc)) from exc
94
+ raise GithubRequestError(str(exc)) from exc
95
+ return f"Bearer {token}"
96
+ raise GithubRequestError("GitHub auth is not configured (token_env or oauth required)")
65
97
 
66
98
  def _api_url(self, path: str, query: dict[str, Any] | None = None) -> str:
67
99
  url = f"{self.base_url}{path}"
@@ -77,23 +109,42 @@ class GithubClient:
77
109
  query: dict[str, Any] | None = None,
78
110
  payload: dict[str, Any] | None = None,
79
111
  ) -> Any:
80
- body = None
81
- if payload is not None:
82
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
83
- request = urllib.request.Request(
84
- self._api_url(path, query),
85
- data=body,
86
- method=method,
87
- headers={
88
- "Accept": "application/vnd.github+json",
89
- "Authorization": self._auth_header(),
90
- "X-GitHub-Api-Version": "2022-11-28",
91
- **({"Content-Type": "application/json"} if body is not None else {}),
92
- },
93
- )
94
- return execute_json_request(
95
- request, self.config.timeout_seconds, GithubRequestError, method
96
- )
112
+ # Retry once when GitHub rejects an OAuth token (revoked/expired)
113
+ for attempt in (0, 1):
114
+ body = None
115
+ if payload is not None:
116
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
117
+ auth_header = self._auth_header()
118
+ request = urllib.request.Request(
119
+ self._api_url(path, query),
120
+ data=body,
121
+ method=method,
122
+ headers={
123
+ "Accept": "application/vnd.github+json",
124
+ "Authorization": auth_header,
125
+ "X-GitHub-Api-Version": "2022-11-28",
126
+ **({"Content-Type": "application/json"} if body is not None else {}),
127
+ },
128
+ )
129
+ try:
130
+ return execute_json_request(
131
+ request, self.config.timeout_seconds, GithubRequestError, method
132
+ )
133
+ except GithubRequestError as exc:
134
+ # On 401/403 with OAuth, invalidate cache and retry once so a freshly
135
+ # written token (e.g. after `forgeo auth login`) is picked up.
136
+ if (
137
+ attempt == 0
138
+ and exc.status in (401, 403)
139
+ and self.config.auth.oauth is not None
140
+ and self._oauth_provider is not None
141
+ ):
142
+ try:
143
+ self._oauth_provider.invalidate() # noqa: BLE001
144
+ except Exception: # noqa: BLE001
145
+ pass
146
+ continue
147
+ raise
97
148
 
98
149
  def _repo_path(self) -> str:
99
150
  # GitHub API expects owner/repo as two separate path segments;
@@ -57,11 +57,43 @@ class GitlabClient:
57
57
  def __init__(self, base_url: str, config: GitlabBacklogConfig) -> None:
58
58
  self.base_url = base_url.rstrip("/")
59
59
  self.config = config
60
+ self._oauth_provider: Any | None = None
61
+
62
+ def _oauth_token_provider(self) -> Any | None:
63
+ if self._oauth_provider is not None:
64
+ return self._oauth_provider
65
+ auth = self.config.auth
66
+ if auth.oauth is None:
67
+ return None
68
+ from forgeo.oauth_gitlab import GitlabOAuthTokenProvider, GitlabTokenStore
69
+
70
+ token_file = auth.oauth.token_file
71
+ store = GitlabTokenStore(path=token_file, api_base=self.base_url) if token_file is not None else GitlabTokenStore(api_base=self.base_url)
72
+ provider = GitlabOAuthTokenProvider(store)
73
+ self._oauth_provider = provider
74
+ return provider
60
75
 
61
76
  def _auth_headers(self) -> dict[str, str]:
62
- token = require_env_token(self.config.auth.token_env, "GitLab", GitlabRequestError)
63
- # GitLab prefers PRIVATE-TOKEN, but also accepts Bearer
64
- return {"PRIVATE-TOKEN": token, "Authorization": f"Bearer {token}"}
77
+ auth = self.config.auth
78
+ if auth.token_env is not None:
79
+ token = require_env_token(auth.token_env, "GitLab", GitlabRequestError)
80
+ # GitLab prefers PRIVATE-TOKEN, but also accepts Bearer
81
+ return {"PRIVATE-TOKEN": token, "Authorization": f"Bearer {token}"}
82
+ if auth.oauth is not None:
83
+ provider = self._oauth_token_provider()
84
+ assert provider is not None
85
+ try:
86
+ token = provider.token()
87
+ except Exception as exc:
88
+ if isinstance(exc, GitlabRequestError):
89
+ raise
90
+ from forgeo.oauth_gitlab import GitlabOAuthError
91
+
92
+ if isinstance(exc, GitlabOAuthError):
93
+ raise GitlabRequestError(str(exc)) from exc
94
+ raise GitlabRequestError(str(exc)) from exc
95
+ return {"Authorization": f"Bearer {token}"}
96
+ raise GitlabRequestError("GitLab auth is not configured (token_env or oauth required)")
65
97
 
66
98
  def _project_path(self) -> str:
67
99
  # GitLab API expects URL-encoded project path or numeric id
@@ -81,20 +113,35 @@ class GitlabClient:
81
113
  query: dict[str, Any] | None = None,
82
114
  payload: dict[str, Any] | None = None,
83
115
  ) -> Any:
84
- body = None
85
- if payload is not None:
86
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
87
- headers = {
88
- "Accept": "application/json",
89
- **self._auth_headers(),
90
- **({"Content-Type": "application/json"} if body is not None else {}),
91
- }
92
- request = urllib.request.Request(
93
- self._api_url(path, query), data=body, method=method, headers=headers
94
- )
95
- return execute_json_request(
96
- request, self.config.timeout_seconds, GitlabRequestError, method
97
- )
116
+ for attempt in (0, 1):
117
+ body = None
118
+ if payload is not None:
119
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
120
+ headers = {
121
+ "Accept": "application/json",
122
+ **self._auth_headers(),
123
+ **({"Content-Type": "application/json"} if body is not None else {}),
124
+ }
125
+ request = urllib.request.Request(
126
+ self._api_url(path, query), data=body, method=method, headers=headers
127
+ )
128
+ try:
129
+ return execute_json_request(
130
+ request, self.config.timeout_seconds, GitlabRequestError, method
131
+ )
132
+ except GitlabRequestError as exc:
133
+ if (
134
+ attempt == 0
135
+ and exc.status in (401, 403)
136
+ and self.config.auth.oauth is not None
137
+ and self._oauth_provider is not None
138
+ ):
139
+ try:
140
+ self._oauth_provider.invalidate() # noqa: BLE001
141
+ except Exception: # noqa: BLE001
142
+ pass
143
+ continue
144
+ raise
98
145
 
99
146
  def search_issues(self, *, page: int = 1, per_page: int = 20) -> list[dict[str, Any]]:
100
147
  path = f"/projects/{self._project_path()}/issues"
@@ -83,22 +83,85 @@ class JiraClient:
83
83
  self.base_url = base_url.rstrip("/")
84
84
  self.config = config
85
85
  self.api_version = config.api_version or DEFAULT_API_VERSION
86
+ self._oauth_provider: Any | None = None
87
+
88
+ def _oauth_token_provider(self) -> Any | None:
89
+ if self._oauth_provider is not None:
90
+ return self._oauth_provider
91
+ auth = self.config.auth
92
+ if auth.oauth is None:
93
+ return None
94
+ from forgeo.oauth_jira import JiraOAuthTokenProvider, JiraTokenStore
95
+
96
+ token_file = auth.oauth.token_file
97
+ store = JiraTokenStore(path=token_file, api_base=self.base_url) if token_file is not None else JiraTokenStore(api_base=self.base_url)
98
+ provider = JiraOAuthTokenProvider(
99
+ store, client_id=auth.oauth.client_id, client_secret_env=auth.oauth.client_secret_env
100
+ )
101
+ self._oauth_provider = provider
102
+ return provider
86
103
 
87
104
  def _api_url(self, path: str, query: dict[str, Any] | None = None) -> str:
88
- url = f"{self.base_url}/rest/api/{self.api_version}{path}"
105
+ # When OAuth is used with cloud_id, Jira Cloud API lives at api.atlassian.com/ex/jira/{cloudId}
106
+ base = self.base_url
107
+ auth = self.config.auth
108
+ if auth.oauth is not None:
109
+ # Prefer cloud_id from config oauth, else from token file
110
+ cloud_id: str | None = auth.oauth.cloud_id
111
+ if cloud_id is None and self._oauth_provider is not None:
112
+ try:
113
+ data = self._oauth_provider.store.load()
114
+ if isinstance(data, dict) and isinstance(data.get("cloud_id"), str):
115
+ cloud_id = data["cloud_id"]
116
+ except Exception:
117
+ pass
118
+ elif cloud_id is None:
119
+ # Try to load directly via store without provider cache
120
+ try:
121
+ from forgeo.oauth_jira import JiraTokenStore
122
+
123
+ tmp_store = JiraTokenStore(
124
+ path=auth.oauth.token_file, api_base=self.base_url
125
+ ) if auth.oauth.token_file is not None else JiraTokenStore(api_base=self.base_url)
126
+ data = tmp_store.load()
127
+ if isinstance(data, dict) and isinstance(data.get("cloud_id"), str):
128
+ cloud_id = data["cloud_id"]
129
+ except Exception:
130
+ pass
131
+ if cloud_id:
132
+ base = f"https://api.atlassian.com/ex/jira/{cloud_id}"
133
+ url = f"{base}/rest/api/{self.api_version}{path}"
89
134
  if query:
90
135
  url += "?" + urlencode(query, doseq=True)
91
136
  return url
92
137
 
93
138
  def _auth_header(self) -> str:
94
- token = require_env_token(self.config.auth.token_env, "Jira", JiraRequestError)
95
- if self.config.auth.scheme == "bearer":
139
+ auth = self.config.auth
140
+ if auth.oauth is not None:
141
+ provider = self._oauth_token_provider()
142
+ assert provider is not None
143
+ try:
144
+ token = provider.token()
145
+ except Exception as exc:
146
+ if isinstance(exc, JiraRequestError):
147
+ raise
148
+ from forgeo.oauth_jira import JiraOAuthError
149
+
150
+ if isinstance(exc, JiraOAuthError):
151
+ raise JiraRequestError(str(exc)) from exc
152
+ raise JiraRequestError(str(exc)) from exc
153
+ return f"Bearer {token}"
154
+ # PAT branch
155
+ if auth.token_env is None or not auth.token_env.strip():
156
+ raise JiraRequestError("Jira token environment variable is not set")
157
+ token = require_env_token(auth.token_env, "Jira", JiraRequestError)
158
+ if auth.scheme == "bearer":
96
159
  return f"Bearer {token}"
97
- username = self.config.auth.username
98
- if self.config.auth.username_env is not None:
99
- username = os.environ.get(self.config.auth.username_env)
160
+ username = auth.username
161
+ if auth.username_env is not None:
162
+ username = os.environ.get(auth.username_env)
100
163
  if not username:
101
- source = self.config.auth.username_env or "username"
164
+ source = auth.username_env or "username"
102
165
  raise JiraRequestError(f"Jira username {source!r} is not set")
103
166
  encoded = base64.b64encode(f"{username}:{token}".encode()).decode("ascii")
104
167
  return f"Basic {encoded}"
@@ -112,25 +175,41 @@ class JiraClient:
112
175
  payload: dict[str, Any] | None = None,
113
176
  ) -> dict[str, Any]:
114
177
  """Perform one authenticated request and decode its JSON response."""
115
- body = None
116
- if payload is not None:
117
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
118
- request = urllib.request.Request(
119
- self._api_url(path, query),
120
- data=body,
121
- method=method,
122
- headers={
123
- "Accept": "application/json",
124
- "Authorization": self._auth_header(),
125
- **({"Content-Type": "application/json"} if body is not None else {}),
126
- },
127
- )
128
- data = execute_json_request(
129
- request, self.config.timeout_seconds, JiraRequestError, method
130
- )
131
- if not isinstance(data, dict):
132
- raise JiraRequestError(f"{method} {request.full_url} returned a non-object JSON body")
133
- return data
178
+ for attempt in (0, 1):
179
+ body = None
180
+ if payload is not None:
181
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
182
+ request = urllib.request.Request(
183
+ self._api_url(path, query),
184
+ data=body,
185
+ method=method,
186
+ headers={
187
+ "Accept": "application/json",
188
+ "Authorization": self._auth_header(),
189
+ **({"Content-Type": "application/json"} if body is not None else {}),
190
+ },
191
+ )
192
+ try:
193
+ data = execute_json_request(
194
+ request, self.config.timeout_seconds, JiraRequestError, method
195
+ )
196
+ except JiraRequestError as exc:
197
+ if (
198
+ attempt == 0
199
+ and exc.status in (401, 403)
200
+ and self.config.auth.oauth is not None
201
+ and self._oauth_provider is not None
202
+ ):
203
+ try:
204
+ self._oauth_provider.invalidate() # noqa: BLE001
205
+ except Exception: # noqa: BLE001
206
+ pass
207
+ continue
208
+ raise
209
+ if not isinstance(data, dict):
210
+ raise JiraRequestError(f"{method} {request.full_url} returned a non-object JSON body")
211
+ return data
212
+ raise JiraRequestError(f"{method} {self._api_url(path, query)} failed after retry")
134
213
 
135
214
  def search_issues(
136
215
  self,