ynab-api-cli 0.1.0__py3-none-any.whl

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 (225) hide show
  1. ynab_api_cli/__init__.py +3 -0
  2. ynab_api_cli/__main__.py +266 -0
  3. ynab_api_cli/cli.py +171 -0
  4. ynab_api_cli/compat.py +120 -0
  5. ynab_api_cli/config.py +110 -0
  6. ynab_api_cli/contract.py +197 -0
  7. ynab_api_cli/contract_testing.py +141 -0
  8. ynab_api_cli/development.py +269 -0
  9. ynab_api_cli/distribution.py +123 -0
  10. ynab_api_cli/exceptions.py +7 -0
  11. ynab_api_cli/generated/__init__.py +3 -0
  12. ynab_api_cli/generated/api/__init__.py +3 -0
  13. ynab_api_cli/generated/api/accounts/__init__.py +3 -0
  14. ynab_api_cli/generated/api/accounts/create_account.py +54 -0
  15. ynab_api_cli/generated/api/accounts/get_account_by_id.py +53 -0
  16. ynab_api_cli/generated/api/accounts/get_accounts.py +57 -0
  17. ynab_api_cli/generated/api/categories/__init__.py +3 -0
  18. ynab_api_cli/generated/api/categories/create_category.py +54 -0
  19. ynab_api_cli/generated/api/categories/create_category_group.py +58 -0
  20. ynab_api_cli/generated/api/categories/get_categories.py +57 -0
  21. ynab_api_cli/generated/api/categories/get_category_by_id.py +52 -0
  22. ynab_api_cli/generated/api/categories/get_month_category_by_id.py +58 -0
  23. ynab_api_cli/generated/api/categories/update_category.py +57 -0
  24. ynab_api_cli/generated/api/categories/update_category_group.py +63 -0
  25. ynab_api_cli/generated/api/categories/update_month_category.py +65 -0
  26. ynab_api_cli/generated/api/money_movements/__init__.py +3 -0
  27. ynab_api_cli/generated/api/money_movements/get_money_movement_groups.py +51 -0
  28. ynab_api_cli/generated/api/money_movements/get_money_movement_groups_by_month.py +57 -0
  29. ynab_api_cli/generated/api/money_movements/get_money_movements.py +51 -0
  30. ynab_api_cli/generated/api/money_movements/get_money_movements_by_month.py +57 -0
  31. ynab_api_cli/generated/api/months/__init__.py +3 -0
  32. ynab_api_cli/generated/api/months/get_plan_month.py +55 -0
  33. ynab_api_cli/generated/api/months/get_plan_months.py +59 -0
  34. ynab_api_cli/generated/api/payee_locations/__init__.py +3 -0
  35. ynab_api_cli/generated/api/payee_locations/get_payee_location_by_id.py +54 -0
  36. ynab_api_cli/generated/api/payee_locations/get_payee_locations.py +51 -0
  37. ynab_api_cli/generated/api/payee_locations/get_payee_locations_by_payee.py +54 -0
  38. ynab_api_cli/generated/api/payees/__init__.py +3 -0
  39. ynab_api_cli/generated/api/payees/create_payee.py +54 -0
  40. ynab_api_cli/generated/api/payees/get_payee_by_id.py +52 -0
  41. ynab_api_cli/generated/api/payees/get_payees.py +57 -0
  42. ynab_api_cli/generated/api/payees/update_payee.py +57 -0
  43. ynab_api_cli/generated/api/plans/__init__.py +3 -0
  44. ynab_api_cli/generated/api/plans/get_plan_by_id.py +57 -0
  45. ynab_api_cli/generated/api/plans/get_plan_settings_by_id.py +49 -0
  46. ynab_api_cli/generated/api/plans/get_plans.py +51 -0
  47. ynab_api_cli/generated/api/scheduled_transactions/__init__.py +3 -0
  48. ynab_api_cli/generated/api/scheduled_transactions/create_scheduled_transaction.py +58 -0
  49. ynab_api_cli/generated/api/scheduled_transactions/delete_scheduled_transaction.py +56 -0
  50. ynab_api_cli/generated/api/scheduled_transactions/get_scheduled_transaction_by_id.py +56 -0
  51. ynab_api_cli/generated/api/scheduled_transactions/get_scheduled_transactions.py +59 -0
  52. ynab_api_cli/generated/api/scheduled_transactions/update_scheduled_transaction.py +63 -0
  53. ynab_api_cli/generated/api/transactions/__init__.py +3 -0
  54. ynab_api_cli/generated/api/transactions/create_transaction.py +58 -0
  55. ynab_api_cli/generated/api/transactions/delete_transaction.py +54 -0
  56. ynab_api_cli/generated/api/transactions/get_transaction_by_id.py +54 -0
  57. ynab_api_cli/generated/api/transactions/get_transactions.py +80 -0
  58. ynab_api_cli/generated/api/transactions/get_transactions_by_account.py +87 -0
  59. ynab_api_cli/generated/api/transactions/get_transactions_by_category.py +89 -0
  60. ynab_api_cli/generated/api/transactions/get_transactions_by_month.py +87 -0
  61. ynab_api_cli/generated/api/transactions/get_transactions_by_payee.py +89 -0
  62. ynab_api_cli/generated/api/transactions/import_transactions.py +54 -0
  63. ynab_api_cli/generated/api/transactions/update_transaction.py +59 -0
  64. ynab_api_cli/generated/api/transactions/update_transactions.py +58 -0
  65. ynab_api_cli/generated/api/user/__init__.py +3 -0
  66. ynab_api_cli/generated/api/user/get_user.py +42 -0
  67. ynab_api_cli/generated/bindings.py +141 -0
  68. ynab_api_cli/generated/models/__init__.py +416 -0
  69. ynab_api_cli/generated/models/account.py +71 -0
  70. ynab_api_cli/generated/models/account_base.py +57 -0
  71. ynab_api_cli/generated/models/account_response.py +19 -0
  72. ynab_api_cli/generated/models/account_response_data.py +19 -0
  73. ynab_api_cli/generated/models/account_type.py +23 -0
  74. ynab_api_cli/generated/models/accounts_response.py +21 -0
  75. ynab_api_cli/generated/models/accounts_response_data.py +20 -0
  76. ynab_api_cli/generated/models/bulk_response.py +19 -0
  77. ynab_api_cli/generated/models/bulk_response_data.py +21 -0
  78. ynab_api_cli/generated/models/bulk_response_data_bulk.py +15 -0
  79. ynab_api_cli/generated/models/bulk_transactions.py +21 -0
  80. ynab_api_cli/generated/models/categories_response.py +21 -0
  81. ynab_api_cli/generated/models/categories_response_data.py +22 -0
  82. ynab_api_cli/generated/models/category.py +104 -0
  83. ynab_api_cli/generated/models/category_base.py +74 -0
  84. ynab_api_cli/generated/models/category_group.py +20 -0
  85. ynab_api_cli/generated/models/category_group_with_categories.py +25 -0
  86. ynab_api_cli/generated/models/category_response.py +21 -0
  87. ynab_api_cli/generated/models/category_response_data.py +19 -0
  88. ynab_api_cli/generated/models/currency_format_type_0.py +21 -0
  89. ynab_api_cli/generated/models/date_format_type_0.py +14 -0
  90. ynab_api_cli/generated/models/debt_1.py +18 -0
  91. ynab_api_cli/generated/models/debt_2.py +18 -0
  92. ynab_api_cli/generated/models/debt_3.py +18 -0
  93. ynab_api_cli/generated/models/error_detail.py +16 -0
  94. ynab_api_cli/generated/models/error_response.py +19 -0
  95. ynab_api_cli/generated/models/existing_category.py +34 -0
  96. ynab_api_cli/generated/models/existing_transaction.py +43 -0
  97. ynab_api_cli/generated/models/flag_1.py +17 -0
  98. ynab_api_cli/generated/models/flag_2.py +17 -0
  99. ynab_api_cli/generated/models/flag_3.py +17 -0
  100. ynab_api_cli/generated/models/get_transactions_by_account_type.py +12 -0
  101. ynab_api_cli/generated/models/get_transactions_by_category_type.py +12 -0
  102. ynab_api_cli/generated/models/get_transactions_by_month_type.py +12 -0
  103. ynab_api_cli/generated/models/get_transactions_by_payee_type.py +12 -0
  104. ynab_api_cli/generated/models/get_transactions_type.py +12 -0
  105. ynab_api_cli/generated/models/goal_1.py +15 -0
  106. ynab_api_cli/generated/models/goal_2.py +15 -0
  107. ynab_api_cli/generated/models/goal_3.py +15 -0
  108. ynab_api_cli/generated/models/hybrid_transaction.py +70 -0
  109. ynab_api_cli/generated/models/hybrid_transaction_type.py +12 -0
  110. ynab_api_cli/generated/models/hybrid_transactions_response.py +21 -0
  111. ynab_api_cli/generated/models/hybrid_transactions_response_data.py +22 -0
  112. ynab_api_cli/generated/models/loan_account_periodic_value_type_0.py +33 -0
  113. ynab_api_cli/generated/models/money_movement.py +35 -0
  114. ynab_api_cli/generated/models/money_movement_base.py +33 -0
  115. ynab_api_cli/generated/models/money_movement_group.py +25 -0
  116. ynab_api_cli/generated/models/money_movement_groups_response.py +21 -0
  117. ynab_api_cli/generated/models/money_movement_groups_response_data.py +22 -0
  118. ynab_api_cli/generated/models/money_movements_response.py +21 -0
  119. ynab_api_cli/generated/models/money_movements_response_data.py +20 -0
  120. ynab_api_cli/generated/models/month_detail.py +42 -0
  121. ynab_api_cli/generated/models/month_detail_base.py +30 -0
  122. ynab_api_cli/generated/models/month_detail_response.py +21 -0
  123. ynab_api_cli/generated/models/month_detail_response_data.py +19 -0
  124. ynab_api_cli/generated/models/month_summaries_response.py +21 -0
  125. ynab_api_cli/generated/models/month_summaries_response_data.py +20 -0
  126. ynab_api_cli/generated/models/month_summary.py +37 -0
  127. ynab_api_cli/generated/models/month_summary_base.py +25 -0
  128. ynab_api_cli/generated/models/new_category.py +34 -0
  129. ynab_api_cli/generated/models/new_transaction.py +44 -0
  130. ynab_api_cli/generated/models/patch_category_group_wrapper.py +19 -0
  131. ynab_api_cli/generated/models/patch_category_wrapper.py +19 -0
  132. ynab_api_cli/generated/models/patch_month_category_wrapper.py +19 -0
  133. ynab_api_cli/generated/models/patch_payee_wrapper.py +19 -0
  134. ynab_api_cli/generated/models/patch_transactions_wrapper.py +21 -0
  135. ynab_api_cli/generated/models/payee.py +23 -0
  136. ynab_api_cli/generated/models/payee_location.py +20 -0
  137. ynab_api_cli/generated/models/payee_location_response.py +21 -0
  138. ynab_api_cli/generated/models/payee_location_response_data.py +19 -0
  139. ynab_api_cli/generated/models/payee_locations_response.py +21 -0
  140. ynab_api_cli/generated/models/payee_locations_response_data.py +19 -0
  141. ynab_api_cli/generated/models/payee_response.py +19 -0
  142. ynab_api_cli/generated/models/payee_response_data.py +19 -0
  143. ynab_api_cli/generated/models/payees_response.py +19 -0
  144. ynab_api_cli/generated/models/payees_response_data.py +20 -0
  145. ynab_api_cli/generated/models/plan_detail.py +74 -0
  146. ynab_api_cli/generated/models/plan_detail_response.py +21 -0
  147. ynab_api_cli/generated/models/plan_detail_response_data.py +20 -0
  148. ynab_api_cli/generated/models/plan_settings.py +21 -0
  149. ynab_api_cli/generated/models/plan_settings_response.py +21 -0
  150. ynab_api_cli/generated/models/plan_settings_response_data.py +19 -0
  151. ynab_api_cli/generated/models/plan_summary.py +38 -0
  152. ynab_api_cli/generated/models/plan_summary_response.py +21 -0
  153. ynab_api_cli/generated/models/plan_summary_response_data.py +24 -0
  154. ynab_api_cli/generated/models/post_account_wrapper.py +19 -0
  155. ynab_api_cli/generated/models/post_category_group_wrapper.py +19 -0
  156. ynab_api_cli/generated/models/post_category_wrapper.py +19 -0
  157. ynab_api_cli/generated/models/post_payee.py +14 -0
  158. ynab_api_cli/generated/models/post_payee_wrapper.py +19 -0
  159. ynab_api_cli/generated/models/post_scheduled_transaction_wrapper.py +23 -0
  160. ynab_api_cli/generated/models/post_transactions_wrapper.py +24 -0
  161. ynab_api_cli/generated/models/put_scheduled_transaction_wrapper.py +23 -0
  162. ynab_api_cli/generated/models/put_transaction_wrapper.py +19 -0
  163. ynab_api_cli/generated/models/save_account.py +18 -0
  164. ynab_api_cli/generated/models/save_account_type.py +16 -0
  165. ynab_api_cli/generated/models/save_category.py +34 -0
  166. ynab_api_cli/generated/models/save_category_goal_frequency.py +13 -0
  167. ynab_api_cli/generated/models/save_category_group.py +14 -0
  168. ynab_api_cli/generated/models/save_category_group_response.py +21 -0
  169. ynab_api_cli/generated/models/save_category_group_response_data.py +20 -0
  170. ynab_api_cli/generated/models/save_category_response.py +21 -0
  171. ynab_api_cli/generated/models/save_category_response_data.py +20 -0
  172. ynab_api_cli/generated/models/save_month_category.py +14 -0
  173. ynab_api_cli/generated/models/save_payee.py +16 -0
  174. ynab_api_cli/generated/models/save_payee_response.py +21 -0
  175. ynab_api_cli/generated/models/save_payee_response_data.py +20 -0
  176. ynab_api_cli/generated/models/save_scheduled_transaction.py +37 -0
  177. ynab_api_cli/generated/models/save_sub_transaction.py +22 -0
  178. ynab_api_cli/generated/models/save_transaction_with_id_or_import_id.py +45 -0
  179. ynab_api_cli/generated/models/save_transaction_with_optional_fields.py +43 -0
  180. ynab_api_cli/generated/models/save_transactions_response.py +21 -0
  181. ynab_api_cli/generated/models/save_transactions_response_data.py +29 -0
  182. ynab_api_cli/generated/models/scheduled_sub_transaction.py +31 -0
  183. ynab_api_cli/generated/models/scheduled_sub_transaction_base.py +29 -0
  184. ynab_api_cli/generated/models/scheduled_transaction_detail.py +53 -0
  185. ynab_api_cli/generated/models/scheduled_transaction_frequency.py +23 -0
  186. ynab_api_cli/generated/models/scheduled_transaction_response.py +21 -0
  187. ynab_api_cli/generated/models/scheduled_transaction_response_data.py +23 -0
  188. ynab_api_cli/generated/models/scheduled_transaction_summary.py +43 -0
  189. ynab_api_cli/generated/models/scheduled_transaction_summary_base.py +41 -0
  190. ynab_api_cli/generated/models/scheduled_transaction_summary_base_frequency.py +23 -0
  191. ynab_api_cli/generated/models/scheduled_transactions_response.py +21 -0
  192. ynab_api_cli/generated/models/scheduled_transactions_response_data.py +24 -0
  193. ynab_api_cli/generated/models/sub_transaction.py +34 -0
  194. ynab_api_cli/generated/models/sub_transaction_base.py +32 -0
  195. ynab_api_cli/generated/models/transaction_cleared_status.py +13 -0
  196. ynab_api_cli/generated/models/transaction_detail.py +70 -0
  197. ynab_api_cli/generated/models/transaction_response.py +21 -0
  198. ynab_api_cli/generated/models/transaction_response_data.py +20 -0
  199. ynab_api_cli/generated/models/transaction_summary.py +62 -0
  200. ynab_api_cli/generated/models/transaction_summary_base.py +60 -0
  201. ynab_api_cli/generated/models/transactions_import_response.py +21 -0
  202. ynab_api_cli/generated/models/transactions_import_response_data.py +14 -0
  203. ynab_api_cli/generated/models/transactions_response.py +21 -0
  204. ynab_api_cli/generated/models/transactions_response_data.py +20 -0
  205. ynab_api_cli/generated/models/user.py +16 -0
  206. ynab_api_cli/generated/models/user_response.py +19 -0
  207. ynab_api_cli/generated/models/user_response_data.py +19 -0
  208. ynab_api_cli/live.py +449 -0
  209. ynab_api_cli/py.typed +0 -0
  210. ynab_api_cli/resources/__init__.py +3 -0
  211. ynab_api_cli/resources/catalog.json +178 -0
  212. ynab_api_cli/resources/openapi.yaml +4035 -0
  213. ynab_api_cli/resources/provenance.json +9 -0
  214. ynab_api_cli/resources/schema_features.json +913 -0
  215. ynab_api_cli/resources/skill/SKILL.md +21 -0
  216. ynab_api_cli/resources/skill/references/operations.md +36 -0
  217. ynab_api_cli/resources/skill/references/setup.md +32 -0
  218. ynab_api_cli/security.py +83 -0
  219. ynab_api_cli/transport.py +133 -0
  220. ynab_api_cli/types.py +110 -0
  221. ynab_api_cli/verification.py +167 -0
  222. ynab_api_cli-0.1.0.dist-info/METADATA +90 -0
  223. ynab_api_cli-0.1.0.dist-info/RECORD +225 -0
  224. ynab_api_cli-0.1.0.dist-info/WHEEL +4 -0
  225. ynab_api_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: ynab-api-cli
3
+ description: Use the YNAB API CLI to inspect plans, accounts, categories, transactions, scheduled transactions, and other REST API resources; configure a connection or perform explicitly requested changes.
4
+ ---
5
+
6
+ # YNAB API CLI
7
+
8
+ Use `ynab-api-cli`, the REST-only CLI distributed with this skill. YNAB calls budgets **plans** in the current API.
9
+
10
+ - Resolve the installed executable with `ynab-api-cli --help`. For a source checkout, use `uv run --project <project> ynab-api-cli`.
11
+ - On first use in a session, run `doctor`. If configuration is missing, read [references/setup.md](references/setup.md) and give the user the hidden local token-prompt command. Never request a token in chat.
12
+ - `discover`, `discover GROUP`, and `inspect OPERATION_ID` require no connection and make no API requests. Inspect only the operation needed; its output includes only reachable schemas.
13
+ - Use resource commands for ordinary operations or `call OPERATION_ID --request @file` for a JSON envelope containing `path`, `query`, and optional `body`.
14
+ - Prefer explicit UUIDs. A configured default plan is supplied only when plan_id is omitted. Resolve duplicate names through IDs; never infer selection from list position.
15
+ - Read [references/operations.md](references/operations.md) for milliunits, request examples, delta responses, splits, and transfers.
16
+ - Read affected records before an authorized change. Writes preview by default; review target IDs and the exact payload, then use `--execute` within the user's authorization. Existing authorization is sufficient.
17
+ - Verify submitted changes through subsequent reads. After ambiguous failures, inspect state before retrying; the CLI never retries automatically.
18
+ - `compat check` reports upstream contract drift. It does not change the installed CLI or grant permission to adopt new operations.
19
+ - Output is JSON; errors go to stderr. Report exact records and any incomplete verification.
20
+
21
+ Export this bundled skill with `ynab-api-cli skill export <new-directory>`. This skill is supplementary; the CLI works independently of Codex.
@@ -0,0 +1,36 @@
1
+ # Operation conventions
2
+
3
+ ```text
4
+ ynab-api-cli discover transactions
5
+ ynab-api-cli inspect getTransactions
6
+ ynab-api-cli transactions get-transactions --plan-id UUID --since-date 2026-01-01
7
+ ynab-api-cli call getTransactions --request @request.json
8
+ ```
9
+
10
+ The equivalent uniform request is:
11
+
12
+ ```json
13
+ {"path":{"plan_id":"UUID"},"query":{"since_date":"2026-01-01"}}
14
+ ```
15
+
16
+ - Amounts are integer milliunits: 12.34 currency units becomes 12340. Expenses are negative. Preserve the plan's currency.
17
+ - Dates follow the inspected ISO schema. Booleans use `true` or `false`; cleared status uses its declared enum.
18
+ - An omitted field differs from explicit null. Never replace unspecified fields with null during an update.
19
+ - Preserve server_knowledge and deleted records when intentionally processing delta responses. The CLI does not maintain synchronization state.
20
+ - Splits and transfers affect linked records. Inspect their models before editing them.
21
+ - Transaction creation accepts exactly one of `transaction` or `transactions`. Direct Import is a separate mutation from creating transactions with import_id.
22
+
23
+ Create an authorized expense using a body file:
24
+
25
+ ```json
26
+ {"transaction":{"account_id":"ACCOUNT_UUID","date":"2026-01-01","amount":-12340,"category_id":"CATEGORY_UUID","memo":"Authorized expense","approved":false}}
27
+ ```
28
+
29
+ ```text
30
+ ynab-api-cli transactions create-transaction --plan-id PLAN_UUID --body @expense.json
31
+ ynab-api-cli transactions create-transaction --plan-id PLAN_UUID --body @expense.json --execute
32
+ ```
33
+
34
+ The first command previews without contacting YNAB. The second submits once. Read the returned transaction ID using get-transaction-by-id to verify the result.
35
+
36
+ `--metadata` adds HTTP status, relevant headers, and contract version. Unknown parameters and invalid schemas fail before networking; properties allowed by the contract are retained. [Official API conventions](https://api.ynab.com/#data-formats).
@@ -0,0 +1,32 @@
1
+ # Connection setup
2
+
3
+ 1. Install the CLI from a release wheel or source checkout using `uv tool install <wheel-or-project-path>`.
4
+ 2. Open [YNAB Developer Settings](https://app.ynab.com/settings/developer) and generate a personal access token for your own account. [Authentication documentation](https://api.ynab.com/#personal-access-tokens).
5
+ 3. In a local interactive terminal:
6
+
7
+ ```text
8
+ ynab-api-cli configure
9
+ ynab-api-cli doctor
10
+ ynab-api-cli plans get-plans
11
+ ynab-api-cli select-plan EXPLICIT_PLAN_UUID
12
+ ynab-api-cli doctor
13
+ ```
14
+
15
+ Configuration uses `$XDG_CONFIG_HOME/ynab-api-cli` or `~/.config/ynab-api-cli`, including Windows. XDG_CONFIG_HOME must be absolute. `auth.json` holds `access_token`; `config.json` holds the optional `plan_id`. Existing unrelated fields are preserved.
16
+
17
+ `configure` uses hidden input and protects credentials with POSIX mode 600 or a private current-user Windows ACL. It refuses redirected input and existing credentials. `configure --replace` rotates credentials explicitly. Credentials are plaintext: use a private, local directory and exclude it from source control and shared storage.
18
+
19
+ `doctor --offline` validates files and protection only; it does not prove authentication. Online doctor reads the user and configured plan. `doctor --upstream` additionally checks the published contract.
20
+
21
+ | Failure | Action |
22
+ | --- | --- |
23
+ | Missing auth.json | Run configure locally |
24
+ | Permissions/ACL failure | Repair file protection or use configure --replace |
25
+ | HTTP 401 | Replace an invalid or revoked token |
26
+ | HTTP 403 | Check account access and token scope |
27
+ | HTTP 404 on selected plan | List plans and select the correct UUID |
28
+ | HTTP 429 | Stop requests; respect rate-limit guidance |
29
+ | Network failure during a write | Read affected records before any retry |
30
+ | Contract or model mismatch | Run compat check and review the supported version |
31
+
32
+ OAuth refresh and delegated multi-user authentication are outside this release. Each adopter configures a personal token locally.
@@ -0,0 +1,83 @@
1
+ """Protect credentials with private POSIX permissions or Windows ACLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from ynab_api_cli.exceptions import AppError
11
+
12
+
13
+ class FileSecurity:
14
+ """Apply and inspect credential protection without revealing file contents."""
15
+
16
+ def __init__(self, platform: str = os.name) -> None:
17
+ self.platform = platform
18
+
19
+ def powershell(self, script: str) -> str:
20
+ """Execute a fixed ACL script with no profile or interactive window."""
21
+ encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
22
+ try:
23
+ result = subprocess.run( # noqa: S603 - encoded fixed ACL script; no shell.
24
+ [
25
+ str(
26
+ Path(os.environ.get("SYSTEMROOT", "C:/Windows"))
27
+ / "System32/WindowsPowerShell/v1.0/powershell.exe"
28
+ ),
29
+ "-NoProfile",
30
+ "-NonInteractive",
31
+ "-EncodedCommand",
32
+ encoded,
33
+ ],
34
+ capture_output=True,
35
+ text=True,
36
+ check=True,
37
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
38
+ )
39
+ except (OSError, subprocess.CalledProcessError) as err:
40
+ raise AppError(
41
+ "Cannot inspect or protect Windows credential ACLs."
42
+ ) from err
43
+ return result.stdout.strip()
44
+
45
+ def protect(self, path: Path) -> None:
46
+ """Replace permissions on a newly created credential file."""
47
+ if self.platform != "nt":
48
+ path.chmod(0o600)
49
+ return
50
+ literal = str(path).replace("'", "''")
51
+ self.powershell(
52
+ "$ErrorActionPreference='Stop'; "
53
+ "$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; "
54
+ "$acl=New-Object System.Security.AccessControl.FileSecurity; "
55
+ "$acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); "
56
+ "$rule=New-Object System.Security.AccessControl.FileSystemAccessRule"
57
+ "($sid,'FullControl','Allow'); "
58
+ "$acl.AddAccessRule($rule); "
59
+ f"[System.IO.File]::SetAccessControl('{literal}', $acl)"
60
+ )
61
+
62
+ def validate(self, path: Path) -> None:
63
+ """Refuse credential files readable by other principals."""
64
+ if self.platform != "nt":
65
+ if path.stat().st_mode & 0o077:
66
+ raise AppError("Credential file must have mode 600.")
67
+ return
68
+ literal = str(path).replace("'", "''")
69
+ result = self.powershell(
70
+ "$ErrorActionPreference='Stop'; "
71
+ "$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent()"
72
+ ".User.Value; "
73
+ f"$acl=[System.IO.File]::GetAccessControl('{literal}'); "
74
+ "$bad=@($acl.Access | Where-Object { "
75
+ "$_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate"
76
+ "([System.Security.Principal.SecurityIdentifier]).Value -ne $sid }); "
77
+ "if ($bad.Count -eq 0 -and $acl.AreAccessRulesProtected) "
78
+ "{ 'private' } else { 'shared' }"
79
+ )
80
+ if result != "private":
81
+ raise AppError(
82
+ "Credential ACL is not private; rerun configure --replace locally."
83
+ )
@@ -0,0 +1,133 @@
1
+ """Bounded REST execution with independent schema checks and raw JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from http import HTTPStatus
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+
12
+ from ynab_api_cli.contract import Contract
13
+ from ynab_api_cli.exceptions import AppError
14
+ from ynab_api_cli.types import JsonCodec, RequestValues, Response
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Connection:
19
+ """Connection settings whose token is never included in representations."""
20
+
21
+ token: str = field(repr=False)
22
+ plan_id: str | None = None
23
+
24
+ def __post_init__(self) -> None:
25
+ """Reject invalid header characters without including token contents."""
26
+ if (
27
+ not self.token
28
+ or not self.token.isascii()
29
+ or not self.token.isprintable()
30
+ or any(character.isspace() for character in self.token)
31
+ ):
32
+ raise AppError("Access token must use visible ASCII without whitespace.")
33
+
34
+
35
+ class Transport:
36
+ """Submit exactly one request; forbid credentials following redirects."""
37
+
38
+ BASE_URL = "https://api.ynab.com/v1"
39
+
40
+ def __init__(
41
+ self,
42
+ connection: Connection,
43
+ contract: Contract | None = None,
44
+ client: httpx.Client | None = None,
45
+ ) -> None:
46
+ self.connection = connection
47
+ self.contract = contract if contract is not None else Contract()
48
+ self.client = client
49
+
50
+ def send(self, identifier: str, request: RequestValues) -> Response[None]:
51
+ """Validate, serialize, send once, and validate the response JSON."""
52
+ operation = self.contract.get(identifier)
53
+ self.contract.validate_request(operation, request)
54
+ values = request.json()
55
+ path = operation.path
56
+ for name, value in self.contract.object(values["path"]).items():
57
+ path = path.replace("{" + name + "}", quote(str(value), safe=""))
58
+ query = {
59
+ key: self.query_value(value)
60
+ for key, value in self.contract.object(values["query"]).items()
61
+ }
62
+ body = (
63
+ json.dumps(values["body"], ensure_ascii=False, allow_nan=False).encode(
64
+ "utf-8"
65
+ )
66
+ if "body" in values
67
+ else None
68
+ )
69
+ headers = {
70
+ "Authorization": "Bearer " + self.connection.token,
71
+ "Accept": "application/json",
72
+ }
73
+ if body is not None:
74
+ headers["Content-Type"] = "application/json"
75
+ try:
76
+ if self.client is not None:
77
+ response = self.client.request(
78
+ operation.method,
79
+ self.BASE_URL + path,
80
+ params=query,
81
+ content=body,
82
+ headers=headers,
83
+ timeout=30,
84
+ follow_redirects=False,
85
+ )
86
+ else:
87
+ with httpx.Client(
88
+ verify=True,
89
+ follow_redirects=False,
90
+ transport=httpx.HTTPTransport(retries=0),
91
+ trust_env=False,
92
+ ) as client:
93
+ response = client.request(
94
+ operation.method,
95
+ self.BASE_URL + path,
96
+ params=query,
97
+ content=body,
98
+ headers=headers,
99
+ timeout=30,
100
+ )
101
+ except httpx.HTTPError as err:
102
+ uncertainty = (
103
+ " Write outcome may be unknown; inspect state before retrying."
104
+ if operation.write
105
+ else ""
106
+ )
107
+ raise AppError("YNAB network failure; no retry." + uncertainty) from err
108
+ if response.status_code >= HTTPStatus.MULTIPLE_CHOICES:
109
+ retry = response.headers.get("Retry-After")
110
+ suffix = (
111
+ f" Retry-After: {retry}."
112
+ if retry is not None and retry.isdecimal()
113
+ else ""
114
+ )
115
+ raise AppError(f"YNAB HTTP {response.status_code}; no retry." + suffix)
116
+ try:
117
+ raw = JsonCodec.loads(response.text)
118
+ except ValueError as err:
119
+ raise AppError(
120
+ "YNAB returned malformed JSON; outcome may be unknown."
121
+ ) from err
122
+ self.contract.validate_response(operation, response.status_code, raw)
123
+ relevant = {
124
+ key: value
125
+ for key, value in response.headers.items()
126
+ if key.lower() in {"content-type", "x-rate-limit", "retry-after"}
127
+ }
128
+ return Response(response.status_code, raw, relevant, None)
129
+
130
+ @staticmethod
131
+ def query_value(value: object) -> str:
132
+ """Encode boolean query values in their lowercase wire form."""
133
+ return str(value).lower() if isinstance(value, bool) else str(value)
ynab_api_cli/types.py ADDED
@@ -0,0 +1,110 @@
1
+ """Shared JSON values and typed REST requests and responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from datetime import date
10
+ from enum import Enum
11
+ from typing import NoReturn, TypeVar, cast
12
+ from uuid import UUID
13
+
14
+ from pydantic import BaseModel, GetCoreSchemaHandler, TypeAdapter
15
+ from pydantic_core import CoreSchema, core_schema
16
+
17
+ type JsonValue = (
18
+ bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
19
+ )
20
+ T = TypeVar("T")
21
+
22
+
23
+ class Unset:
24
+ """Distinguish an omitted field from an explicit JSON null."""
25
+
26
+ @classmethod
27
+ def __get_pydantic_core_schema__(
28
+ cls, _source_type: object, _handler: GetCoreSchemaHandler
29
+ ) -> CoreSchema:
30
+ """Require the actual sentinel; never coerce a JSON object into omission."""
31
+ return core_schema.is_instance_schema(cls)
32
+
33
+
34
+ UNSET = Unset()
35
+
36
+
37
+ class JsonCodec:
38
+ """Convert validated Python values without erasing JSON field presence."""
39
+
40
+ @classmethod
41
+ def encode(cls, value: object) -> JsonValue:
42
+ """Normalize JSON-compatible values and typed contract models."""
43
+ if isinstance(value, BaseModel):
44
+ return cls.encode(
45
+ value.model_dump(mode="python", by_alias=True, exclude_unset=True)
46
+ )
47
+ if isinstance(value, dict):
48
+ return {
49
+ str(key): cls.encode(item)
50
+ for key, item in value.items()
51
+ if not isinstance(item, Unset)
52
+ }
53
+ if isinstance(value, (list, tuple)):
54
+ return [cls.encode(item) for item in value]
55
+ if isinstance(value, Enum):
56
+ return cls.encode(value.value)
57
+ if isinstance(value, (date, UUID)):
58
+ return value.isoformat() if isinstance(value, date) else str(value)
59
+ if value is None or isinstance(value, (str, int, float, bool)):
60
+ if isinstance(value, float) and not math.isfinite(value):
61
+ raise ValueError("Non-finite numbers are not valid JSON.")
62
+ return value
63
+ raise ValueError("Value cannot be represented as JSON.")
64
+
65
+ @staticmethod
66
+ def loads(text: str) -> JsonValue:
67
+ """Decode JSON into its recursive value type."""
68
+ # JSON's parser guarantees this recursive union for successfully decoded input.
69
+ return cast(
70
+ "JsonValue", json.loads(text, parse_constant=JsonCodec.reject_constant)
71
+ )
72
+
73
+ @staticmethod
74
+ def reject_constant(_text: str) -> NoReturn:
75
+ """Reject non-standard JSON numeric constants without exposing values."""
76
+ raise ValueError("Non-finite numbers are not valid JSON.")
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class RequestValues:
81
+ """Raw or typed endpoint arguments with explicit body presence."""
82
+
83
+ path: Mapping[str, object] = field(default_factory=dict)
84
+ query: Mapping[str, object] = field(default_factory=dict)
85
+ body: object = UNSET
86
+
87
+ def value(self, section: str, name: str, adapter: TypeAdapter[T]) -> T:
88
+ """Decode one argument using the generated endpoint's exact type."""
89
+ source = self.path if section == "path" else self.query
90
+ return adapter.validate_python(source.get(name, UNSET))
91
+
92
+ def json(self) -> dict[str, JsonValue]:
93
+ """Normalize the request without inserting omitted bodies or fields."""
94
+ values: dict[str, JsonValue] = {
95
+ "path": JsonCodec.encode(self.path),
96
+ "query": JsonCodec.encode(self.query),
97
+ }
98
+ if not isinstance(self.body, Unset):
99
+ values["body"] = JsonCodec.encode(self.body)
100
+ return values
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class Response[T_co]:
105
+ """HTTP response retaining raw JSON, headers, and its typed representation."""
106
+
107
+ status: int
108
+ raw: JsonValue
109
+ headers: dict[str, str]
110
+ parsed: T_co
@@ -0,0 +1,167 @@
1
+ """Reviewed feature manifests and public upstream candidate verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ from ynab_api_cli.compat import Compatibility
18
+ from ynab_api_cli.contract import Contract
19
+ from ynab_api_cli.contract_testing import SchemaCases
20
+ from ynab_api_cli.development import Development
21
+
22
+
23
+ class Verification:
24
+ """Keep acceptance manifests reviewed and detect all structural changes."""
25
+
26
+ def __init__(self, root: Path | None = None) -> None:
27
+ self.root = root if root is not None else Path(__file__).resolve().parents[2]
28
+
29
+ @classmethod
30
+ def main(cls) -> None:
31
+ """Execute the public poe verification tasks."""
32
+ parser = argparse.ArgumentParser()
33
+ parser.add_argument(
34
+ "command",
35
+ choices=["manifest-init", "contract-check", "compat-check", "skill-check"],
36
+ )
37
+ command = str(parser.parse_args().command)
38
+ instance = cls()
39
+ if command == "manifest-init":
40
+ with (
41
+ instance.root / "src/ynab_api_cli/resources/schema_features.json"
42
+ ).open("x", encoding="utf-8") as stream:
43
+ json.dump(instance.manifest(), stream, indent=2)
44
+ stream.write("\n")
45
+ elif command == "contract-check":
46
+ instance.contract_check()
47
+ elif command == "skill-check":
48
+ instance.skill_check()
49
+ else:
50
+ instance.compat_check()
51
+
52
+ @staticmethod
53
+ def manifest() -> dict[str, str]:
54
+ """Fingerprint every declared schema node, enum variant, and null branch."""
55
+ return {
56
+ probe.identifier: hashlib.sha256(
57
+ json.dumps(
58
+ Compatibility.structural(probe.schema), sort_keys=True
59
+ ).encode("utf-8")
60
+ ).hexdigest()
61
+ for probe in SchemaCases(Contract()).probes()
62
+ }
63
+
64
+ def contract_check(self) -> None:
65
+ """Require review when any existing or additional schema feature changes."""
66
+ resource = self.root / "src/ynab_api_cli/resources"
67
+ reviewed = json.loads(
68
+ (resource / "schema_features.json").read_text(encoding="utf-8")
69
+ )
70
+ if reviewed != self.manifest():
71
+ raise SystemExit(
72
+ "Schema feature manifest differs; review all changed cases."
73
+ )
74
+ subprocess.run(
75
+ [
76
+ sys.executable,
77
+ "-m",
78
+ "pytest",
79
+ "tests/test_contract.py",
80
+ "tests/test_typing.py",
81
+ "tests/test_features.py",
82
+ "-q",
83
+ ],
84
+ cwd=self.root,
85
+ check=True,
86
+ )
87
+
88
+ def skill_check(self) -> None:
89
+ """Ensure canonical and installed skill files match exactly."""
90
+ canonical = self.root / "skills/ynab-api-cli"
91
+ bundled = self.root / "src/ynab_api_cli/resources/skill"
92
+ left = {
93
+ str(path.relative_to(canonical)): path.read_bytes()
94
+ for path in canonical.rglob("*")
95
+ if path.is_file()
96
+ }
97
+ right = {
98
+ str(path.relative_to(bundled)): path.read_bytes()
99
+ for path in bundled.rglob("*")
100
+ if path.is_file()
101
+ }
102
+ if not left or left != right:
103
+ raise SystemExit("Canonical and bundled skill resources differ.")
104
+
105
+ def compat_check(self) -> None:
106
+ """Generate, type-check, and test a candidate without editing the checkout."""
107
+ checker = Compatibility()
108
+ latest = checker.fetch()
109
+ report = checker.compare(latest)
110
+ sys.stdout.write(json.dumps(report, indent=2) + "\n")
111
+ with tempfile.TemporaryDirectory() as temporary:
112
+ candidate = Path(temporary) / "candidate"
113
+ shutil.copytree(
114
+ self.root,
115
+ candidate,
116
+ ignore=shutil.ignore_patterns(
117
+ ".git",
118
+ ".venv",
119
+ ".ruff_cache",
120
+ ".pytest_cache",
121
+ "__pycache__",
122
+ "dist",
123
+ ".hypothesis",
124
+ ".coverage",
125
+ ),
126
+ )
127
+ spec_path = candidate / "src/ynab_api_cli/resources/openapi.yaml"
128
+ spec_path.write_text(
129
+ yaml.safe_dump(latest, sort_keys=False), encoding="utf-8"
130
+ )
131
+ Development(candidate).generate(check=False)
132
+ subprocess.run( # noqa: S603 - fixed checker module; no shell.
133
+ [
134
+ sys.executable,
135
+ "-m",
136
+ "ty",
137
+ "check",
138
+ str(candidate),
139
+ "--python",
140
+ sys.executable,
141
+ ],
142
+ cwd=candidate,
143
+ check=True,
144
+ )
145
+ environment = dict(os.environ)
146
+ environment["PYTHONPATH"] = str(candidate / "src")
147
+ subprocess.run(
148
+ [
149
+ sys.executable,
150
+ "-m",
151
+ "pytest",
152
+ "tests/test_contract.py",
153
+ "tests/test_compat.py",
154
+ "tests/test_typing.py",
155
+ "tests/test_features.py",
156
+ "-q",
157
+ ],
158
+ cwd=candidate,
159
+ env=environment,
160
+ check=True,
161
+ )
162
+ if report["compatible"] is not True:
163
+ raise SystemExit(1)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ Verification.main()
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.3
2
+ Name: ynab-api-cli
3
+ Version: 0.1.0
4
+ Summary: Complete contract-driven REST CLI for YNAB
5
+ Author: Mike Hill
6
+ Requires-Dist: httpx>=0.28.1
7
+ Requires-Dist: jsonschema[format]>=4.26.0
8
+ Requires-Dist: pydantic>=2.14.1
9
+ Requires-Dist: pyyaml>=6.0.3
10
+ Requires-Python: >=3.12
11
+ Project-URL: Homepage, https://github.com/mikejhill/ynab-api-cli
12
+ Project-URL: Repository, https://github.com/mikejhill/ynab-api-cli
13
+ Project-URL: Issues, https://github.com/mikejhill/ynab-api-cli/issues
14
+ Description-Content-Type: text/markdown
15
+
16
+ # YNAB API CLI
17
+
18
+ Complete, synchronous REST CLI for the reviewed YNAB API contract. The official SDK is not a dependency. Generated, strictly typed endpoint classes and models are included for Python callers.
19
+
20
+ ## Install and connect
21
+
22
+ ```text
23
+ uv tool install ynab-api-cli
24
+ ynab-api-cli configure
25
+ ynab-api-cli doctor
26
+ ynab-api-cli plans get-plans
27
+ ynab-api-cli select-plan PLAN_UUID
28
+ ynab-api-cli doctor
29
+ ```
30
+
31
+ Generate your personal token in [YNAB Developer Settings](https://app.ynab.com/settings/developer), then enter it through the hidden terminal prompt. Do not put it in command arguments. Configuration is stored under `$XDG_CONFIG_HOME/ynab-api-cli`, falling back to `~/.config/ynab-api-cli` on all platforms. Credentials are plaintext and protected with private POSIX permissions or a current-user Windows ACL.
32
+
33
+ ```text
34
+ ynab-api-cli discover
35
+ ynab-api-cli discover transactions
36
+ ynab-api-cli inspect createTransaction
37
+ ynab-api-cli transactions get-transactions --since-date 2026-01-01
38
+ ynab-api-cli call getTransactions --request @request.json
39
+ ```
40
+
41
+ Uniform requests use `{"path":{},"query":{},"body":{}}`; omit body for operations without one. `--request -` and `--body -` read stdin. Endpoint flags use kebab-case names. Boolean flags take `true` or `false`.
42
+
43
+ Writes preview by default. Add `--execute` only for an intended mutation and verify its result through reads. Requests use TLS, a 30-second timeout, and no automatic retries or redirects. An uncertain write must be reconciled before retrying. Amounts are integer milliunits, and explicit null differs from omission.
44
+
45
+ `--metadata` adds status, selected headers, and contract version. Exit codes: 0 success, 1 configuration/API/compatibility failure, 2 usage error, and 130 interruption. `doctor --offline` does not validate API access. `configure --replace` explicitly rotates credentials while preserving unrelated fields.
46
+
47
+ ## Typed Python interface
48
+
49
+ ```python
50
+ from ynab_api_cli.generated.api.transactions.get_transactions import Endpoint
51
+ from ynab_api_cli.transport import Connection, Transport
52
+
53
+ # Load protected credentials through ConfigStore in real applications.
54
+ response = Endpoint.execute(transport=Transport(Connection(token)), plan_id=plan_id)
55
+ raw_json = response.raw
56
+ typed_result = response.parsed
57
+ ```
58
+
59
+ Typed endpoint execution directly submits mutations; preview guards belong to the CLI. The transport independently validates requests and responses against the bundled contract. Raw JSON remains available alongside typed parsing.
60
+
61
+ ## Development and maintenance
62
+
63
+ ```text
64
+ uv sync --locked
65
+ uv run poe fix
66
+ uv run poe check
67
+ uv run poe codegen-check
68
+ uv run poe contract-check
69
+ uv run poe compat-check
70
+ uv build
71
+ ```
72
+
73
+ The contract snapshot, reviewed operation catalog, and provenance are packaged resources. Generator configuration lives in pyproject.toml; templates and deterministic transforms produce all generated code. Do not edit generated files manually.
74
+
75
+ See [maintenance](docs/maintenance.md) for reviewed upgrades, test coverage, and CI. See [live testing](docs/live-testing.md) before enabling writes against a dedicated test plan.
76
+
77
+ ## Supplementary Codex skill
78
+
79
+ ```text
80
+ ynab-api-cli skill show
81
+ ynab-api-cli skill export /path/to/codex/skills/ynab-api-cli
82
+ ```
83
+
84
+ Export refuses an existing destination. The canonical skill is in skills/ynab-api-cli and is also included as installed package data. The CLI works independently of Codex.
85
+
86
+ For a source checkout, install with `uv tool install .` or run commands with `uv run ynab-api-cli`.
87
+
88
+ ## Releases
89
+
90
+ The publishing pipeline follows VerdiClip's release-please and PyPI trusted-publishing pattern. See [publishing](docs/publishing.md) for the initial project setup, release process, and TestPyPI option.